mirror of
https://github.com/element-hq/element-android
synced 2024-11-24 10:25:35 +03:00
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:
commit
a131d28b3e
186 changed files with 5739 additions and 1252 deletions
2
.github/workflows/integration_tests.yml
vendored
2
.github/workflows/integration_tests.yml
vendored
|
@ -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 }}
|
||||
|
|
6
.github/workflows/quality.yml
vendored
6
.github/workflows/quality.yml
vendored
|
@ -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: |
|
||||
|
|
|
@ -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
1
changelog.d/4746.feature
Normal file
|
@ -0,0 +1 @@
|
|||
Initial implementation of thread messages
|
1
changelog.d/4873.misc
Normal file
1
changelog.d/4873.misc
Normal file
|
@ -0,0 +1 @@
|
|||
Qr code scanning fragments merged into one
|
1
changelog.d/5088.bugfix
Normal file
1
changelog.d/5088.bugfix
Normal file
|
@ -0,0 +1 @@
|
|||
Fixes call statuses in the timeline for missed/rejected calls and connected calls.
|
1
changelog.d/5118.misc
Normal file
1
changelog.d/5118.misc
Normal file
|
@ -0,0 +1 @@
|
|||
Fix CI/CD errors after merges for quality and integration tests
|
|
@ -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",
|
||||
|
|
|
@ -175,6 +175,7 @@ ext.groups = [
|
|||
'org.sonatype.oss',
|
||||
'org.testng',
|
||||
'org.threeten',
|
||||
'org.webjars',
|
||||
'ru.noties',
|
||||
'xerces',
|
||||
'xml-apis',
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
4
library/ui-styles/src/main/res/values-v23/dimens.xml
Normal file
4
library/ui-styles/src/main/res/values-v23/dimens.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<dimen name="menu_item_ripple_size">28dp</dimen>
|
||||
</resources>
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 *****************************************************************************
|
||||
|
||||
/**
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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>()
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
)
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
0
tools/check/forbidden_strings_in_code.txt
Normal file → Executable file
2
tools/check/forbidden_strings_in_layout.txt
Normal file → Executable file
2
tools/check/forbidden_strings_in_layout.txt
Normal file → Executable 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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
|
@ -48,4 +48,8 @@ class UserPreferencesProvider @Inject constructor(private val vectorPreferences:
|
|||
fun shouldShowAvatarDisplayNameChanges(): Boolean {
|
||||
return vectorPreferences.showAvatarDisplayNameChangeMessages()
|
||||
}
|
||||
|
||||
fun areThreadMessagesEnabled(): Boolean {
|
||||
return vectorPreferences.areThreadMessagesEnabled()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -23,4 +23,8 @@ sealed class CreateDirectRoomAction : VectorViewModelAction {
|
|||
data class CreateRoomAndInviteSelectedUsers(
|
||||
val selections: Set<PendingSelection>
|
||||
) : CreateDirectRoomAction()
|
||||
|
||||
data class QrScannedAction(
|
||||
val result: String
|
||||
) : CreateDirectRoomAction()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue