diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 4653d4b5..30ff7df4 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -28,6 +28,7 @@ android { targetSdkVersion 28 versionCode 1 versionName "1.0" + multiDexEnabled true testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -104,17 +105,17 @@ dependencies { testImplementation 'org.robolectric:shadows-support-v4:3.0' testImplementation "io.mockk:mockk:1.8.13.kotlin13" testImplementation 'org.amshove.kluent:kluent-android:1.44' - testImplementation "androidx.arch.core:core-testing:$lifecycle_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" androidTestImplementation "org.koin:koin-test:$koin_version" + androidTestImplementation 'androidx.test:core:1.1.0' androidTestImplementation 'androidx.test:runner:1.1.1' androidTestImplementation 'androidx.test:rules:1.1.1' + androidTestImplementation 'androidx.test.ext:junit:1.1.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' androidTestImplementation 'org.amshove.kluent:kluent-android:1.44' androidTestImplementation "io.mockk:mockk-android:1.8.13.kotlin13" androidTestImplementation "androidx.arch.core:core-testing:$lifecycle_version" androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" - } diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/ChunkEntityTest.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/ChunkEntityTest.kt index 397dcfc8..b76014d1 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/ChunkEntityTest.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/ChunkEntityTest.kt @@ -16,10 +16,9 @@ package im.vector.matrix.android.session.room.timeline +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.InstrumentedTest -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.internal.database.helper.add import im.vector.matrix.android.internal.database.helper.addAll import im.vector.matrix.android.internal.database.helper.isUnlinked @@ -27,6 +26,9 @@ import im.vector.matrix.android.internal.database.helper.lastStateIndex import im.vector.matrix.android.internal.database.helper.merge import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection +import im.vector.matrix.android.session.room.timeline.RoomDataHelper.createFakeListOfEvents +import im.vector.matrix.android.session.room.timeline.RoomDataHelper.createFakeMessageEvent +import im.vector.matrix.android.session.room.timeline.RoomDataHelper.createFakeRoomMemberEvent import io.realm.Realm import io.realm.RealmConfiguration import io.realm.kotlin.createObject @@ -35,9 +37,10 @@ import org.amshove.kluent.shouldBeTrue import org.amshove.kluent.shouldEqual import org.junit.Before import org.junit.Test -import kotlin.random.Random +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) internal class ChunkEntityTest : InstrumentedTest { private lateinit var monarchy: Monarchy @@ -54,7 +57,7 @@ internal class ChunkEntityTest : InstrumentedTest { fun add_shouldAdd_whenNotAlreadyIncluded() { monarchy.runTransactionSync { realm -> val chunk: ChunkEntity = realm.createObject() - val fakeEvent = createFakeEvent(false) + val fakeEvent = createFakeMessageEvent() chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS) chunk.events.size shouldEqual 1 } @@ -64,7 +67,7 @@ internal class ChunkEntityTest : InstrumentedTest { fun add_shouldNotAdd_whenAlreadyIncluded() { monarchy.runTransactionSync { realm -> val chunk: ChunkEntity = realm.createObject() - val fakeEvent = createFakeEvent(false) + val fakeEvent = createFakeMessageEvent() chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS) chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS) chunk.events.size shouldEqual 1 @@ -75,7 +78,7 @@ internal class ChunkEntityTest : InstrumentedTest { fun add_shouldStateIndexIncremented_whenStateEventIsAddedForward() { monarchy.runTransactionSync { realm -> val chunk: ChunkEntity = realm.createObject() - val fakeEvent = createFakeEvent(true) + val fakeEvent = createFakeRoomMemberEvent() chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS) chunk.lastStateIndex(PaginationDirection.FORWARDS) shouldEqual 1 } @@ -85,7 +88,7 @@ internal class ChunkEntityTest : InstrumentedTest { fun add_shouldStateIndexNotIncremented_whenNoStateEventIsAdded() { monarchy.runTransactionSync { realm -> val chunk: ChunkEntity = realm.createObject() - val fakeEvent = createFakeEvent(false) + val fakeEvent = createFakeMessageEvent() chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS) chunk.lastStateIndex(PaginationDirection.FORWARDS) shouldEqual 0 } @@ -196,15 +199,4 @@ internal class ChunkEntityTest : InstrumentedTest { } } - - private fun createFakeListOfEvents(size: Int = 10): List { - return (0 until size).map { createFakeEvent(Random.nextBoolean()) } - } - - private fun createFakeEvent(asStateEvent: Boolean = false): Event { - val eventId = Random.nextLong(System.currentTimeMillis()).toString() - val type = if (asStateEvent) EventType.STATE_ROOM_NAME else EventType.MESSAGE - return Event(type, eventId) - } - } \ No newline at end of file diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/RoomDataHelper.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/RoomDataHelper.kt index 35e9cb12..9d2edd74 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/RoomDataHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/RoomDataHelper.kt @@ -17,9 +17,15 @@ package im.vector.matrix.android.session.room.timeline import com.zhuinden.monarchy.Monarchy +import im.vector.matrix.android.api.session.events.model.Content 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.toContent +import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.MyMembership +import im.vector.matrix.android.api.session.room.model.RoomMember +import im.vector.matrix.android.api.session.room.model.message.MessageTextContent +import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.internal.database.helper.addAll import im.vector.matrix.android.internal.database.helper.addOrUpdate import im.vector.matrix.android.internal.database.model.ChunkEntity @@ -30,27 +36,56 @@ import kotlin.random.Random object RoomDataHelper { + private const val FAKE_TEST_SENDER = "@sender:test.org" + private val EVENT_FACTORIES = hashMapOf( + 0 to { createFakeMessageEvent() }, + 1 to { createFakeRoomMemberEvent() } + ) + fun createFakeListOfEvents(size: Int = 10): List { - return (0 until size).map { createFakeEvent(Random.nextBoolean()) } + return (0 until size).mapNotNull { + val nextInt = Random.nextInt(EVENT_FACTORIES.size) + EVENT_FACTORIES[nextInt]?.invoke() + } } - fun createFakeEvent(asStateEvent: Boolean = false): Event { - val eventId = Random.nextLong(System.currentTimeMillis()).toString() - val type = if (asStateEvent) EventType.STATE_ROOM_NAME else EventType.MESSAGE - return Event(type, eventId) + fun createFakeEvent(type: String, + content: Content? = null, + prevContent: Content? = null, + sender: String = FAKE_TEST_SENDER, + stateKey: String = FAKE_TEST_SENDER + ): Event { + return Event( + type = type, + eventId = Random.nextLong().toString(), + content = content, + prevContent = prevContent, + sender = sender, + stateKey = stateKey + ) + } + + fun createFakeMessageEvent(): Event { + val message = MessageTextContent(MessageType.MSGTYPE_TEXT, "Fake message #${Random.nextLong()}").toContent() + return createFakeEvent(EventType.MESSAGE, message) + } + + fun createFakeRoomMemberEvent(): Event { + val roomMember = RoomMember(Membership.JOIN, "Fake name #${Random.nextLong()}").toContent() + return createFakeEvent(EventType.STATE_ROOM_MEMBER, roomMember) } fun fakeInitialSync(monarchy: Monarchy, roomId: String) { monarchy.runTransactionSync { realm -> val roomEntity = realm.createObject(roomId) roomEntity.membership = MyMembership.JOINED - val eventList = createFakeListOfEvents(30) + val eventList = createFakeListOfEvents(10) val chunkEntity = realm.createObject().apply { nextToken = null prevToken = Random.nextLong(System.currentTimeMillis()).toString() isLastForward = true } - chunkEntity.addAll("roomId", eventList, PaginationDirection.FORWARDS) + chunkEntity.addAll(roomId, eventList, PaginationDirection.FORWARDS) roomEntity.addOrUpdate(chunkEntity) } } diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineHolderTest.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineTest.kt similarity index 50% rename from matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineHolderTest.kt rename to matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineTest.kt index 2063a618..a80e92a0 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineHolderTest.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineTest.kt @@ -16,55 +16,75 @@ package im.vector.matrix.android.session.room.timeline -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.test.annotation.UiThreadTest import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.InstrumentedTest -import im.vector.matrix.android.LiveDataTestObserver +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.internal.session.room.members.RoomMemberExtractor -import im.vector.matrix.android.internal.session.room.timeline.DefaultTimelineService +import im.vector.matrix.android.internal.session.room.timeline.DefaultTimeline +import im.vector.matrix.android.internal.session.room.timeline.TimelineEventFactory import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEventPersistor import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.testCoroutineDispatchers import io.realm.Realm import io.realm.RealmConfiguration +import org.amshove.kluent.shouldEqual import org.junit.Before -import org.junit.Rule import org.junit.Test +import timber.log.Timber +import java.util.concurrent.CountDownLatch -internal class TimelineHolderTest : InstrumentedTest { +internal class TimelineTest : InstrumentedTest { + + companion object { + private const val ROOM_ID = "roomId" + } - @get:Rule val testRule = InstantTaskExecutorRule() private lateinit var monarchy: Monarchy @Before fun setup() { + Timber.plant(Timber.DebugTree()) Realm.init(context()) val testConfiguration = RealmConfiguration.Builder().name("test-realm").build() Realm.deleteRealm(testConfiguration) monarchy = Monarchy.Builder().setRealmConfiguration(testConfiguration).build() + RoomDataHelper.fakeInitialSync(monarchy, ROOM_ID) } - @Test - @UiThreadTest - fun backPaginate_shouldLoadMoreEvents_whenLoadAroundIsCalled() { - val roomId = "roomId" + private fun createTimeline(initialEventId: String? = null): Timeline { val taskExecutor = TaskExecutor(testCoroutineDispatchers) val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy) val paginationTask = FakePaginationTask(tokenChunkEventPersistor) val getContextOfEventTask = FakeGetContextOfEventTask(tokenChunkEventPersistor) - RoomDataHelper.fakeInitialSync(monarchy, roomId) - val timelineHolder = DefaultTimelineService(roomId, monarchy, taskExecutor, getContextOfEventTask, RoomMemberExtractor(roomId)) - val timelineObserver = LiveDataTestObserver.test(timelineHolder.timeline()) - timelineObserver.awaitNextValue().assertHasValue() - var timelineData = timelineObserver.value() - timelineData.events.size shouldEqual 30 - (0 until timelineData.events.size).map { - timelineData.events.loadAround(it) + val roomMemberExtractor = RoomMemberExtractor(ROOM_ID) + val timelineEventFactory = TimelineEventFactory(roomMemberExtractor) + return DefaultTimeline(ROOM_ID, initialEventId, monarchy.realmConfiguration, taskExecutor, getContextOfEventTask, timelineEventFactory, paginationTask, null) + } + + @Test + fun backPaginate_shouldLoadMoreEvents_whenPaginateIsCalled() { + val timeline = createTimeline() + timeline.start() + val paginationCount = 30 + var initialLoad = 0 + val latch = CountDownLatch(2) + var timelineEvents: List = emptyList() + timeline.listener = object : Timeline.Listener { + override fun onUpdated(snapshot: List) { + if (snapshot.isNotEmpty()) { + if (initialLoad == 0) { + initialLoad = snapshot.size + } + timelineEvents = snapshot + latch.countDown() + timeline.paginate(Timeline.Direction.BACKWARDS, paginationCount) + } + } } - timelineObserver.awaitNextValue().assertHasValue() - timelineData = timelineObserver.value() - timelineData.events.size shouldEqual 60 + latch.await() + timelineEvents.size shouldEqual initialLoad + paginationCount + timeline.dispose() } 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 6d99c9eb..9a66ffed 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 @@ -35,6 +35,18 @@ inline fun Content?.toModel(): T? { } } +/** + * This methods is a facility method to map a model to a json Content + */ +@Suppress("UNCHECKED_CAST") +inline fun T?.toContent(): Content? { + return this?.let { + val moshi = MoshiProvider.providesMoshi() + val moshiAdapter = moshi.adapter(T::class.java) + return moshiAdapter.toJsonValue(it) as Content + } +} + /** * Generic event class with all possible fields for events. * The content and prevContent json fields can easily be mapped to a model with [toModel] method. 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 9a9e4b7c..b92598ab 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 @@ -54,10 +54,14 @@ internal fun ChunkEntity.merge(roomId: String, if (direction == PaginationDirection.FORWARDS) { this.nextToken = chunkToMerge.nextToken this.isLastForward = chunkToMerge.isLastForward + this.forwardsStateIndex = chunkToMerge.forwardsStateIndex + this.forwardsDisplayIndex = chunkToMerge.forwardsDisplayIndex eventsToMerge = chunkToMerge.events.sort(EventEntityFields.DISPLAY_INDEX, Sort.ASCENDING) } else { this.prevToken = chunkToMerge.prevToken this.isLastBackward = chunkToMerge.isLastBackward + this.backwardsStateIndex = chunkToMerge.backwardsStateIndex + this.backwardsDisplayIndex = chunkToMerge.backwardsDisplayIndex eventsToMerge = chunkToMerge.events.sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) } eventsToMerge.forEach { @@ -111,8 +115,7 @@ internal fun ChunkEntity.add(roomId: String, this.displayIndex = currentDisplayIndex } // We are not using the order of the list, but will be sorting with displayIndex field - val position = if (direction == PaginationDirection.FORWARDS) 0 else this.events.size - events.add(position, eventEntity) + events.add(eventEntity) } private fun ChunkEntity.assertIsManaged() { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/LoadRoomMembersTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/LoadRoomMembersTask.kt index 7cd8bbaf..c421b3bd 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/LoadRoomMembersTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/LoadRoomMembersTask.kt @@ -48,7 +48,6 @@ internal class DefaultLoadRoomMembersTask(private val roomAPI: RoomAPI, return if (areAllMembersAlreadyLoaded(params.roomId)) { Try.just(true) } else { - //TODO use this token val lastToken = syncTokenStore.getLastToken() executeRequest { apiCall = roomAPI.getMembers(params.roomId, lastToken, null, params.excludeMembership?.value) @@ -63,7 +62,7 @@ internal class DefaultLoadRoomMembersTask(private val roomAPI: RoomAPI, .tryTransactionSync { realm -> // We ignore all the already known members val roomEntity = RoomEntity.where(realm, roomId).findFirst() - ?: realm.createObject(roomId) + ?: realm.createObject(roomId) val roomMembers = RoomMembers(realm, roomId).getLoaded() val eventsToInsert = response.roomMemberEvents.filter { !roomMembers.containsKey(it.stateKey) } @@ -78,9 +77,9 @@ internal class DefaultLoadRoomMembersTask(private val roomAPI: RoomAPI, private fun areAllMembersAlreadyLoaded(roomId: String): Boolean { return monarchy - .fetchAllCopiedSync { RoomEntity.where(it, roomId) } - .firstOrNull() - ?.areAllMembersLoaded ?: false + .fetchAllCopiedSync { RoomEntity.where(it, roomId) } + .firstOrNull() + ?.areAllMembersLoaded ?: false } } \ No newline at end of file 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 ef037ac5..38555d20 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 @@ -36,7 +36,12 @@ import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoo import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.configureWith -import io.realm.* +import io.realm.OrderedRealmCollectionChangeListener +import io.realm.Realm +import io.realm.RealmConfiguration +import io.realm.RealmQuery +import io.realm.RealmResults +import io.realm.Sort import timber.log.Timber import java.util.* import java.util.concurrent.atomic.AtomicBoolean @@ -97,10 +102,14 @@ internal class DefaultTimeline( val state = getPaginationState(direction) if (state.isPaginating) { // We are getting new items from pagination - paginateInternal(startDisplayIndex, direction, state.requestedCount) + val shouldPostSnapshot = paginateInternal(startDisplayIndex, direction, state.requestedCount) + if (shouldPostSnapshot) { + postSnapshot() + } } else { // We are getting new items from sync buildTimelineEvents(startDisplayIndex, direction, range.length.toLong()) + postSnapshot() } } } @@ -114,7 +123,10 @@ internal class DefaultTimeline( } Timber.v("Paginate $direction of $count items") val startDisplayIndex = if (direction == Timeline.Direction.BACKWARDS) prevDisplayIndex else nextDisplayIndex - paginateInternal(startDisplayIndex, direction, count) + val shouldPostSnapshot = paginateInternal(startDisplayIndex, direction, count) + if (shouldPostSnapshot) { + postSnapshot() + } } } @@ -191,13 +203,15 @@ internal class DefaultTimeline( /** * This has to be called on TimelineThread as it access realm live results + * @return true if snapshot should be posted */ private fun paginateInternal(startDisplayIndex: Int, direction: Timeline.Direction, - count: Int) { + count: Int): Boolean { updatePaginationState(direction) { it.copy(requestedCount = count, isPaginating = true) } val builtCount = buildTimelineEvents(startDisplayIndex, direction, count.toLong()) - if (builtCount < count && !hasReachedEnd(direction)) { + val shouldFetchMore = builtCount < count && !hasReachedEnd(direction) + if (shouldFetchMore) { val newRequestedCount = count - builtCount updatePaginationState(direction) { it.copy(requestedCount = newRequestedCount) } val fetchingCount = Math.max(MIN_FETCHING_COUNT, newRequestedCount) @@ -205,6 +219,7 @@ internal class DefaultTimeline( } else { updatePaginationState(direction) { it.copy(isPaginating = false, requestedCount = 0) } } + return !shouldFetchMore } private fun snapshot(): List { @@ -252,12 +267,13 @@ internal class DefaultTimeline( } else { val count = Math.min(INITIAL_LOAD_SIZE, liveEvents.size) if (isLive) { - paginate(Timeline.Direction.BACKWARDS, count) + paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count) } else { - paginate(Timeline.Direction.FORWARDS, count / 2) - paginate(Timeline.Direction.BACKWARDS, count / 2) + paginateInternal(initialDisplayIndex, Timeline.Direction.FORWARDS, count / 2) + paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count / 2) } } + postSnapshot() } /** @@ -266,9 +282,9 @@ internal class DefaultTimeline( private fun executePaginationTask(direction: Timeline.Direction, limit: Int) { val token = getTokenLive(direction) ?: return val params = PaginationTask.Params(roomId = roomId, - from = token, - direction = direction.toPaginationDirection(), - limit = limit) + from = token, + direction = direction.toPaginationDirection(), + limit = limit) Timber.v("Should fetch $limit items $direction") paginationTask.configureWith(params) @@ -336,8 +352,6 @@ internal class DefaultTimeline( builtEvents.add(position, timelineEvent) } Timber.v("Built ${offsetResults.size} items from db") - val snapshot = snapshot() - mainHandler.post { listener?.onUpdated(snapshot) } return offsetResults.size } @@ -399,6 +413,11 @@ internal class DefaultTimeline( contextOfEventTask.configureWith(params).executeBy(taskExecutor) } + private fun postSnapshot() { + val snapshot = snapshot() + mainHandler.post { listener?.onUpdated(snapshot) } + } + // Extension methods *************************************************************************** private fun Timeline.Direction.toPaginationDirection(): PaginationDirection {