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