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
21 import androidx.paging.LoadType.APPEND
22 import androidx.paging.LoadType.PREPEND
23 import androidx.paging.LoadType.REFRESH
24 import androidx.paging.Pager
25 import androidx.paging.PagingConfig
26 import androidx.paging.PagingSource
27 import androidx.paging.PagingSource.LoadParams
28 import androidx.paging.PagingSource.LoadResult
29 import androidx.paging.PagingSource.LoadResult.Page.Companion.COUNT_UNDEFINED
30 import androidx.paging.PagingState
31 import androidx.paging.testing.internal.AtomicBoolean
32 import kotlin.jvm.JvmSuppressWildcards
33 import kotlinx.coroutines.sync.Mutex
34 import kotlinx.coroutines.sync.withLock
35 
36 /**
37  * A fake [Pager] class to simulate how a real Pager and UI would load data from a PagingSource.
38  *
39  * As Paging's first load is always of type [LoadType.REFRESH], the first load operation of the
40  * [TestPager] must be a call to [refresh].
41  *
42  * This class only supports loads from a single instance of PagingSource. To simulate
43  * multi-generational Paging behavior, you must create a new [TestPager] by supplying a new instance
44  * of [PagingSource].
45  *
46  * @param config the [PagingConfig] to configure this TestPager's loading behavior.
47  * @param pagingSource the [PagingSource] to load data from.
48  */
49 @VisibleForTesting
50 public class TestPager<Key : Any, Value : Any>(
51     private val config: PagingConfig,
52     private val pagingSource: PagingSource<Key, Value>,
53 ) {
54     private val hasRefreshed = AtomicBoolean(false)
55 
56     private val lock = Mutex()
57 
58     private val pages = ArrayDeque<LoadResult.Page<Key, Value>>()
59 
60     /**
61      * Performs a load of [LoadType.REFRESH] on the PagingSource.
62      *
63      * If initialKey != null, refresh will start loading from the supplied key.
64      *
65      * Since Paging's first load is always of [LoadType.REFRESH], this method must be the very first
66      * load operation to be called on the TestPager before either [append] or [prepend] can be
67      * called. However, other non-loading operations can still be invoked. For example, you can call
68      * [getLastLoadedPage] before any load operations.
69      *
70      * Returns the LoadResult upon refresh on the [PagingSource].
71      *
72      * @param initialKey the [Key] to start loading data from on initial refresh.
73      * @throws IllegalStateException TestPager does not support multi-generational paging behavior.
74      *   As such, multiple calls to refresh() on this TestPager is illegal. The [PagingSource]
75      *   passed in to this [TestPager] will also be invalidated to prevent reuse of this pager for
76      *   loads. However, other [TestPager] methods that does not invoke loads can still be called,
77      *   such as [getLastLoadedPage].
78      */
79     public suspend fun refresh(
80         initialKey: Key? = null
81     ): @JvmSuppressWildcards LoadResult<Key, Value> {
82         if (!hasRefreshed.compareAndSet(false, true)) {
83             pagingSource.invalidate()
84             throw IllegalStateException(
85                 "TestPager does not support multi-generational access " +
86                     "and refresh() can only be called once per TestPager. To start a new generation," +
87                     "create a new TestPager with a new PagingSource."
88             )
89         }
90         return doInitialLoad(initialKey)
91     }
92 
93     /**
94      * Performs a load of [LoadType.APPEND] on the PagingSource.
95      *
96      * Since Paging's first load is always of [LoadType.REFRESH], [refresh] must always be called
97      * first before this [append] is called.
98      *
99      * If [PagingConfig.maxSize] is implemented, [append] loads that exceed [PagingConfig.maxSize]
100      * will cause pages to be dropped from the front of loaded pages.
101      *
102      * Returns the [LoadResult] from calling [PagingSource.load]. If the [LoadParams.key] is null,
103      * such as when there is no more data to append, this append will be no-op by returning null.
104      */
105     public suspend fun append(): @JvmSuppressWildcards LoadResult<Key, Value>? {
106         return doLoad(APPEND)
107     }
108 
109     /**
110      * Performs a load of [LoadType.PREPEND] on the PagingSource.
111      *
112      * Since Paging's first load is always of [LoadType.REFRESH], [refresh] must always be called
113      * first before this [prepend] is called.
114      *
115      * If [PagingConfig.maxSize] is implemented, [prepend] loads that exceed [PagingConfig.maxSize]
116      * will cause pages to be dropped from the end of loaded pages.
117      *
118      * Returns the [LoadResult] from calling [PagingSource.load]. If the [LoadParams.key] is null,
119      * such as when there is no more data to prepend, this prepend will be no-op by returning null.
120      */
121     public suspend fun prepend(): @JvmSuppressWildcards LoadResult<Key, Value>? {
122         return doLoad(PREPEND)
123     }
124 
125     /** Helper to perform REFRESH loads. */
126     private suspend fun doInitialLoad(
127         initialKey: Key?
128     ): @JvmSuppressWildcards LoadResult<Key, Value> {
129         return lock.withLock {
130             pagingSource
131                 .load(
132                     LoadParams.Refresh(
133                         initialKey,
134                         config.initialLoadSize,
135                         config.enablePlaceholders
136                     )
137                 )
138                 .also { result ->
139                     if (result is LoadResult.Page) {
140                         pages.addLast(result)
141                     }
142                 }
143         }
144     }
145 
146     /** Helper to perform APPEND or PREPEND loads. */
147     private suspend fun doLoad(loadType: LoadType): LoadResult<Key, Value>? {
148         return lock.withLock {
149             if (!hasRefreshed.get()) {
150                 throw IllegalStateException(
151                     "TestPager's first load operation must be a refresh. " +
152                         "Please call refresh() once before calling ${loadType.name.lowercase()}()."
153                 )
154             }
155             when (loadType) {
156                 REFRESH ->
157                     throw IllegalArgumentException("For LoadType.REFRESH use doInitialLoad()")
158                 APPEND -> {
159                     val key = pages.lastOrNull()?.nextKey ?: return null
160                     pagingSource
161                         .load(LoadParams.Append(key, config.pageSize, config.enablePlaceholders))
162                         .also { result ->
163                             if (result is LoadResult.Page) {
164                                 pages.addLast(result)
165                             }
166                             dropPagesOrNoOp(PREPEND)
167                         }
168                 }
169                 PREPEND -> {
170                     val key = pages.firstOrNull()?.prevKey ?: return null
171                     pagingSource
172                         .load(LoadParams.Prepend(key, config.pageSize, config.enablePlaceholders))
173                         .also { result ->
174                             if (result is LoadResult.Page) {
175                                 pages.addFirst(result)
176                             }
177                             dropPagesOrNoOp(APPEND)
178                         }
179                 }
180             }
181         }
182     }
183 
184     /**
185      * Returns the most recent [LoadResult.Page] loaded from the [PagingSource]. Null if no pages
186      * have been returned from [PagingSource]. For example, if PagingSource has only returned
187      * [LoadResult.Error] or [LoadResult.Invalid].
188      */
189     public suspend fun getLastLoadedPage(): @JvmSuppressWildcards LoadResult.Page<Key, Value>? {
190         return lock.withLock { pages.lastOrNull() }
191     }
192 
193     /** Returns the current list of [LoadResult.Page] loaded so far from the [PagingSource]. */
194     public suspend fun getPages(): @JvmSuppressWildcards List<LoadResult.Page<Key, Value>> {
195         return lock.withLock { pages.toList() }
196     }
197 
198     /**
199      * Returns a [PagingState] to generate a [LoadParams.key] by supplying it to
200      * [PagingSource.getRefreshKey]. The key returned from [PagingSource.getRefreshKey] should be
201      * used as the [LoadParams.Refresh.key] when calling [refresh] on a new generation of TestPager.
202      *
203      * The anchorPosition must be within index of loaded items, which can include placeholders if
204      * [PagingConfig.enablePlaceholders] is true. For example:
205      * - No placeholders: If 40 items have been loaded so far , anchorPosition must be in [0 .. 39].
206      * - With placeholders: If there are a total of 100 loadable items, the anchorPosition must be
207      *   in [0..99].
208      *
209      * The [anchorPosition] should be the index that the user has hypothetically scrolled to on the
210      * UI. Since the [PagingState.anchorPosition] in Paging can be based on any item or placeholder
211      * currently visible on the screen, the actual value of [PagingState.anchorPosition] may not
212      * exactly match the [anchorPosition] passed to this function even if viewing the same page of
213      * data.
214      *
215      * Note that when `[PagingConfig.enablePlaceholders] = false`, the [PagingState.anchorPosition]
216      * returned from this function references the absolute index within all loadable data. For
217      * example, with items[0 - 99]: If items[20 - 30] were loaded without placeholders,
218      * anchorPosition 0 references item[20]. But once translated into [PagingState.anchorPosition],
219      * anchorPosition 0 references item[0]. The [PagingSource] is expected to handle this correctly
220      * within [PagingSource.getRefreshKey] when [PagingConfig.enablePlaceholders] = false.
221      *
222      * @param anchorPosition the index representing the last accessed item within the items
223      *   presented on the UI, which may be a placeholder if [PagingConfig.enablePlaceholders] is
224      *   true.
225      * @throws IllegalStateException if anchorPosition is out of bounds.
226      */
227     public suspend fun getPagingState(
228         anchorPosition: Int
229     ): @JvmSuppressWildcards PagingState<Key, Value> {
230         lock.withLock {
231             checkWithinBoundary(anchorPosition)
232             return PagingState(
233                 pages = pages.toList(),
234                 anchorPosition = anchorPosition,
235                 config = config,
236                 leadingPlaceholderCount = getLeadingPlaceholderCount()
237             )
238         }
239     }
240 
241     /**
242      * Returns a [PagingState] to generate a [LoadParams.key] by supplying it to
243      * [PagingSource.getRefreshKey]. The key returned from [PagingSource.getRefreshKey] should be
244      * used as the [LoadParams.Refresh.key] when calling [refresh] on a new generation of TestPager.
245      *
246      * The [anchorPositionLookup] lambda should return an item that the user has hypothetically
247      * scrolled to on the UI. The item must have already been loaded prior to using this helper. To
248      * generate a PagingState anchored to a placeholder, use the overloaded [getPagingState]
249      * function instead. Since the [PagingState.anchorPosition] in Paging can be based on any item
250      * or placeholder currently visible on the screen, the actual value of
251      * [PagingState.anchorPosition] may not exactly match the anchorPosition returned from this
252      * function even if viewing the same page of data.
253      *
254      * Note that when `[PagingConfig.enablePlaceholders] = false`, the [PagingState.anchorPosition]
255      * returned from this function references the absolute index within all loadable data. For
256      * example, with items[0 - 99]: If items[20 - 30] were loaded without placeholders,
257      * anchorPosition 0 references item[20]. But once translated into [PagingState.anchorPosition],
258      * anchorPosition 0 references item[0]. The [PagingSource] is expected to handle this correctly
259      * within [PagingSource.getRefreshKey] when [PagingConfig.enablePlaceholders] = false.
260      *
261      * @param anchorPositionLookup the predicate to match with an item which will serve as the basis
262      *   for generating the [PagingState].
263      * @throws IllegalArgumentException if the given predicate fails to match with an item.
264      */
265     public suspend fun getPagingState(
266         anchorPositionLookup: (item: @JvmSuppressWildcards Value) -> Boolean
267     ): @JvmSuppressWildcards PagingState<Key, Value> {
268         lock.withLock {
269             val indexInPages = pages.flatten().indexOfFirst { anchorPositionLookup(it) }
270             return when {
271                 indexInPages < 0 ->
272                     throw IllegalArgumentException(
273                         "The given predicate has returned false for every loaded item. To generate a" +
274                             "PagingState anchored to an item, the expected item must have already " +
275                             "been loaded."
276                     )
277                 else -> {
278                     val finalIndex =
279                         if (config.enablePlaceholders) {
280                             indexInPages + (pages.firstOrNull()?.itemsBefore ?: 0)
281                         } else {
282                             indexInPages
283                         }
284                     PagingState(
285                         pages = pages.toList(),
286                         anchorPosition = finalIndex,
287                         config = config,
288                         leadingPlaceholderCount = getLeadingPlaceholderCount()
289                     )
290                 }
291             }
292         }
293     }
294 
295     /**
296      * Ensures the anchorPosition is within boundary of loaded data.
297      *
298      * If placeholders are enabled, the provided anchorPosition must be within boundaries of
299      * [0 .. itemCount - 1], which includes placeholders before and after loaded data.
300      *
301      * If placeholders are disabled, the provided anchorPosition must be within boundaries of
302      * [0 .. loaded data size - 1].
303      *
304      * @throws IllegalStateException if anchorPosition is out of bounds
305      */
306     private fun checkWithinBoundary(anchorPosition: Int) {
307         val loadedSize = pages.flatten().size
308         val maxBoundary =
309             if (config.enablePlaceholders) {
310                 (pages.firstOrNull()?.itemsBefore ?: 0) +
311                     loadedSize +
312                     (pages.lastOrNull()?.itemsAfter ?: 0) - 1
313             } else {
314                 loadedSize - 1
315             }
316         check(anchorPosition in 0..maxBoundary) {
317             "anchorPosition $anchorPosition is out of bounds between [0..$maxBoundary]. Please " +
318                 "provide a valid anchorPosition."
319         }
320     }
321 
322     // Number of placeholders before the first loaded item if placeholders are enabled, otherwise 0.
323     private fun getLeadingPlaceholderCount(): Int {
324         return if (config.enablePlaceholders) {
325             // itemsBefore represents placeholders before first loaded item, and can be
326             // one of three.
327             // 1. valid int if implemented
328             // 2. null if pages empty
329             // 3. COUNT_UNDEFINED if not implemented
330             val itemsBefore: Int? = pages.firstOrNull()?.itemsBefore
331             // finalItemsBefore is `null` if it is either case 2. or 3.
332             val finalItemsBefore =
333                 if (itemsBefore == null || itemsBefore == COUNT_UNDEFINED) {
334                     null
335                 } else {
336                     itemsBefore
337                 }
338             // This will ultimately return 0 if user didn't implement itemsBefore or if pages
339             // are empty, i.e. user called getPagingState before any loads.
340             finalItemsBefore ?: 0
341         } else {
342             0
343         }
344     }
345 
346     private fun dropPagesOrNoOp(dropType: LoadType) {
347         require(dropType != REFRESH) { "Drop loadType must be APPEND or PREPEND but got $dropType" }
348 
349         // check if maxSize has been set
350         if (config.maxSize == PagingConfig.MAX_SIZE_UNBOUNDED) return
351 
352         var itemCount = pages.flatten().size
353         if (itemCount < config.maxSize) return
354 
355         // represents the max droppable amount of items
356         val presentedItemsBeforeOrAfter =
357             when (dropType) {
358                 PREPEND -> pages.take(pages.lastIndex)
359                 else -> pages.takeLast(pages.lastIndex)
360             }.fold(0) { acc, page -> acc + page.data.size }
361 
362         var itemsDropped = 0
363 
364         // mirror Paging requirement to never drop below 2 pages
365         while (pages.size > 2 && itemCount - itemsDropped > config.maxSize) {
366             val pageSize =
367                 when (dropType) {
368                     PREPEND -> pages.first().data.size
369                     else -> pages.last().data.size
370                 }
371 
372             val itemsAfterDrop = presentedItemsBeforeOrAfter - itemsDropped - pageSize
373 
374             // mirror Paging behavior of ensuring prefetchDistance is fulfilled in dropped
375             // direction
376             if (itemsAfterDrop < config.prefetchDistance) break
377 
378             when (dropType) {
379                 PREPEND -> pages.removeFirst()
380                 else -> pages.removeLast()
381             }
382 
383             itemsDropped += pageSize
384         }
385     }
386 }
387