Add LoadResult.Invalid support for Paging2
This change adds LoadResult.Invalid support for Paging2 leveraging
PagingSource, such as LivePagedList and RxPagedList.
In the event of a PagingSource returning LoadResult.Invalid to its
PagedList, paging will detach the PagedList to stop attempts to load
on this PagedList and invalidate the PagingSource.
If no initial page is provided to PagedList.Builder and the initial
load fails with a LoadResult.Invalid, an IllegalStateException
will be thrown. To use a PagingSource that supports invalidation, use a
PagedList builder that accepts a factory method for PagingSource or
DataSource.Factory such as LivePagedList.
Bug: 192013267
Test: ./gradlew :paging:paging-common:test
Test: ./gradlew :paging:paging-runtime:cC
Test: ./gradlew :paging:paging-rxjava2:test
Test: ./gradlew :paging:paging-rxjava3:test
Change-Id: I97de7e67cc5fe01b2c5c5a8e13bd1926c5ca2cde
diff --git a/paging/common/src/main/kotlin/androidx/paging/LegacyPageFetcher.kt b/paging/common/src/main/kotlin/androidx/paging/LegacyPageFetcher.kt
index 1bc1f44..868af5e 100644
--- a/paging/common/src/main/kotlin/androidx/paging/LegacyPageFetcher.kt
+++ b/paging/common/src/main/kotlin/androidx/paging/LegacyPageFetcher.kt
@@ -63,6 +63,7 @@
when (value) {
is PagingSource.LoadResult.Page -> onLoadSuccess(type, value)
is PagingSource.LoadResult.Error -> onLoadError(type, value.throwable)
+ is PagingSource.LoadResult.Invalid -> onLoadInvalid()
}
}
}
@@ -92,6 +93,11 @@
loadStateManager.setState(type, state)
}
+ private fun onLoadInvalid() {
+ source.invalidate()
+ detach()
+ }
+
fun trySchedulePrepend() {
val startState = loadStateManager.startState
if (startState is NotLoading && !startState.endOfPaginationReached) {
diff --git a/paging/common/src/test/kotlin/androidx/paging/ContiguousPagedListTest.kt b/paging/common/src/test/kotlin/androidx/paging/ContiguousPagedListTest.kt
index 03811c2..ffdfee5 100644
--- a/paging/common/src/test/kotlin/androidx/paging/ContiguousPagedListTest.kt
+++ b/paging/common/src/test/kotlin/androidx/paging/ContiguousPagedListTest.kt
@@ -64,6 +64,8 @@
private inner class TestPagingSource(
val listData: List<Item> = ITEMS
) : PagingSource<Int, Item>() {
+ var invalidData = false
+
override fun getRefreshKey(state: PagingState<Int, Item>): Int? {
return state.anchorPosition
?.let { anchorPosition -> state.closestItemToPosition(anchorPosition)?.pos }
@@ -89,6 +91,10 @@
val start = maxOf(initPos - params.loadSize / 2, 0)
val result = getClampedRange(start, start + params.loadSize)
+ if (invalidData) {
+ invalidData = false
+ return LoadResult.Invalid()
+ }
return when {
result == null -> LoadResult.Error(EXCEPTION)
placeholdersEnabled -> Page(
@@ -109,6 +115,10 @@
private fun loadAfter(params: LoadParams<Int>): LoadResult<Int, Item> {
val result = getClampedRange(params.key!! + 1, params.key!! + 1 + params.loadSize)
?: return LoadResult.Error(EXCEPTION)
+ if (invalidData) {
+ invalidData = false
+ return LoadResult.Invalid()
+ }
return Page(
data = result,
prevKey = if (result.isNotEmpty()) result.first().pos else null,
@@ -119,6 +129,10 @@
private fun loadBefore(params: LoadParams<Int>): LoadResult<Int, Item> {
val result = getClampedRange(params.key!! - params.loadSize, params.key!!)
?: return LoadResult.Error(EXCEPTION)
+ if (invalidData) {
+ invalidData = false
+ return LoadResult.Invalid()
+ }
return Page(
data = result,
prevKey = result.firstOrNull()?.pos,
@@ -329,6 +343,30 @@
}
@Test
+ fun append_invalidData_detach() {
+ val pagedList = createCountedPagedList(0)
+ val callback = mock<Callback>()
+ pagedList.addWeakCallback(callback)
+ verifyRange(0, 40, pagedList)
+ verifyZeroInteractions(callback)
+
+ pagedList.loadAround(35)
+ // return a LoadResult.Invalid
+ val pagingSource = pagedList.pagingSource as TestPagingSource
+ pagingSource.invalidData = true
+ drain()
+
+ // nothing new should be loaded
+ verifyRange(0, 40, pagedList)
+ verifyNoMoreInteractions(callback)
+ assertTrue(pagingSource.invalid)
+ assertTrue(pagedList.isDetached)
+ // detached status should turn pagedList into immutable, and snapshot should return the
+ // pagedList itself
+ assertSame(pagedList.snapshot(), pagedList)
+ }
+
+ @Test
fun prepend() {
val pagedList = createCountedPagedList(80)
val callback = mock<Callback>()
@@ -345,6 +383,30 @@
}
@Test
+ fun prepend_invalidData_detach() {
+ val pagedList = createCountedPagedList(80)
+ val callback = mock<Callback>()
+ pagedList.addWeakCallback(callback)
+ verifyRange(60, 40, pagedList)
+ verifyZeroInteractions(callback)
+
+ pagedList.loadAround(if (placeholdersEnabled) 65 else 5)
+ // return a LoadResult.Invalid
+ val pagingSource = pagedList.pagingSource as TestPagingSource
+ pagingSource.invalidData = true
+ drain()
+
+ // nothing new should be loaded
+ verifyRange(60, 40, pagedList)
+ verifyNoMoreInteractions(callback)
+ assertTrue(pagingSource.invalid)
+ assertTrue(pagedList.isDetached)
+ // detached status should turn pagedList into immutable, and snapshot should return the
+ // pagedList itself
+ assertSame(pagedList.snapshot(), pagedList)
+ }
+
+ @Test
fun outwards() {
val pagedList = createCountedPagedList(40)
val callback = mock<Callback>()
diff --git a/paging/common/src/test/kotlin/androidx/paging/LegacyPageFetcherTest.kt b/paging/common/src/test/kotlin/androidx/paging/LegacyPageFetcherTest.kt
index 71166f4..f059a72 100644
--- a/paging/common/src/test/kotlin/androidx/paging/LegacyPageFetcherTest.kt
+++ b/paging/common/src/test/kotlin/androidx/paging/LegacyPageFetcherTest.kt
@@ -27,6 +27,7 @@
import androidx.paging.PagingSource.LoadResult
import androidx.paging.PagingSource.LoadResult.Page
import androidx.testutils.TestDispatcher
+import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.runBlocking
@@ -42,6 +43,8 @@
private val data = List(9) { "$it" }
inner class ImmediateListDataSource(val data: List<String>) : PagingSource<Int, String>() {
+ var invalidData = false
+
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, String> {
val key = params.key ?: 0
@@ -52,6 +55,11 @@
}.let { (start, end) ->
start.coerceAtLeast(0) to end.coerceAtMost(data.size)
}
+
+ if (invalidData) {
+ invalidData = false
+ return LoadResult.Invalid()
+ }
return Page(
data = data.subList(start, end),
prevKey = if (start > 0) start else null,
@@ -345,4 +353,76 @@
consumer.takeStateChanges()
)
}
+
+ @Test
+ fun append_invalidData() {
+ val consumer = MockConsumer()
+ val pager = createPager(consumer, 0, 3)
+
+ // try a normal append first
+ pager.tryScheduleAppend()
+ testDispatcher.executeAll()
+
+ assertThat(consumer.takeResults()).containsExactly(
+ Result(APPEND, rangeResult(3, 5))
+ )
+ assertThat(consumer.takeStateChanges()).containsExactly(
+ StateChange(APPEND, Loading),
+ StateChange(APPEND, NotLoading.Incomplete)
+ )
+
+ // now make next append return LoadResult.Invalid
+ val pagingSource = pager.source as ImmediateListDataSource
+ pagingSource.invalidData = true
+
+ pager.tryScheduleAppend()
+ testDispatcher.executeAll()
+
+ // the load should return before returning any data
+ assertThat(consumer.takeResults()).isEmpty()
+ assertThat(consumer.takeStateChanges()).containsExactly(
+ StateChange(APPEND, Loading),
+ )
+
+ // exception handler should invalidate the paging source and result in fetcher to be
+ // detached
+ assertTrue(pagingSource.invalid)
+ assertTrue(pager.isDetached)
+ }
+
+ @Test
+ fun prepend_invalidData() {
+ val consumer = MockConsumer()
+ val pager = createPager(consumer, 6, 9)
+
+ // try a normal prepend first
+ pager.trySchedulePrepend()
+ testDispatcher.executeAll()
+
+ assertThat(consumer.takeResults()).containsExactly(
+ Result(PREPEND, rangeResult(4, 6))
+ )
+ assertThat(consumer.takeStateChanges()).containsExactly(
+ StateChange(PREPEND, Loading),
+ StateChange(PREPEND, NotLoading.Incomplete)
+ )
+
+ // now make next prepend throw error
+ val pagingSource = pager.source as ImmediateListDataSource
+ pagingSource.invalidData = true
+
+ pager.trySchedulePrepend()
+ testDispatcher.executeAll()
+
+ // the load should return before returning any data
+ assertThat(consumer.takeResults()).isEmpty()
+ assertThat(consumer.takeStateChanges()).containsExactly(
+ StateChange(PREPEND, Loading),
+ )
+
+ // exception handler should invalidate the paging source and result in fetcher to be
+ // detached
+ assertTrue(pagingSource.invalid)
+ assertTrue(pager.isDetached)
+ }
}
diff --git a/paging/runtime/src/androidTest/java/androidx/paging/LivePagedListBuilderTest.kt b/paging/runtime/src/androidTest/java/androidx/paging/LivePagedListBuilderTest.kt
index 5caf5f0..4a5dfdf 100644
--- a/paging/runtime/src/androidTest/java/androidx/paging/LivePagedListBuilderTest.kt
+++ b/paging/runtime/src/androidTest/java/androidx/paging/LivePagedListBuilderTest.kt
@@ -24,6 +24,7 @@
import androidx.paging.LoadState.Error
import androidx.paging.LoadState.Loading
import androidx.paging.LoadState.NotLoading
+import androidx.paging.LoadType.APPEND
import androidx.paging.LoadType.REFRESH
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
@@ -94,7 +95,10 @@
throwable = EXCEPTION
}
- private inner class MockPagingSource : PagingSource<Int, String>() {
+ inner class MockPagingSource : PagingSource<Int, String>() {
+
+ var invalidInitialLoadResult = false
+
override suspend fun load(params: LoadParams<Int>) = when (params) {
is LoadParams.Refresh -> loadInitial(params)
else -> loadRange()
@@ -103,6 +107,10 @@
override fun getRefreshKey(state: PagingState<Int, String>): Int? = null
private fun loadInitial(params: LoadParams<Int>): LoadResult<Int, String> {
+ if (invalidInitialLoadResult) {
+ invalidInitialLoadResult = false
+ return LoadResult.Invalid()
+ }
if (params is LoadParams.Refresh) {
assertEquals(6, params.loadSize)
} else {
@@ -275,6 +283,153 @@
}
@Test
+ fun initialPagedList_invalidInitialResult() {
+ val factory = MockPagingSourceFactory()
+ val pagingSources = mutableListOf<MockPagingSourceFactory.MockPagingSource>()
+ val pagedListHolder = mutableListOf<PagedList<String>>()
+
+ val livePagedList = LivePagedListBuilder(
+ {
+ factory.create().also { pagingSource ->
+ pagingSource as MockPagingSourceFactory.MockPagingSource
+ if (pagingSources.size == 0) {
+ pagingSource.invalidInitialLoadResult = true
+ }
+ pagingSources.add(pagingSource)
+ }
+ },
+ pageSize = 2
+ )
+ .setFetchExecutor(backgroundExecutor)
+ .build()
+
+ val loadStates = mutableListOf<LoadStateEvent>()
+
+ val loadStateChangedCallback = { type: LoadType, state: LoadState ->
+ if (type == REFRESH) {
+ loadStates.add(LoadStateEvent(type, state))
+ }
+ }
+
+ livePagedList.observe(lifecycleOwner) { newList ->
+ newList.addWeakLoadStateListener(loadStateChangedCallback)
+ pagedListHolder.add(newList)
+ }
+
+ // the initial empty paged list
+ val initPagedList = pagedListHolder[0]
+ assertNotNull(initPagedList)
+ assertThat(initPagedList).isInstanceOf(InitialPagedList::class.java)
+
+ drain()
+
+ // the first pagingSource returns LoadResult.Invalid. This should invalidate the first
+ // pagingSource and generate a second source
+ assertThat(pagingSources.size).isEqualTo(2)
+ assertTrue(pagingSources[0].invalid)
+ // The second source should have the successful initial load required to create a
+ // ContiguousPagedList
+ assertThat(pagedListHolder.size).isEqualTo(2)
+ assertThat(pagedListHolder[1]).isInstanceOf(ContiguousPagedList::class.java)
+
+ assertThat(loadStates).containsExactly(
+ LoadStateEvent(
+ REFRESH,
+ NotLoading(endOfPaginationReached = false)
+ ),
+ LoadStateEvent(REFRESH, Loading),
+ // when LoadResult.Invalid is returned, REFRESH is reset back to NotLoading
+ LoadStateEvent(
+ REFRESH,
+ NotLoading(endOfPaginationReached = false)
+ ),
+ LoadStateEvent(REFRESH, Loading),
+ LoadStateEvent(
+ REFRESH,
+ NotLoading(endOfPaginationReached = false)
+ )
+ )
+ }
+
+ @Test
+ fun loadAround_invalidResult() {
+ val pagingSources = mutableListOf<TestPagingSource>()
+ val pagedLists = mutableListOf<PagedList<Int>>()
+ val factory = { TestPagingSource(loadDelay = 0).also { pagingSources.add(it) } }
+
+ val livePagedList = LivePagedListBuilder(
+ factory,
+ config = PagedList.Config.Builder()
+ .setPageSize(2)
+ .setInitialLoadSizeHint(6)
+ .setEnablePlaceholders(false)
+ .build()
+ )
+ .setFetchExecutor(backgroundExecutor)
+ .build()
+
+ val loadStates = mutableListOf<LoadStateEvent>()
+
+ val loadStateChangedCallback = { type: LoadType, state: LoadState ->
+ if (type == APPEND) {
+ loadStates.add(LoadStateEvent(type, state))
+ }
+ }
+
+ livePagedList.observeForever { newList ->
+ newList.addWeakLoadStateListener(loadStateChangedCallback)
+ pagedLists.add(newList)
+ }
+
+ drain()
+
+ // pagedLists[0] is the empty InitialPagedList, we don't care about it here
+ val pagedList1 = pagedLists[1]
+ // initial load 6 items
+ assertEquals(listOf(0, 1, 2, 3, 4, 5), pagedList1)
+ // append 2 items after initial load
+ pagedList1.loadAround(5)
+
+ drain()
+ // should load 6 + 2 = 8 items
+ assertEquals(listOf(0, 1, 2, 3, 4, 5, 6, 7), pagedList1)
+ // append 2 more items but this time, return LoadResult.Invalid
+ pagedList1.loadAround(7)
+ val source = pagedLists[1].pagingSource as TestPagingSource
+ source.nextLoadResult = PagingSource.LoadResult.Invalid()
+
+ drain()
+
+ // nothing more should be loaded from invalid paged list
+ assertEquals(listOf(0, 1, 2, 3, 4, 5, 6, 7), pagedList1)
+ // a new pagedList should be generated with a refresh load starting from index 7
+ assertEquals(listOf(7, 8, 9, 10, 11, 12), pagedLists[2])
+
+ assertThat(pagingSources.size).isEqualTo(2)
+ assertThat(pagedLists.size).isEqualTo(3)
+ assertThat(loadStates).containsExactly(
+ LoadStateEvent(
+ APPEND,
+ NotLoading(endOfPaginationReached = false)
+ ), // first empty paged list
+ LoadStateEvent(
+ APPEND,
+ NotLoading(endOfPaginationReached = false)
+ ), // second paged list
+ LoadStateEvent(APPEND, Loading), // second paged list append
+ LoadStateEvent(
+ APPEND,
+ NotLoading(endOfPaginationReached = false)
+ ), // append success
+ LoadStateEvent(APPEND, Loading), // second paged list append again but fails
+ LoadStateEvent(
+ APPEND,
+ NotLoading(endOfPaginationReached = false)
+ ) // third paged list
+ )
+ }
+
+ @Test
fun legacyPagingSourcePageSize() {
val dataSources = mutableListOf<DataSource<Int, Int>>()
val pagedLists = mutableListOf<PagedList<Int>>()
diff --git a/paging/runtime/src/androidTest/java/androidx/paging/LivePagedListTest.kt b/paging/runtime/src/androidTest/java/androidx/paging/LivePagedListTest.kt
index c3138ca..00e2936 100644
--- a/paging/runtime/src/androidTest/java/androidx/paging/LivePagedListTest.kt
+++ b/paging/runtime/src/androidTest/java/androidx/paging/LivePagedListTest.kt
@@ -26,10 +26,14 @@
import androidx.test.filters.SmallTest
import androidx.testutils.TestDispatcher
import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.test.TestCoroutineScope
+import kotlinx.coroutines.test.runBlockingTest
import org.junit.Assert.assertNotNull
import org.junit.Rule
import org.junit.Test
@@ -40,11 +44,14 @@
@RunWith(AndroidJUnit4::class)
@SmallTest
@Suppress("DEPRECATION")
+@OptIn(ExperimentalCoroutinesApi::class)
class LivePagedListTest {
@JvmField
@Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
+ private val testScope = TestCoroutineScope()
+
@OptIn(DelicateCoroutinesApi::class)
@Test
fun instantiatesPagingSourceOnFetchDispatcher() {
@@ -167,6 +174,47 @@
}
}
+ @OptIn(ExperimentalStdlibApi::class)
+ @Test
+ fun initialLoad_loadResultInvalid() = testScope.runBlockingTest {
+ val dispatcher = coroutineContext[CoroutineDispatcher.Key]!!
+ val pagingSources = mutableListOf<TestPagingSource>()
+ val factory = {
+ TestPagingSource().also {
+ if (pagingSources.size == 0) it.nextLoadResult = PagingSource.LoadResult.Invalid()
+ pagingSources.add(it)
+ }
+ }
+ val config = PagedList.Config.Builder()
+ .setEnablePlaceholders(false)
+ .setPageSize(3)
+ .build()
+
+ val livePagedList = LivePagedList(
+ coroutineScope = testScope,
+ initialKey = null,
+ config = config,
+ boundaryCallback = null,
+ pagingSourceFactory = factory,
+ notifyDispatcher = dispatcher,
+ fetchDispatcher = dispatcher,
+ )
+
+ val pagedLists = mutableListOf<PagedList<Int>>()
+ livePagedList.observeForever {
+ pagedLists.add(it)
+ }
+
+ advanceUntilIdle()
+
+ assertThat(pagedLists.size).isEqualTo(2)
+ assertThat(pagingSources.size).isEqualTo(2)
+ assertThat(pagedLists.size).isEqualTo(2)
+ assertThat(pagedLists[1]).containsExactly(
+ 0, 1, 2, 3, 4, 5, 6, 7, 8
+ )
+ }
+
companion object {
@Suppress("DEPRECATION")
private val dataSource = object : PositionalDataSource<String>() {
diff --git a/paging/runtime/src/main/java/androidx/paging/LivePagedList.kt b/paging/runtime/src/main/java/androidx/paging/LivePagedList.kt
index ab754be..ea2f8d1f 100644
--- a/paging/runtime/src/main/java/androidx/paging/LivePagedList.kt
+++ b/paging/runtime/src/main/java/androidx/paging/LivePagedList.kt
@@ -88,6 +88,13 @@
val params = config.toRefreshLoadParams(lastKey)
when (val initialResult = pagingSource.load(params)) {
+ is PagingSource.LoadResult.Invalid -> {
+ currentData.setInitialLoadState(
+ REFRESH,
+ LoadState.NotLoading(false)
+ )
+ pagingSource.invalidate()
+ }
is PagingSource.LoadResult.Error -> {
currentData.setInitialLoadState(
REFRESH,
diff --git a/paging/rxjava2/build.gradle b/paging/rxjava2/build.gradle
index d7a7de6..459bc18 100644
--- a/paging/rxjava2/build.gradle
+++ b/paging/rxjava2/build.gradle
@@ -33,9 +33,11 @@
testImplementation(project(":internal-testutils-common"))
testImplementation(project(":internal-testutils-paging"))
+ testImplementation(project(":internal-testutils-ktx"))
testImplementation(libs.junit)
testImplementation(libs.kotlinTest)
testImplementation(libs.kotlinCoroutinesTest)
+ testImplementation(libs.truth)
androidTestImplementation(libs.testExtJunit)
androidTestImplementation(libs.testCore)
diff --git a/paging/rxjava2/src/main/java/androidx/paging/RxPagedListBuilder.kt b/paging/rxjava2/src/main/java/androidx/paging/RxPagedListBuilder.kt
index ee513d8..69cc178 100644
--- a/paging/rxjava2/src/main/java/androidx/paging/RxPagedListBuilder.kt
+++ b/paging/rxjava2/src/main/java/androidx/paging/RxPagedListBuilder.kt
@@ -404,6 +404,13 @@
val lastKey = currentData.lastKey as Key?
val params = config.toRefreshLoadParams(lastKey)
when (val initialResult = pagingSource.load(params)) {
+ is PagingSource.LoadResult.Invalid -> {
+ currentData.setInitialLoadState(
+ LoadType.REFRESH,
+ LoadState.NotLoading(endOfPaginationReached = false)
+ )
+ pagingSource.invalidate()
+ }
is PagingSource.LoadResult.Error -> {
currentData.setInitialLoadState(
LoadType.REFRESH,
diff --git a/paging/rxjava2/src/test/java/androidx/paging/RxPagedListBuilderTest.kt b/paging/rxjava2/src/test/java/androidx/paging/RxPagedListBuilderTest.kt
index 07bfe47..aca95206 100644
--- a/paging/rxjava2/src/test/java/androidx/paging/RxPagedListBuilderTest.kt
+++ b/paging/rxjava2/src/test/java/androidx/paging/RxPagedListBuilderTest.kt
@@ -20,6 +20,8 @@
import androidx.paging.LoadState.Loading
import androidx.paging.LoadState.NotLoading
import androidx.paging.LoadType.REFRESH
+import androidx.testutils.DirectDispatcher
+import androidx.testutils.TestDispatcher
import io.reactivex.Observable
import io.reactivex.observers.TestObserver
import io.reactivex.schedulers.Schedulers
@@ -29,6 +31,10 @@
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import kotlin.test.assertTrue
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.withContext
@Suppress("DEPRECATION")
@RunWith(JUnit4::class)
@@ -53,8 +59,10 @@
}
class MockDataSourceFactory {
- fun create(): PagingSource<Int, String> {
- return MockPagingSource()
+ fun create(
+ loadDispatcher: CoroutineDispatcher = DirectDispatcher
+ ): PagingSource<Int, String> {
+ return MockPagingSource(loadDispatcher)
}
var throwable: Throwable? = null
@@ -63,10 +71,25 @@
throwable = EXCEPTION
}
- private inner class MockPagingSource : PagingSource<Int, String>() {
- override suspend fun load(params: LoadParams<Int>) = when (params) {
- is LoadParams.Refresh -> loadInitial(params)
- else -> loadRange()
+ inner class MockPagingSource(
+ // Allow explicit control of load calls outside of fetch / notify. Note: This is
+ // different from simply setting fetchDispatcher because PagingObservableOnSubscribe
+ // init happens on fetchDispatcher which makes it difficult to differentiate
+ // InitialPagedList.
+ val loadDispatcher: CoroutineDispatcher
+ ) : PagingSource<Int, String>() {
+ var invalidInitialLoad = false
+
+ override suspend fun load(params: LoadParams<Int>): LoadResult<Int, String> {
+ return withContext(loadDispatcher) {
+ if (invalidInitialLoad) {
+ invalidInitialLoad = false
+ LoadResult.Invalid()
+ } else when (params) {
+ is LoadParams.Refresh -> loadInitial(params)
+ else -> loadRange()
+ }
+ }
}
override fun getRefreshKey(state: PagingState<Int, String>): Int? = null
@@ -256,6 +279,105 @@
}
@Test
+ fun observablePagedList_invalidInitialResult() {
+ // this TestDispatcher is used to queue up pagingSource.load(). This allows us to control
+ // and assert against each load() attempt outside of fetch/notify dispatcher
+ val loadDispatcher = TestDispatcher()
+ val pagingSources = mutableListOf<MockDataSourceFactory.MockPagingSource>()
+ val factory = {
+ MockDataSourceFactory().create(loadDispatcher).also {
+ val source = it as MockDataSourceFactory.MockPagingSource
+ if (pagingSources.size == 0) source.invalidInitialLoad = true
+ pagingSources.add(source)
+ }
+ }
+ // this is essentially a direct scheduler so jobs are run immediately
+ val scheduler = Schedulers.from(DirectDispatcher.asExecutor())
+ val observable = RxPagedListBuilder(factory, 2)
+ .setFetchScheduler(scheduler)
+ .setNotifyScheduler(scheduler)
+ .buildObservable()
+
+ val observer = TestObserver<PagedList<String>>()
+ // subscribe triggers the PagingObservableOnSubscribe's invalidate() to create first
+ // pagingSource
+ observable.subscribe(observer)
+
+ // ensure the InitialPagedList with empty data is observed
+ observer.assertValueCount(1)
+ val initPagedList = observer.values()[0]!!
+ assertThat(initPagedList).isInstanceOf(InitialPagedList::class.java)
+ assertThat(initPagedList).isEmpty()
+ // ensure first pagingSource is also created at this point
+ assertThat(pagingSources.size).isEqualTo(1)
+
+ val loadStates = mutableListOf<LoadStateEvent>()
+ val loadStateChangedCallback = { type: LoadType, state: LoadState ->
+ if (type == REFRESH) {
+ loadStates.add(LoadStateEvent(type, state))
+ }
+ }
+
+ initPagedList.addWeakLoadStateListener(loadStateChangedCallback)
+
+ assertThat(loadStates).containsExactly(
+ // before first load() is called, REFRESH is set to loading, represents load
+ // attempt on first pagingSource
+ LoadStateEvent(REFRESH, Loading)
+ )
+
+ // execute first load, represents load attempt on first paging source
+ //
+ // using poll().run() instead of executeAll(), because executeAll() + immediate schedulers
+ // result in first load + subsequent loads executing immediately and we won't be able to
+ // assert the pagedLists/loads incrementally
+ loadDispatcher.queue.poll()?.run()
+
+ // the load failed so there should still be only one PagedList, but the first
+ // pagingSource should invalidated, and the second pagingSource is created
+ observer.assertValueCount(1)
+ assertTrue(pagingSources[0].invalid)
+ assertThat(pagingSources.size).isEqualTo(2)
+
+ assertThat(loadStates).containsExactly(
+ // the first load attempt
+ LoadStateEvent(REFRESH, Loading),
+ // LoadResult.Invalid resets RERFRESH state
+ LoadStateEvent(
+ REFRESH,
+ NotLoading(endOfPaginationReached = false)
+ ),
+ // before second load() is called, REFRESH is set to loading, represents load
+ // attempt on second pagingSource
+ LoadStateEvent(REFRESH, Loading),
+ )
+
+ // execute the load attempt on second pagingSource which succeeds
+ loadDispatcher.queue.poll()?.run()
+
+ // ensure second pagedList created with the correct data loaded
+ observer.assertValueCount(2)
+ val secondPagedList = observer.values()[1]
+ assertThat(secondPagedList).containsExactly("a", "b", null, null)
+ assertThat(secondPagedList).isNotInstanceOf(InitialPagedList::class.java)
+ assertThat(secondPagedList).isInstanceOf(ContiguousPagedList::class.java)
+
+ secondPagedList.addWeakLoadStateListener(loadStateChangedCallback)
+ assertThat(loadStates).containsExactly(
+ LoadStateEvent(REFRESH, Loading), // first load
+ LoadStateEvent(
+ REFRESH,
+ NotLoading(endOfPaginationReached = false)
+ ), // first load reset
+ LoadStateEvent(REFRESH, Loading), // second load
+ LoadStateEvent(
+ REFRESH,
+ NotLoading(endOfPaginationReached = false)
+ ), // second load succeeds
+ )
+ }
+
+ @Test
fun instantiatesPagingSourceOnFetchDispatcher() {
var pagingSourcesCreated = 0
val pagingSourceFactory = {
diff --git a/paging/rxjava3/build.gradle b/paging/rxjava3/build.gradle
index 5b5bf91..5755ccd 100644
--- a/paging/rxjava3/build.gradle
+++ b/paging/rxjava3/build.gradle
@@ -33,9 +33,11 @@
testImplementation(project(":internal-testutils-common"))
testImplementation(project(":internal-testutils-paging"))
+ testImplementation(project(":internal-testutils-ktx"))
testImplementation(libs.junit)
testImplementation(libs.kotlinTest)
testImplementation(libs.kotlinCoroutinesTest)
+ testImplementation(libs.truth)
androidTestImplementation(libs.testExtJunit)
androidTestImplementation(libs.testCore)
diff --git a/paging/rxjava3/src/main/java/androidx/paging/rxjava3/RxPagedListBuilder.kt b/paging/rxjava3/src/main/java/androidx/paging/rxjava3/RxPagedListBuilder.kt
index 289b9b4a..d720c98 100644
--- a/paging/rxjava3/src/main/java/androidx/paging/rxjava3/RxPagedListBuilder.kt
+++ b/paging/rxjava3/src/main/java/androidx/paging/rxjava3/RxPagedListBuilder.kt
@@ -412,6 +412,13 @@
val lastKey = currentData.lastKey as Key?
val params = config.toRefreshLoadParams(lastKey)
when (val initialResult = pagingSource.load(params)) {
+ is PagingSource.LoadResult.Invalid -> {
+ currentData.setInitialLoadState(
+ LoadType.REFRESH,
+ LoadState.NotLoading(endOfPaginationReached = false)
+ )
+ pagingSource.invalidate()
+ }
is PagingSource.LoadResult.Error -> {
currentData.setInitialLoadState(
LoadType.REFRESH,
diff --git a/paging/rxjava3/src/test/java/androidx/paging/RxPagedListBuilderTest.kt b/paging/rxjava3/src/test/java/androidx/paging/RxPagedListBuilderTest.kt
index 091672a..3e371e3 100644
--- a/paging/rxjava3/src/test/java/androidx/paging/RxPagedListBuilderTest.kt
+++ b/paging/rxjava3/src/test/java/androidx/paging/RxPagedListBuilderTest.kt
@@ -23,6 +23,8 @@
import androidx.paging.LoadState.NotLoading
import androidx.paging.LoadType.REFRESH
import androidx.paging.rxjava3.RxPagedListBuilder
+import androidx.testutils.DirectDispatcher
+import androidx.testutils.TestDispatcher
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.observers.TestObserver
import io.reactivex.rxjava3.schedulers.Schedulers
@@ -32,6 +34,10 @@
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import kotlin.test.assertTrue
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.withContext
@RunWith(JUnit4::class)
class RxPagedListBuilderTest {
@@ -55,8 +61,10 @@
}
class MockDataSourceFactory {
- fun create(): PagingSource<Int, String> {
- return MockPagingSource()
+ fun create(
+ loadDispatcher: CoroutineDispatcher = DirectDispatcher
+ ): PagingSource<Int, String> {
+ return MockPagingSource(loadDispatcher)
}
var throwable: Throwable? = null
@@ -65,10 +73,25 @@
throwable = EXCEPTION
}
- private inner class MockPagingSource : PagingSource<Int, String>() {
- override suspend fun load(params: LoadParams<Int>) = when (params) {
- is LoadParams.Refresh -> loadInitial(params)
- else -> loadRange()
+ inner class MockPagingSource(
+ // Allow explicit control of load calls outside of fetch / notify. Note: This is
+ // different from simply setting fetchDispatcher because PagingObservableOnSubscribe
+ // init happens on fetchDispatcher which makes it difficult to differentiate
+ // InitialPagedList.
+ val loadDispatcher: CoroutineDispatcher
+ ) : PagingSource<Int, String>() {
+ var invalidInitialLoad = false
+
+ override suspend fun load(params: LoadParams<Int>): LoadResult<Int, String> {
+ return withContext(loadDispatcher) {
+ if (invalidInitialLoad) {
+ invalidInitialLoad = false
+ LoadResult.Invalid()
+ } else when (params) {
+ is LoadParams.Refresh -> loadInitial(params)
+ else -> loadRange()
+ }
+ }
}
override fun getRefreshKey(state: PagingState<Int, String>): Int? = null
@@ -258,6 +281,105 @@
}
@Test
+ fun observablePagedList_invalidInitialResult() {
+ // this TestDispatcher is used to queue up pagingSource.load(). This allows us to control
+ // and assert against each load() attempt outside of fetch/notify dispatcher
+ val loadDispatcher = TestDispatcher()
+ val pagingSources = mutableListOf<MockDataSourceFactory.MockPagingSource>()
+ val factory = {
+ MockDataSourceFactory().create(loadDispatcher).also {
+ val source = it as MockDataSourceFactory.MockPagingSource
+ if (pagingSources.size == 0) source.invalidInitialLoad = true
+ pagingSources.add(source)
+ }
+ }
+ // this is essentially a direct scheduler so jobs are run immediately
+ val scheduler = Schedulers.from(DirectDispatcher.asExecutor())
+ val observable = RxPagedListBuilder(factory, 2)
+ .setFetchScheduler(scheduler)
+ .setNotifyScheduler(scheduler)
+ .buildObservable()
+
+ val observer = TestObserver<PagedList<String>>()
+ // subscribe triggers the PagingObservableOnSubscribe's invalidate() to create first
+ // pagingSource
+ observable.subscribe(observer)
+
+ // ensure the InitialPagedList with empty data is observed
+ observer.assertValueCount(1)
+ val initPagedList = observer.values()[0]!!
+ assertThat(initPagedList).isInstanceOf(InitialPagedList::class.java)
+ assertThat(initPagedList).isEmpty()
+ // ensure first pagingSource is also created at this point
+ assertThat(pagingSources.size).isEqualTo(1)
+
+ val loadStates = mutableListOf<LoadStateEvent>()
+ val loadStateChangedCallback = { type: LoadType, state: LoadState ->
+ if (type == REFRESH) {
+ loadStates.add(LoadStateEvent(type, state))
+ }
+ }
+
+ initPagedList.addWeakLoadStateListener(loadStateChangedCallback)
+
+ assertThat(loadStates).containsExactly(
+ // before first load() is called, REFRESH is set to loading, represents load
+ // attempt on first pagingSource
+ LoadStateEvent(REFRESH, Loading)
+ )
+
+ // execute first load, represents load attempt on first paging source
+ //
+ // using poll().run() instead of executeAll(), because executeAll() + immediate schedulers
+ // result in first load + subsequent loads executing immediately and we won't be able to
+ // assert the pagedLists/loads incrementally
+ loadDispatcher.queue.poll()?.run()
+
+ // the load failed so there should still be only one PagedList, but the first
+ // pagingSource should invalidated, and the second pagingSource is created
+ observer.assertValueCount(1)
+ assertTrue(pagingSources[0].invalid)
+ assertThat(pagingSources.size).isEqualTo(2)
+
+ assertThat(loadStates).containsExactly(
+ // the first load attempt
+ LoadStateEvent(REFRESH, Loading),
+ // LoadResult.Invalid resets RERFRESH state
+ LoadStateEvent(
+ REFRESH,
+ NotLoading(endOfPaginationReached = false)
+ ),
+ // before second load() is called, REFRESH is set to loading, represents load
+ // attempt on second pagingSource
+ LoadStateEvent(REFRESH, Loading),
+ )
+
+ // execute the load attempt on second pagingSource which succeeds
+ loadDispatcher.queue.poll()?.run()
+
+ // ensure second pagedList created with the correct data loaded
+ observer.assertValueCount(2)
+ val secondPagedList = observer.values()[1]
+ assertThat(secondPagedList).containsExactly("a", "b", null, null)
+ assertThat(secondPagedList).isNotInstanceOf(InitialPagedList::class.java)
+ assertThat(secondPagedList).isInstanceOf(ContiguousPagedList::class.java)
+
+ secondPagedList.addWeakLoadStateListener(loadStateChangedCallback)
+ assertThat(loadStates).containsExactly(
+ LoadStateEvent(REFRESH, Loading), // first load
+ LoadStateEvent(
+ REFRESH,
+ NotLoading(endOfPaginationReached = false)
+ ), // first load reset
+ LoadStateEvent(REFRESH, Loading), // second load
+ LoadStateEvent(
+ REFRESH,
+ NotLoading(endOfPaginationReached = false)
+ ), // second load succeeds
+ )
+ }
+
+ @Test
fun instantiatesPagingSourceOnFetchDispatcher() {
var pagingSourcesCreated = 0
val pagingSourceFactory = {