diff --git a/CHANGES.md b/CHANGES.md index a4767f8be9..9a5caaa0c4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,25 +4,32 @@ Changes in RiotX 0.21.0 (2020-XX-XX) Features ✨: - Identity server support (#607) - Switch language support (#41) + - Display list of attachments of a room (#860) Improvements 🙌: - Better connectivity lost indicator when airplane mode is on - Add a setting to hide redacted events (#951) + - Render formatted_body for m.notice and m.emote (#1196) + - Change icon to magnifying-glass to filter room (#1384) Bugfix 🐛: + - After jump to unread, newer messages are never loaded (#1008) - Fix issues with FontScale switch (#69, #645) + - "Seen by" uses 12h time (#1378) + - Enable markdown (if active) when sending emote (#734) + - Screenshots for Rageshake now includes Dialogs such as BottomSheet (#1349) Translations 🗣: - SDK API changes ⚠️: - - + - initialize with proxy configuration Build 🧱: - Other changes: - - + - support new key agreement method for SAS (#1374) Changes in RiotX 0.20.0 (2020-05-15) =================================================== 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/internal/crypto/verification/SASTest.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/verification/SASTest.kt index 9bdd8f1131..460c411d43 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/verification/SASTest.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/verification/SASTest.kt @@ -468,14 +468,19 @@ class SASTest : InstrumentedTest { val aliceSASLatch = CountDownLatch(1) val aliceListener = object : VerificationService.Listener { + var matchOnce = true override fun transactionUpdated(tx: VerificationTransaction) { val uxState = (tx as OutgoingSasVerificationTransaction).uxState + Log.v("TEST", "== aliceState ${uxState.name}") when (uxState) { OutgoingSasVerificationTransaction.UxState.SHOW_SAS -> { tx.userHasVerifiedShortCode() } OutgoingSasVerificationTransaction.UxState.VERIFIED -> { - aliceSASLatch.countDown() + if (matchOnce) { + matchOnce = false + aliceSASLatch.countDown() + } } else -> Unit } @@ -485,14 +490,23 @@ class SASTest : InstrumentedTest { val bobSASLatch = CountDownLatch(1) val bobListener = object : VerificationService.Listener { + var acceptOnce = true + var matchOnce = true override fun transactionUpdated(tx: VerificationTransaction) { val uxState = (tx as IncomingSasVerificationTransaction).uxState + Log.v("TEST", "== bobState ${uxState.name}") when (uxState) { IncomingSasVerificationTransaction.UxState.SHOW_ACCEPT -> { - tx.performAccept() + if (acceptOnce) { + acceptOnce = false + tx.performAccept() + } } IncomingSasVerificationTransaction.UxState.SHOW_SAS -> { - tx.userHasVerifiedShortCode() + if (matchOnce) { + matchOnce = false + tx.userHasVerifiedShortCode() + } } IncomingSasVerificationTransaction.UxState.VERIFIED -> { bobSASLatch.countDown() 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/Matrix.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/Matrix.kt index 4c6e3ea3bd..1a4c4aceee 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/Matrix.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/Matrix.kt @@ -23,7 +23,6 @@ import androidx.work.WorkManager import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.BuildConfig import im.vector.matrix.android.api.auth.AuthenticationService -import im.vector.matrix.android.api.crypto.MXCryptoConfig import im.vector.matrix.android.internal.SessionManager import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt import im.vector.matrix.android.internal.crypto.attachments.MXEncryptedAttachments @@ -32,20 +31,10 @@ import im.vector.matrix.android.internal.network.UserAgentHolder import im.vector.matrix.android.internal.util.BackgroundDetectionObserver import org.matrix.olm.OlmManager import java.io.InputStream -import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject -data class MatrixConfiguration( - val applicationFlavor: String = "Default-application-flavor", - val cryptoConfig: MXCryptoConfig = MXCryptoConfig() -) { - - interface Provider { - fun providesMatrixConfiguration(): MatrixConfiguration - } -} - /** * This is the main entry point to the matrix sdk. * To get the singleton instance, use getInstance static method. diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/MatrixConfiguration.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/MatrixConfiguration.kt new file mode 100644 index 0000000000..d7c62f8bef --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/MatrixConfiguration.kt @@ -0,0 +1,38 @@ +/* + * 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.api + +import im.vector.matrix.android.api.crypto.MXCryptoConfig +import java.net.Proxy + +data class MatrixConfiguration( + val applicationFlavor: String = "Default-application-flavor", + val cryptoConfig: MXCryptoConfig = MXCryptoConfig(), + /** + * Optional proxy to connect to the matrix servers + * You can create one using for instance Proxy(proxyType, InetSocketAddress(hostname, port) + */ + val proxy: Proxy? = null +) { + + /** + * Can be implemented by your Application class + */ + interface Provider { + fun providesMatrixConfiguration(): MatrixConfiguration + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/crypto/MXCryptoConfig.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/crypto/MXCryptoConfig.kt index a8d576bae9..fa8178334c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/crypto/MXCryptoConfig.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/crypto/MXCryptoConfig.kt @@ -19,11 +19,11 @@ package im.vector.matrix.android.api.crypto /** * Class to define the parameters used to customize or configure the end-to-end crypto. */ -data class MXCryptoConfig( +data class MXCryptoConfig constructor( // Tell whether the encryption of the event content is enabled for the invited members. // SDK clients can disable this by settings it to false. // Note that the encryption for the invited members will be blocked if the history visibility is "joined". - var enableEncryptionForInvitedMembers: Boolean = true, + val enableEncryptionForInvitedMembers: Boolean = true, /** * If set to true, the SDK will automatically ignore room key request (gossiping) @@ -31,6 +31,5 @@ data class MXCryptoConfig( * If set to false, the request will be forwarded to the application layer; in this * case the application can decide to prompt the user. */ - var discardRoomKeyRequestsFromUntrustedDevices : Boolean = true - + val discardRoomKeyRequestsFromUntrustedDevices: Boolean = true ) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/Event.kt index a60d0fd9ac..d3780ebe60 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/Event.kt @@ -220,3 +220,11 @@ fun Event.isImageMessage(): Boolean { else -> false } } + +fun Event.isVideoMessage(): Boolean { + return getClearType() == EventType.MESSAGE + && when (getClearContent()?.toModel()?.msgType) { + MessageType.MSGTYPE_VIDEO -> true + else -> false + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt index 0c3316e802..2fd7d84f04 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt @@ -29,6 +29,7 @@ import im.vector.matrix.android.api.session.room.send.SendService import im.vector.matrix.android.api.session.room.state.StateService import im.vector.matrix.android.api.session.room.timeline.TimelineService import im.vector.matrix.android.api.session.room.typing.TypingService +import im.vector.matrix.android.api.session.room.uploads.UploadsService import im.vector.matrix.android.api.util.Optional /** @@ -42,6 +43,7 @@ interface Room : TypingService, MembershipService, StateService, + UploadsService, ReportingService, RelationService, RoomCryptoService, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageContentWithFormattedBody.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageContentWithFormattedBody.kt new file mode 100644 index 0000000000..b51e3eb841 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageContentWithFormattedBody.kt @@ -0,0 +1,35 @@ +/* + * 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.api.session.room.model.message + +interface MessageContentWithFormattedBody : MessageContent { + /** + * The format used in the formatted_body. Currently only "org.matrix.custom.html" is supported. + */ + val format: String? + + /** + * The formatted version of the body. This is required if format is specified. + */ + val formattedBody: String? + + /** + * Get the formattedBody, only if not blank and if the format is equal to "org.matrix.custom.html" + */ + val matrixFormattedBody: String? + get() = formattedBody?.takeIf { it.isNotBlank() && format == MessageFormat.FORMAT_MATRIX_HTML } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEmoteContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEmoteContent.kt index e7106a9755..7b63959f78 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEmoteContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEmoteContent.kt @@ -34,15 +34,15 @@ data class MessageEmoteContent( @Json(name = "body") override val body: String, /** - * The format used in the formatted_body. Currently only org.matrix.custom.html is supported. + * The format used in the formatted_body. Currently only "org.matrix.custom.html" is supported. */ - @Json(name = "format") val format: String? = null, + @Json(name = "format") override val format: String? = null, /** * The formatted version of the body. This is required if format is specified. */ - @Json(name = "formatted_body") val formattedBody: String? = null, + @Json(name = "formatted_body") override val formattedBody: String? = null, @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, @Json(name = "m.new_content") override val newContent: Content? = null -) : MessageContent +) : MessageContentWithFormattedBody diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageNoticeContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageNoticeContent.kt index e08e07e9da..41e63bb457 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageNoticeContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageNoticeContent.kt @@ -34,15 +34,15 @@ data class MessageNoticeContent( @Json(name = "body") override val body: String, /** - * The format used in the formatted_body. Currently only org.matrix.custom.html is supported. + * The format used in the formatted_body. Currently only "org.matrix.custom.html" is supported. */ - @Json(name = "format") val format: String? = null, + @Json(name = "format") override val format: String? = null, /** * The formatted version of the body. This is required if format is specified. */ - @Json(name = "formatted_body") val formattedBody: String? = null, + @Json(name = "formatted_body") override val formattedBody: String? = null, @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, @Json(name = "m.new_content") override val newContent: Content? = null -) : MessageContent +) : MessageContentWithFormattedBody diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageTextContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageTextContent.kt index cc5bb1f774..d6c54e3ff5 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageTextContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageTextContent.kt @@ -34,15 +34,15 @@ data class MessageTextContent( @Json(name = "body") override val body: String, /** - * The format used in the formatted_body. Currently only org.matrix.custom.html is supported. + * The format used in the formatted_body. Currently only "org.matrix.custom.html" is supported. */ - @Json(name = "format") val format: String? = null, + @Json(name = "format") override val format: String? = null, /** * The formatted version of the body. This is required if format is specified. */ - @Json(name = "formatted_body") val formattedBody: String? = null, + @Json(name = "formatted_body") override val formattedBody: String? = null, @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, @Json(name = "m.new_content") override val newContent: Content? = null -) : MessageContent +) : MessageContentWithFormattedBody diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/sender/SenderInfo.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/sender/SenderInfo.kt new file mode 100644 index 0000000000..1a0908a6d3 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/sender/SenderInfo.kt @@ -0,0 +1,34 @@ +/* + * 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.api.session.room.sender + +data class SenderInfo( + val userId: String, + /** + * Consider using [disambiguatedDisplayName] + */ + val displayName: String?, + val isUniqueDisplayName: Boolean, + val avatarUrl: String? +) { + val disambiguatedDisplayName: String + get() = when { + displayName.isNullOrBlank() -> userId + isUniqueDisplayName -> displayName + else -> "$displayName ($userId)" + } +} 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..273ea2366a 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 @@ -25,6 +26,7 @@ import im.vector.matrix.android.api.session.room.model.ReadReceipt import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageStickerContent import im.vector.matrix.android.api.session.room.model.message.isReply +import im.vector.matrix.android.api.session.room.sender.SenderInfo import im.vector.matrix.android.api.util.ContentUtils.extractUsefulTextFromReply import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent @@ -38,13 +40,17 @@ data class TimelineEvent( val localId: Long, val eventId: String, val displayIndex: Int, - val senderName: String?, - val isUniqueDisplayName: Boolean, - val senderAvatar: String?, + val senderInfo: SenderInfo, val annotations: EventAnnotationsSummary? = null, val readReceipts: List = emptyList() ) { + init { + if (BuildConfig.DEBUG) { + assert(eventId == root.eventId) + } + } + val metadata = HashMap() /** @@ -62,14 +68,6 @@ data class TimelineEvent( } } - fun getDisambiguatedDisplayName(): String { - return when { - senderName.isNullOrBlank() -> root.senderId ?: "" - isUniqueDisplayName -> senderName - else -> "$senderName (${root.senderId})" - } - } - /** * Get the metadata associated with a key. * @param key the key to get the metadata diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/uploads/GetUploadsResult.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/uploads/GetUploadsResult.kt new file mode 100644 index 0000000000..4c75d909aa --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/uploads/GetUploadsResult.kt @@ -0,0 +1,26 @@ +/* + * 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.api.session.room.uploads + +data class GetUploadsResult( + // List of fetched Events, most recent first + val uploadEvents: List, + // token to get more events + val nextToken: String, + // True if there are more event to load + val hasMore: Boolean +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/uploads/UploadEvent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/uploads/UploadEvent.kt new file mode 100644 index 0000000000..5df2b9c9e0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/uploads/UploadEvent.kt @@ -0,0 +1,32 @@ +/* + * 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.api.session.room.uploads + +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.api.session.room.model.message.MessageWithAttachmentContent +import im.vector.matrix.android.api.session.room.sender.SenderInfo + +/** + * Wrapper around on Event. + * Similar to [im.vector.matrix.android.api.session.room.timeline.TimelineEvent], contains an Event with extra useful data + */ +data class UploadEvent( + val root: Event, + val eventId: String, + val contentWithAttachmentContent: MessageWithAttachmentContent, + val senderInfo: SenderInfo +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/uploads/UploadsService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/uploads/UploadsService.kt new file mode 100644 index 0000000000..54a87cdcd9 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/uploads/UploadsService.kt @@ -0,0 +1,35 @@ +/* + * 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.api.session.room.uploads + +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.util.Cancelable + +/** + * This interface defines methods to get event with uploads (= attachments) sent to a room. It's implemented at the room level. + */ +interface UploadsService { + + /** + * Get a list of events containing URL sent to a room, from most recent to oldest one + * @param numberOfEvents the expected number of events to retrieve. The result can contain less events. + * @param since token to get next page, or null to get the first page + */ + fun getUploads(numberOfEvents: Int, + since: String?, + callback: MatrixCallback): Cancelable +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/MatrixItem.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/MatrixItem.kt index d5aa897c7d..f30494711b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/MatrixItem.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/MatrixItem.kt @@ -21,6 +21,7 @@ import im.vector.matrix.android.api.session.group.model.GroupSummary import im.vector.matrix.android.api.session.room.model.RoomMemberSummary import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom +import im.vector.matrix.android.api.session.room.sender.SenderInfo import im.vector.matrix.android.api.session.user.model.User import java.util.Locale @@ -154,3 +155,5 @@ fun RoomSummary.toRoomAliasMatrixItem() = MatrixItem.RoomAliasItem(canonicalAlia fun PublicRoom.toMatrixItem() = MatrixItem.RoomItem(roomId, name ?: getPrimaryAlias() ?: "", avatarUrl) fun RoomMemberSummary.toMatrixItem() = MatrixItem.UserItem(userId, displayName, avatarUrl) + +fun SenderInfo.toMatrixItem() = MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegistrationFlowResponse.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegistrationFlowResponse.kt index 7512454052..dd5a553193 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegistrationFlowResponse.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegistrationFlowResponse.kt @@ -76,7 +76,7 @@ fun RegistrationFlowResponse.toFlowResult(): FlowResult { this.flows?.forEach { it.stages?.mapTo(allFlowTypes) { type -> type } } allFlowTypes.forEach { type -> - val isMandatory = flows?.all { type in it.stages ?: emptyList() } == true + val isMandatory = flows?.all { type in it.stages.orEmpty() } == true val stage = when (type) { LoginFlowTypes.RECAPTCHA -> Stage.ReCaptcha(isMandatory, ((params?.get(type) as? Map<*, *>)?.get("public_key") as? String) @@ -88,7 +88,7 @@ fun RegistrationFlowResponse.toFlowResult(): FlowResult { else -> Stage.Other(isMandatory, type, (params?.get(type) as? Map<*, *>)) } - if (type in completedStages ?: emptyList()) { + if (type in completedStages.orEmpty()) { completedStage.add(stage) } else { missingStage.add(stage) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/DefaultCryptoService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/DefaultCryptoService.kt index d529cf4ae5..7badb26d8a 100755 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/DefaultCryptoService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/DefaultCryptoService.kt @@ -262,7 +262,7 @@ internal class DefaultCryptoService @Inject constructor( override fun onSuccess(data: DevicesListResponse) { // Save in local DB - cryptoStore.saveMyDevicesInfo(data.devices ?: emptyList()) + cryptoStore.saveMyDevicesInfo(data.devices.orEmpty()) callback.onSuccess(data) } } @@ -446,7 +446,7 @@ internal class DefaultCryptoService @Inject constructor( } override fun getCryptoDeviceInfo(userId: String): List { - return cryptoStore.getUserDeviceList(userId) ?: emptyList() + return cryptoStore.getUserDeviceList(userId).orEmpty() } override fun getLiveCryptoDeviceInfo(): LiveData> { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OneTimeKeysUploader.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OneTimeKeysUploader.kt index a0483335e5..89965e7da9 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OneTimeKeysUploader.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OneTimeKeysUploader.kt @@ -137,7 +137,7 @@ internal class OneTimeKeysUploader @Inject constructor( private suspend fun uploadOneTimeKeys(oneTimeKeys: Map>?): KeysUploadResponse { val oneTimeJson = mutableMapOf() - val curve25519Map = oneTimeKeys?.get(OlmAccount.JSON_KEY_ONE_TIME_KEY) ?: emptyMap() + val curve25519Map = oneTimeKeys?.get(OlmAccount.JSON_KEY_ONE_TIME_KEY).orEmpty() curve25519Map.forEach { (key_id, value) -> val k = mutableMapOf() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/actions/EnsureOlmSessionsForUsersAction.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/actions/EnsureOlmSessionsForUsersAction.kt index 5766ee9980..1d452f4515 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/actions/EnsureOlmSessionsForUsersAction.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/actions/EnsureOlmSessionsForUsersAction.kt @@ -34,7 +34,7 @@ internal class EnsureOlmSessionsForUsersAction @Inject constructor(private val o suspend fun handle(users: List): MXUsersDevicesMap { Timber.v("## ensureOlmSessionsForUsers() : ensureOlmSessionsForUsers $users") val devicesByUser = users.associateWith { userId -> - val devices = cryptoStore.getUserDevices(userId)?.values ?: emptyList() + val devices = cryptoStore.getUserDevices(userId)?.values.orEmpty() devices.filter { // Don't bother setting up session to ourself diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt index 59ffa5f874..4c66f63d6a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt @@ -103,7 +103,7 @@ internal class MXMegolmDecryption(private val userId: String, senderCurve25519Key = olmDecryptionResult.senderKey, claimedEd25519Key = olmDecryptionResult.keysClaimed?.get("ed25519"), forwardingCurve25519KeyChain = olmDecryptionResult.forwardingCurve25519KeyChain - ?: emptyList() + .orEmpty() ) } else { throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_FIELDS, MXCryptoError.MISSING_FIELDS_REASON) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/olm/MXOlmEncryption.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/olm/MXOlmEncryption.kt index a9b84a8e48..fc40331af2 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/olm/MXOlmEncryption.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/olm/MXOlmEncryption.kt @@ -44,7 +44,7 @@ internal class MXOlmEncryption( ensureSession(userIds) val deviceInfos = ArrayList() for (userId in userIds) { - val devices = cryptoStore.getUserDevices(userId)?.values ?: emptyList() + val devices = cryptoStore.getUserDevices(userId)?.values.orEmpty() for (device in devices) { val key = device.identityKey() if (key == olmDevice.deviceCurve25519Key) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/model/CryptoInfoMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/model/CryptoInfoMapper.kt index f3ddfb8faa..37aae2c47b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/model/CryptoInfoMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/model/CryptoInfoMapper.kt @@ -47,7 +47,7 @@ internal object CryptoInfoMapper { return CryptoCrossSigningKey( userId = keyInfo.userId, usages = keyInfo.usages, - keys = keyInfo.keys ?: emptyMap(), + keys = keyInfo.keys.orEmpty(), signatures = keyInfo.signatures, trustLevel = null ) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/RealmCryptoStore.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/RealmCryptoStore.kt index 7064663995..6af96f886d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/RealmCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/RealmCryptoStore.kt @@ -450,7 +450,7 @@ internal class RealmCryptoStore @Inject constructor( } ) return Transformations.map(liveData) { - it.firstOrNull() ?: emptyList() + it.firstOrNull().orEmpty() } } @@ -480,7 +480,7 @@ internal class RealmCryptoStore @Inject constructor( } ) return Transformations.map(liveData) { - it.firstOrNull() ?: emptyList() + it.firstOrNull().orEmpty() } } 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/crypto/store/db/model/OutgoingGossipingRequestEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/model/OutgoingGossipingRequestEntity.kt index 19049c099c..21960ec9a0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/model/OutgoingGossipingRequestEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/model/OutgoingGossipingRequestEntity.kt @@ -78,7 +78,7 @@ internal open class OutgoingGossipingRequestEntity( GossipRequestType.KEY -> { OutgoingRoomKeyRequest( requestBody = getRequestedKeyInfo(), - recipients = getRecipients() ?: emptyMap(), + recipients = getRecipients().orEmpty(), requestId = requestId ?: "", state = requestState ) @@ -86,7 +86,7 @@ internal open class OutgoingGossipingRequestEntity( GossipRequestType.SECRET -> { OutgoingSecretRequest( secretName = getRequestedSecretName(), - recipients = getRecipients() ?: emptyMap(), + recipients = getRecipients().orEmpty(), requestId = requestId ?: "", state = requestState ) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultIncomingSASDefaultVerificationTransaction.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultIncomingSASDefaultVerificationTransaction.kt index e3a765f95c..b1368b45b4 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultIncomingSASDefaultVerificationTransaction.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultIncomingSASDefaultVerificationTransaction.kt @@ -198,18 +198,8 @@ internal class DefaultIncomingSASDefaultVerificationTransaction( // using the result as the shared secret. getSAS().setTheirPublicKey(otherKey) - // (Note: In all of the following HKDF is as defined in RFC 5869, and uses the previously agreed-on hash function as the hash function, - // the shared secret as the input keying material, no salt, and with the input parameter set to the concatenation of: - // - the string “MATRIX_KEY_VERIFICATION_SAS”, - // - the Matrix ID of the user who sent the m.key.verification.start message, - // - the device ID of the device that sent the m.key.verification.start message, - // - the Matrix ID of the user who sent the m.key.verification.accept message, - // - he device ID of the device that sent the m.key.verification.accept message - // - the transaction ID. - val sasInfo = "MATRIX_KEY_VERIFICATION_SAS$otherUserId$otherDeviceId$userId$deviceId$transactionId" - // decimal: generate five bytes by using HKDF. - // emoji: generate six bytes by using HKDF. - shortCodeBytes = getSAS().generateShortCode(sasInfo, 6) + + shortCodeBytes = calculateSASBytes() if (BuildConfig.LOG_PRIVATE_DATA) { Timber.v("************ BOB CODE ${getDecimalCodeRepresentation(shortCodeBytes!!)}") @@ -219,6 +209,35 @@ internal class DefaultIncomingSASDefaultVerificationTransaction( state = VerificationTxState.ShortCodeReady } + private fun calculateSASBytes(): ByteArray { + when (accepted?.keyAgreementProtocol) { + KEY_AGREEMENT_V1 -> { + // (Note: In all of the following HKDF is as defined in RFC 5869, and uses the previously agreed-on hash function as the hash function, + // the shared secret as the input keying material, no salt, and with the input parameter set to the concatenation of: + // - the string “MATRIX_KEY_VERIFICATION_SAS”, + // - the Matrix ID of the user who sent the m.key.verification.start message, + // - the device ID of the device that sent the m.key.verification.start message, + // - the Matrix ID of the user who sent the m.key.verification.accept message, + // - he device ID of the device that sent the m.key.verification.accept message + // - the transaction ID. + val sasInfo = "MATRIX_KEY_VERIFICATION_SAS$otherUserId$otherDeviceId$userId$deviceId$transactionId" + + // decimal: generate five bytes by using HKDF. + // emoji: generate six bytes by using HKDF. + return getSAS().generateShortCode(sasInfo, 6) + } + KEY_AGREEMENT_V2 -> { + // Adds the SAS public key, and separate by | + val sasInfo = "MATRIX_KEY_VERIFICATION_SAS|$otherUserId|$otherDeviceId|$otherKey|$userId|$deviceId|${getSAS().publicKey}|$transactionId" + return getSAS().generateShortCode(sasInfo, 6) + } + else -> { + // Protocol has been checked earlier + throw IllegalArgumentException() + } + } + } + override fun onKeyVerificationMac(vMac: ValidVerificationInfoMac) { Timber.v("## SAS I: received mac for request id:$transactionId") // Check for state? diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultOutgoingSASDefaultVerificationTransaction.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultOutgoingSASDefaultVerificationTransaction.kt index 1480029d6d..8081c07bcc 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultOutgoingSASDefaultVerificationTransaction.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultOutgoingSASDefaultVerificationTransaction.kt @@ -193,18 +193,7 @@ internal class DefaultOutgoingSASDefaultVerificationTransaction( if (accepted!!.commitment.equals(otherCommitment)) { getSAS().setTheirPublicKey(otherKey) - // (Note: In all of the following HKDF is as defined in RFC 5869, and uses the previously agreed-on hash function as the hash function, - // the shared secret as the input keying material, no salt, and with the input parameter set to the concatenation of: - // - the string “MATRIX_KEY_VERIFICATION_SAS”, - // - the Matrix ID of the user who sent the m.key.verification.start message, - // - the device ID of the device that sent the m.key.verification.start message, - // - the Matrix ID of the user who sent the m.key.verification.accept message, - // - he device ID of the device that sent the m.key.verification.accept message - // - the transaction ID. - val sasInfo = "MATRIX_KEY_VERIFICATION_SAS$userId$deviceId$otherUserId$otherDeviceId$transactionId" - // decimal: generate five bytes by using HKDF. - // emoji: generate six bytes by using HKDF. - shortCodeBytes = getSAS().generateShortCode(sasInfo, 6) + shortCodeBytes = calculateSASBytes() state = VerificationTxState.ShortCodeReady } else { // bad commitment @@ -212,14 +201,45 @@ internal class DefaultOutgoingSASDefaultVerificationTransaction( } } + private fun calculateSASBytes(): ByteArray { + when (accepted?.keyAgreementProtocol) { + KEY_AGREEMENT_V1 -> { + // (Note: In all of the following HKDF is as defined in RFC 5869, and uses the previously agreed-on hash function as the hash function, + // the shared secret as the input keying material, no salt, and with the input parameter set to the concatenation of: + // - the string “MATRIX_KEY_VERIFICATION_SAS”, + // - the Matrix ID of the user who sent the m.key.verification.start message, + // - the device ID of the device that sent the m.key.verification.start message, + // - the Matrix ID of the user who sent the m.key.verification.accept message, + // - he device ID of the device that sent the m.key.verification.accept message + // - the transaction ID. + val sasInfo = "MATRIX_KEY_VERIFICATION_SAS$userId$deviceId$otherUserId$otherDeviceId$transactionId" + + // decimal: generate five bytes by using HKDF. + // emoji: generate six bytes by using HKDF. + return getSAS().generateShortCode(sasInfo, 6) + } + KEY_AGREEMENT_V2 -> { + // Adds the SAS public key, and separate by | + val sasInfo = "MATRIX_KEY_VERIFICATION_SAS|$userId|$deviceId|${getSAS().publicKey}|$otherUserId|$otherDeviceId|$otherKey|$transactionId" + return getSAS().generateShortCode(sasInfo, 6) + } + else -> { + // Protocol has been checked earlier + throw IllegalArgumentException() + } + } + } + override fun onKeyVerificationMac(vMac: ValidVerificationInfoMac) { Timber.v("## SAS O: onKeyVerificationMac id:$transactionId") + // There is starting to be a huge amount of state / race here :/ if (state != VerificationTxState.OnKeyReceived && state != VerificationTxState.ShortCodeReady && state != VerificationTxState.ShortCodeAccepted + && state != VerificationTxState.KeySent && state != VerificationTxState.SendingMac && state != VerificationTxState.MacSent) { - Timber.e("## SAS O: received key from invalid state $state") + Timber.e("## SAS O: received mac from invalid state $state") cancel(CancelCode.UnexpectedMessage) return } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationService.kt index 7479c55aa3..0f1666bd9d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationService.kt @@ -611,7 +611,7 @@ internal class DefaultVerificationService @Inject constructor( if (validCancelReq == null) { // ignore - Timber.e("## SAS Received invalid key request") + Timber.e("## SAS Received invalid cancel request") // TODO should we cancel? return } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SASDefaultVerificationTransaction.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SASDefaultVerificationTransaction.kt index 2a502730fa..7048d790a0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SASDefaultVerificationTransaction.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SASDefaultVerificationTransaction.kt @@ -26,7 +26,6 @@ import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.internal.crypto.IncomingGossipingRequestManager import im.vector.matrix.android.internal.crypto.OutgoingGossipingRequestManager import im.vector.matrix.android.internal.crypto.actions.SetDeviceVerificationAction -import im.vector.matrix.android.internal.crypto.model.MXKey import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore import im.vector.matrix.android.internal.extensions.toUnsignedInt import im.vector.matrix.android.internal.util.withoutPrefix @@ -66,8 +65,11 @@ internal abstract class SASDefaultVerificationTransaction( const val SAS_MAC_SHA256_LONGKDF = "hmac-sha256" const val SAS_MAC_SHA256 = "hkdf-hmac-sha256" + // Deprecated maybe removed later, use V2 + const val KEY_AGREEMENT_V1 = "curve25519" + const val KEY_AGREEMENT_V2 = "curve25519-hkdf-sha256" // ordered by preferred order - val KNOWN_AGREEMENT_PROTOCOLS = listOf(MXKey.KEY_CURVE_25519_TYPE) + val KNOWN_AGREEMENT_PROTOCOLS = listOf(KEY_AGREEMENT_V2, KEY_AGREEMENT_V1) // ordered by preferred order val KNOWN_HASHES = listOf("sha256") // ordered by preferred order 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/mapper/PushRulesMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/PushRulesMapper.kt index a0f644a7cf..4425582a9b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/PushRulesMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/PushRulesMapper.kt @@ -45,7 +45,7 @@ internal object PushRulesMapper { private fun fromActionStr(actionsStr: String?): List { try { - return actionsStr?.let { moshiActionsAdapter.fromJson(it) } ?: emptyList() + return actionsStr?.let { moshiActionsAdapter.fromJson(it) }.orEmpty() } catch (e: Throwable) { Timber.e(e, "## failed to map push rule actions <$actionsStr>") return emptyList() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt index 20651069b0..9d3da20b0e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt @@ -49,7 +49,7 @@ internal class RoomSummaryMapper @Inject constructor(private val timelineEventMa membership = roomSummaryEntity.membership, versioningState = roomSummaryEntity.versioningState, readMarkerId = roomSummaryEntity.readMarkerId, - userDrafts = roomSummaryEntity.userDrafts?.userDrafts?.map { DraftMapper.map(it) } ?: emptyList(), + userDrafts = roomSummaryEntity.userDrafts?.userDrafts?.map { DraftMapper.map(it) }.orEmpty(), canonicalAlias = roomSummaryEntity.canonicalAlias, aliases = roomSummaryEntity.aliases.toList(), isEncrypted = roomSummaryEntity.isEncrypted, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt index 4bd9b9855b..a90e62e15c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt @@ -18,7 +18,7 @@ package im.vector.matrix.android.internal.database.mapper import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.room.model.ReadReceipt - +import im.vector.matrix.android.api.session.room.sender.SenderInfo import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.internal.database.model.TimelineEventEntity import javax.inject.Inject @@ -41,15 +41,18 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS annotations = timelineEventEntity.annotations?.asDomain(), localId = timelineEventEntity.localId, displayIndex = timelineEventEntity.displayIndex, - senderName = timelineEventEntity.senderName, - isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName, - senderAvatar = timelineEventEntity.senderAvatar, + senderInfo = SenderInfo( + userId = timelineEventEntity.root?.sender ?: "", + displayName = timelineEventEntity.senderName, + isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName, + avatarUrl = timelineEventEntity.senderAvatar + ), readReceipts = readReceipts ?.distinctBy { it.user }?.sortedByDescending { it.originServerTs - } ?: emptyList() + }.orEmpty() ) } } 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/FilterEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/FilterEntityQueries.kt index 6902d39a82..42a84113ca 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/FilterEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/FilterEntityQueries.kt @@ -35,7 +35,7 @@ internal fun FilterEntity.Companion.get(realm: Realm): FilterEntity? { internal fun FilterEntity.Companion.getOrCreate(realm: Realm): FilterEntity { return get(realm) ?: realm.createObject() .apply { - filterBodyJson = FilterFactory.createDefaultFilterBody().toJSONString() + filterBodyJson = FilterFactory.createDefaultFilter().toJSONString() roomEventFilterJson = FilterFactory.createDefaultRoomFilter().toJSONString() filterId = "" } 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/di/NetworkModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/NetworkModule.kt index b7ab257363..ddde4fc7d6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/NetworkModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/NetworkModule.kt @@ -21,6 +21,7 @@ import com.squareup.moshi.Moshi import dagger.Module import dagger.Provides import im.vector.matrix.android.BuildConfig +import im.vector.matrix.android.api.MatrixConfiguration import im.vector.matrix.android.internal.network.TimeOutInterceptor import im.vector.matrix.android.internal.network.UserAgentInterceptor import im.vector.matrix.android.internal.network.interceptors.CurlLoggingInterceptor @@ -64,7 +65,8 @@ internal object NetworkModule { @Provides @JvmStatic @Unauthenticated - fun providesOkHttpClient(stethoInterceptor: StethoInterceptor, + fun providesOkHttpClient(matrixConfiguration: MatrixConfiguration, + stethoInterceptor: StethoInterceptor, timeoutInterceptor: TimeOutInterceptor, userAgentInterceptor: UserAgentInterceptor, httpLoggingInterceptor: HttpLoggingInterceptor, @@ -82,6 +84,9 @@ internal object NetworkModule { if (BuildConfig.LOG_PRIVATE_DATA) { addInterceptor(curlLoggingInterceptor) } + matrixConfiguration.proxy?.let { + proxy(it) + } } .addInterceptor(okReplayInterceptor) .build() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/extensions/Strings.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/extensions/Strings.kt new file mode 100644 index 0000000000..09913b9f04 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/extensions/Strings.kt @@ -0,0 +1,24 @@ +/* + * 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.extensions + +/** + * Ex: "abcdef".subStringBetween("a", "f") -> "bcde" + * Ex: "abcdefff".subStringBetween("a", "f") -> "bcdeff" + * Ex: "aaabcdef".subStringBetween("a", "f") -> "aabcde" + */ +internal fun String.subStringBetween(prefix: String, suffix: String) = substringAfter(prefix).substringBeforeLast(suffix) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt index 9d5772b82a..c7afcc1d47 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt @@ -229,40 +229,40 @@ internal abstract class SessionModule { abstract fun bindSession(session: DefaultSession): Session @Binds - abstract fun bindNetworkConnectivityChecker(networkConnectivityChecker: DefaultNetworkConnectivityChecker): NetworkConnectivityChecker + abstract fun bindNetworkConnectivityChecker(checker: DefaultNetworkConnectivityChecker): NetworkConnectivityChecker @Binds @IntoSet - abstract fun bindGroupSummaryUpdater(groupSummaryUpdater: GroupSummaryUpdater): LiveEntityObserver + abstract fun bindGroupSummaryUpdater(updater: GroupSummaryUpdater): LiveEntityObserver @Binds @IntoSet - abstract fun bindEventsPruner(eventsPruner: EventsPruner): LiveEntityObserver + abstract fun bindEventsPruner(pruner: EventsPruner): LiveEntityObserver @Binds @IntoSet - abstract fun bindEventRelationsAggregationUpdater(eventRelationsAggregationUpdater: EventRelationsAggregationUpdater): LiveEntityObserver + abstract fun bindEventRelationsAggregationUpdater(updater: EventRelationsAggregationUpdater): LiveEntityObserver @Binds @IntoSet - abstract fun bindRoomTombstoneEventLiveObserver(roomTombstoneEventLiveObserver: RoomTombstoneEventLiveObserver): LiveEntityObserver + abstract fun bindRoomTombstoneEventLiveObserver(observer: RoomTombstoneEventLiveObserver): LiveEntityObserver @Binds @IntoSet - abstract fun bindRoomCreateEventLiveObserver(roomCreateEventLiveObserver: RoomCreateEventLiveObserver): LiveEntityObserver + abstract fun bindRoomCreateEventLiveObserver(observer: RoomCreateEventLiveObserver): LiveEntityObserver @Binds @IntoSet - abstract fun bindVerificationMessageLiveObserver(verificationMessageLiveObserver: VerificationMessageLiveObserver): LiveEntityObserver + abstract fun bindVerificationMessageLiveObserver(observer: VerificationMessageLiveObserver): LiveEntityObserver @Binds - abstract fun bindInitialSyncProgressService(initialSyncProgressService: DefaultInitialSyncProgressService): InitialSyncProgressService + abstract fun bindInitialSyncProgressService(service: DefaultInitialSyncProgressService): InitialSyncProgressService @Binds - abstract fun bindSecureStorageService(secureStorageService: DefaultSecureStorageService): SecureStorageService + abstract fun bindSecureStorageService(service: DefaultSecureStorageService): SecureStorageService @Binds - abstract fun bindHomeServerCapabilitiesService(homeServerCapabilitiesService: DefaultHomeServerCapabilitiesService): HomeServerCapabilitiesService + abstract fun bindHomeServerCapabilitiesService(service: DefaultHomeServerCapabilitiesService): HomeServerCapabilitiesService @Binds abstract fun bindAccountDataService(service: DefaultAccountDataService): AccountDataService diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/ContentModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/ContentModule.kt index 1faf489dc4..577626c8ac 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/ContentModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/ContentModule.kt @@ -25,8 +25,8 @@ import im.vector.matrix.android.api.session.content.ContentUrlResolver internal abstract class ContentModule { @Binds - abstract fun bindContentUploadStateTracker(contentUploadStateTracker: DefaultContentUploadStateTracker): ContentUploadStateTracker + abstract fun bindContentUploadStateTracker(tracker: DefaultContentUploadStateTracker): ContentUploadStateTracker @Binds - abstract fun bindContentUrlResolver(contentUrlResolver: DefaultContentUrlResolver): ContentUrlResolver + abstract fun bindContentUrlResolver(resolver: DefaultContentUrlResolver): ContentUrlResolver } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/DefaultFilterRepository.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/DefaultFilterRepository.kt index f7df8c512e..95291de4b6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/DefaultFilterRepository.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/DefaultFilterRepository.kt @@ -28,25 +28,25 @@ import javax.inject.Inject internal class DefaultFilterRepository @Inject constructor(private val monarchy: Monarchy) : FilterRepository { - override suspend fun storeFilter(filterBody: FilterBody, roomEventFilter: RoomEventFilter): Boolean { + override suspend fun storeFilter(filter: Filter, roomEventFilter: RoomEventFilter): Boolean { return Realm.getInstance(monarchy.realmConfiguration).use { realm -> - val filter = FilterEntity.get(realm) + val filterEntity = FilterEntity.get(realm) // Filter has changed, or no filter Id yet - filter == null - || filter.filterBodyJson != filterBody.toJSONString() - || filter.filterId.isBlank() + filterEntity == null + || filterEntity.filterBodyJson != filter.toJSONString() + || filterEntity.filterId.isBlank() }.also { hasChanged -> if (hasChanged) { // Filter is new or has changed, store it and reset the filter Id. // This has to be done outside of the Realm.use(), because awaitTransaction change the current thread monarchy.awaitTransaction { realm -> // We manage only one filter for now - val filterBodyJson = filterBody.toJSONString() + val filterJson = filter.toJSONString() val roomEventFilterJson = roomEventFilter.toJSONString() val filterEntity = FilterEntity.getOrCreate(realm) - filterEntity.filterBodyJson = filterBodyJson + filterEntity.filterBodyJson = filterJson filterEntity.roomEventFilterJson = roomEventFilterJson // Reset filterId filterEntity.filterId = "" @@ -55,14 +55,14 @@ internal class DefaultFilterRepository @Inject constructor(private val monarchy: } } - override suspend fun storeFilterId(filterBody: FilterBody, filterId: String) { + override suspend fun storeFilterId(filter: Filter, filterId: String) { monarchy.awaitTransaction { // We manage only one filter for now - val filterBodyJson = filterBody.toJSONString() + val filterJson = filter.toJSONString() // Update the filter id, only if the filter body matches it.where() - .equalTo(FilterEntityFields.FILTER_BODY_JSON, filterBodyJson) + .equalTo(FilterEntityFields.FILTER_BODY_JSON, filterJson) ?.findFirst() ?.filterId = filterId } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/DefaultSaveFilterTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/DefaultSaveFilterTask.kt index 47c5e4a08a..f396e01e86 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/DefaultSaveFilterTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/DefaultSaveFilterTask.kt @@ -43,10 +43,10 @@ internal class DefaultSaveFilterTask @Inject constructor( override suspend fun execute(params: SaveFilterTask.Params) { val filterBody = when (params.filterPreset) { FilterService.FilterPreset.RiotFilter -> { - FilterFactory.createRiotFilterBody() + FilterFactory.createRiotFilter() } FilterService.FilterPreset.NoFilter -> { - FilterFactory.createDefaultFilterBody() + FilterFactory.createDefaultFilter() } } val roomFilter = when (params.filterPreset) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/EventFilter.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/EventFilter.kt new file mode 100644 index 0000000000..f5d159588b --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/EventFilter.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2019 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.filter + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Represents "Filter" as mentioned in the SPEC + * https://matrix.org/docs/spec/client_server/r0.3.0.html#post-matrix-client-r0-user-userid-filter + */ +@JsonClass(generateAdapter = true) +data class EventFilter( + /** + * The maximum number of events to return. + */ + @Json(name = "limit") val limit: Int? = null, + /** + * A list of senders IDs to include. If this list is absent then all senders are included. + */ + @Json(name = "senders") val senders: List? = null, + /** + * A list of sender IDs to exclude. If this list is absent then no senders are excluded. + * A matching sender will be excluded even if it is listed in the 'senders' filter. + */ + @Json(name = "not_senders") val notSenders: List? = null, + /** + * A list of event types to include. If this list is absent then all event types are included. + * A '*' can be used as a wildcard to match any sequence of characters. + */ + @Json(name = "types") val types: List? = null, + /** + * A list of event types to exclude. If this list is absent then no event types are excluded. + * A matching type will be excluded even if it is listed in the 'types' filter. + * A '*' can be used as a wildcard to match any sequence of characters. + */ + @Json(name = "not_types") val notTypes: List? = null +) { + fun hasData(): Boolean { + return limit != null + || senders != null + || notSenders != null + || types != null + || notTypes != null + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/Filter.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/Filter.kt index fc0472e32f..4b826a00f8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/Filter.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/Filter.kt @@ -17,28 +17,42 @@ package im.vector.matrix.android.internal.session.filter import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import im.vector.matrix.android.internal.di.MoshiProvider /** - * Represents "Filter" as mentioned in the SPEC + * Class which can be parsed to a filter json string. Used for POST and GET + * Have a look here for further information: * https://matrix.org/docs/spec/client_server/r0.3.0.html#post-matrix-client-r0-user-userid-filter */ @JsonClass(generateAdapter = true) -data class Filter( - @Json(name = "limit") val limit: Int? = null, - @Json(name = "senders") val senders: List? = null, - @Json(name = "not_senders") val notSenders: List? = null, - @Json(name = "types") val types: List? = null, - @Json(name = "not_types") val notTypes: List? = null, - @Json(name = "rooms") val rooms: List? = null, - @Json(name = "not_rooms") val notRooms: List? = null +internal data class Filter( + /** + * List of event fields to include. If this list is absent then all fields are included. The entries may + * include '.' characters to indicate sub-fields. So ['content.body'] will include the 'body' field of the + * 'content' object. A literal '.' character in a field name may be escaped using a '\'. A server may + * include more fields than were requested. + */ + @Json(name = "event_fields") val eventFields: List? = null, + /** + * The format to use for events. 'client' will return the events in a format suitable for clients. + * 'federation' will return the raw event as received over federation. The default is 'client'. One of: ["client", "federation"] + */ + @Json(name = "event_format") val eventFormat: String? = null, + /** + * The presence updates to include. + */ + @Json(name = "presence") val presence: EventFilter? = null, + /** + * The user account data that isn't associated with rooms to include. + */ + @Json(name = "account_data") val accountData: EventFilter? = null, + /** + * Filters to be applied to room data. + */ + @Json(name = "room") val room: RoomFilter? = null ) { - fun hasData(): Boolean { - return (limit != null - || senders != null - || notSenders != null - || types != null - || notTypes != null - || rooms != null - || notRooms != null) + + fun toJSONString(): String { + return MoshiProvider.providesMoshi().adapter(Filter::class.java).toJson(this) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterApi.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterApi.kt index 092038ee5d..deae2d5b3a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterApi.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterApi.kt @@ -32,7 +32,7 @@ internal interface FilterApi { * @param body the Json representation of a FilterBody object */ @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "user/{userId}/filter") - fun uploadFilter(@Path("userId") userId: String, @Body body: FilterBody): Call + fun uploadFilter(@Path("userId") userId: String, @Body body: Filter): Call /** * Gets a filter with a given filterId from the homeserver @@ -42,6 +42,5 @@ internal interface FilterApi { * @return Filter */ @GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "user/{userId}/filter/{filterId}") - fun getFilterById(@Path("userId") userId: String, @Path("filterId") - filterId: String): Call + fun getFilterById(@Path("userId") userId: String, @Path("filterId") filterId: String): Call } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterBody.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterBody.kt deleted file mode 100644 index 535c66f637..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterBody.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2019 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.filter - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import im.vector.matrix.android.internal.di.MoshiProvider - -/** - * Class which can be parsed to a filter json string. Used for POST and GET - * Have a look here for further information: - * https://matrix.org/docs/spec/client_server/r0.3.0.html#post-matrix-client-r0-user-userid-filter - */ -@JsonClass(generateAdapter = true) -internal data class FilterBody( - @Json(name = "event_fields") val eventFields: List? = null, - @Json(name = "event_format") val eventFormat: String? = null, - @Json(name = "presence") val presence: Filter? = null, - @Json(name = "account_data") val accountData: Filter? = null, - @Json(name = "room") val room: RoomFilter? = null -) { - - fun toJSONString(): String { - return MoshiProvider.providesMoshi().adapter(FilterBody::class.java).toJson(this) - } -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterFactory.kt index a070759de9..15c57ab1c8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterFactory.kt @@ -20,12 +20,21 @@ import im.vector.matrix.android.api.session.events.model.EventType internal object FilterFactory { - fun createDefaultFilterBody(): FilterBody { - return FilterUtil.enableLazyLoading(FilterBody(), true) + fun createUploadsFilter(numberOfEvents: Int): RoomEventFilter { + return RoomEventFilter( + limit = numberOfEvents, + containsUrl = true, + types = listOf(EventType.MESSAGE), + lazyLoadMembers = true + ) } - fun createRiotFilterBody(): FilterBody { - return FilterBody( + fun createDefaultFilter(): Filter { + return FilterUtil.enableLazyLoading(Filter(), true) + } + + fun createRiotFilter(): Filter { + return Filter( room = RoomFilter( timeline = createRiotTimelineFilter(), state = createRiotStateFilter() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterRepository.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterRepository.kt index d205ea8a87..c558732f44 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterRepository.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterRepository.kt @@ -21,12 +21,12 @@ internal interface FilterRepository { /** * Return true if the filterBody has changed, or need to be sent to the server */ - suspend fun storeFilter(filterBody: FilterBody, roomEventFilter: RoomEventFilter): Boolean + suspend fun storeFilter(filter: Filter, roomEventFilter: RoomEventFilter): Boolean /** * Set the filterId of this filter */ - suspend fun storeFilterId(filterBody: FilterBody, filterId: String) + suspend fun storeFilterId(filter: Filter, filterId: String) /** * Return filter json or filter id diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterResponse.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterResponse.kt index 75e2c23da9..a9bfb70d5e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterResponse.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterResponse.kt @@ -24,5 +24,10 @@ import com.squareup.moshi.JsonClass */ @JsonClass(generateAdapter = true) data class FilterResponse( + /** + * Required. The ID of the filter that was created. Cannot start with a { as this character + * is used to determine if the filter provided is inline JSON or a previously declared + * filter by homeservers on some APIs. + */ @Json(name = "filter_id") val filterId: String ) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterUtil.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterUtil.kt index 3f4e61e6b5..53ede5ad45 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterUtil.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/FilterUtil.kt @@ -81,30 +81,30 @@ internal object FilterUtil { } */ /** - * Compute a new filterBody to enable or disable the lazy loading + * Compute a new filter to enable or disable the lazy loading * * - * If lazy loading is on, the filterBody will looks like + * If lazy loading is on, the filter will looks like * {"room":{"state":{"lazy_load_members":true})} * - * @param filterBody filterBody to patch + * @param filter filter to patch * @param useLazyLoading true to enable lazy loading */ - fun enableLazyLoading(filterBody: FilterBody, useLazyLoading: Boolean): FilterBody { + fun enableLazyLoading(filter: Filter, useLazyLoading: Boolean): Filter { if (useLazyLoading) { // Enable lazy loading - return filterBody.copy( - room = filterBody.room?.copy( - state = filterBody.room.state?.copy(lazyLoadMembers = true) + return filter.copy( + room = filter.room?.copy( + state = filter.room.state?.copy(lazyLoadMembers = true) ?: RoomEventFilter(lazyLoadMembers = true) ) ?: RoomFilter(state = RoomEventFilter(lazyLoadMembers = true)) ) } else { - val newRoomEventFilter = filterBody.room?.state?.copy(lazyLoadMembers = null)?.takeIf { it.hasData() } - val newRoomFilter = filterBody.room?.copy(state = newRoomEventFilter)?.takeIf { it.hasData() } + val newRoomEventFilter = filter.room?.state?.copy(lazyLoadMembers = null)?.takeIf { it.hasData() } + val newRoomFilter = filter.room?.copy(state = newRoomEventFilter)?.takeIf { it.hasData() } - return filterBody.copy( + return filter.copy( room = newRoomFilter ) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/RoomEventFilter.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/RoomEventFilter.kt index 9cdccc5c8b..81e7b672b4 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/RoomEventFilter.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/RoomEventFilter.kt @@ -25,14 +25,46 @@ import im.vector.matrix.android.internal.di.MoshiProvider */ @JsonClass(generateAdapter = true) data class RoomEventFilter( - @Json(name = "limit") var limit: Int? = null, + /** + * The maximum number of events to return. + */ + @Json(name = "limit") val limit: Int? = null, + /** + * A list of sender IDs to exclude. If this list is absent then no senders are excluded. A matching sender will + * be excluded even if it is listed in the 'senders' filter. + */ @Json(name = "not_senders") val notSenders: List? = null, + /** + * A list of event types to exclude. If this list is absent then no event types are excluded. A matching type will + * be excluded even if it is listed in the 'types' filter. A '*' can be used as a wildcard to match any sequence of characters. + */ @Json(name = "not_types") val notTypes: List? = null, + /** + * A list of senders IDs to include. If this list is absent then all senders are included. + */ @Json(name = "senders") val senders: List? = null, + /** + * A list of event types to include. If this list is absent then all event types are included. A '*' can be used as + * a wildcard to match any sequence of characters. + */ @Json(name = "types") val types: List? = null, + /** + * A list of room IDs to include. If this list is absent then all rooms are included. + */ @Json(name = "rooms") val rooms: List? = null, + /** + * A list of room IDs to exclude. If this list is absent then no rooms are excluded. A matching room will be excluded + * even if it is listed in the 'rooms' filter. + */ @Json(name = "not_rooms") val notRooms: List? = null, + /** + * If true, includes only events with a url key in their content. If false, excludes those events. If omitted, url + * key is not considered for filtering. + */ @Json(name = "contains_url") val containsUrl: Boolean? = null, + /** + * If true, enables lazy-loading of membership events. See Lazy-loading room members for more information. Defaults to false. + */ @Json(name = "lazy_load_members") val lazyLoadMembers: Boolean? = null ) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/RoomFilter.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/RoomFilter.kt index 3109763570..e79a0a624e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/RoomFilter.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/RoomFilter.kt @@ -24,12 +24,37 @@ import com.squareup.moshi.JsonClass */ @JsonClass(generateAdapter = true) data class RoomFilter( + /** + * A list of room IDs to exclude. If this list is absent then no rooms are excluded. + * A matching room will be excluded even if it is listed in the 'rooms' filter. + * This filter is applied before the filters in ephemeral, state, timeline or account_data + */ @Json(name = "not_rooms") val notRooms: List? = null, + /** + * A list of room IDs to include. If this list is absent then all rooms are included. + * This filter is applied before the filters in ephemeral, state, timeline or account_data + */ @Json(name = "rooms") val rooms: List? = null, + /** + * The events that aren't recorded in the room history, e.g. typing and receipts, to include for rooms. + */ @Json(name = "ephemeral") val ephemeral: RoomEventFilter? = null, + /** + * Include rooms that the user has left in the sync, default false + */ @Json(name = "include_leave") val includeLeave: Boolean? = null, + /** + * The state events to include for rooms. + * Developer remark: StateFilter is exactly the same than RoomEventFilter + */ @Json(name = "state") val state: RoomEventFilter? = null, + /** + * The message and state update events to include for rooms. + */ @Json(name = "timeline") val timeline: RoomEventFilter? = null, + /** + * The per user account data to include for rooms. + */ @Json(name = "account_data") val accountData: RoomEventFilter? = null ) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/todelete/AccountDataMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/todelete/AccountDataMapper.kt index 4627911b72..3a736681e2 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/todelete/AccountDataMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/todelete/AccountDataMapper.kt @@ -30,7 +30,7 @@ internal class AccountDataMapper @Inject constructor(moshi: Moshi) { fun map(entity: UserAccountDataEntity): UserAccountDataEvent { return UserAccountDataEvent( type = entity.type ?: "", - content = entity.contentStr?.let { adapter.fromJson(it) } ?: emptyMap() + content = entity.contentStr?.let { adapter.fromJson(it) }.orEmpty() ) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt index 995e661174..a441d70f5d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt @@ -34,6 +34,7 @@ import im.vector.matrix.android.api.session.room.send.SendService import im.vector.matrix.android.api.session.room.state.StateService import im.vector.matrix.android.api.session.room.timeline.TimelineService import im.vector.matrix.android.api.session.room.typing.TypingService +import im.vector.matrix.android.api.session.room.uploads.UploadsService import im.vector.matrix.android.api.util.Optional import im.vector.matrix.android.api.util.toOptional import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM @@ -54,6 +55,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String, private val sendService: SendService, private val draftService: DraftService, private val stateService: StateService, + private val uploadsService: UploadsService, private val reportingService: ReportingService, private val readService: ReadService, private val typingService: TypingService, @@ -68,6 +70,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String, SendService by sendService, DraftService by draftService, StateService by stateService, + UploadsService by uploadsService, ReportingService by reportingService, ReadService by readService, TypingService by typingService, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt index 7c544d64cf..974c30dba9 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt @@ -32,6 +32,7 @@ import im.vector.matrix.android.internal.session.room.state.DefaultStateService import im.vector.matrix.android.internal.session.room.state.SendStateTask import im.vector.matrix.android.internal.session.room.timeline.DefaultTimelineService import im.vector.matrix.android.internal.session.room.typing.DefaultTypingService +import im.vector.matrix.android.internal.session.room.uploads.DefaultUploadsService import im.vector.matrix.android.internal.task.TaskExecutor import javax.inject.Inject @@ -47,6 +48,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona private val sendServiceFactory: DefaultSendService.Factory, private val draftServiceFactory: DefaultDraftService.Factory, private val stateServiceFactory: DefaultStateService.Factory, + private val uploadsServiceFactory: DefaultUploadsService.Factory, private val reportingServiceFactory: DefaultReportingService.Factory, private val readServiceFactory: DefaultReadService.Factory, private val typingServiceFactory: DefaultTypingService.Factory, @@ -66,6 +68,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona sendService = sendServiceFactory.create(roomId), draftService = draftServiceFactory.create(roomId), stateService = stateServiceFactory.create(roomId), + uploadsService = uploadsServiceFactory.create(roomId), reportingService = reportingServiceFactory.create(roomId), readService = readServiceFactory.create(roomId), typingService = typingServiceFactory.create(roomId), 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..001ce120c8 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,12 +56,16 @@ 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 import im.vector.matrix.android.internal.session.room.typing.SendTypingTask +import im.vector.matrix.android.internal.session.room.uploads.DefaultGetUploadsTask +import im.vector.matrix.android.internal.session.room.uploads.GetUploadsTask import retrofit2.Retrofit @Module @@ -143,6 +147,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 @@ -151,4 +158,7 @@ internal abstract class RoomModule { @Binds abstract fun bindSendTypingTask(task: DefaultSendTypingTask): SendTypingTask + + @Binds + abstract fun bindGetUploadsTask(task: DefaultGetUploadsTask): GetUploadsTask } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt index 6e0adccfb9..d6c1a3ada8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt @@ -131,7 +131,7 @@ internal class RoomSummaryUpdater @Inject constructor( ?.canonicalAlias val roomAliases = ContentMapper.map(lastAliasesEvent?.content).toModel()?.aliases - ?: emptyList() + .orEmpty() roomSummaryEntity.aliases.clear() roomSummaryEntity.aliases.addAll(roomAliases) roomSummaryEntity.flatAliases = roomAliases.joinToString(separator = "|", prefix = "|") diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/draft/DraftRepository.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/draft/DraftRepository.kt index b00bf2aadb..8740567fc8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/draft/DraftRepository.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/draft/DraftRepository.kt @@ -143,7 +143,7 @@ class DraftRepository @Inject constructor(private val monarchy: Monarchy) { } ) return Transformations.map(liveData) { - it.firstOrNull() ?: emptyList() + it.firstOrNull().orEmpty() } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomDisplayNameResolver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomDisplayNameResolver.kt index feb05a3275..3c1df50b75 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomDisplayNameResolver.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomDisplayNameResolver.kt @@ -126,6 +126,7 @@ internal class RoomDisplayNameResolver @Inject constructor(private val context: return name ?: roomId } + /** See [im.vector.matrix.android.api.session.room.sender.SenderInfo.disambiguatedDisplayName] */ private fun resolveRoomMemberName(roomMemberSummary: RoomMemberSummaryEntity?, roomMemberHelper: RoomMemberHelper): String? { if (roomMemberSummary == null) return null diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt index 59a7dc4b9c..252c8a31fd 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt @@ -111,7 +111,7 @@ internal class DefaultReadService @AssistedInject constructor( { readReceiptsSummaryMapper.map(it) } ) return Transformations.map(liveRealmData) { - it.firstOrNull() ?: emptyList() + it.firstOrNull().orEmpty() } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/UpdateQuickReactionTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/UpdateQuickReactionTask.kt index 6ec316e9a4..9684161c4e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/UpdateQuickReactionTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/UpdateQuickReactionTask.kt @@ -48,7 +48,7 @@ internal class DefaultUpdateQuickReactionTask @Inject constructor(private val mo monarchy.doWithRealm { realm -> res = updateQuickReaction(realm, params.reaction, params.oppositeReaction, params.eventId) } - return UpdateQuickReactionTask.Result(res?.first, res?.second ?: emptyList()) + return UpdateQuickReactionTask.Result(res?.first, res?.second.orEmpty()) } private fun updateQuickReaction(realm: Realm, reaction: String, oppositeReaction: String, eventId: String): Pair?> { 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/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt index 5f0515e669..82d393e79a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt @@ -35,6 +35,7 @@ import im.vector.matrix.android.api.session.room.model.message.FileInfo import im.vector.matrix.android.api.session.room.model.message.ImageInfo import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent import im.vector.matrix.android.api.session.room.model.message.MessageContent +import im.vector.matrix.android.api.session.room.model.message.MessageContentWithFormattedBody import im.vector.matrix.android.api.session.room.model.message.MessageFileContent import im.vector.matrix.android.api.session.room.model.message.MessageFormat import im.vector.matrix.android.api.session.room.model.message.MessageImageContent @@ -56,6 +57,7 @@ import im.vector.matrix.android.api.session.room.model.relation.ReplyToContent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent import im.vector.matrix.android.internal.di.UserId +import im.vector.matrix.android.internal.extensions.subStringBetween import im.vector.matrix.android.internal.session.content.ThumbnailExtractor import im.vector.matrix.android.internal.session.room.send.pills.TextPillsUtils import im.vector.matrix.android.internal.task.TaskExecutor @@ -84,6 +86,7 @@ internal class LocalEchoEventFactory @Inject constructor( ) { // TODO Inject private val parser = Parser.builder().build() + // TODO Inject private val renderer = HtmlRenderer.builder().build() @@ -102,8 +105,15 @@ internal class LocalEchoEventFactory @Inject constructor( val document = parser.parse(source) val htmlText = renderer.render(document) - if (isFormattedTextPertinent(source, htmlText)) { - return TextContent(text.toString(), htmlText) + // Cleanup extra paragraph + val cleanHtmlText = if (htmlText.startsWith("

") && htmlText.endsWith("

\n")) { + htmlText.subStringBetween("

", "

\n") + } else { + htmlText + } + + if (isFormattedTextPertinent(source, cleanHtmlText)) { + return TextContent(text.toString(), cleanHtmlText) } } else { // Try to detect pills @@ -192,7 +202,7 @@ internal class LocalEchoEventFactory @Inject constructor( permalink, stringProvider.getString(R.string.message_reply_to_prefix), userLink, - originalEvent.getDisambiguatedDisplayName(), + originalEvent.senderInfo.disambiguatedDisplayName, body.takeFormatted(), createTextContent(newBodyText, newBodyAutoMarkdown).takeFormatted() ) @@ -433,10 +443,8 @@ internal class LocalEchoEventFactory @Inject constructor( MessageType.MSGTYPE_TEXT, MessageType.MSGTYPE_NOTICE -> { var formattedText: String? = null - if (content is MessageTextContent) { - if (content.format == MessageFormat.FORMAT_MATRIX_HTML) { - formattedText = content.formattedBody - } + if (content is MessageContentWithFormattedBody) { + formattedText = content.matrixFormattedBody } val isReply = content.isReply() || originalContent.isReply() return if (isReply) { 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/TokenChunkEvent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEvent.kt index 95edf9bc49..7344f5598b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEvent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEvent.kt @@ -23,4 +23,6 @@ internal interface TokenChunkEvent { val end: String? val events: List val stateEvents: List + + fun hasMore() = start != end } 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..e0f5b106d6 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 @@ -149,7 +149,7 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy } ?: ChunkEntity.create(realm, prevToken, nextToken) - if (receivedChunk.events.isEmpty() && receivedChunk.end == receivedChunk.start) { + if (receivedChunk.events.isEmpty() && !receivedChunk.hasMore()) { handleReachEnd(realm, roomId, direction, currentChunk) } else { handlePagination(realm, roomId, direction, receivedChunk, currentChunk) @@ -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/room/uploads/DefaultUploadsService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/uploads/DefaultUploadsService.kt new file mode 100644 index 0000000000..dd8269a079 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/uploads/DefaultUploadsService.kt @@ -0,0 +1,46 @@ +/* + * 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.uploads + +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.room.uploads.GetUploadsResult +import im.vector.matrix.android.api.session.room.uploads.UploadsService +import im.vector.matrix.android.api.util.Cancelable +import im.vector.matrix.android.internal.task.TaskExecutor +import im.vector.matrix.android.internal.task.configureWith + +internal class DefaultUploadsService @AssistedInject constructor( + @Assisted private val roomId: String, + private val taskExecutor: TaskExecutor, + private val getUploadsTask: GetUploadsTask +) : UploadsService { + + @AssistedInject.Factory + interface Factory { + fun create(roomId: String): UploadsService + } + + override fun getUploads(numberOfEvents: Int, since: String?, callback: MatrixCallback): Cancelable { + return getUploadsTask + .configureWith(GetUploadsTask.Params(roomId, numberOfEvents, since)) { + this.callback = callback + } + .executeBy(taskExecutor) + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/uploads/GetUploadsTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/uploads/GetUploadsTask.kt new file mode 100644 index 0000000000..fa707c0bf8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/uploads/GetUploadsTask.kt @@ -0,0 +1,100 @@ +/* + * 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.uploads + +import com.zhuinden.monarchy.Monarchy +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.model.message.MessageWithAttachmentContent +import im.vector.matrix.android.api.session.room.sender.SenderInfo +import im.vector.matrix.android.api.session.room.uploads.GetUploadsResult +import im.vector.matrix.android.api.session.room.uploads.UploadEvent +import im.vector.matrix.android.internal.network.executeRequest +import im.vector.matrix.android.internal.session.filter.FilterFactory +import im.vector.matrix.android.internal.session.room.RoomAPI +import im.vector.matrix.android.internal.session.room.membership.RoomMemberHelper +import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection +import im.vector.matrix.android.internal.session.room.timeline.PaginationResponse +import im.vector.matrix.android.internal.session.sync.SyncTokenStore +import im.vector.matrix.android.internal.task.Task +import org.greenrobot.eventbus.EventBus +import javax.inject.Inject + +internal interface GetUploadsTask : Task { + + data class Params( + val roomId: String, + val numberOfEvents: Int, + val since: String? + ) +} + +internal class DefaultGetUploadsTask @Inject constructor( + private val roomAPI: RoomAPI, + private val tokenStore: SyncTokenStore, + private val monarchy: Monarchy, + private val eventBus: EventBus) + : GetUploadsTask { + + override suspend fun execute(params: GetUploadsTask.Params): GetUploadsResult { + val since = params.since ?: tokenStore.getLastToken() ?: throw IllegalStateException("No token available") + + val filter = FilterFactory.createUploadsFilter(params.numberOfEvents).toJSONString() + val chunk = executeRequest(eventBus) { + apiCall = roomAPI.getRoomMessagesFrom(params.roomId, since, PaginationDirection.BACKWARDS.value, params.numberOfEvents, filter) + } + + var uploadEvents = listOf() + + val cacheOfSenderInfos = mutableMapOf() + + // Get a snapshot of all room members + monarchy.doWithRealm { realm -> + val roomMemberHelper = RoomMemberHelper(realm, params.roomId) + + uploadEvents = chunk.events.mapNotNull { event -> + val eventId = event.eventId ?: return@mapNotNull null + val messageContent = event.getClearContent()?.toModel() ?: return@mapNotNull null + val messageWithAttachmentContent = (messageContent as? MessageWithAttachmentContent) ?: return@mapNotNull null + val senderId = event.senderId ?: return@mapNotNull null + + val senderInfo = cacheOfSenderInfos.getOrPut(senderId) { + val roomMemberSummaryEntity = roomMemberHelper.getLastRoomMember(senderId) + SenderInfo( + userId = senderId, + displayName = roomMemberSummaryEntity?.displayName, + isUniqueDisplayName = roomMemberHelper.isUniqueDisplayName(roomMemberSummaryEntity?.displayName), + avatarUrl = roomMemberSummaryEntity?.avatarUrl + ) + } + + UploadEvent( + root = event, + eventId = eventId, + contentWithAttachmentContent = messageWithAttachmentContent, + senderInfo = senderInfo + ) + } + } + + return GetUploadsResult( + uploadEvents = uploadEvents, + nextToken = chunk.end ?: "", + hasMore = chunk.hasMore() + ) + } +} 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/java/im/vector/matrix/android/internal/session/terms/TermsResponse.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/terms/TermsResponse.kt index 7c6451e3a0..394ee958ac 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/terms/TermsResponse.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/terms/TermsResponse.kt @@ -44,7 +44,7 @@ data class TermsResponse( version = tos[VERSION] as? String ) } - }?.filterNotNull() ?: emptyList() + }?.filterNotNull().orEmpty() } private companion object { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/AccountDataModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/AccountDataModule.kt index 1fd4162d0a..2378ab3f0e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/AccountDataModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/AccountDataModule.kt @@ -41,5 +41,5 @@ internal abstract class AccountDataModule { abstract fun bindSaveBreadcrumbsTask(task: DefaultSaveBreadcrumbsTask): SaveBreadcrumbsTask @Binds - abstract fun bindUpdateBreadcrumsTask(task: DefaultUpdateBreadcrumbsTask): UpdateBreadcrumbsTask + abstract fun bindUpdateBreadcrumbsTask(task: DefaultUpdateBreadcrumbsTask): UpdateBreadcrumbsTask } 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/build.gradle b/vector/build.gradle index 74fc96a425..6f1afc5038 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -260,6 +260,7 @@ dependencies { def autofill_version = "1.0.0" def work_version = '2.3.3' def arch_version = '2.1.0' + def lifecycle_version = '2.2.0' implementation project(":matrix-sdk-android") implementation project(":matrix-sdk-android-rx") @@ -282,6 +283,7 @@ dependencies { implementation "com.gabrielittner.threetenbp:lazythreetenbp:0.7.0" implementation "com.squareup.moshi:moshi-adapters:$moshi_version" + implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version" // Log diff --git a/vector/src/main/java/im/vector/riotx/core/date/VectorDateFormatter.kt b/vector/src/main/java/im/vector/riotx/core/date/VectorDateFormatter.kt index 367615d765..344398b91e 100644 --- a/vector/src/main/java/im/vector/riotx/core/date/VectorDateFormatter.kt +++ b/vector/src/main/java/im/vector/riotx/core/date/VectorDateFormatter.kt @@ -45,11 +45,12 @@ class VectorDateFormatter @Inject constructor(private val context: Context, if (time == null) { return "" } - return DateUtils.getRelativeDateTimeString(context, - time, - DateUtils.DAY_IN_MILLIS, - 2 * DateUtils.DAY_IN_MILLIS, - DateUtils.FORMAT_SHOW_WEEKDAY + return DateUtils.getRelativeDateTimeString( + context, + time, + DateUtils.DAY_IN_MILLIS, + 2 * DateUtils.DAY_IN_MILLIS, + DateUtils.FORMAT_SHOW_WEEKDAY or DateUtils.FORMAT_SHOW_TIME ).toString() } } diff --git a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt index 763dc9298e..4845618ca6 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt @@ -76,6 +76,9 @@ import im.vector.riotx.features.roommemberprofile.devices.DeviceTrustInfoActionF import im.vector.riotx.features.roomprofile.RoomProfileFragment import im.vector.riotx.features.roomprofile.members.RoomMemberListFragment import im.vector.riotx.features.roomprofile.settings.RoomSettingsFragment +import im.vector.riotx.features.roomprofile.uploads.RoomUploadsFragment +import im.vector.riotx.features.roomprofile.uploads.files.RoomUploadsFilesFragment +import im.vector.riotx.features.roomprofile.uploads.media.RoomUploadsMediaFragment import im.vector.riotx.features.settings.VectorSettingsAdvancedNotificationPreferenceFragment import im.vector.riotx.features.settings.VectorSettingsHelpAboutFragment import im.vector.riotx.features.settings.VectorSettingsLabsFragment @@ -310,6 +313,21 @@ interface FragmentModule { @FragmentKey(RoomMemberListFragment::class) fun bindRoomMemberListFragment(fragment: RoomMemberListFragment): Fragment + @Binds + @IntoMap + @FragmentKey(RoomUploadsFragment::class) + fun bindRoomUploadsFragment(fragment: RoomUploadsFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(RoomUploadsMediaFragment::class) + fun bindRoomUploadsMediaFragment(fragment: RoomUploadsMediaFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(RoomUploadsFilesFragment::class) + fun bindRoomUploadsFilesFragment(fragment: RoomUploadsFilesFragment): Fragment + @Binds @IntoMap @FragmentKey(RoomSettingsFragment::class) diff --git a/vector/src/main/java/im/vector/riotx/core/di/VectorModule.kt b/vector/src/main/java/im/vector/riotx/core/di/VectorModule.kt index 848c1e0d97..3665df31dd 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/VectorModule.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/VectorModule.kt @@ -75,8 +75,8 @@ abstract class VectorModule { abstract fun bindNavigator(navigator: DefaultNavigator): Navigator @Binds - abstract fun bindErrorFormatter(errorFormatter: DefaultErrorFormatter): ErrorFormatter + abstract fun bindErrorFormatter(formatter: DefaultErrorFormatter): ErrorFormatter @Binds - abstract fun bindUiStateRepository(uiStateRepository: SharedPreferencesUiStateRepository): UiStateRepository + abstract fun bindUiStateRepository(repository: SharedPreferencesUiStateRepository): UiStateRepository } diff --git a/vector/src/main/java/im/vector/riotx/core/epoxy/SquareLoadingItem.kt b/vector/src/main/java/im/vector/riotx/core/epoxy/SquareLoadingItem.kt new file mode 100644 index 0000000000..c0f6eb198f --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/epoxy/SquareLoadingItem.kt @@ -0,0 +1,26 @@ +/* + * 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.epoxy + +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R + +@EpoxyModelClass(layout = R.layout.item_loading_square) +abstract class SquareLoadingItem : VectorEpoxyModel() { + + class Holder : VectorEpoxyHolder() +} 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/core/extensions/Fragment.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt index b93ab3fdce..c28dcf12d3 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt @@ -80,5 +80,12 @@ fun VectorBaseFragment.addChildFragmentToBackstack(frameId: Int, } } +/** + * Return a list of all child Fragments, recursively + */ +fun Fragment.getAllChildFragments(): List { + return listOf(this) + childFragmentManager.fragments.map { it.getAllChildFragments() }.flatten() +} + // Define a missing constant const val POP_BACK_STACK_EXCLUSIVE = 0 diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/RecyclerView.kt b/vector/src/main/java/im/vector/riotx/core/extensions/RecyclerView.kt index 3b3132229c..3762c52d45 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/RecyclerView.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/RecyclerView.kt @@ -21,6 +21,7 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.EpoxyController +import com.airbnb.epoxy.EpoxyVisibilityTracker import im.vector.riotx.R import im.vector.riotx.features.themes.ThemeUtils @@ -61,3 +62,5 @@ fun RecyclerView.configureWith(epoxyController: EpoxyController, fun RecyclerView.cleanup() { adapter = null } + +fun RecyclerView.trackItemsVisibilityChange() = EpoxyVisibilityTracker().attach(this) diff --git a/vector/src/main/java/im/vector/riotx/core/platform/StateView.kt b/vector/src/main/java/im/vector/riotx/core/platform/StateView.kt index 4c5a987b4b..bc24874f9f 100755 --- a/vector/src/main/java/im/vector/riotx/core/platform/StateView.kt +++ b/vector/src/main/java/im/vector/riotx/core/platform/StateView.kt @@ -21,6 +21,7 @@ import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.View import android.widget.FrameLayout +import androidx.core.view.isVisible import im.vector.riotx.R import kotlinx.android.synthetic.main.view_state.view.* @@ -31,6 +32,7 @@ class StateView @JvmOverloads constructor(context: Context, attrs: AttributeSet? object Content : State() object Loading : State() data class Empty(val title: CharSequence? = null, val image: Drawable? = null, val message: CharSequence? = null) : State() + data class Error(val message: CharSequence? = null) : State() } @@ -59,34 +61,21 @@ class StateView @JvmOverloads constructor(context: Context, attrs: AttributeSet? } private fun update(newState: State) { + progressBar.isVisible = newState is State.Loading + errorView.isVisible = newState is State.Error + emptyView.isVisible = newState is State.Empty + contentView?.isVisible = newState is State.Content + when (newState) { - is State.Content -> { - progressBar.visibility = View.INVISIBLE - errorView.visibility = View.INVISIBLE - emptyView.visibility = View.INVISIBLE - contentView?.visibility = View.VISIBLE - } - is State.Loading -> { - progressBar.visibility = View.VISIBLE - errorView.visibility = View.INVISIBLE - emptyView.visibility = View.INVISIBLE - contentView?.visibility = View.INVISIBLE - } + is State.Content -> Unit + is State.Loading -> Unit is State.Empty -> { - progressBar.visibility = View.INVISIBLE - errorView.visibility = View.INVISIBLE - emptyView.visibility = View.VISIBLE emptyImageView.setImageDrawable(newState.image) emptyMessageView.text = newState.message emptyTitleView.text = newState.title - contentView?.visibility = View.INVISIBLE } is State.Error -> { - progressBar.visibility = View.INVISIBLE - errorView.visibility = View.VISIBLE - emptyView.visibility = View.INVISIBLE errorMessageView.text = newState.message - contentView?.visibility = View.INVISIBLE } } } diff --git a/vector/src/main/java/im/vector/riotx/core/ui/model/Size.kt b/vector/src/main/java/im/vector/riotx/core/ui/model/Size.kt index 65ab0ad2b2..4a07bb2cea 100644 --- a/vector/src/main/java/im/vector/riotx/core/ui/model/Size.kt +++ b/vector/src/main/java/im/vector/riotx/core/ui/model/Size.kt @@ -16,5 +16,7 @@ package im.vector.riotx.core.ui.model +import androidx.annotation.Px + // android.util.Size in API 21+ -data class Size(val width: Int, val height: Int) +data class Size(@Px val width: Int, @Px val height: Int) diff --git a/vector/src/main/java/im/vector/riotx/core/utils/DimensionConverter.kt b/vector/src/main/java/im/vector/riotx/core/utils/DimensionConverter.kt index 826d9a495a..01cd6a4f8f 100644 --- a/vector/src/main/java/im/vector/riotx/core/utils/DimensionConverter.kt +++ b/vector/src/main/java/im/vector/riotx/core/utils/DimensionConverter.kt @@ -17,10 +17,12 @@ package im.vector.riotx.core.utils import android.content.res.Resources import android.util.TypedValue +import androidx.annotation.Px import javax.inject.Inject class DimensionConverter @Inject constructor(val resources: Resources) { + @Px fun dpToPx(dp: Int): Int { return TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, @@ -29,6 +31,7 @@ class DimensionConverter @Inject constructor(val resources: Resources) { ).toInt() } + @Px fun spToPx(sp: Int): Int { return TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, @@ -36,4 +39,8 @@ class DimensionConverter @Inject constructor(val resources: Resources) { resources.displayMetrics ).toInt() } + + fun pxToDp(@Px px: Int): Int { + return (px.toFloat() / resources.displayMetrics.density).toInt() + } } diff --git a/vector/src/main/java/im/vector/riotx/core/utils/ExternalApplicationsUtil.kt b/vector/src/main/java/im/vector/riotx/core/utils/ExternalApplicationsUtil.kt index afb7c4586a..e46d756523 100644 --- a/vector/src/main/java/im/vector/riotx/core/utils/ExternalApplicationsUtil.kt +++ b/vector/src/main/java/im/vector/riotx/core/utils/ExternalApplicationsUtil.kt @@ -256,7 +256,11 @@ fun shareMedia(context: Context, file: File, mediaMimeType: String?) { sendIntent.type = mediaMimeType sendIntent.putExtra(Intent.EXTRA_STREAM, mediaUri) - context.startActivity(sendIntent) + try { + context.startActivity(sendIntent) + } catch (activityNotFoundException: ActivityNotFoundException) { + context.toast(R.string.error_no_external_application_found) + } } } diff --git a/vector/src/main/java/im/vector/riotx/features/attachments/preview/AttachmentsPreviewActivity.kt b/vector/src/main/java/im/vector/riotx/features/attachments/preview/AttachmentsPreviewActivity.kt index 6c91f70131..21febd81a9 100644 --- a/vector/src/main/java/im/vector/riotx/features/attachments/preview/AttachmentsPreviewActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/attachments/preview/AttachmentsPreviewActivity.kt @@ -43,7 +43,7 @@ class AttachmentsPreviewActivity : VectorBaseActivity(), ToolbarConfigurable { } fun getOutput(intent: Intent): List { - return intent.getParcelableArrayListExtra(ATTACHMENTS_PREVIEW_RESULT) ?: emptyList() + return intent.getParcelableArrayListExtra(ATTACHMENTS_PREVIEW_RESULT).orEmpty() } fun getKeepOriginalSize(intent: Intent): Boolean { diff --git a/vector/src/main/java/im/vector/riotx/features/discovery/DiscoverySettingsViewModel.kt b/vector/src/main/java/im/vector/riotx/features/discovery/DiscoverySettingsViewModel.kt index 7c5086afa7..9693b6a4ad 100644 --- a/vector/src/main/java/im/vector/riotx/features/discovery/DiscoverySettingsViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/discovery/DiscoverySettingsViewModel.kt @@ -151,8 +151,8 @@ class DiscoverySettingsViewModel @AssistedInject constructor( private fun changeThreePidState(threePid: ThreePid, state: Async) { setState { - val currentMails = emailList() ?: emptyList() - val phones = phoneNumbersList() ?: emptyList() + val currentMails = emailList().orEmpty() + val phones = phoneNumbersList().orEmpty() copy( emailList = Success( currentMails.map { @@ -178,8 +178,8 @@ class DiscoverySettingsViewModel @AssistedInject constructor( private fun changeThreePidSubmitState(threePid: ThreePid, submitState: Async) { setState { - val currentMails = emailList() ?: emptyList() - val phones = phoneNumbersList() ?: emptyList() + val currentMails = emailList().orEmpty() + val phones = phoneNumbersList().orEmpty() copy( emailList = Success( currentMails.map { diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt index b17fb87f50..4e5d37af6c 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt @@ -123,12 +123,12 @@ class HomeDetailFragment @Inject constructor( ?.navigator ?.requestSessionVerification(requireContext(), newest.deviceId ?: "") unknownDeviceDetectorSharedViewModel.handle( - UnknownDeviceDetectorSharedViewModel.Action.IgnoreDevice(newest.deviceId?.let { listOf(it) } ?: emptyList()) + UnknownDeviceDetectorSharedViewModel.Action.IgnoreDevice(newest.deviceId?.let { listOf(it) }.orEmpty()) ) } dismissedAction = Runnable { unknownDeviceDetectorSharedViewModel.handle( - UnknownDeviceDetectorSharedViewModel.Action.IgnoreDevice(newest.deviceId?.let { listOf(it) } ?: emptyList()) + UnknownDeviceDetectorSharedViewModel.Action.IgnoreDevice(newest.deviceId?.let { listOf(it) }.orEmpty()) ) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index df33e60c43..c7e30d9efd 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -22,7 +22,6 @@ import android.content.DialogInterface import android.content.Intent import android.graphics.Typeface import android.net.Uri -import android.os.Build import android.os.Bundle import android.os.Parcelable import android.text.Spannable @@ -30,12 +29,10 @@ import android.view.HapticFeedbackConstants import android.view.Menu import android.view.MenuItem import android.view.View -import android.view.Window import android.widget.Toast import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog -import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.core.text.buildSpannedString @@ -49,7 +46,6 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import butterknife.BindView import com.airbnb.epoxy.EpoxyModel -import com.airbnb.epoxy.EpoxyVisibilityTracker import com.airbnb.epoxy.OnModelBuildFinishedListener import com.airbnb.mvrx.Async import com.airbnb.mvrx.Fail @@ -95,6 +91,7 @@ import im.vector.riotx.core.extensions.exhaustive import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.setTextOrHide import im.vector.riotx.core.extensions.showKeyboard +import im.vector.riotx.core.extensions.trackItemsVisibilityChange import im.vector.riotx.core.files.addEntryToDownloadManager import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.intent.getMimeTypeFromUri @@ -150,9 +147,7 @@ import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.html.PillImageSpan import im.vector.riotx.features.invite.VectorInviteView import im.vector.riotx.features.media.ImageContentRenderer -import im.vector.riotx.features.media.ImageMediaViewerActivity import im.vector.riotx.features.media.VideoContentRenderer -import im.vector.riotx.features.media.VideoMediaViewerActivity import im.vector.riotx.features.notifications.NotificationDrawerManager import im.vector.riotx.features.permalink.NavigationInterceptor import im.vector.riotx.features.permalink.PermalinkHandler @@ -469,7 +464,7 @@ class RoomDetailFragment @Inject constructor( autoCompleter.enterSpecialMode() // switch to expanded bar composerLayout.composerRelatedMessageTitle.apply { - text = event.getDisambiguatedDisplayName() + text = event.senderInfo.disambiguatedDisplayName setTextColor(ContextCompat.getColor(requireContext(), getColorFromUserId(event.root.senderId))) } @@ -488,11 +483,7 @@ class RoomDetailFragment @Inject constructor( composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) composerLayout.sendButton.contentDescription = getString(descriptionRes) - avatarRenderer.render( - MatrixItem.UserItem(event.root.senderId - ?: "", event.getDisambiguatedDisplayName(), event.senderAvatar), - composerLayout.composerRelatedMessageAvatar - ) + avatarRenderer.render(event.senderInfo.toMatrixItem(), composerLayout.composerRelatedMessageAvatar) composerLayout.expand { if (isAdded) { @@ -549,8 +540,7 @@ class RoomDetailFragment @Inject constructor( timelineEventController.callback = this timelineEventController.timeline = roomDetailViewModel.timeline - val epoxyVisibilityTracker = EpoxyVisibilityTracker() - epoxyVisibilityTracker.attach(recyclerView) + recyclerView.trackItemsVisibilityChange() layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, true) val stateRestorer = LayoutManagerStateRestorer(layoutManager).register() scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager, timelineEventController) @@ -1004,31 +994,14 @@ class RoomDetailFragment @Inject constructor( } override fun onImageMessageClicked(messageImageContent: MessageImageInfoContent, mediaData: ImageContentRenderer.Data, view: View) { - // TODO Use navigator - - val intent = ImageMediaViewerActivity.newIntent(vectorBaseActivity, mediaData, ViewCompat.getTransitionName(view)) - val pairs = ArrayList>() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - requireActivity().window.decorView.findViewById(android.R.id.statusBarBackground)?.let { - pairs.add(Pair(it, Window.STATUS_BAR_BACKGROUND_TRANSITION_NAME)) - } - requireActivity().window.decorView.findViewById(android.R.id.navigationBarBackground)?.let { - pairs.add(Pair(it, Window.NAVIGATION_BAR_BACKGROUND_TRANSITION_NAME)) - } + navigator.openImageViewer(requireActivity(), mediaData, view) { pairs -> + pairs.add(Pair(roomToolbar, ViewCompat.getTransitionName(roomToolbar) ?: "")) + pairs.add(Pair(composerLayout, ViewCompat.getTransitionName(composerLayout) ?: "")) } - pairs.add(Pair(view, ViewCompat.getTransitionName(view) ?: "")) - pairs.add(Pair(roomToolbar, ViewCompat.getTransitionName(roomToolbar) ?: "")) - pairs.add(Pair(composerLayout, ViewCompat.getTransitionName(composerLayout) ?: "")) - - val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation( - requireActivity(), *pairs.toTypedArray()).toBundle() - startActivity(intent, bundle) } override fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View) { - // TODO Use navigator - val intent = VideoMediaViewerActivity.newIntent(vectorBaseActivity, mediaData) - startActivity(intent) + navigator.openVideoViewer(requireActivity(), mediaData) } override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index 0ce3a4ebcf..b55bbdf130 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -117,8 +117,10 @@ class RoomDetailViewModel @AssistedInject constructor( // Slot to keep a pending action during permission request var pendingAction: RoomDetailAction? = null + // Slot to keep a pending uri during permission request var pendingUri: Uri? = null + // Slot to store if we want to prevent preview of attachment var preventAttachmentPreview = false @@ -391,7 +393,7 @@ class RoomDetailViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.SlashCommandNotImplemented) } is ParsedCommand.SendEmote -> { - room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE) + room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE, autoMarkdown = action.autoMarkdown) _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) popDraft() } 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/edithistory/ViewEditHistoryEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/edithistory/ViewEditHistoryEpoxyController.kt index 7ac0c8b1e8..329f66459b 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/edithistory/ViewEditHistoryEpoxyController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/edithistory/ViewEditHistoryEpoxyController.kt @@ -27,6 +27,7 @@ import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.message.MessageTextContent import im.vector.matrix.android.api.util.ContentUtils.extractUsefulTextFromReply +import im.vector.matrix.android.internal.session.room.send.TextContent import im.vector.riotx.R import im.vector.riotx.core.date.VectorDateFormatter import im.vector.riotx.core.extensions.localDateTime @@ -90,17 +91,15 @@ class ViewEditHistoryEpoxyController(private val context: Context, } lastDate = evDate val cContent = getCorrectContent(timelineEvent, isOriginalReply) - val body = cContent.second?.let { eventHtmlRenderer.render(it) } - ?: cContent.first + val body = cContent.formattedText?.let { eventHtmlRenderer.render(it) } ?: cContent.text val nextEvent = sourceEvents.getOrNull(index + 1) var spannedDiff: Spannable? = null - if (nextEvent != null && cContent.second == null /*No diff for html*/) { + if (nextEvent != null && cContent.formattedText == null /*No diff for html*/) { // compares the body val nContent = getCorrectContent(nextEvent, isOriginalReply) - val nextBody = nContent.second?.let { eventHtmlRenderer.render(it) } - ?: nContent.first + val nextBody = nContent.formattedText?.let { eventHtmlRenderer.render(it) } ?: nContent.text val dmp = diff_match_patch() val diff = dmp.diff_main(nextBody.toString(), body.toString()) dmp.diff_cleanupSemantic(diff) @@ -138,15 +137,14 @@ class ViewEditHistoryEpoxyController(private val context: Context, } } - private fun getCorrectContent(event: Event, isOriginalReply: Boolean): Pair { + private fun getCorrectContent(event: Event, isOriginalReply: Boolean): TextContent { val clearContent = event.getClearContent().toModel() val newContent = clearContent ?.newContent ?.toModel() if (isOriginalReply) { - return extractUsefulTextFromReply(newContent?.body ?: clearContent?.body ?: "") to null + return TextContent(extractUsefulTextFromReply(newContent?.body ?: clearContent?.body ?: "")) } - return (newContent?.body ?: clearContent?.body ?: "") to (newContent?.formattedBody - ?: clearContent?.formattedBody) + return TextContent(newContent?.body ?: clearContent?.body ?: "", newContent?.formattedBody ?: clearContent?.formattedBody) } } 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..419fd673d1 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,67 @@ 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 data = BasedMergedItem.Data( + userId = mergedEvent.root.senderId ?: "", + avatarUrl = mergedEvent.senderInfo.avatarUrl, + memberName = mergedEvent.senderInfo.disambiguatedDisplayName, + 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 +134,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 +146,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 @@ -149,12 +156,10 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act 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, + avatarUrl = mergedEvent.senderInfo.avatarUrl, + memberName = mergedEvent.senderInfo.disambiguatedDisplayName, localId = mergedEvent.localId, eventId = mergedEvent.root.eventId ?: "" ) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt index ffb71a38c5..96abe1ff40 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -29,6 +29,7 @@ import im.vector.matrix.android.api.session.events.model.RelationType import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent import im.vector.matrix.android.api.session.room.model.message.MessageContent +import im.vector.matrix.android.api.session.room.model.message.MessageContentWithFormattedBody import im.vector.matrix.android.api.session.room.model.message.MessageEmoteContent import im.vector.matrix.android.api.session.room.model.message.MessageFileContent import im.vector.matrix.android.api.session.room.model.message.MessageImageInfoContent @@ -350,7 +351,7 @@ class MessageItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes): VectorEpoxyModel<*>? { - val isFormatted = messageContent.formattedBody.isNullOrBlank().not() + val isFormatted = messageContent.matrixFormattedBody.isNullOrBlank().not() return if (isFormatted) { // First detect if the message contains some code block(s) or inline code val localFormattedBody = htmlRenderer.get().parse(messageContent.body) as Document @@ -462,14 +463,14 @@ class MessageItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes): MessageTextItem? { - val message = messageContent.body.let { - val formattedBody = span { - text = it - textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary) - textStyle = "italic" - } - formattedBody.linkify(callback) + val formattedBody = span { + text = messageContent.getHtmlBody() + textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary) + textStyle = "italic" } + + val message = formattedBody.linkify(callback) + return MessageTextItem_() .leftGuideline(avatarSizeProvider.leftGuideline) .attributes(attributes) @@ -483,10 +484,12 @@ class MessageItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes): MessageTextItem? { - val message = messageContent.body.let { - val formattedBody = "* ${informationData.memberName} $it" - formattedBody.linkify(callback) - } + val formattedBody = SpannableStringBuilder() + formattedBody.append("* ${informationData.memberName} ") + formattedBody.append(messageContent.getHtmlBody()) + + val message = formattedBody.linkify(callback) + return MessageTextItem_() .apply { if (informationData.hasBeenEdited) { @@ -502,6 +505,13 @@ class MessageItemFactory @Inject constructor( .movementMethod(createLinkMovementMethod(callback)) } + private fun MessageContentWithFormattedBody.getHtmlBody(): CharSequence { + return matrixFormattedBody + ?.let { htmlCompressor.compress(it) } + ?.let { htmlRenderer.get().render(it) } + ?: body + } + private fun buildRedactedItem(attributes: AbsMessageItem.Attributes, highlight: Boolean): RedactedMessageItem? { return RedactedMessageItem_() 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/DisplayableEventFormatter.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt index 2f7b52de62..9ab48ad5ee 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt @@ -45,7 +45,7 @@ class DisplayableEventFormatter @Inject constructor( return stringProvider.getString(R.string.encrypted_message) } - val senderName = timelineEvent.getDisambiguatedDisplayName() + val senderName = timelineEvent.senderInfo.disambiguatedDisplayName when (timelineEvent.root.getClearType()) { EventType.MESSAGE -> { 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..86c9f0ab5b 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 @@ -46,19 +47,20 @@ 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_NAME -> formatRoomNameEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) - EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) - EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) - EventType.STATE_ROOM_ALIASES -> formatRoomAliasesEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) - EventType.STATE_ROOM_CANONICAL_ALIAS -> formatRoomCanonicalAliasEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) - EventType.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) - EventType.STATE_ROOM_GUEST_ACCESS -> formatRoomGuestAccessEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) - EventType.STATE_ROOM_ENCRYPTION -> formatRoomEncryptionEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) - EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(timelineEvent.getDisambiguatedDisplayName()) + EventType.STATE_ROOM_JOIN_RULES -> formatJoinRulesEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + EventType.STATE_ROOM_CREATE -> formatRoomCreateEvent(timelineEvent.root) + EventType.STATE_ROOM_NAME -> formatRoomNameEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + EventType.STATE_ROOM_ALIASES -> formatRoomAliasesEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + EventType.STATE_ROOM_CANONICAL_ALIAS -> formatRoomCanonicalAliasEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + EventType.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + EventType.STATE_ROOM_GUEST_ACCESS -> formatRoomGuestAccessEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + EventType.STATE_ROOM_ENCRYPTION -> formatRoomEncryptionEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(timelineEvent.senderInfo.disambiguatedDisplayName) EventType.CALL_INVITE, EventType.CALL_HANGUP, - EventType.CALL_ANSWER -> formatCallEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) + EventType.CALL_ANSWER -> formatCallEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) EventType.MESSAGE, EventType.REACTION, EventType.KEY_VERIFICATION_START, @@ -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/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index 695da73f89..9a912b5af3 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -64,16 +64,14 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses val showInformation = addDaySeparator - || event.senderAvatar != nextEvent?.senderAvatar - || event.getDisambiguatedDisplayName() != nextEvent?.getDisambiguatedDisplayName() + || event.senderInfo.avatarUrl != nextEvent?.senderInfo?.avatarUrl + || event.senderInfo.disambiguatedDisplayName != nextEvent?.senderInfo?.disambiguatedDisplayName || (nextEvent.root.getClearType() != EventType.MESSAGE && nextEvent.root.getClearType() != EventType.ENCRYPTED) || isNextMessageReceivedMoreThanOneHourAgo || isTileTypeMessage(nextEvent) val time = dateFormatter.formatMessageHour(date) - val avatarUrl = event.senderAvatar - val memberName = event.getDisambiguatedDisplayName() - val formattedMemberName = span(memberName) { + val formattedMemberName = span(event.senderInfo.disambiguatedDisplayName) { textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId)) } @@ -85,7 +83,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses sendState = event.root.sendState, time = time, ageLocalTS = event.root.ageLocalTs, - avatarUrl = avatarUrl, + avatarUrl = event.senderInfo.avatarUrl, memberName = formattedMemberName, showInformation = showInformation, orderedReactionList = event.annotations?.reactionsSummary 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() - } -} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/reactions/ViewReactionsViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/reactions/ViewReactionsViewModel.kt index 05cdbc0fd8..3d8382ab98 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/reactions/ViewReactionsViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/reactions/ViewReactionsViewModel.kt @@ -111,7 +111,7 @@ class ViewReactionsViewModel @AssistedInject constructor(@Assisted event.root.eventId!!, summary.key, event.root.senderId ?: "", - event.getDisambiguatedDisplayName(), + event.senderInfo.disambiguatedDisplayName, dateFormatter.formatRelativeDateTime(event.root.originServerTs) ) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListModule.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListModule.kt index 4541b5d2b5..411db4d679 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListModule.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListModule.kt @@ -23,5 +23,5 @@ import dagger.Module abstract class RoomListModule { @Binds - abstract fun providesRoomListViewModelFactory(roomListViewModelFactory: RoomListViewModelFactory): RoomListViewModel.Factory + abstract fun providesRoomListViewModelFactory(factory: RoomListViewModelFactory): RoomListViewModel.Factory } diff --git a/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt b/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt index becb714bf4..ab047fba0d 100644 --- a/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt +++ b/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt @@ -29,6 +29,7 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.ORIENTATION import com.github.piasy.biv.view.BigImageView import im.vector.matrix.android.api.session.content.ContentUrlResolver import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt +import im.vector.riotx.R import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.glide.GlideRequest @@ -65,6 +66,18 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: STICKER } + /** + * For gallery + */ + fun render(data: Data, imageView: ImageView, size: Int) { + // a11y + imageView.contentDescription = data.filename + + createGlideRequest(data, Mode.THUMBNAIL, imageView, Size(size, size)) + .placeholder(R.drawable.ic_image) + .into(imageView) + } + fun render(data: Data, mode: Mode, imageView: ImageView) { val size = processSize(data, mode) imageView.layoutParams.width = size.width diff --git a/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt index 5607668ef9..60de8ac38e 100644 --- a/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt @@ -19,9 +19,12 @@ package im.vector.riotx.features.navigation import android.app.Activity import android.content.Context import android.content.Intent +import android.os.Build import android.view.View +import android.view.Window import androidx.core.app.ActivityOptionsCompat import androidx.core.app.TaskStackBuilder +import androidx.core.util.Pair import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import im.vector.matrix.android.api.session.crypto.verification.IncomingSasVerificationTransaction @@ -45,6 +48,10 @@ import im.vector.riotx.features.home.room.detail.RoomDetailArgs import im.vector.riotx.features.home.room.filtered.FilteredRoomsActivity import im.vector.riotx.features.invite.InviteUsersToRoomActivity import im.vector.riotx.features.media.BigImageViewerActivity +import im.vector.riotx.features.media.ImageContentRenderer +import im.vector.riotx.features.media.ImageMediaViewerActivity +import im.vector.riotx.features.media.VideoContentRenderer +import im.vector.riotx.features.media.VideoMediaViewerActivity import im.vector.riotx.features.roomdirectory.RoomDirectoryActivity import im.vector.riotx.features.roomdirectory.createroom.CreateRoomActivity import im.vector.riotx.features.roomdirectory.roompreview.RoomPreviewActivity @@ -59,7 +66,6 @@ import im.vector.riotx.features.widgets.WidgetActivity import im.vector.riotx.features.widgets.WidgetArgsBuilder import javax.inject.Inject import javax.inject.Singleton - @Singleton class DefaultNavigator @Inject constructor( private val sessionHolder: ActiveSessionHolder, @@ -223,6 +229,29 @@ class DefaultNavigator @Inject constructor( context.startActivity(WidgetActivity.newIntent(context, widgetArgs)) } + override fun openImageViewer(activity: Activity, mediaData: ImageContentRenderer.Data, view: View, options: ((MutableList>) -> Unit)?) { + val intent = ImageMediaViewerActivity.newIntent(activity, mediaData, ViewCompat.getTransitionName(view)) + val pairs = ArrayList>() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + activity.window.decorView.findViewById(android.R.id.statusBarBackground)?.let { + pairs.add(Pair(it, Window.STATUS_BAR_BACKGROUND_TRANSITION_NAME)) + } + activity.window.decorView.findViewById(android.R.id.navigationBarBackground)?.let { + pairs.add(Pair(it, Window.NAVIGATION_BAR_BACKGROUND_TRANSITION_NAME)) + } + } + pairs.add(Pair(view, ViewCompat.getTransitionName(view) ?: "")) + options?.invoke(pairs) + + val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, *pairs.toTypedArray()).toBundle() + activity.startActivity(intent, bundle) + } + + override fun openVideoViewer(activity: Activity, mediaData: VideoContentRenderer.Data) { + val intent = VideoMediaViewerActivity.newIntent(activity, mediaData) + activity.startActivity(intent) + } + private fun startActivity(context: Context, intent: Intent, buildTask: Boolean) { if (buildTask) { val stackBuilder = TaskStackBuilder.create(context) @@ -233,3 +262,4 @@ class DefaultNavigator @Inject constructor( } } } + diff --git a/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt index 10edad2328..ab63efe6fe 100644 --- a/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt +++ b/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt @@ -19,10 +19,13 @@ package im.vector.riotx.features.navigation import android.app.Activity import android.content.Context import android.view.View +import androidx.core.util.Pair import androidx.fragment.app.Fragment import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom import im.vector.matrix.android.api.session.terms.TermsService import im.vector.matrix.android.api.util.MatrixItem +import im.vector.riotx.features.media.ImageContentRenderer +import im.vector.riotx.features.media.VideoContentRenderer import im.vector.riotx.features.settings.VectorSettingsActivity import im.vector.riotx.features.share.SharedData import im.vector.riotx.features.terms.ReviewTermsActivity @@ -79,4 +82,8 @@ interface Navigator { fun openIntegrationManager(context: Context, roomId: String, integId: String?, screenId: String?) + fun openImageViewer(activity: Activity, mediaData: ImageContentRenderer.Data, view: View, options: ((MutableList>) -> Unit)?) + + fun openVideoViewer(activity: Activity, mediaData: VideoContentRenderer.Data) + } diff --git a/vector/src/main/java/im/vector/riotx/features/notifications/NotifiableEventResolver.kt b/vector/src/main/java/im/vector/riotx/features/notifications/NotifiableEventResolver.kt index 1f9f54127b..a2dc8d33f0 100644 --- a/vector/src/main/java/im/vector/riotx/features/notifications/NotifiableEventResolver.kt +++ b/vector/src/main/java/im/vector/riotx/features/notifications/NotifiableEventResolver.kt @@ -93,7 +93,7 @@ class NotifiableEventResolver @Inject constructor(private val stringProvider: St // Ok room is not known in store, but we can still display something val body = displayableEventFormatter.format(event, false) val roomName = stringProvider.getString(R.string.notification_unknown_room_name) - val senderDisplayName = event.getDisambiguatedDisplayName() + val senderDisplayName = event.senderInfo.disambiguatedDisplayName val notifiableEvent = NotifiableMessageEvent( eventId = event.root.eventId!!, @@ -126,7 +126,7 @@ class NotifiableEventResolver @Inject constructor(private val stringProvider: St val body = displayableEventFormatter.format(event, false).toString() val roomName = room.roomSummary()?.displayName ?: "" - val senderDisplayName = event.getDisambiguatedDisplayName() + val senderDisplayName = event.senderInfo.disambiguatedDisplayName val notifiableEvent = NotifiableMessageEvent( eventId = event.root.eventId!!, @@ -151,7 +151,7 @@ class NotifiableEventResolver @Inject constructor(private val stringProvider: St ContentUrlResolver.ThumbnailMethod.SCALE) notifiableEvent.senderAvatarPath = session.contentUrlResolver() - .resolveThumbnail(event.senderAvatar, + .resolveThumbnail(event.senderInfo.avatarUrl, 250, 250, ContentUrlResolver.ThumbnailMethod.SCALE) diff --git a/vector/src/main/java/im/vector/riotx/features/rageshake/BugReportActivity.kt b/vector/src/main/java/im/vector/riotx/features/rageshake/BugReportActivity.kt index 5c1428cb54..f943186e2e 100755 --- a/vector/src/main/java/im/vector/riotx/features/rageshake/BugReportActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/rageshake/BugReportActivity.kt @@ -70,6 +70,8 @@ class BugReportActivity : VectorBaseActivity() { bug_report_button_include_crash_logs.isVisible = false // Keep the screenshot + } else { + supportActionBar?.setTitle(R.string.title_activity_bug_report) } } diff --git a/vector/src/main/java/im/vector/riotx/features/rageshake/BugReporter.kt b/vector/src/main/java/im/vector/riotx/features/rageshake/BugReporter.kt index a001567635..d9020afab4 100755 --- a/vector/src/main/java/im/vector/riotx/features/rageshake/BugReporter.kt +++ b/vector/src/main/java/im/vector/riotx/features/rageshake/BugReporter.kt @@ -19,17 +19,20 @@ package im.vector.riotx.features.rageshake import android.annotation.SuppressLint -import android.app.Activity import android.content.Context import android.content.Intent import android.graphics.Bitmap +import android.graphics.Canvas import android.os.AsyncTask import android.os.Build import android.view.View +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentActivity import im.vector.matrix.android.api.Matrix import im.vector.riotx.BuildConfig import im.vector.riotx.R import im.vector.riotx.core.di.ActiveSessionHolder +import im.vector.riotx.core.extensions.getAllChildFragments import im.vector.riotx.core.extensions.toOnOff import im.vector.riotx.features.settings.VectorLocale import im.vector.riotx.features.settings.VectorPreferences @@ -423,7 +426,7 @@ class BugReporter @Inject constructor( /** * Send a bug report either with email or with Vector. */ - fun openBugReportScreen(activity: Activity, forSuggestion: Boolean = false) { + fun openBugReportScreen(activity: FragmentActivity, forSuggestion: Boolean = false) { screenshot = takeScreenshot(activity) val intent = Intent(activity, BugReportActivity::class.java) @@ -512,41 +515,64 @@ class BugReporter @Inject constructor( * * @return the screenshot */ - private fun takeScreenshot(activity: Activity): Bitmap? { - // get content view - val contentView = activity.findViewById(android.R.id.content) - if (contentView == null) { - Timber.e("Cannot find content view on $activity. Cannot take screenshot.") - return null - } - + private fun takeScreenshot(activity: FragmentActivity): Bitmap? { // get the root view to snapshot - val rootView = contentView.rootView + val rootView = activity.window?.decorView?.rootView if (rootView == null) { Timber.e("Cannot find root view on $activity. Cannot take screenshot.") return null } - // refresh it + + val mainBitmap = getBitmap(rootView) + + if (mainBitmap == null) { + Timber.e("Cannot get main screenshot") + return null + } + + try { + val cumulBitmap = Bitmap.createBitmap(mainBitmap.width, mainBitmap.height, Bitmap.Config.ARGB_8888) + + val canvas = Canvas(cumulBitmap) + canvas.drawBitmap(mainBitmap, 0f, 0f, null) + // Add the dialogs if any + getDialogBitmaps(activity).forEach { + canvas.drawBitmap(it, 0f, 0f, null) + } + + return cumulBitmap + } catch (e: Throwable) { + Timber.e(e, "Cannot get snapshot of screen: $e") + } + + return null + } + + private fun getDialogBitmaps(activity: FragmentActivity): List { + return activity.supportFragmentManager.fragments + .map { it.getAllChildFragments() } + .flatten() + .filterIsInstance(DialogFragment::class.java) + .mapNotNull { fragment -> + fragment.dialog?.window?.decorView?.rootView?.let { rootView -> + getBitmap(rootView) + } + } + } + + private fun getBitmap(rootView: View): Bitmap? { @Suppress("DEPRECATION") rootView.isDrawingCacheEnabled = false @Suppress("DEPRECATION") rootView.isDrawingCacheEnabled = true - try { + return try { @Suppress("DEPRECATION") - var bitmap = rootView.drawingCache - - // Make a copy, because if Activity is destroyed, the bitmap will be recycled - bitmap = Bitmap.createBitmap(bitmap) - - return bitmap - } catch (oom: OutOfMemoryError) { - Timber.e(oom, "Cannot get drawing cache for $activity OOM.") - } catch (e: Exception) { - Timber.e(e, "Cannot get snapshot of screen: $e") + rootView.drawingCache + } catch (e: Throwable) { + Timber.e(e, "Cannot get snapshot of dialog: $e") + null } - - return null } // ============================================================================================================== diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsFragment.kt index a75479275b..869ee85337 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsFragment.kt @@ -19,7 +19,6 @@ package im.vector.riotx.features.roomdirectory import android.os.Bundle import android.view.MenuItem import android.view.View -import com.airbnb.epoxy.EpoxyVisibilityTracker import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.withState import com.google.android.material.snackbar.Snackbar @@ -29,6 +28,7 @@ import im.vector.riotx.R import im.vector.riotx.core.extensions.cleanup import im.vector.riotx.core.extensions.configureWith import im.vector.riotx.core.extensions.exhaustive +import im.vector.riotx.core.extensions.trackItemsVisibilityChange import im.vector.riotx.core.platform.VectorBaseFragment import io.reactivex.rxkotlin.subscribeBy import kotlinx.android.synthetic.main.fragment_public_rooms.* @@ -107,8 +107,7 @@ class PublicRoomsFragment @Inject constructor( } private fun setupRecyclerView() { - val epoxyVisibilityTracker = EpoxyVisibilityTracker() - epoxyVisibilityTracker.attach(publicRoomsList) + publicRoomsList.trackItemsVisibilityChange() publicRoomsList.configureWith(publicRoomsController) publicRoomsController.callback = this } diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileActivity.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileActivity.kt index 1a9b268b90..bfc815f1ed 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileActivity.kt @@ -27,6 +27,7 @@ import im.vector.riotx.core.platform.ToolbarConfigurable import im.vector.riotx.core.platform.VectorBaseActivity import im.vector.riotx.features.roomprofile.members.RoomMemberListFragment import im.vector.riotx.features.roomprofile.settings.RoomSettingsFragment +import im.vector.riotx.features.roomprofile.uploads.RoomUploadsFragment class RoomProfileActivity : VectorBaseActivity(), ToolbarConfigurable { @@ -66,7 +67,7 @@ class RoomProfileActivity : VectorBaseActivity(), ToolbarConfigurable { } private fun openRoomUploads() { - notImplemented("Open room uploads") + addFragmentToBackstack(R.id.simpleFragmentContainer, RoomUploadsFragment::class.java, roomProfileArgs) } private fun openRoomSettings() { diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsAction.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsAction.kt new file mode 100644 index 0000000000..59571de122 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsAction.kt @@ -0,0 +1,28 @@ +/* + * 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.features.roomprofile.uploads + +import im.vector.matrix.android.api.session.room.uploads.UploadEvent +import im.vector.riotx.core.platform.VectorViewModelAction + +sealed class RoomUploadsAction : VectorViewModelAction { + data class Download(val uploadEvent: UploadEvent) : RoomUploadsAction() + data class Share(val uploadEvent: UploadEvent) : RoomUploadsAction() + + object Retry : RoomUploadsAction() + object LoadMore : RoomUploadsAction() +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsFragment.kt new file mode 100644 index 0000000000..99aeb4231b --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsFragment.kt @@ -0,0 +1,100 @@ +/* + * 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.features.roomprofile.uploads + +import android.os.Bundle +import android.view.View +import androidx.core.net.toUri +import com.airbnb.mvrx.args +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayoutMediator +import im.vector.matrix.android.api.util.toMatrixItem +import im.vector.riotx.R +import im.vector.riotx.core.extensions.exhaustive +import im.vector.riotx.core.intent.getMimeTypeFromUri +import im.vector.riotx.core.platform.VectorBaseFragment +import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.core.utils.saveMedia +import im.vector.riotx.core.utils.shareMedia +import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.roomprofile.RoomProfileArgs +import kotlinx.android.synthetic.main.fragment_room_uploads.* +import javax.inject.Inject + +class RoomUploadsFragment @Inject constructor( + private val viewModelFactory: RoomUploadsViewModel.Factory, + private val stringProvider: StringProvider, + private val avatarRenderer: AvatarRenderer +) : VectorBaseFragment(), RoomUploadsViewModel.Factory by viewModelFactory { + + private val roomProfileArgs: RoomProfileArgs by args() + + private val viewModel: RoomUploadsViewModel by fragmentViewModel() + + override fun getLayoutResId() = R.layout.fragment_room_uploads + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val sectionsPagerAdapter = RoomUploadsPagerAdapter(this) + roomUploadsViewPager.adapter = sectionsPagerAdapter + + TabLayoutMediator(roomUploadsTabs, roomUploadsViewPager) { tab, position -> + when (position) { + 0 -> tab.text = stringProvider.getString(R.string.uploads_media_title) + 1 -> tab.text = stringProvider.getString(R.string.uploads_files_title) + } + }.attach() + + setupToolbar(roomUploadsToolbar) + + viewModel.observeViewEvents { + when (it) { + is RoomUploadsViewEvents.FileReadyForSharing -> { + shareMedia(requireContext(), it.file, getMimeTypeFromUri(requireContext(), it.file.toUri())) + } + is RoomUploadsViewEvents.FileReadyForSaving -> { + val saved = saveMedia( + context = requireContext(), + file = it.file, + title = it.title, + mediaMimeType = getMimeTypeFromUri(requireContext(), it.file.toUri()) + ) + if (saved) { + Snackbar.make(roomUploadsCoordinator, R.string.media_file_added_to_gallery, Snackbar.LENGTH_LONG).show() + } else { + Snackbar.make(roomUploadsCoordinator, R.string.error_adding_media_file_to_gallery, Snackbar.LENGTH_LONG).show() + } + } + is RoomUploadsViewEvents.Failure -> showFailure(it.throwable) + }.exhaustive + } + } + + override fun invalidate() = withState(viewModel) { state -> + renderRoomSummary(state) + } + + private fun renderRoomSummary(state: RoomUploadsViewState) { + state.roomSummary()?.let { + roomUploadsToolbarTitleView.text = it.displayName + avatarRenderer.render(it.toMatrixItem(), roomUploadsToolbarAvatarImageView) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsPagerAdapter.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsPagerAdapter.kt new file mode 100644 index 0000000000..6866d5e2fa --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsPagerAdapter.kt @@ -0,0 +1,37 @@ +/* + * 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.features.roomprofile.uploads + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import im.vector.riotx.features.roomprofile.uploads.files.RoomUploadsFilesFragment +import im.vector.riotx.features.roomprofile.uploads.media.RoomUploadsMediaFragment + +class RoomUploadsPagerAdapter( + private val fragment: Fragment +) : FragmentStateAdapter(fragment) { + + override fun getItemCount() = 2 + + override fun createFragment(position: Int): Fragment { + return if (position == 0) { + fragment.childFragmentManager.fragmentFactory.instantiate(fragment.requireContext().classLoader, RoomUploadsMediaFragment::class.java.name) + } else { + fragment.childFragmentManager.fragmentFactory.instantiate(fragment.requireContext().classLoader, RoomUploadsFilesFragment::class.java.name) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewEvents.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewEvents.kt new file mode 100644 index 0000000000..cd0c34494d --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewEvents.kt @@ -0,0 +1,27 @@ +/* + * 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.features.roomprofile.uploads + +import im.vector.riotx.core.platform.VectorViewEvents +import java.io.File + +sealed class RoomUploadsViewEvents : VectorViewEvents { + data class Failure(val throwable: Throwable) : RoomUploadsViewEvents() + + data class FileReadyForSharing(val file: File) : RoomUploadsViewEvents() + data class FileReadyForSaving(val file: File, val title: String) : RoomUploadsViewEvents() +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewModel.kt new file mode 100644 index 0000000000..952e80c035 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewModel.kt @@ -0,0 +1,173 @@ +/* + * 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.features.roomprofile.uploads + +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.ViewModelContext +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.file.FileService +import im.vector.matrix.android.api.session.room.model.message.MessageType +import im.vector.matrix.android.api.session.room.model.message.getFileUrl +import im.vector.matrix.android.api.session.room.uploads.GetUploadsResult +import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt +import im.vector.matrix.android.internal.util.awaitCallback +import im.vector.matrix.rx.rx +import im.vector.matrix.rx.unwrap +import im.vector.riotx.core.extensions.exhaustive +import im.vector.riotx.core.platform.VectorViewModel +import kotlinx.coroutines.launch +import java.io.File + +class RoomUploadsViewModel @AssistedInject constructor( + @Assisted initialState: RoomUploadsViewState, + private val session: Session +) : VectorViewModel(initialState) { + + @AssistedInject.Factory + interface Factory { + fun create(initialState: RoomUploadsViewState): RoomUploadsViewModel + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: RoomUploadsViewState): RoomUploadsViewModel? { + val factory = when (viewModelContext) { + is FragmentViewModelContext -> viewModelContext.fragment as? Factory + is ActivityViewModelContext -> viewModelContext.activity as? Factory + } + return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface") + } + } + + private val room = session.getRoom(initialState.roomId)!! + + init { + observeRoomSummary() + // Send a first request + handleLoadMore() + } + + private fun observeRoomSummary() { + room.rx().liveRoomSummary() + .unwrap() + .execute { async -> + copy(roomSummary = async) + } + } + + private fun handleLoadMore() = withState { state -> + if (state.asyncEventsRequest is Loading) return@withState + if (!state.hasMore) return@withState + + setState { + copy( + asyncEventsRequest = Loading() + ) + } + + viewModelScope.launch { + try { + val result = awaitCallback { + room.getUploads(20, token, it) + } + + token = result.nextToken + + val groupedUploadEvents = result.uploadEvents + .groupBy { + it.contentWithAttachmentContent.msgType == MessageType.MSGTYPE_IMAGE + || it.contentWithAttachmentContent.msgType == MessageType.MSGTYPE_VIDEO + } + + setState { + copy( + asyncEventsRequest = Success(Unit), + mediaEvents = this.mediaEvents + groupedUploadEvents[true].orEmpty(), + fileEvents = this.fileEvents + groupedUploadEvents[false].orEmpty(), + hasMore = result.hasMore + ) + } + } catch (failure: Throwable) { + _viewEvents.post(RoomUploadsViewEvents.Failure(failure)) + setState { + copy( + asyncEventsRequest = Fail(failure) + ) + } + } + } + } + + private var token: String? = null + + override fun handle(action: RoomUploadsAction) { + when (action) { + is RoomUploadsAction.Download -> handleDownload(action) + is RoomUploadsAction.Share -> handleShare(action) + RoomUploadsAction.Retry -> handleLoadMore() + RoomUploadsAction.LoadMore -> handleLoadMore() + }.exhaustive + } + + private fun handleShare(action: RoomUploadsAction.Share) { + viewModelScope.launch { + try { + val file = awaitCallback { + session.downloadFile( + FileService.DownloadMode.FOR_EXTERNAL_SHARE, + action.uploadEvent.eventId, + action.uploadEvent.contentWithAttachmentContent.body, + action.uploadEvent.contentWithAttachmentContent.getFileUrl(), + action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(), + it + ) + } + _viewEvents.post(RoomUploadsViewEvents.FileReadyForSharing(file)) + } catch (failure: Throwable) { + _viewEvents.post(RoomUploadsViewEvents.Failure(failure)) + } + } + } + + private fun handleDownload(action: RoomUploadsAction.Download) { + viewModelScope.launch { + try { + val file = awaitCallback { + session.downloadFile( + FileService.DownloadMode.FOR_EXTERNAL_SHARE, + action.uploadEvent.eventId, + action.uploadEvent.contentWithAttachmentContent.body, + action.uploadEvent.contentWithAttachmentContent.getFileUrl(), + action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(), + it) + } + _viewEvents.post(RoomUploadsViewEvents.FileReadyForSaving(file, action.uploadEvent.contentWithAttachmentContent.body)) + } catch (failure: Throwable) { + _viewEvents.post(RoomUploadsViewEvents.Failure(failure)) + } + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewState.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewState.kt new file mode 100644 index 0000000000..3e31a3cdd6 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewState.kt @@ -0,0 +1,39 @@ +/* + * 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.features.roomprofile.uploads + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.Uninitialized +import im.vector.matrix.android.api.session.room.model.RoomSummary +import im.vector.matrix.android.api.session.room.uploads.UploadEvent +import im.vector.riotx.features.roomprofile.RoomProfileArgs + +data class RoomUploadsViewState( + val roomId: String = "", + val roomSummary: Async = Uninitialized, + // Store cumul of pagination result, grouped by type + val mediaEvents: List = emptyList(), + val fileEvents: List = emptyList(), + // Current pagination request + val asyncEventsRequest: Async = Uninitialized, + // True if more result are available server side + val hasMore: Boolean = true +) : MvRxState { + + constructor(args: RoomProfileArgs) : this(roomId = args.roomId) +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/files/RoomUploadsFilesFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/files/RoomUploadsFilesFragment.kt new file mode 100644 index 0000000000..bba7a40440 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/files/RoomUploadsFilesFragment.kt @@ -0,0 +1,113 @@ +/* + * 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.features.roomprofile.uploads.files + +import android.os.Bundle +import android.view.View +import androidx.core.content.ContextCompat +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.parentFragmentViewModel +import com.airbnb.mvrx.withState +import im.vector.matrix.android.api.session.room.uploads.UploadEvent +import im.vector.riotx.R +import im.vector.riotx.core.extensions.cleanup +import im.vector.riotx.core.extensions.configureWith +import im.vector.riotx.core.extensions.trackItemsVisibilityChange +import im.vector.riotx.core.platform.StateView +import im.vector.riotx.core.platform.VectorBaseFragment +import im.vector.riotx.features.roomprofile.uploads.RoomUploadsAction +import im.vector.riotx.features.roomprofile.uploads.RoomUploadsViewModel +import kotlinx.android.synthetic.main.fragment_generic_state_view_recycler.* +import javax.inject.Inject + +class RoomUploadsFilesFragment @Inject constructor( + private val controller: UploadsFileController +) : VectorBaseFragment(), + UploadsFileController.Listener, + StateView.EventCallback { + + private val uploadsViewModel by parentFragmentViewModel(RoomUploadsViewModel::class) + + override fun getLayoutResId() = R.layout.fragment_generic_state_view_recycler + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + genericStateViewListStateView.contentView = genericStateViewListRecycler + genericStateViewListStateView.eventCallback = this + + genericStateViewListRecycler.trackItemsVisibilityChange() + genericStateViewListRecycler.configureWith(controller, showDivider = true) + controller.listener = this + } + + override fun onDestroyView() { + super.onDestroyView() + genericStateViewListRecycler.cleanup() + controller.listener = null + } + + override fun onOpenClicked(uploadEvent: UploadEvent) { + // Same action than Share + uploadsViewModel.handle(RoomUploadsAction.Share(uploadEvent)) + } + + override fun onRetryClicked() { + uploadsViewModel.handle(RoomUploadsAction.Retry) + } + + override fun loadMore() { + uploadsViewModel.handle(RoomUploadsAction.LoadMore) + } + + override fun onDownloadClicked(uploadEvent: UploadEvent) { + uploadsViewModel.handle(RoomUploadsAction.Download(uploadEvent)) + } + + override fun onShareClicked(uploadEvent: UploadEvent) { + uploadsViewModel.handle(RoomUploadsAction.Share(uploadEvent)) + } + + override fun invalidate() = withState(uploadsViewModel) { state -> + if (state.fileEvents.isEmpty()) { + when (state.asyncEventsRequest) { + is Loading -> { + genericStateViewListStateView.state = StateView.State.Loading + } + is Fail -> { + genericStateViewListStateView.state = StateView.State.Error(errorFormatter.toHumanReadable(state.asyncEventsRequest.error)) + } + is Success -> { + if (state.hasMore) { + // We need to load more items + loadMore() + } else { + genericStateViewListStateView.state = StateView.State.Empty( + title = getString(R.string.uploads_files_no_result), + image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_file) + ) + } + } + } + } else { + genericStateViewListStateView.state = StateView.State.Content + controller.setData(state) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/files/UploadsFileController.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/files/UploadsFileController.kt new file mode 100644 index 0000000000..60f966e7d2 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/files/UploadsFileController.kt @@ -0,0 +1,91 @@ +/* + * 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.features.roomprofile.uploads.files + +import com.airbnb.epoxy.TypedEpoxyController +import com.airbnb.epoxy.VisibilityState +import im.vector.matrix.android.api.session.room.uploads.UploadEvent +import im.vector.riotx.R +import im.vector.riotx.core.date.VectorDateFormatter +import im.vector.riotx.core.epoxy.loadingItem +import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.features.roomprofile.uploads.RoomUploadsViewState +import javax.inject.Inject + +class UploadsFileController @Inject constructor( + private val stringProvider: StringProvider, + private val dateFormatter: VectorDateFormatter +) : TypedEpoxyController() { + + interface Listener { + fun loadMore() + fun onOpenClicked(uploadEvent: UploadEvent) + fun onDownloadClicked(uploadEvent: UploadEvent) + fun onShareClicked(uploadEvent: UploadEvent) + } + + var listener: Listener? = null + + private var idx = 0 + + init { + setData(null) + } + + override fun buildModels(data: RoomUploadsViewState?) { + data ?: return + + buildFileItems(data.fileEvents) + + if (data.hasMore) { + loadingItem { + // Always use a different id, because we can be notified several times of visibility state changed + id("loadMore${idx++}") + onVisibilityStateChanged { _, _, visibilityState -> + if (visibilityState == VisibilityState.VISIBLE) { + listener?.loadMore() + } + } + } + } + } + + private fun buildFileItems(fileEvents: List) { + fileEvents.forEach { uploadEvent -> + uploadsFileItem { + id(uploadEvent.eventId) + title(uploadEvent.contentWithAttachmentContent.body) + subtitle(stringProvider.getString(R.string.uploads_files_subtitle, + uploadEvent.senderInfo.disambiguatedDisplayName, + dateFormatter.formatRelativeDateTime(uploadEvent.root.originServerTs))) + listener(object : UploadsFileItem.Listener { + override fun onItemClicked() { + listener?.onOpenClicked(uploadEvent) + } + + override fun onDownloadClicked() { + listener?.onDownloadClicked(uploadEvent) + } + + override fun onShareClicked() { + listener?.onShareClicked(uploadEvent) + } + }) + } + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/files/UploadsFileItem.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/files/UploadsFileItem.kt new file mode 100644 index 0000000000..927672dd70 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/files/UploadsFileItem.kt @@ -0,0 +1,57 @@ +/* + * 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.features.roomprofile.uploads.files + +import android.view.View +import android.widget.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R +import im.vector.riotx.core.epoxy.VectorEpoxyHolder +import im.vector.riotx.core.epoxy.VectorEpoxyModel +import im.vector.riotx.core.extensions.setTextOrHide + +@EpoxyModelClass(layout = R.layout.item_uploads_file) +abstract class UploadsFileItem : VectorEpoxyModel() { + + @EpoxyAttribute var title: String? = null + @EpoxyAttribute var subtitle: String? = null + + @EpoxyAttribute var listener: Listener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.view.setOnClickListener { listener?.onItemClicked() } + holder.titleView.text = title + holder.subtitleView.setTextOrHide(subtitle) + holder.downloadView.setOnClickListener { listener?.onDownloadClicked() } + holder.shareView.setOnClickListener { listener?.onShareClicked() } + } + + class Holder : VectorEpoxyHolder() { + val titleView by bind(R.id.uploadsFileTitle) + val subtitleView by bind(R.id.uploadsFileSubtitle) + val downloadView by bind(R.id.uploadsFileActionDownload) + val shareView by bind(R.id.uploadsFileActionShare) + } + + interface Listener { + fun onItemClicked() + fun onDownloadClicked() + fun onShareClicked() + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/Config.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/Config.kt new file mode 100644 index 0000000000..50d4feff55 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/Config.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.features.roomprofile.uploads.media + +// Min image size. Size will be adjusted at runtime +const val IMAGE_SIZE_DP = 120 diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/RoomUploadsMediaFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/RoomUploadsMediaFragment.kt new file mode 100644 index 0000000000..a4e6c61238 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/RoomUploadsMediaFragment.kt @@ -0,0 +1,121 @@ +/* + * 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.features.roomprofile.uploads.media + +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.View +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.GridLayoutManager +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.parentFragmentViewModel +import com.airbnb.mvrx.withState +import im.vector.riotx.R +import im.vector.riotx.core.extensions.cleanup +import im.vector.riotx.core.extensions.trackItemsVisibilityChange +import im.vector.riotx.core.platform.StateView +import im.vector.riotx.core.platform.VectorBaseFragment +import im.vector.riotx.core.utils.DimensionConverter +import im.vector.riotx.features.media.ImageContentRenderer +import im.vector.riotx.features.media.VideoContentRenderer +import im.vector.riotx.features.roomprofile.uploads.RoomUploadsAction +import im.vector.riotx.features.roomprofile.uploads.RoomUploadsViewModel +import kotlinx.android.synthetic.main.fragment_generic_state_view_recycler.* +import javax.inject.Inject + +class RoomUploadsMediaFragment @Inject constructor( + private val controller: UploadsMediaController, + private val dimensionConverter: DimensionConverter +) : VectorBaseFragment(), + UploadsMediaController.Listener, + StateView.EventCallback { + + private val uploadsViewModel by parentFragmentViewModel(RoomUploadsViewModel::class) + + override fun getLayoutResId() = R.layout.fragment_generic_state_view_recycler + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + genericStateViewListStateView.contentView = genericStateViewListRecycler + genericStateViewListStateView.eventCallback = this + + genericStateViewListRecycler.trackItemsVisibilityChange() + genericStateViewListRecycler.layoutManager = GridLayoutManager(context, getNumberOfColumns()) + genericStateViewListRecycler.adapter = controller.adapter + genericStateViewListRecycler.setHasFixedSize(true) + + controller.listener = this + } + + private fun getNumberOfColumns(): Int { + val displayMetrics = DisplayMetrics() + requireActivity().windowManager.defaultDisplay.getMetrics(displayMetrics) + return dimensionConverter.pxToDp(displayMetrics.widthPixels) / IMAGE_SIZE_DP + } + + override fun onDestroyView() { + super.onDestroyView() + genericStateViewListRecycler.cleanup() + controller.listener = null + } + + override fun onOpenImageClicked(view: View, mediaData: ImageContentRenderer.Data) { + navigator.openImageViewer(requireActivity(), mediaData, view, null) + } + + override fun onOpenVideoClicked(view: View, mediaData: VideoContentRenderer.Data) { + navigator.openVideoViewer(requireActivity(), mediaData) + } + + override fun loadMore() { + uploadsViewModel.handle(RoomUploadsAction.LoadMore) + } + + override fun onRetryClicked() { + uploadsViewModel.handle(RoomUploadsAction.Retry) + } + + override fun invalidate() = withState(uploadsViewModel) { state -> + if (state.mediaEvents.isEmpty()) { + when (state.asyncEventsRequest) { + is Loading -> { + genericStateViewListStateView.state = StateView.State.Loading + } + is Fail -> { + genericStateViewListStateView.state = StateView.State.Error(errorFormatter.toHumanReadable(state.asyncEventsRequest.error)) + } + is Success -> { + if (state.hasMore) { + // We need to load more items + loadMore() + } else { + genericStateViewListStateView.state = StateView.State.Empty( + title = getString(R.string.uploads_media_no_result), + image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_image) + ) + } + } + } + } else { + genericStateViewListStateView.state = StateView.State.Content + controller.setData(state) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsImageItem.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsImageItem.kt new file mode 100644 index 0000000000..98026901cc --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsImageItem.kt @@ -0,0 +1,49 @@ +/* + * 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.features.roomprofile.uploads.media + +import android.view.View +import android.widget.ImageView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R +import im.vector.riotx.core.epoxy.VectorEpoxyHolder +import im.vector.riotx.core.epoxy.VectorEpoxyModel +import im.vector.riotx.features.media.ImageContentRenderer + +@EpoxyModelClass(layout = R.layout.item_uploads_image) +abstract class UploadsImageItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var imageContentRenderer: ImageContentRenderer + @EpoxyAttribute lateinit var data: ImageContentRenderer.Data + + @EpoxyAttribute var listener: Listener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.view.setOnClickListener { listener?.onItemClicked(holder.imageView, data) } + imageContentRenderer.render(data, holder.imageView, IMAGE_SIZE_DP) + } + + class Holder : VectorEpoxyHolder() { + val imageView by bind(R.id.uploadsImagePreview) + } + + interface Listener { + fun onItemClicked(view: View, data: ImageContentRenderer.Data) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsMediaController.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsMediaController.kt new file mode 100644 index 0000000000..cd3e401dc5 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsMediaController.kt @@ -0,0 +1,148 @@ +/* + * 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.features.roomprofile.uploads.media + +import android.view.View +import com.airbnb.epoxy.TypedEpoxyController +import com.airbnb.epoxy.VisibilityState +import im.vector.matrix.android.api.session.room.model.message.MessageImageContent +import im.vector.matrix.android.api.session.room.model.message.MessageType +import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent +import im.vector.matrix.android.api.session.room.model.message.getFileUrl +import im.vector.matrix.android.api.session.room.uploads.UploadEvent +import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt +import im.vector.riotx.core.epoxy.squareLoadingItem +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.core.utils.DimensionConverter +import im.vector.riotx.features.media.ImageContentRenderer +import im.vector.riotx.features.media.VideoContentRenderer +import im.vector.riotx.features.roomprofile.uploads.RoomUploadsViewState +import javax.inject.Inject + +class UploadsMediaController @Inject constructor( + private val errorFormatter: ErrorFormatter, + private val imageContentRenderer: ImageContentRenderer, + private val stringProvider: StringProvider, + dimensionConverter: DimensionConverter +) : TypedEpoxyController() { + + interface Listener { + fun onOpenImageClicked(view: View, mediaData: ImageContentRenderer.Data) + fun onOpenVideoClicked(view: View, mediaData: VideoContentRenderer.Data) + fun loadMore() + } + + var listener: Listener? = null + + private var idx = 0 + + private val itemSize = dimensionConverter.dpToPx(IMAGE_SIZE_DP) + + init { + setData(null) + } + + override fun buildModels(data: RoomUploadsViewState?) { + data ?: return + + buildMediaItems(data.mediaEvents) + + if (data.hasMore) { + squareLoadingItem { + // Always use a different id, because we can be notified several times of visibility state changed + id("loadMore${idx++}") + onVisibilityStateChanged { _, _, visibilityState -> + if (visibilityState == VisibilityState.VISIBLE) { + listener?.loadMore() + } + } + } + } + } + + private fun buildMediaItems(mediaEvents: List) { + mediaEvents.forEach { uploadEvent -> + when (uploadEvent.contentWithAttachmentContent.msgType) { + MessageType.MSGTYPE_IMAGE -> { + val data = uploadEvent.toImageContentRendererData() ?: return@forEach + uploadsImageItem { + id(uploadEvent.eventId) + imageContentRenderer(imageContentRenderer) + data(data) + listener(object : UploadsImageItem.Listener { + override fun onItemClicked(view: View, data: ImageContentRenderer.Data) { + listener?.onOpenImageClicked(view, data) + } + }) + } + } + MessageType.MSGTYPE_VIDEO -> { + val data = uploadEvent.toVideoContentRendererData() ?: return@forEach + uploadsVideoItem { + id(uploadEvent.eventId) + imageContentRenderer(imageContentRenderer) + data(data) + listener(object : UploadsVideoItem.Listener { + override fun onItemClicked(view: View, data: VideoContentRenderer.Data) { + listener?.onOpenVideoClicked(view, data) + } + }) + } + } + } + } + } + + private fun UploadEvent.toImageContentRendererData(): ImageContentRenderer.Data? { + val messageContent = (contentWithAttachmentContent as? MessageImageContent) ?: return null + + return ImageContentRenderer.Data( + eventId = eventId, + filename = messageContent.body, + url = messageContent.getFileUrl(), + elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(), + height = messageContent.info?.height, + maxHeight = itemSize, + width = messageContent.info?.width, + maxWidth = itemSize + ) + } + + private fun UploadEvent.toVideoContentRendererData(): VideoContentRenderer.Data? { + val messageContent = (contentWithAttachmentContent as? MessageVideoContent) ?: return null + + val thumbnailData = ImageContentRenderer.Data( + eventId = eventId, + filename = messageContent.body, + url = messageContent.videoInfo?.thumbnailFile?.url ?: messageContent.videoInfo?.thumbnailUrl, + elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(), + height = messageContent.videoInfo?.height, + maxHeight = itemSize, + width = messageContent.videoInfo?.width, + maxWidth = itemSize + ) + + return VideoContentRenderer.Data( + eventId = eventId, + filename = messageContent.body, + url = messageContent.getFileUrl(), + elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(), + thumbnailMediaData = thumbnailData + ) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsVideoItem.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsVideoItem.kt new file mode 100644 index 0000000000..82e33b76da --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsVideoItem.kt @@ -0,0 +1,50 @@ +/* + * 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.features.roomprofile.uploads.media + +import android.view.View +import android.widget.ImageView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R +import im.vector.riotx.core.epoxy.VectorEpoxyHolder +import im.vector.riotx.core.epoxy.VectorEpoxyModel +import im.vector.riotx.features.media.ImageContentRenderer +import im.vector.riotx.features.media.VideoContentRenderer + +@EpoxyModelClass(layout = R.layout.item_uploads_video) +abstract class UploadsVideoItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var imageContentRenderer: ImageContentRenderer + @EpoxyAttribute lateinit var data: VideoContentRenderer.Data + + @EpoxyAttribute var listener: Listener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.view.setOnClickListener { listener?.onItemClicked(holder.imageView, data) } + imageContentRenderer.render(data.thumbnailMediaData, holder.imageView, IMAGE_SIZE_DP) + } + + class Holder : VectorEpoxyHolder() { + val imageView by bind(R.id.uploadsVideoPreview) + } + + interface Listener { + fun onItemClicked(view: View, data: VideoContentRenderer.Data) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt index 1455e2f8d8..86a9ee678e 100755 --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt @@ -382,7 +382,7 @@ class VectorPreferences @Inject constructor(private val context: Context) { fun getUnknownDeviceDismissedList(): List { return tryThis { defaultPrefs.getStringSet(SETTINGS_UNKNOWN_DEVICE_DISMISSED_LIST, null)?.toList() - } ?: emptyList() + }.orEmpty() } /** diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt index 491890de7e..76065b63ea 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt @@ -415,7 +415,7 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor( session.cryptoService().fetchDevicesList(object : MatrixCallback { override fun onSuccess(data: DevicesListResponse) { if (isAdded) { - refreshCryptographyPreference(data.devices ?: emptyList()) + refreshCryptographyPreference(data.devices.orEmpty()) } } diff --git a/vector/src/main/res/drawable/ic_image.xml b/vector/src/main/res/drawable/ic_image.xml new file mode 100644 index 0000000000..70bc4a73a6 --- /dev/null +++ b/vector/src/main/res/drawable/ic_image.xml @@ -0,0 +1,29 @@ + + + + + diff --git a/vector/src/main/res/layout/fragment_generic_state_view_recycler.xml b/vector/src/main/res/layout/fragment_generic_state_view_recycler.xml new file mode 100644 index 0000000000..410373b97f --- /dev/null +++ b/vector/src/main/res/layout/fragment_generic_state_view_recycler.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/vector/src/main/res/layout/fragment_room_uploads.xml b/vector/src/main/res/layout/fragment_room_uploads.xml new file mode 100644 index 0000000000..5e289d4724 --- /dev/null +++ b/vector/src/main/res/layout/fragment_room_uploads.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_loading_square.xml b/vector/src/main/res/layout/item_loading_square.xml new file mode 100644 index 0000000000..7596c39fc6 --- /dev/null +++ b/vector/src/main/res/layout/item_loading_square.xml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/vector/src/main/res/layout/item_uploads_file.xml b/vector/src/main/res/layout/item_uploads_file.xml new file mode 100644 index 0000000000..9232583a28 --- /dev/null +++ b/vector/src/main/res/layout/item_uploads_file.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_uploads_image.xml b/vector/src/main/res/layout/item_uploads_image.xml new file mode 100644 index 0000000000..464816d74a --- /dev/null +++ b/vector/src/main/res/layout/item_uploads_image.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_uploads_video.xml b/vector/src/main/res/layout/item_uploads_video.xml new file mode 100644 index 0000000000..97d7529c13 --- /dev/null +++ b/vector/src/main/res/layout/item_uploads_video.xml @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/view_state.xml b/vector/src/main/res/layout/view_state.xml index c17e1b216b..082a0bb24c 100644 --- a/vector/src/main/res/layout/view_state.xml +++ b/vector/src/main/res/layout/view_state.xml @@ -73,6 +73,7 @@ android:layout_width="64dp" android:layout_height="64dp" android:layout_gravity="center_horizontal" + android:tint="?riotx_text_primary" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/vector/src/main/res/menu/home.xml b/vector/src/main/res/menu/home.xml index 2ef09e0ea1..2fe2464f77 100644 --- a/vector/src/main/res/menu/home.xml +++ b/vector/src/main/res/menu/home.xml @@ -14,7 +14,7 @@ diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 4f8474760d..36532f25e3 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -1788,6 +1788,13 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming "Sticker" Couldn\'t handle share data + MEDIA + There are no media in this room + FILES + + %1$s at %2$s + There are no files in this room + "It's spam" "It's inappropriate" "Custom report…" @@ -2139,6 +2146,7 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming Verify this login Other users may not trust it + Complete Security Use an existing session to verify this one, granting it access to encrypted messages.