diff --git a/CHANGES.md b/CHANGES.md index a4767f8be9..b63432c489 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,7 @@ Improvements 🙌: - Add a setting to hide redacted events (#951) Bugfix 🐛: + - After jump to unread, newer messages are never loaded (#1008) - Fix issues with FontScale switch (#69, #645) Translations 🗣: diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CommonTestHelper.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CommonTestHelper.kt index 3ca04a86d1..600bcf2983 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CommonTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CommonTestHelper.kt @@ -28,10 +28,10 @@ import im.vector.matrix.android.api.auth.data.LoginFlowResult import im.vector.matrix.android.api.auth.registration.RegistrationResult import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.events.model.EventType -import im.vector.matrix.android.api.session.events.model.LocalEcho import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.Room import im.vector.matrix.android.api.session.room.model.message.MessageContent +import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineSettings @@ -117,7 +117,7 @@ class CommonTestHelper(context: Context) { */ fun sendTextMessage(room: Room, message: String, nbOfMessages: Int): List { val sentEvents = ArrayList(nbOfMessages) - val latch = CountDownLatch(nbOfMessages) + val latch = CountDownLatch(1) val timelineListener = object : Timeline.Listener { override fun onTimelineFailure(throwable: Throwable) { } @@ -128,7 +128,7 @@ class CommonTestHelper(context: Context) { override fun onTimelineUpdated(snapshot: List) { val newMessages = snapshot - .filter { LocalEcho.isLocalEchoId(it.eventId).not() } + .filter { it.root.sendState == SendState.SYNCED } .filter { it.root.getClearType() == EventType.MESSAGE } .filter { it.root.getClearContent().toModel()?.body?.startsWith(message) == true } @@ -144,7 +144,8 @@ class CommonTestHelper(context: Context) { for (i in 0 until nbOfMessages) { room.sendTextMessage(message + " #" + (i + 1)) } - await(latch) + // Wait 3 second more per message + await(latch, timeout = TestConstants.timeOutMillis + 3_000L * nbOfMessages) timeline.removeListener(timelineListener) timeline.dispose() @@ -292,6 +293,24 @@ class CommonTestHelper(context: Context) { return requestFailure!! } + fun createEventListener(latch: CountDownLatch, predicate: (List) -> Boolean): Timeline.Listener { + return object : Timeline.Listener { + override fun onTimelineFailure(throwable: Throwable) { + // noop + } + + override fun onNewTimelineEvents(eventIds: List) { + // noop + } + + override fun onTimelineUpdated(snapshot: List) { + if (predicate(snapshot)) { + latch.countDown() + } + } + } + } + /** * Await for a latch and ensure the result is true * @@ -350,3 +369,13 @@ class CommonTestHelper(context: Context) { session.close() } } + +fun List.checkSendOrder(baseTextMessage: String, numberOfMessages: Int, startIndex: Int): Boolean { + return drop(startIndex) + .take(numberOfMessages) + .foldRightIndexed(true) { index, timelineEvent, acc -> + val body = timelineEvent.root.content.toModel()?.body + val currentMessageSuffix = numberOfMessages - index + acc && (body == null || body.startsWith(baseTextMessage) && body.endsWith("#$currentMessageSuffix")) + } +} diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt index f9aef3604a..35ad8ff4e1 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt @@ -53,17 +53,19 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) { /** * @return alice session */ - fun doE2ETestWithAliceInARoom(): CryptoTestData { + fun doE2ETestWithAliceInARoom(encryptedRoom: Boolean = true): CryptoTestData { val aliceSession = mTestHelper.createAccount(TestConstants.USER_ALICE, defaultSessionParams) val roomId = mTestHelper.doSync { aliceSession.createRoom(CreateRoomParams(name = "MyRoom"), it) } - val room = aliceSession.getRoom(roomId)!! + if (encryptedRoom) { + val room = aliceSession.getRoom(roomId)!! - mTestHelper.doSync { - room.enableEncryption(callback = it) + mTestHelper.doSync { + room.enableEncryption(callback = it) + } } return CryptoTestData(aliceSession, roomId) @@ -72,8 +74,8 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) { /** * @return alice and bob sessions */ - fun doE2ETestWithAliceAndBobInARoom(): CryptoTestData { - val cryptoTestData = doE2ETestWithAliceInARoom() + fun doE2ETestWithAliceAndBobInARoom(encryptedRoom: Boolean = true): CryptoTestData { + val cryptoTestData = doE2ETestWithAliceInARoom(encryptedRoom) val aliceSession = cryptoTestData.firstSession val aliceRoomId = cryptoTestData.roomId diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineBackToPreviousLastForwardTest.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineBackToPreviousLastForwardTest.kt new file mode 100644 index 0000000000..7c7de8170b --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineBackToPreviousLastForwardTest.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 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.matrix.android.session.room.timeline + +import im.vector.matrix.android.InstrumentedTest +import im.vector.matrix.android.api.extensions.orFalse +import im.vector.matrix.android.api.session.events.model.EventType +import im.vector.matrix.android.api.session.events.model.toModel +import im.vector.matrix.android.api.session.room.model.message.MessageContent +import im.vector.matrix.android.api.session.room.timeline.Timeline +import im.vector.matrix.android.api.session.room.timeline.TimelineSettings +import im.vector.matrix.android.common.CommonTestHelper +import im.vector.matrix.android.common.CryptoTestHelper +import im.vector.matrix.android.common.checkSendOrder +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeTrue +import org.junit.Assert.assertTrue +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import timber.log.Timber +import java.util.concurrent.CountDownLatch + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class TimelineBackToPreviousLastForwardTest : InstrumentedTest { + + private val commonTestHelper = CommonTestHelper(context()) + private val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + + /** + * This test ensure that if we have a chunk in the timeline which is due to a sync, and we click to permalink of an + * even contained in a previous lastForward chunk, we will be able to go back to the live + */ + @Test + fun backToPreviousLastForwardTest() { + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false) + + val aliceSession = cryptoTestData.firstSession + val bobSession = cryptoTestData.secondSession!! + val aliceRoomId = cryptoTestData.roomId + + aliceSession.cryptoService().setWarnOnUnknownDevices(false) + bobSession.cryptoService().setWarnOnUnknownDevices(false) + + val roomFromAlicePOV = aliceSession.getRoom(aliceRoomId)!! + val roomFromBobPOV = bobSession.getRoom(aliceRoomId)!! + + val bobTimeline = roomFromBobPOV.createTimeline(null, TimelineSettings(30)) + bobTimeline.start() + + var roomCreationEventId: String? = null + + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Bob timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root}") + } + + roomCreationEventId = snapshot.lastOrNull()?.root?.eventId + // Ok, we have the 8 first messages of the initial sync (room creation and bob join event) + snapshot.size == 8 + } + + bobTimeline.addListener(eventsListener) + commonTestHelper.await(lock) + bobTimeline.removeAllListeners() + + bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeFalse() + bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeFalse() + } + + // Bob stop to sync + bobSession.stopSync() + + val messageRoot = "First messages from Alice" + + // Alice sends 30 messages + commonTestHelper.sendTextMessage( + roomFromAlicePOV, + messageRoot, + 30) + + // Bob start to sync + bobSession.startSync(true) + + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Bob timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root}") + } + + // Ok, we have the 10 last messages from Alice. + snapshot.size == 10 + && snapshot.all { it.root.content.toModel()?.body?.startsWith(messageRoot).orFalse() } + } + + bobTimeline.addListener(eventsListener) + commonTestHelper.await(lock) + bobTimeline.removeAllListeners() + + bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeTrue() + bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeFalse() + } + + // Bob navigate to the first event (room creation event), so inside the previous last forward chunk + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Bob timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root}") + } + + // The event is in db, so it is fetch and auto pagination occurs, half of the number of events we have for this chunk (?) + snapshot.size == 4 + } + + bobTimeline.addListener(eventsListener) + + // Restart the timeline to the first sent event, which is already in the database, so pagination should start automatically + assertTrue(roomFromBobPOV.getTimeLineEvent(roomCreationEventId!!) != null) + + bobTimeline.restartWithEventId(roomCreationEventId) + + commonTestHelper.await(lock) + bobTimeline.removeAllListeners() + + bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeTrue() + bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeFalse() + } + + // Bob scroll to the future + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Bob timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root}") + } + + // Bob can see the first event of the room (so Back pagination has worked) + snapshot.lastOrNull()?.root?.getClearType() == EventType.STATE_ROOM_CREATE + // 8 for room creation item, and 30 for the forward pagination + && snapshot.size == 38 + && snapshot.checkSendOrder(messageRoot, 30, 0) + } + + bobTimeline.addListener(eventsListener) + + bobTimeline.paginate(Timeline.Direction.FORWARDS, 50) + + commonTestHelper.await(lock) + bobTimeline.removeAllListeners() + + bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeFalse() + bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeFalse() + } + bobTimeline.dispose() + + cryptoTestData.cleanUp(commonTestHelper) + } +} diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineForwardPaginationTest.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineForwardPaginationTest.kt new file mode 100644 index 0000000000..adb5c81378 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineForwardPaginationTest.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 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.matrix.android.session.room.timeline + +import im.vector.matrix.android.InstrumentedTest +import im.vector.matrix.android.api.extensions.orFalse +import im.vector.matrix.android.api.session.events.model.EventType +import im.vector.matrix.android.api.session.events.model.toModel +import im.vector.matrix.android.api.session.room.model.message.MessageContent +import im.vector.matrix.android.api.session.room.timeline.Timeline +import im.vector.matrix.android.api.session.room.timeline.TimelineSettings +import im.vector.matrix.android.common.CommonTestHelper +import im.vector.matrix.android.common.CryptoTestHelper +import im.vector.matrix.android.common.checkSendOrder +import org.amshove.kluent.shouldBeFalse +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 timber.log.Timber +import java.util.concurrent.CountDownLatch + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class TimelineForwardPaginationTest : InstrumentedTest { + + private val commonTestHelper = CommonTestHelper(context()) + private val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + + /** + * This test ensure that if we click to permalink, we will be able to go back to the live + */ + @Test + fun forwardPaginationTest() { + val numberOfMessagesToSend = 90 + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceInARoom(false) + + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + + aliceSession.cryptoService().setWarnOnUnknownDevices(false) + + val roomFromAlicePOV = aliceSession.getRoom(aliceRoomId)!! + + // Alice sends X messages + val message = "Message from Alice" + val sentMessages = commonTestHelper.sendTextMessage( + roomFromAlicePOV, + message, + numberOfMessagesToSend) + + // Alice clear the cache + commonTestHelper.doSync { + aliceSession.clearCache(it) + } + + // And restarts the sync + aliceSession.startSync(true) + + val aliceTimeline = roomFromAlicePOV.createTimeline(null, TimelineSettings(30)) + aliceTimeline.start() + + // Alice sees the 10 last message of the room, and can only navigate BACKWARD + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Alice timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root.content}") + } + + // Ok, we have the 10 last messages of the initial sync + snapshot.size == 10 + && snapshot.all { it.root.content.toModel()?.body?.startsWith(message).orFalse() } + } + + // Open the timeline at last sent message + aliceTimeline.addListener(eventsListener) + commonTestHelper.await(lock) + aliceTimeline.removeAllListeners() + + aliceTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeTrue() + aliceTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeFalse() + } + + // Alice navigates to the first message of the room, which is not in its database. A GET /context is performed + // Then she can paginate BACKWARD and FORWARD + run { + val lock = CountDownLatch(1) + val aliceEventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Alice timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root.content}") + } + + // The event is not in db, so it is fetch alone + snapshot.size == 1 + && snapshot.all { it.root.content.toModel()?.body?.startsWith("Message from Alice").orFalse() } + } + + aliceTimeline.addListener(aliceEventsListener) + + // Restart the timeline to the first sent event + aliceTimeline.restartWithEventId(sentMessages.last().eventId) + + commonTestHelper.await(lock) + aliceTimeline.removeAllListeners() + + aliceTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeTrue() + aliceTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeTrue() + } + + // Alice paginates BACKWARD and FORWARD of 50 events each + // Then she can only navigate FORWARD + run { + val lock = CountDownLatch(1) + val aliceEventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Alice timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root.content}") + } + + // Alice can see the first event of the room (so Back pagination has worked) + snapshot.lastOrNull()?.root?.getClearType() == EventType.STATE_ROOM_CREATE + // 6 for room creation item (backward pagination), 1 for the context, and 50 for the forward pagination + && snapshot.size == 6 + 1 + 50 + } + + aliceTimeline.addListener(aliceEventsListener) + + // Restart the timeline to the first sent event + // We ask to load event backward and forward + aliceTimeline.paginate(Timeline.Direction.BACKWARDS, 50) + aliceTimeline.paginate(Timeline.Direction.FORWARDS, 50) + + commonTestHelper.await(lock) + aliceTimeline.removeAllListeners() + + aliceTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeTrue() + aliceTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeFalse() + } + + // Alice paginates once again FORWARD for 50 events + // All the timeline is retrieved, she cannot paginate anymore in both direction + run { + val lock = CountDownLatch(1) + val aliceEventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Alice timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root.content}") + } + // 6 for room creation item (backward pagination),and numberOfMessagesToSend (all the message of the room) + snapshot.size == 6 + numberOfMessagesToSend + && snapshot.checkSendOrder(message, numberOfMessagesToSend, 0) + } + + aliceTimeline.addListener(aliceEventsListener) + + // Ask for a forward pagination + aliceTimeline.paginate(Timeline.Direction.FORWARDS, 50) + + commonTestHelper.await(lock) + aliceTimeline.removeAllListeners() + + // The timeline is fully loaded + aliceTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeFalse() + aliceTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeFalse() + } + + aliceTimeline.dispose() + + cryptoTestData.cleanUp(commonTestHelper) + } +} diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelinePreviousLastForwardTest.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelinePreviousLastForwardTest.kt new file mode 100644 index 0000000000..3e673e4c08 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelinePreviousLastForwardTest.kt @@ -0,0 +1,241 @@ +/* + * Copyright (c) 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.matrix.android.session.room.timeline + +import im.vector.matrix.android.InstrumentedTest +import im.vector.matrix.android.api.extensions.orFalse +import im.vector.matrix.android.api.session.events.model.EventType +import im.vector.matrix.android.api.session.events.model.toModel +import im.vector.matrix.android.api.session.room.model.message.MessageContent +import im.vector.matrix.android.api.session.room.timeline.Timeline +import im.vector.matrix.android.api.session.room.timeline.TimelineSettings +import im.vector.matrix.android.common.CommonTestHelper +import im.vector.matrix.android.common.CryptoTestHelper +import im.vector.matrix.android.common.checkSendOrder +import org.amshove.kluent.shouldBeFalse +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 timber.log.Timber +import java.util.concurrent.CountDownLatch + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class TimelinePreviousLastForwardTest : InstrumentedTest { + + private val commonTestHelper = CommonTestHelper(context()) + private val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + + /** + * This test ensure that if we have a chunk in the timeline which is due to a sync, and we click to permalink, we will be able to go back to the live + */ + @Test + fun previousLastForwardTest() { + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false) + + val aliceSession = cryptoTestData.firstSession + val bobSession = cryptoTestData.secondSession!! + val aliceRoomId = cryptoTestData.roomId + + aliceSession.cryptoService().setWarnOnUnknownDevices(false) + bobSession.cryptoService().setWarnOnUnknownDevices(false) + + val roomFromAlicePOV = aliceSession.getRoom(aliceRoomId)!! + val roomFromBobPOV = bobSession.getRoom(aliceRoomId)!! + + val bobTimeline = roomFromBobPOV.createTimeline(null, TimelineSettings(30)) + bobTimeline.start() + + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Bob timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root}") + } + + // Ok, we have the 8 first messages of the initial sync (room creation and bob invite and join events) + snapshot.size == 8 + } + + bobTimeline.addListener(eventsListener) + commonTestHelper.await(lock) + bobTimeline.removeAllListeners() + + bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeFalse() + bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeFalse() + } + + // Bob stop to sync + bobSession.stopSync() + + val firstMessage = "First messages from Alice" + // Alice sends 30 messages + val firstMessageFromAliceId = commonTestHelper.sendTextMessage( + roomFromAlicePOV, + firstMessage, + 30) + .last() + .eventId + + // Bob start to sync + bobSession.startSync(true) + + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Bob timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root}") + } + + // Ok, we have the 10 last messages from Alice. This will be our future previous lastForward chunk + snapshot.size == 10 + && snapshot.all { it.root.content.toModel()?.body?.startsWith(firstMessage).orFalse() } + } + + bobTimeline.addListener(eventsListener) + commonTestHelper.await(lock) + bobTimeline.removeAllListeners() + + bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeTrue() + bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeFalse() + } + + // Bob stop to sync + bobSession.stopSync() + + val secondMessage = "Second messages from Alice" + // Alice sends again 30 messages + commonTestHelper.sendTextMessage( + roomFromAlicePOV, + secondMessage, + 30) + + // Bob start to sync + bobSession.startSync(true) + + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Bob timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root}") + } + + // Ok, we have the 10 last messages from Alice. This will be our future previous lastForward chunk + snapshot.size == 10 + && snapshot.all { it.root.content.toModel()?.body?.startsWith(secondMessage).orFalse() } + } + + bobTimeline.addListener(eventsListener) + commonTestHelper.await(lock) + bobTimeline.removeAllListeners() + + bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeTrue() + bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeFalse() + } + + // Bob navigate to the first message sent from Alice + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Bob timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root}") + } + + // The event is not in db, so it is fetch + snapshot.size == 1 + } + + bobTimeline.addListener(eventsListener) + + // Restart the timeline to the first sent event, and paginate in both direction + bobTimeline.restartWithEventId(firstMessageFromAliceId) + bobTimeline.paginate(Timeline.Direction.BACKWARDS, 50) + bobTimeline.paginate(Timeline.Direction.FORWARDS, 50) + + commonTestHelper.await(lock) + bobTimeline.removeAllListeners() + + bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeTrue() + bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeTrue() + } + + // Paginate in both direction + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Bob timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root}") + } + + snapshot.size == 8 + 1 + 35 + } + + bobTimeline.addListener(eventsListener) + + // Paginate in both direction + bobTimeline.paginate(Timeline.Direction.BACKWARDS, 50) + // Ensure the chunk in the middle is included in the next pagination + bobTimeline.paginate(Timeline.Direction.FORWARDS, 35) + + commonTestHelper.await(lock) + bobTimeline.removeAllListeners() + + bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeTrue() + bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeFalse() + } + + // Bob scroll to the future, till the live + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + Timber.e("Bob timeline updated: with ${snapshot.size} events:") + snapshot.forEach { + Timber.w(" event ${it.root}") + } + + // Bob can see the first event of the room (so Back pagination has worked) + snapshot.lastOrNull()?.root?.getClearType() == EventType.STATE_ROOM_CREATE + // 8 for room creation item 60 message from Alice + && snapshot.size == 8 + 60 + && snapshot.checkSendOrder(secondMessage, 30, 0) + && snapshot.checkSendOrder(firstMessage, 30, 30) + } + + bobTimeline.addListener(eventsListener) + + bobTimeline.paginate(Timeline.Direction.FORWARDS, 50) + + commonTestHelper.await(lock) + bobTimeline.removeAllListeners() + + bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeFalse() + bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeFalse() + } + + bobTimeline.dispose() + + cryptoTestData.cleanUp(commonTestHelper) + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt index d7d6682046..19ff65dbe2 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt @@ -58,7 +58,7 @@ interface Timeline { /** * Check if the timeline can be enriched by paginating. - * @param the direction to check in + * @param direction the direction to check in * @return true if timeline can be enriched */ fun hasMoreToLoad(direction: Direction): Boolean diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt index 0c8a04db36..7adc438e20 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt @@ -16,6 +16,7 @@ package im.vector.matrix.android.api.session.room.timeline +import im.vector.matrix.android.BuildConfig import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.RelationType @@ -45,6 +46,12 @@ data class TimelineEvent( val readReceipts: List = emptyList() ) { + init { + if (BuildConfig.DEBUG) { + assert(eventId == root.eventId) + } + } + val metadata = HashMap() /** diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/RealmCryptoStoreMigration.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/RealmCryptoStoreMigration.kt index 885abb776d..66ee0c58f5 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/RealmCryptoStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/RealmCryptoStoreMigration.kt @@ -200,6 +200,7 @@ internal class RealmCryptoStoreMigration @Inject constructor(private val crossSi } private fun migrateTo3(realm: DynamicRealm) { + Timber.d("Step 2 -> 3") Timber.d("Updating CryptoMetadataEntity table") realm.schema.get("CryptoMetadataEntity") ?.addField(CryptoMetadataEntityFields.KEY_BACKUP_RECOVERY_KEY, String::class.java) @@ -207,6 +208,7 @@ internal class RealmCryptoStoreMigration @Inject constructor(private val crossSi } private fun migrateTo4(realm: DynamicRealm) { + Timber.d("Step 3 -> 4") Timber.d("Updating KeyInfoEntity table") val keyInfoEntities = realm.where("KeyInfoEntity").findAll() try { @@ -238,6 +240,7 @@ internal class RealmCryptoStoreMigration @Inject constructor(private val crossSi } private fun migrateTo5(realm: DynamicRealm) { + Timber.d("Step 4 -> 5") realm.schema.create("MyDeviceLastSeenInfoEntity") .addField(MyDeviceLastSeenInfoEntityFields.DEVICE_ID, String::class.java) .addPrimaryKey(MyDeviceLastSeenInfoEntityFields.DEVICE_ID) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt index 80376fb6ee..d86151e694 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt @@ -60,10 +60,9 @@ internal fun ChunkEntity.merge(roomId: String, chunkToMerge: ChunkEntity, direct chunkToMerge.stateEvents.forEach { stateEvent -> addStateEvent(roomId, stateEvent, direction) } - return eventsToMerge - .forEach { - addTimelineEventFromMerge(localRealm, it, direction) - } + eventsToMerge.forEach { + addTimelineEventFromMerge(localRealm, it, direction) + } } internal fun ChunkEntity.addStateEvent(roomId: String, stateEvent: EventEntity, direction: PaginationDirection) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ChunkEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ChunkEntity.kt index 2d294e6783..19bf72970c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ChunkEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ChunkEntity.kt @@ -23,15 +23,20 @@ import io.realm.annotations.Index import io.realm.annotations.LinkingObjects internal open class ChunkEntity(@Index var prevToken: String? = null, + // Because of gaps we can have several chunks with nextToken == null @Index var nextToken: String? = null, var stateEvents: RealmList = RealmList(), var timelineEvents: RealmList = RealmList(), + // Only one chunk will have isLastForward == true @Index var isLastForward: Boolean = false, @Index var isLastBackward: Boolean = false ) : RealmObject() { fun identifier() = "${prevToken}_$nextToken" + // If true, then this chunk was previously a last forward chunk + fun hasBeenALastForwardChunk() = nextToken == null && !isLastForward + @LinkingObjects("chunks") val room: RealmResults? = null diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ChunkEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ChunkEntityQueries.kt index 009ee4b7fe..5efb84a105 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ChunkEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ChunkEntityQueries.kt @@ -41,7 +41,7 @@ internal fun ChunkEntity.Companion.find(realm: Realm, roomId: String, prevToken: return query.findFirst() } -internal fun ChunkEntity.Companion.findLastLiveChunkFromRoom(realm: Realm, roomId: String): ChunkEntity? { +internal fun ChunkEntity.Companion.findLastForwardChunkOfRoom(realm: Realm, roomId: String): ChunkEntity? { return where(realm, roomId) .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) .findFirst() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadQueries.kt index 1b83577a8c..9c73dff1dd 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadQueries.kt @@ -36,7 +36,7 @@ internal fun isEventRead(monarchy: Monarchy, var isEventRead = false monarchy.doWithRealm { realm -> - val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) ?: return@doWithRealm + val liveChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) ?: return@doWithRealm val eventToCheck = liveChunk.timelineEvents.find(eventId) isEventRead = if (eventToCheck == null || eventToCheck.root?.sender == userId) { true diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/TimelineEventEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/TimelineEventEntityQueries.kt index f798dbcf41..fb1cc8136a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/TimelineEventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/TimelineEventEntityQueries.kt @@ -59,7 +59,7 @@ internal fun TimelineEventEntity.Companion.latestEvent(realm: Realm, filterTypes: List = emptyList()): TimelineEventEntity? { val roomEntity = RoomEntity.where(realm, roomId).findFirst() ?: return null val sendingTimelineEvents = roomEntity.sendingTimelineEvents.where().filterTypes(filterTypes) - val liveEvents = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId)?.timelineEvents?.where()?.filterTypes(filterTypes) + val liveEvents = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId)?.timelineEvents?.where()?.filterTypes(filterTypes) if (filterContentRelation) { liveEvents ?.not()?.like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.EDIT) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt index 6b003b5ba2..b0a60480e3 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt @@ -56,8 +56,10 @@ import im.vector.matrix.android.internal.session.room.reporting.DefaultReportCon import im.vector.matrix.android.internal.session.room.reporting.ReportContentTask import im.vector.matrix.android.internal.session.room.state.DefaultSendStateTask import im.vector.matrix.android.internal.session.room.state.SendStateTask +import im.vector.matrix.android.internal.session.room.timeline.DefaultFetchNextTokenAndPaginateTask import im.vector.matrix.android.internal.session.room.timeline.DefaultGetContextOfEventTask import im.vector.matrix.android.internal.session.room.timeline.DefaultPaginationTask +import im.vector.matrix.android.internal.session.room.timeline.FetchNextTokenAndPaginateTask import im.vector.matrix.android.internal.session.room.timeline.GetContextOfEventTask import im.vector.matrix.android.internal.session.room.timeline.PaginationTask import im.vector.matrix.android.internal.session.room.typing.DefaultSendTypingTask @@ -143,6 +145,9 @@ internal abstract class RoomModule { @Binds abstract fun bindPaginationTask(task: DefaultPaginationTask): PaginationTask + @Binds + abstract fun bindFetchNextTokenAndPaginateTask(task: DefaultFetchNextTokenAndPaginateTask): FetchNextTokenAndPaginateTask + @Binds abstract fun bindFetchEditHistoryTask(task: DefaultFetchEditHistoryTask): FetchEditHistoryTask diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt index 1037b7c79c..9c8723af05 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt @@ -68,31 +68,40 @@ internal class DefaultSendService @AssistedInject constructor( private val workerFutureListenerExecutor = Executors.newSingleThreadExecutor() override fun sendTextMessage(text: CharSequence, msgType: String, autoMarkdown: Boolean): Cancelable { - val event = localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown).also { - createLocalEcho(it) + return localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown) + .also { createLocalEcho(it) } + .let { sendEvent(it) } + } + + // For test only + private fun sendTextMessages(text: CharSequence, msgType: String, autoMarkdown: Boolean, times: Int): Cancelable { + return CancelableBag().apply { + // Send the event several times + repeat(times) { i -> + localEchoEventFactory.createTextEvent(roomId, msgType, "$text - $i", autoMarkdown) + .also { createLocalEcho(it) } + .let { sendEvent(it) } + .also { add(it) } + } } - return sendEvent(event) } override fun sendFormattedTextMessage(text: String, formattedText: String, msgType: String): Cancelable { - val event = localEchoEventFactory.createFormattedTextEvent(roomId, TextContent(text, formattedText), msgType).also { - createLocalEcho(it) - } - return sendEvent(event) + return localEchoEventFactory.createFormattedTextEvent(roomId, TextContent(text, formattedText), msgType) + .also { createLocalEcho(it) } + .let { sendEvent(it) } } override fun sendPoll(question: String, options: List): Cancelable { - val event = localEchoEventFactory.createPollEvent(roomId, question, options).also { - createLocalEcho(it) - } - return sendEvent(event) + return localEchoEventFactory.createPollEvent(roomId, question, options) + .also { createLocalEcho(it) } + .let { sendEvent(it) } } override fun sendOptionsReply(pollEventId: String, optionIndex: Int, optionValue: String): Cancelable { - val event = localEchoEventFactory.createOptionsReplyEvent(roomId, pollEventId, optionIndex, optionValue).also { - createLocalEcho(it) - } - return sendEvent(event) + return localEchoEventFactory.createOptionsReplyEvent(roomId, pollEventId, optionIndex, optionValue) + .also { createLocalEcho(it) } + .let { sendEvent(it) } } private fun sendEvent(event: Event): Cancelable { @@ -119,8 +128,8 @@ internal class DefaultSendService @AssistedInject constructor( override fun redactEvent(event: Event, reason: String?): Cancelable { // TODO manage media/attachements? - val redactWork = createRedactEventWork(event, reason) - return timelineSendEventWorkCommon.postWork(roomId, redactWork) + return createRedactEventWork(event, reason) + .let { timelineSendEventWorkCommon.postWork(roomId, it) } } override fun resendTextMessage(localEcho: TimelineEvent): Cancelable? { @@ -263,31 +272,30 @@ internal class DefaultSendService @AssistedInject constructor( private fun createEncryptEventWork(event: Event, startChain: Boolean): OneTimeWorkRequest { // Same parameter - val params = EncryptEventWorker.Params(sessionId, event) - val sendWorkData = WorkerParamsFactory.toData(params) - - return workManagerProvider.matrixOneTimeWorkRequestBuilder() - .setConstraints(WorkManagerProvider.workConstraints) - .setInputData(sendWorkData) - .startChain(startChain) - .setBackoffCriteria(BackoffPolicy.LINEAR, WorkManagerProvider.BACKOFF_DELAY, TimeUnit.MILLISECONDS) - .build() + return EncryptEventWorker.Params(sessionId, event) + .let { WorkerParamsFactory.toData(it) } + .let { + workManagerProvider.matrixOneTimeWorkRequestBuilder() + .setConstraints(WorkManagerProvider.workConstraints) + .setInputData(it) + .startChain(startChain) + .setBackoffCriteria(BackoffPolicy.LINEAR, WorkManagerProvider.BACKOFF_DELAY, TimeUnit.MILLISECONDS) + .build() + } } private fun createSendEventWork(event: Event, startChain: Boolean): OneTimeWorkRequest { - val sendContentWorkerParams = SendEventWorker.Params(sessionId, event) - val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams) - - return timelineSendEventWorkCommon.createWork(sendWorkData, startChain) + return SendEventWorker.Params(sessionId, event) + .let { WorkerParamsFactory.toData(it) } + .let { timelineSendEventWorkCommon.createWork(it, startChain) } } private fun createRedactEventWork(event: Event, reason: String?): OneTimeWorkRequest { - val redactEvent = localEchoEventFactory.createRedactEvent(roomId, event.eventId!!, reason).also { - createLocalEcho(it) - } - val sendContentWorkerParams = RedactEventWorker.Params(sessionId, redactEvent.eventId!!, roomId, event.eventId, reason) - val redactWorkData = WorkerParamsFactory.toData(sendContentWorkerParams) - return timelineSendEventWorkCommon.createWork(redactWorkData, true) + return localEchoEventFactory.createRedactEvent(roomId, event.eventId!!, reason) + .also { createLocalEcho(it) } + .let { RedactEventWorker.Params(sessionId, it.eventId!!, roomId, event.eventId, reason) } + .let { WorkerParamsFactory.toData(it) } + .let { timelineSendEventWorkCommon.createWork(it, true) } } private fun createUploadMediaWork(allLocalEchos: List, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index bf6b81b57c..95a8581c2b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -17,6 +17,7 @@ package im.vector.matrix.android.internal.session.room.timeline import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.extensions.orFalse import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.RelationType import im.vector.matrix.android.api.session.events.model.toModel @@ -71,6 +72,7 @@ internal class DefaultTimeline( private val realmConfiguration: RealmConfiguration, private val taskExecutor: TaskExecutor, private val contextOfEventTask: GetContextOfEventTask, + private val fetchNextTokenAndPaginateTask: FetchNextTokenAndPaginateTask, private val paginationTask: PaginationTask, private val timelineEventMapper: TimelineEventMapper, private val settings: TimelineSettings, @@ -383,7 +385,7 @@ internal class DefaultTimeline( } /** - * This has to be called on TimelineThread as it access realm live results + * This has to be called on TimelineThread as it accesses realm live results * @return true if createSnapshot should be posted */ private fun paginateInternal(startDisplayIndex: Int?, @@ -446,7 +448,7 @@ internal class DefaultTimeline( } /** - * This has to be called on TimelineThread as it access realm live results + * This has to be called on TimelineThread as it accesses realm live results */ private fun handleInitialLoad() { var shouldFetchInitialEvent = false @@ -478,7 +480,7 @@ internal class DefaultTimeline( } /** - * This has to be called on TimelineThread as it access realm live results + * This has to be called on TimelineThread as it accesses realm live results */ private fun handleUpdates(results: RealmResults, changeSet: OrderedCollectionChangeSet) { // If changeSet has deletion we are having a gap, so we clear everything @@ -516,68 +518,90 @@ internal class DefaultTimeline( } /** - * This has to be called on TimelineThread as it access realm live results + * This has to be called on TimelineThread as it accesses realm live results */ private fun executePaginationTask(direction: Timeline.Direction, limit: Int) { - val token = getTokenLive(direction) + val currentChunk = getLiveChunk() + val token = if (direction == Timeline.Direction.BACKWARDS) currentChunk?.prevToken else currentChunk?.nextToken if (token == null) { - updateState(direction) { it.copy(isPaginating = false, requestedPaginationCount = 0) } - return - } - val params = PaginationTask.Params(roomId = roomId, - from = token, - direction = direction.toPaginationDirection(), - limit = limit) - - Timber.v("Should fetch $limit items $direction") - cancelableBag += paginationTask - .configureWith(params) { - this.callback = object : MatrixCallback { - override fun onSuccess(data: TokenChunkEventPersistor.Result) { - when (data) { - TokenChunkEventPersistor.Result.SUCCESS -> { - Timber.v("Success fetching $limit items $direction from pagination request") - } - TokenChunkEventPersistor.Result.REACHED_END -> { - postSnapshot() - } - TokenChunkEventPersistor.Result.SHOULD_FETCH_MORE -> - // Database won't be updated, so we force pagination request - BACKGROUND_HANDLER.post { - executePaginationTask(direction, limit) - } + if (direction == Timeline.Direction.FORWARDS && currentChunk?.hasBeenALastForwardChunk().orFalse()) { + // We are in the case that next event exists, but we do not know the next token. + // Fetch (again) the last event to get a nextToken + val lastKnownEventId = nonFilteredEvents.firstOrNull()?.eventId + if (lastKnownEventId == null) { + updateState(direction) { it.copy(isPaginating = false, requestedPaginationCount = 0) } + } else { + val params = FetchNextTokenAndPaginateTask.Params( + roomId = roomId, + limit = limit, + lastKnownEventId = lastKnownEventId + ) + cancelableBag += fetchNextTokenAndPaginateTask + .configureWith(params) { + this.callback = createPaginationCallback(limit, direction) } - } + .executeBy(taskExecutor) + } + } else { + updateState(direction) { it.copy(isPaginating = false, requestedPaginationCount = 0) } + } + } else { + val params = PaginationTask.Params( + roomId = roomId, + from = token, + direction = direction.toPaginationDirection(), + limit = limit + ) + Timber.v("Should fetch $limit items $direction") + cancelableBag += paginationTask + .configureWith(params) { + this.callback = createPaginationCallback(limit, direction) + } + .executeBy(taskExecutor) + } + } - override fun onFailure(failure: Throwable) { - updateState(direction) { it.copy(isPaginating = false, requestedPaginationCount = 0) } - postSnapshot() - Timber.v("Failure fetching $limit items $direction from pagination request") + // For debug purpose only + private fun dumpAndLogChunks() { + val liveChunk = getLiveChunk() + Timber.w("Live chunk: $liveChunk") + + Realm.getInstance(realmConfiguration).use { realm -> + ChunkEntity.where(realm, roomId).findAll() + .also { Timber.w("Found ${it.size} chunks") } + .forEach { + Timber.w("") + Timber.w("ChunkEntity: $it") + Timber.w("prevToken: ${it.prevToken}") + Timber.w("nextToken: ${it.nextToken}") + Timber.w("isLastBackward: ${it.isLastBackward}") + Timber.w("isLastForward: ${it.isLastForward}") + it.timelineEvents.forEach { tle -> + Timber.w(" TLE: ${tle.root?.content}") } } - } - .executeBy(taskExecutor) + } } /** - * This has to be called on TimelineThread as it access realm live results + * This has to be called on TimelineThread as it accesses realm live results */ - private fun getTokenLive(direction: Timeline.Direction): String? { val chunkEntity = getLiveChunk() ?: return null return if (direction == Timeline.Direction.BACKWARDS) chunkEntity.prevToken else chunkEntity.nextToken } /** - * This has to be called on TimelineThread as it access realm live results + * This has to be called on TimelineThread as it accesses realm live results + * Return the current Chunk */ private fun getLiveChunk(): ChunkEntity? { return nonFilteredEvents.firstOrNull()?.chunk?.firstOrNull() } /** - * This has to be called on TimelineThread as it access realm live results - * @return number of items who have been added + * This has to be called on TimelineThread as it accesses realm live results + * @return the number of items who have been added */ private fun buildTimelineEvents(startDisplayIndex: Int?, direction: Timeline.Direction, @@ -618,6 +642,8 @@ internal class DefaultTimeline( } val time = System.currentTimeMillis() - start Timber.v("Built ${offsetResults.size} items from db in $time ms") + // For the case where wo reach the lastForward chunk + updateLoadingStates(filteredEvents) return offsetResults.size } @@ -628,7 +654,7 @@ internal class DefaultTimeline( ) /** - * This has to be called on TimelineThread as it access realm live results + * This has to be called on TimelineThread as it accesses realm live results */ private fun getOffsetResults(startDisplayIndex: Int, direction: Timeline.Direction, @@ -713,6 +739,32 @@ internal class DefaultTimeline( forwardsState.set(State()) } + private fun createPaginationCallback(limit: Int, direction: Timeline.Direction): MatrixCallback { + return object : MatrixCallback { + override fun onSuccess(data: TokenChunkEventPersistor.Result) { + when (data) { + TokenChunkEventPersistor.Result.SUCCESS -> { + Timber.v("Success fetching $limit items $direction from pagination request") + } + TokenChunkEventPersistor.Result.REACHED_END -> { + postSnapshot() + } + TokenChunkEventPersistor.Result.SHOULD_FETCH_MORE -> + // Database won't be updated, so we force pagination request + BACKGROUND_HANDLER.post { + executePaginationTask(direction, limit) + } + } + } + + override fun onFailure(failure: Throwable) { + updateState(direction) { it.copy(isPaginating = false, requestedPaginationCount = 0) } + postSnapshot() + Timber.v("Failure fetching $limit items $direction from pagination request") + } + } + } + // Extension methods *************************************************************************** private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt index c02bb915ef..ffa282d088 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt @@ -42,6 +42,7 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv private val contextOfEventTask: GetContextOfEventTask, private val eventDecryptor: TimelineEventDecryptor, private val paginationTask: PaginationTask, + private val fetchNextTokenAndPaginateTask: FetchNextTokenAndPaginateTask, private val timelineEventMapper: TimelineEventMapper, private val readReceiptsSummaryMapper: ReadReceiptsSummaryMapper ) : TimelineService { @@ -63,7 +64,8 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv settings = settings, hiddenReadReceipts = TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings), eventBus = eventBus, - eventDecryptor = eventDecryptor + eventDecryptor = eventDecryptor, + fetchNextTokenAndPaginateTask = fetchNextTokenAndPaginateTask ) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/FetchNextTokenAndPaginateTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/FetchNextTokenAndPaginateTask.kt new file mode 100644 index 0000000000..1189e627c4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/FetchNextTokenAndPaginateTask.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 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.matrix.android.internal.session.room.timeline + +import com.zhuinden.monarchy.Monarchy +import im.vector.matrix.android.internal.database.model.ChunkEntity +import im.vector.matrix.android.internal.database.query.findIncludingEvent +import im.vector.matrix.android.internal.network.executeRequest +import im.vector.matrix.android.internal.session.filter.FilterRepository +import im.vector.matrix.android.internal.session.room.RoomAPI +import im.vector.matrix.android.internal.task.Task +import im.vector.matrix.android.internal.util.awaitTransaction +import org.greenrobot.eventbus.EventBus +import javax.inject.Inject + +internal interface FetchNextTokenAndPaginateTask : Task { + + data class Params( + val roomId: String, + val lastKnownEventId: String, + val limit: Int + ) +} + +internal class DefaultFetchNextTokenAndPaginateTask @Inject constructor( + private val roomAPI: RoomAPI, + private val monarchy: Monarchy, + private val filterRepository: FilterRepository, + private val paginationTask: PaginationTask, + private val eventBus: EventBus +) : FetchNextTokenAndPaginateTask { + + override suspend fun execute(params: FetchNextTokenAndPaginateTask.Params): TokenChunkEventPersistor.Result { + val filter = filterRepository.getRoomFilter() + val response = executeRequest(eventBus) { + apiCall = roomAPI.getContextOfEvent(params.roomId, params.lastKnownEventId, 0, filter) + } + if (response.end == null) { + throw IllegalStateException("No next token found") + } + monarchy.awaitTransaction { + ChunkEntity.findIncludingEvent(it, params.lastKnownEventId)?.nextToken = response.end + } + val paginationParams = PaginationTask.Params( + roomId = params.roomId, + from = response.end, + direction = PaginationDirection.FORWARDS, + limit = params.limit + ) + return paginationTask.execute(paginationParams) + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt index 164626224b..f7411b3bf1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -35,7 +35,7 @@ import im.vector.matrix.android.internal.database.query.copyToRealmOrIgnore import im.vector.matrix.android.internal.database.query.create import im.vector.matrix.android.internal.database.query.find import im.vector.matrix.android.internal.database.query.findAllIncludingEvents -import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom +import im.vector.matrix.android.internal.database.query.findLastForwardChunkOfRoom import im.vector.matrix.android.internal.database.query.getOrCreate import im.vector.matrix.android.internal.database.query.latestEvent import im.vector.matrix.android.internal.database.query.where @@ -169,10 +169,10 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy private fun handleReachEnd(realm: Realm, roomId: String, direction: PaginationDirection, currentChunk: ChunkEntity) { Timber.v("Reach end of $roomId") if (direction == PaginationDirection.FORWARDS) { - val currentLiveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) - if (currentChunk != currentLiveChunk) { + val currentLastForwardChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) + if (currentChunk != currentLastForwardChunk) { currentChunk.isLastForward = true - currentLiveChunk?.deleteOnCascade() + currentLastForwardChunk?.deleteOnCascade() RoomSummaryEntity.where(realm, roomId).findFirst()?.apply { latestPreviewableEvent = TimelineEventEntity.latestEvent( realm, @@ -224,10 +224,13 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy currentChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser) } + // Find all the chunks which contain at least one event from the list of eventIds val chunks = ChunkEntity.findAllIncludingEvents(realm, eventIds) + Timber.d("Found ${chunks.size} chunks containing at least one of the eventIds") val chunksToDelete = ArrayList() chunks.forEach { if (it != currentChunk) { + Timber.d("Merge $it") currentChunk.merge(roomId, it, direction) chunksToDelete.add(it) } @@ -246,6 +249,8 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy ) roomSummaryEntity.latestPreviewableEvent = latestPreviewableEvent } - RoomEntity.where(realm, roomId).findFirst()?.addOrUpdate(currentChunk) + if (currentChunk.isValid) { + RoomEntity.where(realm, roomId).findFirst()?.addOrUpdate(currentChunk) + } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt index 70c1e39334..8c21d23a8c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt @@ -36,7 +36,7 @@ import im.vector.matrix.android.internal.database.model.CurrentStateEventEntity import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.query.copyToRealmOrIgnore import im.vector.matrix.android.internal.database.query.find -import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom +import im.vector.matrix.android.internal.database.query.findLastForwardChunkOfRoom import im.vector.matrix.android.internal.database.query.getOrCreate import im.vector.matrix.android.internal.database.query.getOrNull import im.vector.matrix.android.internal.database.query.where @@ -220,12 +220,13 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle prevToken: String? = null, isLimited: Boolean = true, syncLocalTimestampMillis: Long): ChunkEntity { - val lastChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomEntity.roomId) + val lastChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomEntity.roomId) val chunkEntity = if (!isLimited && lastChunk != null) { lastChunk } else { realm.createObject().apply { this.prevToken = prevToken } } + // Only one chunk has isLastForward set to true lastChunk?.isLastForward = false chunkEntity.isLastForward = true diff --git a/matrix-sdk-android/src/main/res/values/strings.xml b/matrix-sdk-android/src/main/res/values/strings.xml index 50169fd982..69907e5835 100644 --- a/matrix-sdk-android/src/main/res/values/strings.xml +++ b/matrix-sdk-android/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ %1$s sent a sticker. %s\'s invitation + %1$s created the room %1$s invited %2$s %1$s invited you %1$s joined the room diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Collections.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Collections.kt new file mode 100644 index 0000000000..af5d5babb6 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Collections.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 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.riotx.core.extensions + +inline fun List.nextOrNull(index: Int) = getOrNull(index + 1) +inline fun List.prevOrNull(index: Int) = getOrNull(index - 1) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index addbfab43c..e074af1da6 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -35,6 +35,7 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotx.core.date.VectorDateFormatter import im.vector.riotx.core.epoxy.LoadingItem_ import im.vector.riotx.core.extensions.localDateTime +import im.vector.riotx.core.extensions.nextOrNull import im.vector.riotx.features.home.room.detail.RoomDetailAction import im.vector.riotx.features.home.room.detail.RoomDetailViewState import im.vector.riotx.features.home.room.detail.UnreadState @@ -45,7 +46,6 @@ import im.vector.riotx.features.home.room.detail.timeline.helper.ReadMarkerVisib import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider -import im.vector.riotx.features.home.room.detail.timeline.helper.nextOrNull import im.vector.riotx.features.home.room.detail.timeline.item.BaseEventItem import im.vector.riotx.features.home.room.detail.timeline.item.BasedMergedItem import im.vector.riotx.features.home.room.detail.timeline.item.DaySeparatorItem diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index 03c273800a..9529693e6b 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -22,7 +22,7 @@ import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM import im.vector.matrix.android.internal.crypto.model.event.EncryptionEventContent -import im.vector.riotx.core.di.ActiveSessionHolder +import im.vector.riotx.core.extensions.prevOrNull import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider @@ -37,15 +37,15 @@ import im.vector.riotx.features.home.room.detail.timeline.item.MergedRoomCreatio import im.vector.riotx.features.home.room.detail.timeline.item.MergedRoomCreationItem_ import javax.inject.Inject -class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: ActiveSessionHolder, - private val avatarRenderer: AvatarRenderer, +class MergedHeaderItemFactory @Inject constructor(private val avatarRenderer: AvatarRenderer, private val avatarSizeProvider: AvatarSizeProvider) { private val collapsedEventIds = linkedSetOf() private val mergeItemCollapseStates = HashMap() /** - * Note: nextEvent is an older event than event + * @param nextEvent is an older event than event + * @param items all known items, sorted from newer event to oldest event */ fun create(event: TimelineEvent, nextEvent: TimelineEvent?, @@ -64,60 +64,69 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act } else if (!event.canBeMerged() || (nextEvent?.root?.getClearType() == event.root.getClearType() && !addDaySeparator)) { null } else { - val prevSameTypeEvents = items.prevSameTypeEvents(currentPosition, 2) - if (prevSameTypeEvents.isEmpty()) { - null - } else { - var highlighted = false - val mergedEvents = (prevSameTypeEvents + listOf(event)).asReversed() - val mergedData = ArrayList(mergedEvents.size) - mergedEvents.forEach { mergedEvent -> - if (!highlighted && mergedEvent.root.eventId == eventIdToHighlight) { - highlighted = true - } - val senderAvatar = mergedEvent.senderAvatar - val senderName = mergedEvent.getDisambiguatedDisplayName() - val data = BasedMergedItem.Data( - userId = mergedEvent.root.senderId ?: "", - avatarUrl = senderAvatar, - memberName = senderName, - localId = mergedEvent.localId, - eventId = mergedEvent.root.eventId ?: "" - ) - mergedData.add(data) + buildMembershipEventsMergedSummary(currentPosition, items, event, eventIdToHighlight, requestModelBuild, callback) + } + } + + private fun buildMembershipEventsMergedSummary(currentPosition: Int, + items: List, + event: TimelineEvent, + eventIdToHighlight: String?, + requestModelBuild: () -> Unit, + callback: TimelineEventController.Callback?): MergedMembershipEventsItem_? { + val prevSameTypeEvents = items.prevSameTypeEvents(currentPosition, 2) + return if (prevSameTypeEvents.isEmpty()) { + null + } else { + var highlighted = false + val mergedEvents = (prevSameTypeEvents + listOf(event)).asReversed() + val mergedData = ArrayList(mergedEvents.size) + mergedEvents.forEach { mergedEvent -> + if (!highlighted && mergedEvent.root.eventId == eventIdToHighlight) { + highlighted = true } - val mergedEventIds = mergedEvents.map { it.localId } - // We try to find if one of the item id were used as mergeItemCollapseStates key - // => handle case where paginating from mergeable events and we get more - val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull() - val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey) - ?: true - val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState } - if (isCollapsed) { - collapsedEventIds.addAll(mergedEventIds) - } else { - collapsedEventIds.removeAll(mergedEventIds) - } - val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() } - val attributes = MergedMembershipEventsItem.Attributes( - isCollapsed = isCollapsed, - mergeData = mergedData, - avatarRenderer = avatarRenderer, - onCollapsedStateChanged = { - mergeItemCollapseStates[event.localId] = it - requestModelBuild() - }, - readReceiptsCallback = callback + val senderAvatar = mergedEvent.senderAvatar + val senderName = mergedEvent.getDisambiguatedDisplayName() + val data = BasedMergedItem.Data( + userId = mergedEvent.root.senderId ?: "", + avatarUrl = senderAvatar, + memberName = senderName, + localId = mergedEvent.localId, + eventId = mergedEvent.root.eventId ?: "" ) - MergedMembershipEventsItem_() - .id(mergeId) - .leftGuideline(avatarSizeProvider.leftGuideline) - .highlighted(isCollapsed && highlighted) - .attributes(attributes) - .also { - it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents)) - } + mergedData.add(data) } + val mergedEventIds = mergedEvents.map { it.localId } + // We try to find if one of the item id were used as mergeItemCollapseStates key + // => handle case where paginating from mergeable events and we get more + val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull() + val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey) + ?: true + val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState } + if (isCollapsed) { + collapsedEventIds.addAll(mergedEventIds) + } else { + collapsedEventIds.removeAll(mergedEventIds) + } + val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() } + val attributes = MergedMembershipEventsItem.Attributes( + isCollapsed = isCollapsed, + mergeData = mergedData, + avatarRenderer = avatarRenderer, + onCollapsedStateChanged = { + mergeItemCollapseStates[event.localId] = it + requestModelBuild() + }, + readReceiptsCallback = callback + ) + MergedMembershipEventsItem_() + .id(mergeId) + .leftGuideline(avatarSizeProvider.leftGuideline) + .highlighted(isCollapsed && highlighted) + .attributes(attributes) + .also { + it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents)) + } } } @@ -127,9 +136,9 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act eventIdToHighlight: String?, requestModelBuild: () -> Unit, callback: TimelineEventController.Callback?): MergedRoomCreationItem_? { - var prevEvent = if (currentPosition > 0) items[currentPosition - 1] else null + var prevEvent = items.prevOrNull(currentPosition) var tmpPos = currentPosition - 1 - val mergedEvents = ArrayList().also { it.add(event) } + val mergedEvents = mutableListOf(event) var hasEncryption = false var encryptionAlgorithm: String? = null while (prevEvent != null && prevEvent.isRoomConfiguration(null)) { @@ -139,7 +148,7 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act } mergedEvents.add(prevEvent) tmpPos-- - prevEvent = if (tmpPos >= 0) items[tmpPos] else null + prevEvent = items.getOrNull(tmpPos) } return if (mergedEvents.size > 2) { var highlighted = false diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt index bf3b82ab4d..d5471d7f4f 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt @@ -21,21 +21,21 @@ import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotx.R -import im.vector.riotx.core.resources.ColorProvider +import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.core.resources.UserPreferencesProvider import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController -import im.vector.riotx.features.home.room.detail.timeline.item.RoomCreateItem import im.vector.riotx.features.home.room.detail.timeline.item.RoomCreateItem_ import me.gujun.android.span.span import javax.inject.Inject -class RoomCreateItemFactory @Inject constructor(private val colorProvider: ColorProvider, - private val stringProvider: StringProvider) { +class RoomCreateItemFactory @Inject constructor(private val stringProvider: StringProvider, + private val userPreferencesProvider: UserPreferencesProvider, + private val noticeItemFactory: NoticeItemFactory) { - fun create(event: TimelineEvent, callback: TimelineEventController.Callback?): RoomCreateItem? { - val createRoomContent = event.root.getClearContent().toModel() - ?: return null - val predecessorId = createRoomContent.predecessor?.roomId ?: return null + fun create(event: TimelineEvent, callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? { + val createRoomContent = event.root.getClearContent().toModel() ?: return null + val predecessorId = createRoomContent.predecessor?.roomId ?: return defaultRendering(event, callback) val roomLink = PermalinkFactory.createPermalink(predecessorId) ?: return null val text = span { +stringProvider.getString(R.string.room_tombstone_continuation_description) @@ -48,4 +48,12 @@ class RoomCreateItemFactory @Inject constructor(private val colorProvider: Color return RoomCreateItem_() .text(text) } + + private fun defaultRendering(event: TimelineEvent, callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? { + return if (userPreferencesProvider.shouldShowHiddenEvents()) { + noticeItemFactory.create(event, false, callback) + } else { + null + } + } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 7e6c387934..f2ac7018aa 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -58,7 +58,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.CALL_HANGUP, EventType.CALL_ANSWER, EventType.REACTION, - EventType.REDACTION -> noticeItemFactory.create(event, highlight, callback) + EventType.REDACTION -> noticeItemFactory.create(event, highlight, callback) EventType.STATE_ROOM_ENCRYPTION -> { encryptionItemFactory.create(event, highlight, callback) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt index 39e17b7c35..f29bd72e0a 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt @@ -32,6 +32,7 @@ import im.vector.matrix.android.api.session.room.model.RoomMemberContent import im.vector.matrix.android.api.session.room.model.RoomNameContent import im.vector.matrix.android.api.session.room.model.RoomTopicContent import im.vector.matrix.android.api.session.room.model.call.CallInviteContent +import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM import im.vector.matrix.android.internal.crypto.model.event.EncryptionEventContent @@ -47,6 +48,7 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active fun format(timelineEvent: TimelineEvent): CharSequence? { return when (val type = timelineEvent.root.getClearType()) { EventType.STATE_ROOM_JOIN_RULES -> formatJoinRulesEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) + EventType.STATE_ROOM_CREATE -> formatRoomCreateEvent(timelineEvent.root) EventType.STATE_ROOM_NAME -> formatRoomNameEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) @@ -98,6 +100,12 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active return "{ \"type\": ${event.getClearType()} }" } + private fun formatRoomCreateEvent(event: Event): CharSequence? { + return event.getClearContent().toModel() + ?.takeIf { it.creator.isNullOrBlank().not() } + ?.let { sp.getString(R.string.notice_room_created, it.creator) } + } + private fun formatRoomNameEvent(event: Event, senderName: String?): CharSequence? { val content = event.getClearContent().toModel() ?: return null return if (content.name.isNullOrBlank()) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index f1106d276e..daf0100bbb 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -106,11 +106,3 @@ fun List.prevSameTypeEvents(index: Int, minSize: Int): List.nextOrNull(index: Int): TimelineEvent? { - return if (index >= size - 1) { - null - } else { - subList(index + 1, this.size).firstOrNull() - } -}