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