1 /*
<lambda>null2  * Copyright 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.paging.testing
18 
19 import androidx.annotation.VisibleForTesting
20 import androidx.paging.LoadType.APPEND
21 import androidx.paging.LoadType.PREPEND
22 import androidx.paging.PagingConfig
23 import androidx.paging.PagingData
24 import androidx.paging.PagingDataPresenter
25 import androidx.paging.PagingSource
26 import androidx.paging.testing.internal.AtomicInt
27 import androidx.paging.testing.internal.AtomicRef
28 import kotlin.jvm.JvmSuppressWildcards
29 import kotlin.math.abs
30 import kotlinx.coroutines.CoroutineScope
31 import kotlinx.coroutines.flow.MutableStateFlow
32 import kotlinx.coroutines.flow.filterNotNull
33 import kotlinx.coroutines.flow.first
34 import kotlinx.coroutines.flow.map
35 import kotlinx.coroutines.launch
36 
37 /**
38  * Contains the public APIs for load operations in tests.
39  *
40  * Tracks generational information and provides the listener to [LoaderCallback] on
41  * [PagingDataPresenter] operations.
42  */
43 @VisibleForTesting
44 public class SnapshotLoader<Value : Any>
45 internal constructor(
46     private val presenter: CompletablePagingDataPresenter<Value>,
47     private val errorHandler: LoadErrorHandler,
48 ) {
49     internal val generations = MutableStateFlow(Generation())
50 
51     /**
52      * Refresh the data that is presented on the UI.
53      *
54      * [refresh] triggers a new generation of [PagingData] / [PagingSource] to represent an updated
55      * snapshot of the backing dataset.
56      *
57      * This fake paging operation mimics UI-driven refresh signals such as swipe-to-refresh.
58      */
59     public suspend fun refresh(): @JvmSuppressWildcards Unit {
60         presenter.awaitNotLoading(errorHandler)
61         presenter.refresh()
62         presenter.awaitNotLoading(errorHandler)
63     }
64 
65     /**
66      * Imitates scrolling down paged items, [appending][APPEND] data until the given predicate
67      * returns false.
68      *
69      * Note: This API loads an item before passing it into the predicate. This means the loaded
70      * pages may include the page which contains the item that does not match the predicate. For
71      * example, if pageSize = 2, the predicate {item: Int -> item < 3 } will return items [[1,
72      * 2],[3, 4]] where [3, 4] is the page containing the boundary item[3] not matching the
73      * predicate.
74      *
75      * The loaded pages are also dependent on [PagingConfig] settings such as
76      * [PagingConfig.prefetchDistance]:
77      * - if `prefetchDistance` > 0, the resulting appends will include prefetched items. For
78      *   example, if pageSize = 2 and prefetchDistance = 2, the predicate {item: Int -> item < 3 }
79      *   will load items [[1, 2], [3, 4], [5, 6]] where [5, 6] is the prefetched page.
80      *
81      * @param [predicate] the predicate to match (return true) to continue append scrolls
82      */
83     public suspend fun appendScrollWhile(
84         predicate: (item: @JvmSuppressWildcards Value) -> @JvmSuppressWildcards Boolean
85     ): @JvmSuppressWildcards Unit {
86         presenter.awaitNotLoading(errorHandler)
87         appendOrPrependScrollWhile(LoadType.APPEND, predicate)
88         presenter.awaitNotLoading(errorHandler)
89     }
90 
91     /**
92      * Imitates scrolling up paged items, [prepending][PREPEND] data until the given predicate
93      * returns false.
94      *
95      * Note: This API loads an item before passing it into the predicate. This means the loaded
96      * pages may include the page which contains the item that does not match the predicate. For
97      * example, if pageSize = 2, initialKey = 3, the predicate {item: Int -> item >= 3 } will return
98      * items [[1, 2],[3, 4]] where [1, 2] is the page containing the boundary item[2] not matching
99      * the predicate.
100      *
101      * The loaded pages are also dependent on [PagingConfig] settings such as
102      * [PagingConfig.prefetchDistance]:
103      * - if `prefetchDistance` > 0, the resulting prepends will include prefetched items. For
104      *   example, if pageSize = 2, initialKey = 3, and prefetchDistance = 2, the predicate {item:
105      *   Int -> item > 4 } will load items [[1, 2], [3, 4], [5, 6]] where both [1,2] and [5, 6] are
106      *   the prefetched pages.
107      *
108      * @param [predicate] the predicate to match (return true) to continue prepend scrolls
109      */
110     public suspend fun prependScrollWhile(
111         predicate: (item: @JvmSuppressWildcards Value) -> @JvmSuppressWildcards Boolean
112     ): @JvmSuppressWildcards Unit {
113         presenter.awaitNotLoading(errorHandler)
114         appendOrPrependScrollWhile(LoadType.PREPEND, predicate)
115         presenter.awaitNotLoading(errorHandler)
116     }
117 
118     private suspend fun appendOrPrependScrollWhile(
119         loadType: LoadType,
120         predicate: (item: Value) -> Boolean
121     ) {
122         do {
123             // awaits for next item where the item index is determined based on
124             // this generation's lastAccessedIndex. If null, it means there are no more
125             // items to load for this loadType.
126             val item = awaitNextItem(loadType) ?: return
127         } while (predicate(item))
128     }
129 
130     /**
131      * Imitates scrolling from current index to the target index. It waits for an item to be loaded
132      * in before triggering load on next item. Returns all available data that has been scrolled
133      * through.
134      *
135      * The scroll direction (prepend or append) is dependent on current index and target index. In
136      * general, scrolling to a smaller index triggers [PREPEND] while scrolling to a larger index
137      * triggers [APPEND].
138      *
139      * When [PagingConfig.enablePlaceholders] is false, the [index] is scoped within currently
140      * loaded items. For example, in a list of items(0-20) with currently loaded items(10-15),
141      * index[0] = item(10), index[4] = item(15).
142      *
143      * Supports [index] beyond currently loaded items when [PagingConfig.enablePlaceholders] is
144      * false:
145      * 1. For prepends, it supports negative indices for as long as there are still available data
146      *    to load from. For example, take a list of items(0-20), pageSize = 1, with currently loaded
147      *    items(10-15). With index[0] = item(10), a `scrollTo(-4)` will scroll to item(6) and update
148      *    index[0] = item(6).
149      * 2. For appends, it supports indices >= loadedDataSize. For example, take a list of
150      *    items(0-20), pageSize = 1, with currently loaded items(10-15). With index[4] = item(15), a
151      *    `scrollTo(7)` will scroll to item(18) and update index[7] = item(18). Note that both
152      *    examples does not account for prefetches.
153      *
154      * The [index] accounts for separators/headers/footers where each one of those consumes one
155      * scrolled index.
156      *
157      * For both append/prepend, this function stops loading prior to fulfilling requested scroll
158      * distance if there are no more data to load from.
159      *
160      * @param [index] The target index to scroll to
161      * @see [flingTo] for faking a scroll that continues scrolling without waiting for items to be
162      *   loaded in. Supports jumping.
163      */
164     public suspend fun scrollTo(index: Int): @JvmSuppressWildcards Unit {
165         presenter.awaitNotLoading(errorHandler)
166         appendOrPrependScrollTo(index)
167         presenter.awaitNotLoading(errorHandler)
168     }
169 
170     /**
171      * Scrolls from current index to targeted [index].
172      *
173      * Internally this method scrolls until it fulfills requested index differential
174      * (Math.abs(requested index - current index)) rather than scrolling to the exact requested
175      * index. This is because item indices can shift depending on scroll direction and placeholders.
176      * Therefore we try to fulfill the expected amount of scrolling rather than the actual requested
177      * index.
178      */
179     private suspend fun appendOrPrependScrollTo(index: Int) {
180         val startIndex = generations.value.lastAccessedIndex.get()
181         val loadType = if (startIndex > index) LoadType.PREPEND else LoadType.APPEND
182         val scrollCount = abs(startIndex - index)
183         awaitScroll(loadType, scrollCount)
184     }
185 
186     /**
187      * Imitates flinging from current index to the target index. It will continue scrolling even as
188      * data is being loaded in. Returns all available data that has been scrolled through.
189      *
190      * The scroll direction (prepend or append) is dependent on current index and target index. In
191      * general, scrolling to a smaller index triggers [PREPEND] while scrolling to a larger index
192      * triggers [APPEND].
193      *
194      * This function will scroll into placeholders. This means jumping is supported when
195      * [PagingConfig.enablePlaceholders] is true and the amount of placeholders traversed has
196      * reached [PagingConfig.jumpThreshold]. Jumping is disabled when
197      * [PagingConfig.enablePlaceholders] is false.
198      *
199      * When [PagingConfig.enablePlaceholders] is false, the [index] is scoped within currently
200      * loaded items. For example, in a list of items(0-20) with currently loaded items(10-15),
201      * index[0] = item(10), index[4] = item(15).
202      *
203      * Supports [index] beyond currently loaded items when [PagingConfig.enablePlaceholders] is
204      * false:
205      * 1. For prepends, it supports negative indices for as long as there are still available data
206      *    to load from. For example, take a list of items(0-20), pageSize = 1, with currently loaded
207      *    items(10-15). With index[0] = item(10), a `scrollTo(-4)` will scroll to item(6) and update
208      *    index[0] = item(6).
209      * 2. For appends, it supports indices >= loadedDataSize. For example, take a list of
210      *    items(0-20), pageSize = 1, with currently loaded items(10-15). With index[4] = item(15), a
211      *    `scrollTo(7)` will scroll to item(18) and update index[7] = item(18). Note that both
212      *    examples does not account for prefetches.
213      *
214      * The [index] accounts for separators/headers/footers where each one of those consumes one
215      * scrolled index.
216      *
217      * For both append/prepend, this function stops loading prior to fulfilling requested scroll
218      * distance if there are no more data to load from.
219      *
220      * @param [index] The target index to scroll to
221      * @see [scrollTo] for faking scrolls that awaits for placeholders to load before continuing to
222      *   scroll.
223      */
224     public suspend fun flingTo(index: Int): @JvmSuppressWildcards Unit {
225         presenter.awaitNotLoading(errorHandler)
226         appendOrPrependFlingTo(index)
227         presenter.awaitNotLoading(errorHandler)
228     }
229 
230     /**
231      * We start scrolling from startIndex +/- 1 so we don't accidentally trigger a prefetch on the
232      * opposite direction.
233      */
234     private suspend fun appendOrPrependFlingTo(index: Int) {
235         val startIndex = generations.value.lastAccessedIndex.get()
236         val loadType = if (startIndex > index) LoadType.PREPEND else LoadType.APPEND
237 
238         if (loadType == LoadType.PREPEND) {
239             prependFlingTo(startIndex, index)
240         } else {
241             appendFlingTo(startIndex, index)
242         }
243     }
244 
245     /**
246      * Prepend flings to target index.
247      *
248      * If target index is negative, from index[0] onwards it will normal scroll until it fulfills
249      * remaining distance.
250      */
251     private suspend fun prependFlingTo(startIndex: Int, index: Int) {
252         var lastAccessedIndex = startIndex
253         val endIndex = maxOf(0, index)
254         // first, fast scroll to index or zero
255         for (i in startIndex - 1 downTo endIndex) {
256             presenter[i]
257             lastAccessedIndex = i
258         }
259         setLastAccessedIndex(lastAccessedIndex)
260         // for negative indices, we delegate remainder of scrolling (distance below zero)
261         // to the awaiting version.
262         if (index < 0) {
263             val scrollCount = abs(index)
264             flingToOutOfBounds(LoadType.PREPEND, lastAccessedIndex, scrollCount)
265         }
266     }
267 
268     /**
269      * Append flings to target index.
270      *
271      * If target index is beyond [PagingDataPresenter.size] - 1, from index(presenter.size) and
272      * onwards, it will normal scroll until it fulfills remaining distance.
273      */
274     private suspend fun appendFlingTo(startIndex: Int, index: Int) {
275         var lastAccessedIndex = startIndex
276         val endIndex = minOf(index, presenter.size - 1)
277         // first, fast scroll to endIndex
278         for (i in startIndex + 1..endIndex) {
279             presenter[i]
280             lastAccessedIndex = i
281         }
282         setLastAccessedIndex(lastAccessedIndex)
283         // for indices at or beyond presenter.size, we delegate remainder of scrolling (distance
284         // beyond presenter.size) to the awaiting version.
285         if (index >= presenter.size) {
286             val scrollCount = index - lastAccessedIndex
287             flingToOutOfBounds(LoadType.APPEND, lastAccessedIndex, scrollCount)
288         }
289     }
290 
291     /**
292      * Delegated work from [flingTo] that is responsible for scrolling to indices that is beyond the
293      * range of [0 to presenter.size-1].
294      *
295      * When [PagingConfig.enablePlaceholders] is true, this function is no-op because there is no
296      * more data to load from.
297      *
298      * When [PagingConfig.enablePlaceholders] is false, its delegated work to [awaitScroll]
299      * essentially loops (trigger next page --> await for next page) until it fulfills remaining
300      * (out of bounds) requested scroll distance.
301      */
302     private suspend fun flingToOutOfBounds(
303         loadType: LoadType,
304         lastAccessedIndex: Int,
305         scrollCount: Int
306     ) {
307         // Wait for the page triggered by presenter[lastAccessedIndex] to load in. This gives us the
308         // offsetIndex for next presenter.get() because the current lastAccessedIndex is already the
309         // boundary index, such that presenter[lastAccessedIndex +/- 1] will throw IndexOutOfBounds.
310         val (_, offsetIndex) = awaitLoad(lastAccessedIndex)
311         setLastAccessedIndex(offsetIndex)
312         // starts loading from the offsetIndex and scrolls the remaining requested distance
313         awaitScroll(loadType, scrollCount)
314     }
315 
316     private suspend fun awaitScroll(loadType: LoadType, scrollCount: Int) {
317         repeat(scrollCount) { awaitNextItem(loadType) ?: return }
318     }
319 
320     /**
321      * Triggers load for next item, awaits for it to be loaded and returns the loaded item.
322      *
323      * It calculates the next load index based on loadType and this generation's
324      * [Generation.lastAccessedIndex]. The lastAccessedIndex is updated when item is loaded in.
325      */
326     private suspend fun awaitNextItem(loadType: LoadType): Value? {
327         // Get the index to load from. Return if index is invalid.
328         val index = nextIndexOrNull(loadType) ?: return null
329         // OffsetIndex accounts for items that are prepended when placeholders are disabled,
330         // as the new items shift the position of existing items. The offsetIndex (which may
331         // or may not be the same as original index) is stored as lastAccessedIndex after load and
332         // becomes the basis for next load index.
333         val (item, offsetIndex) = awaitLoad(index)
334         setLastAccessedIndex(offsetIndex)
335         return item
336     }
337 
338     /**
339      * Get and update the index to load from. Returns null if next index is out of bounds.
340      *
341      * This method computes the next load index based on the [LoadType] and
342      * [Generation.lastAccessedIndex]
343      */
344     private fun nextIndexOrNull(loadType: LoadType): Int? {
345         val currIndex = generations.value.lastAccessedIndex.get()
346         return when (loadType) {
347             LoadType.PREPEND -> {
348                 if (currIndex <= 0) {
349                     return null
350                 }
351                 currIndex - 1
352             }
353             LoadType.APPEND -> {
354                 if (currIndex >= presenter.size - 1) {
355                     return null
356                 }
357                 currIndex + 1
358             }
359             LoadType.REFRESH -> currIndex
360         }
361     }
362 
363     // Executes actual loading by accessing the PagingDataPresenter
364     @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
365     private suspend fun awaitLoad(index: Int): Pair<Value, Int> {
366         presenter[index]
367         presenter.awaitNotLoading(errorHandler)
368         var offsetIndex = index
369 
370         // awaits for the item to be loaded
371         return generations
372             .map { generation ->
373                 // reset callbackState to null so it doesn't get applied on the next load
374                 val callbackState = generation.callbackState.getAndSet(null)
375                 // offsetIndex accounts for items prepended when placeholders are disabled. This
376                 // is necessary because the new items shift the position of existing items, and
377                 // the index no longer tracks the correct item.
378                 offsetIndex += callbackState?.computeIndexOffset() ?: 0
379                 presenter.peek(offsetIndex)
380             }
381             .filterNotNull()
382             .first() to offsetIndex
383     }
384 
385     /**
386      * Computes the offset to add to the index when loading items from presenter.
387      *
388      * The purpose of this is to address shifted item positions when new items are prepended with
389      * placeholders disabled. For example, loaded items(10-12) in the PlaceholderPaddedList would
390      * have item(12) at presenter[2]. If we prefetched items(7-9), item(12) would now be in
391      * presenter[5].
392      *
393      * Without the offset, [PREPEND] operations would call presenter[1] to load next item(11) which
394      * would now yield item(8) instead of item(11). The offset would add the inserted count to the
395      * next load index such that after prepending 3 new items(7-9), the next [PREPEND] operation
396      * would call presenter[1+3 = 4] to properly load next item(11).
397      *
398      * This method is essentially no-op unless the callback meets three conditions:
399      * - the [LoaderCallback.loadType] is [LoadType.PREPEND]
400      * - position is 0 as we only care about item prepended to front of list
401      * - inserted count > 0
402      */
403     private fun LoaderCallback.computeIndexOffset(): Int {
404         return if (loadType == LoadType.PREPEND && position == 0) count else 0
405     }
406 
407     private fun setLastAccessedIndex(index: Int) {
408         generations.value.lastAccessedIndex.set(index)
409     }
410 
411     /** The callback to be invoked when presenter emits a new PagingDataEvent. */
412     internal fun onDataSetChanged(
413         gen: Generation,
414         callback: LoaderCallback,
415         scope: CoroutineScope? = null
416     ) {
417         val currGen = generations.value
418         // we make sure the generation with the dataset change is still valid because we
419         // want to disregard callbacks on stale generations
420         if (gen.id == currGen.id) {
421             callback.apply {
422                 if (loadType == LoadType.REFRESH) {
423                     generations.value.lastAccessedIndex.set(position)
424                     // If there are presented items, we should imitate the UI by accessing a
425                     // real item.
426                     if (count > 0) {
427                         scope?.launch {
428                             awaitLoad(nextIndexOrNull(LoadType.REFRESH)!!)
429                             presenter.awaitNotLoading(errorHandler)
430                         }
431                     }
432                 }
433                 if (loadType == LoadType.PREPEND) {
434                     generations.value =
435                         gen.copy(callbackState = currGen.callbackState.apply { set(callback) })
436                 }
437             }
438         }
439     }
440 }
441 
442 internal data class Generation(
443     /**
444      * Id of the current Paging generation. Incremented on each new generation (when a new
445      * PagingData is received).
446      */
447     val id: Int = -1,
448 
449     /**
450      * Temporarily stores the latest [LoaderCallback] to track prepends to the beginning of list.
451      * Value is reset to null once read.
452      */
453     val callbackState: AtomicRef<LoaderCallback?> = AtomicRef(null),
454 
455     /** Tracks the last accessed(peeked) index on the presenter for this generation */
456     var lastAccessedIndex: AtomicInt = AtomicInt(0)
457 )
458 
459 internal data class LoaderCallback(
460     val loadType: LoadType,
461     val position: Int,
462     val count: Int,
463 )
464 
465 internal enum class LoadType {
466     REFRESH,
467     PREPEND,
468     APPEND,
469 }
470