diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index cf9e857e25..335c19e431 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -10,7 +10,7 @@ buildscript { jcenter() } dependencies { - classpath "io.realm:realm-gradle-plugin:5.8.0" + classpath "io.realm:realm-gradle-plugin:5.7.0" } } @@ -42,12 +42,6 @@ android { adbOptions { installOptions "-g" } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - } dependencies { @@ -115,12 +109,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 "android.arch.core:core-testing:$lifecycle_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" androidTestImplementation "org.koin:koin-test:$koin_version" androidTestImplementation 'com.android.support.test:runner:1.0.2' androidTestImplementation "com.android.support.test:rules:1.0.2" androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' androidTestImplementation 'org.amshove.kluent:kluent-android:1.44' + androidTestImplementation "io.mockk:mockk-android:1.8.13.kotlin13" + androidTestImplementation "android.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/LiveDataTestObserver.java b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/LiveDataTestObserver.java new file mode 100644 index 0000000000..6cbd7799cc --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/LiveDataTestObserver.java @@ -0,0 +1,195 @@ +package im.vector.matrix.android; + +import android.arch.lifecycle.LiveData; +import android.arch.lifecycle.MutableLiveData; +import android.arch.lifecycle.Observer; +import android.support.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +public final class LiveDataTestObserver implements Observer { + private final List valueHistory = new ArrayList<>(); + private final List> childObservers = new ArrayList<>(); + + @Deprecated // will be removed in version 1.0 + private final LiveData observedLiveData; + + private CountDownLatch valueLatch = new CountDownLatch(1); + + private LiveDataTestObserver(LiveData observedLiveData) { + this.observedLiveData = observedLiveData; + } + + @Override + public void onChanged(@Nullable T value) { + valueHistory.add(value); + valueLatch.countDown(); + for (Observer childObserver : childObservers) { + childObserver.onChanged(value); + } + } + + public T value() { + assertHasValue(); + return valueHistory.get(valueHistory.size() - 1); + } + + public List valueHistory() { + return Collections.unmodifiableList(valueHistory); + } + + /** + * Disposes and removes observer from observed live data. + * + * @return This Observer + * @deprecated Please use {@link LiveData#removeObserver(Observer)} instead, will be removed in 1.0 + */ + @Deprecated + public LiveDataTestObserver dispose() { + observedLiveData.removeObserver(this); + return this; + } + + public LiveDataTestObserver assertHasValue() { + if (valueHistory.isEmpty()) { + throw fail("Observer never received any value"); + } + + return this; + } + + public LiveDataTestObserver assertNoValue() { + if (!valueHistory.isEmpty()) { + throw fail("Expected no value, but received: " + value()); + } + + return this; + } + + public LiveDataTestObserver assertHistorySize(int expectedSize) { + int size = valueHistory.size(); + if (size != expectedSize) { + throw fail("History size differ; Expected: " + expectedSize + ", Actual: " + size); + } + return this; + } + + public LiveDataTestObserver assertValue(T expected) { + T value = value(); + + if (expected == null && value == null) { + return this; + } + + if (!value.equals(expected)) { + throw fail("Expected: " + valueAndClass(expected) + ", Actual: " + valueAndClass(value)); + } + + return this; + } + + public LiveDataTestObserver assertValue(Function valuePredicate) { + T value = value(); + + if (!valuePredicate.apply(value)) { + throw fail("Value not present"); + } + + return this; + } + + public LiveDataTestObserver assertNever(Function valuePredicate) { + int size = valueHistory.size(); + for (int valueIndex = 0; valueIndex < size; valueIndex++) { + T value = this.valueHistory.get(valueIndex); + if (valuePredicate.apply(value)) { + throw fail("Value at position " + valueIndex + " matches predicate " + + valuePredicate.toString() + ", which was not expected."); + } + } + + return this; + } + + /** + * Awaits until this TestObserver has any value. + *

+ * If this TestObserver has already value then this method returns immediately. + * + * @return this + * @throws InterruptedException if the current thread is interrupted while waiting + */ + public LiveDataTestObserver awaitValue() throws InterruptedException { + valueLatch.await(); + return this; + } + + /** + * Awaits the specified amount of time or until this TestObserver has any value. + *

+ * If this TestObserver has already value then this method returns immediately. + * + * @return this + * @throws InterruptedException if the current thread is interrupted while waiting + */ + public LiveDataTestObserver awaitValue(long timeout, TimeUnit timeUnit) throws InterruptedException { + valueLatch.await(timeout, timeUnit); + return this; + } + + /** + * Awaits until this TestObserver receives next value. + *

+ * If this TestObserver has already value then it awaits for another one. + * + * @return this + * @throws InterruptedException if the current thread is interrupted while waiting + */ + public LiveDataTestObserver awaitNextValue() throws InterruptedException { + return withNewLatch().awaitValue(); + } + + + /** + * Awaits the specified amount of time or until this TestObserver receives next value. + *

+ * If this TestObserver has already value then it awaits for another one. + * + * @return this + * @throws InterruptedException if the current thread is interrupted while waiting + */ + public LiveDataTestObserver awaitNextValue(long timeout, TimeUnit timeUnit) throws InterruptedException { + return withNewLatch().awaitValue(timeout, timeUnit); + } + + private LiveDataTestObserver withNewLatch() { + valueLatch = new CountDownLatch(1); + return this; + } + + private AssertionError fail(String message) { + return new AssertionError(message); + } + + private static String valueAndClass(Object value) { + if (value != null) { + return value + " (class: " + value.getClass().getSimpleName() + ")"; + } + return "null"; + } + + public static LiveDataTestObserver create() { + return new LiveDataTestObserver<>(new MutableLiveData()); + } + + public static LiveDataTestObserver test(LiveData liveData) { + LiveDataTestObserver observer = new LiveDataTestObserver<>(liveData); + liveData.observeForever(observer); + return observer; + } +} \ No newline at end of file diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/SingleThreadCoroutineDispatcher.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/SingleThreadCoroutineDispatcher.kt new file mode 100644 index 0000000000..2b9be56f1b --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/SingleThreadCoroutineDispatcher.kt @@ -0,0 +1,6 @@ +package im.vector.matrix.android + +import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers +import kotlinx.coroutines.Dispatchers.Main + +internal val testCoroutineDispatchers = MatrixCoroutineDispatchers(Main, Main, Main) \ No newline at end of file diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/FakeGetContextOfEventTask.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/FakeGetContextOfEventTask.kt new file mode 100644 index 0000000000..ae02747728 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/FakeGetContextOfEventTask.kt @@ -0,0 +1,24 @@ +package im.vector.matrix.android.session.room.timeline + +import arrow.core.Try +import im.vector.matrix.android.internal.session.room.timeline.GetContextOfEventTask +import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection +import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEvent +import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEventPersistor +import kotlin.random.Random + +internal class FakeGetContextOfEventTask(private val tokenChunkEventPersistor: TokenChunkEventPersistor) : GetContextOfEventTask { + + override fun execute(params: GetContextOfEventTask.Params): Try { + val fakeEvents = RoomDataHelper.createFakeListOfEvents(30) + val tokenChunkEvent = FakeTokenChunkEvent( + Random.nextLong(System.currentTimeMillis()).toString(), + Random.nextLong(System.currentTimeMillis()).toString(), + fakeEvents + ) + return tokenChunkEventPersistor.insertInDb(tokenChunkEvent, params.roomId, PaginationDirection.BACKWARDS) + .map { tokenChunkEvent } + } + + +} \ No newline at end of file diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/FakePaginationTask.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/FakePaginationTask.kt new file mode 100644 index 0000000000..ecd878c747 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/FakePaginationTask.kt @@ -0,0 +1,19 @@ +package im.vector.matrix.android.session.room.timeline + +import arrow.core.Try +import im.vector.matrix.android.internal.session.room.timeline.PaginationTask +import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEvent +import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEventPersistor +import kotlin.random.Random + +internal class FakePaginationTask(private val tokenChunkEventPersistor: TokenChunkEventPersistor) : PaginationTask { + + override fun execute(params: PaginationTask.Params): Try { + val fakeEvents = RoomDataHelper.createFakeListOfEvents(30) + val tokenChunkEvent = FakeTokenChunkEvent(params.from, Random.nextLong(System.currentTimeMillis()).toString(), fakeEvents) + return tokenChunkEventPersistor.insertInDb(tokenChunkEvent, params.roomId, params.direction) + .map { tokenChunkEvent } + } + +} + diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/FakeTokenChunkEvent.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/FakeTokenChunkEvent.kt new file mode 100644 index 0000000000..9229958f66 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/FakeTokenChunkEvent.kt @@ -0,0 +1,10 @@ +package im.vector.matrix.android.session.room.timeline + +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEvent + +internal data class FakeTokenChunkEvent(override val start: String?, + override val end: String?, + override val events: List = emptyList(), + override val stateEvents: List = emptyList() +) : TokenChunkEvent \ 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 new file mode 100644 index 0000000000..2385d79405 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/RoomDataHelper.kt @@ -0,0 +1,43 @@ +package im.vector.matrix.android.session.room.timeline + +import com.zhuinden.monarchy.Monarchy +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.room.model.MyMembership +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 +import im.vector.matrix.android.internal.database.model.RoomEntity +import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection +import io.realm.kotlin.createObject +import kotlin.random.Random + +object RoomDataHelper { + + fun createFakeListOfEvents(size: Int = 10): List { + return (0 until size).map { createFakeEvent(Random.nextBoolean()) } + } + + 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 fakeInitialSync(monarchy: Monarchy, roomId: String) { + monarchy.runTransactionSync { realm -> + val roomEntity = realm.createObject(roomId) + roomEntity.membership = MyMembership.JOINED + val eventList = createFakeListOfEvents(30) + val chunkEntity = realm.createObject().apply { + nextToken = null + prevToken = Random.nextLong(System.currentTimeMillis()).toString() + isLast = true + } + chunkEntity.addAll(eventList, PaginationDirection.FORWARDS) + roomEntity.addOrUpdate(chunkEntity) + } + } + + +} \ No newline at end of file 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/TimelineHolderTest.kt new file mode 100644 index 0000000000..eb68af982d --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineHolderTest.kt @@ -0,0 +1,60 @@ +package im.vector.matrix.android.session.room.timeline + +import android.arch.core.executor.testing.InstantTaskExecutorRule +import android.support.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.thread.MainThreadExecutor +import im.vector.matrix.android.internal.TaskExecutor +import im.vector.matrix.android.internal.session.room.timeline.DefaultTimelineHolder +import im.vector.matrix.android.internal.session.room.timeline.TimelineBoundaryCallback +import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEventPersistor +import im.vector.matrix.android.internal.util.PagingRequestHelper +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 + +internal class TimelineHolderTest : InstrumentedTest { + + @get:Rule val testRule = InstantTaskExecutorRule() + private lateinit var monarchy: Monarchy + + @Before + fun setup() { + Realm.init(context()) + val testConfiguration = RealmConfiguration.Builder().name("test-realm").build() + Realm.deleteRealm(testConfiguration) + monarchy = Monarchy.Builder().setRealmConfiguration(testConfiguration).build() + } + + @Test + @UiThreadTest + fun backPaginate_shouldLoadMoreEvents_whenLoadAroundIsCalled() { + val roomId = "roomId" + val taskExecutor = TaskExecutor(testCoroutineDispatchers) + val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy) + val paginationTask = FakePaginationTask(tokenChunkEventPersistor) + val getContextOfEventTask = FakeGetContextOfEventTask(tokenChunkEventPersistor) + val boundaryCallback = TimelineBoundaryCallback(roomId, taskExecutor, paginationTask, monarchy, PagingRequestHelper(MainThreadExecutor())) + + RoomDataHelper.fakeInitialSync(monarchy, roomId) + val timelineHolder = DefaultTimelineHolder(roomId, monarchy, taskExecutor, boundaryCallback, getContextOfEventTask) + val timelineObserver = LiveDataTestObserver.test(timelineHolder.timeline()) + timelineObserver.awaitNextValue().assertHasValue() + var pagedList = timelineObserver.value() + pagedList.size shouldEqual 30 + (0 until pagedList.size).map { + pagedList.loadAround(it) + } + timelineObserver.awaitNextValue().assertHasValue() + pagedList = timelineObserver.value() + pagedList.size shouldEqual 60 + } + + +} \ No newline at end of file