1 /* 2 * Copyright 2020 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 @file:Suppress("DEPRECATION") // b/220884819 18 19 package androidx.paging 20 21 import android.app.Application 22 import android.content.Context 23 import android.os.Parcelable 24 import android.view.View 25 import android.view.View.MeasureSpec.EXACTLY 26 import android.view.ViewGroup 27 import androidx.recyclerview.widget.DiffUtil 28 import androidx.recyclerview.widget.LinearLayoutManager 29 import androidx.recyclerview.widget.RecyclerView 30 import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy.ALLOW 31 import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy.PREVENT 32 import androidx.test.core.app.ApplicationProvider 33 import androidx.test.ext.junit.runners.AndroidJUnit4 34 import androidx.test.filters.MediumTest 35 import androidx.test.filters.SdkSuppress 36 import com.google.common.truth.Truth.assertThat 37 import com.google.common.truth.Truth.assertWithMessage 38 import kotlin.coroutines.CoroutineContext 39 import kotlin.time.ExperimentalTime 40 import kotlinx.coroutines.CoroutineStart 41 import kotlinx.coroutines.ExperimentalCoroutinesApi 42 import kotlinx.coroutines.Job 43 import kotlinx.coroutines.SupervisorJob 44 import kotlinx.coroutines.cancel 45 import kotlinx.coroutines.flow.Flow 46 import kotlinx.coroutines.flow.collectLatest 47 import kotlinx.coroutines.launch 48 import kotlinx.coroutines.test.StandardTestDispatcher 49 import kotlinx.coroutines.test.TestCoroutineScheduler 50 import kotlinx.coroutines.test.TestScope 51 import kotlinx.coroutines.test.UnconfinedTestDispatcher 52 import kotlinx.coroutines.test.runTest 53 import kotlinx.coroutines.withContext 54 import org.junit.Before 55 import org.junit.Test 56 import org.junit.runner.RunWith 57 58 /** 59 * We are only capable of restoring state if one the two is valid: a) pager's flow is cached in the 60 * view model (only for config change) b) data source is counted and placeholders are enabled (both 61 * config change and app restart) 62 * 63 * Both of these cases actually work without using the initial key, except it is relatively slower 64 * in option B because we need to load all items from initial key to the required position. 65 * 66 * This test validates those two cases for now. For more complicated cases, we need some helper as 67 * developer needs to intervene to provide more information. 68 */ 69 @ExperimentalCoroutinesApi 70 @ExperimentalTime 71 @MediumTest 72 @RunWith(AndroidJUnit4::class) 73 class StateRestorationTest { 74 /** 75 * List of dispatchers we track in the test for idling + pushing execution. We have 3 76 * dispatchers for more granular control: main, and background for pager. testScope for running 77 * tests. 78 */ 79 private val scheduler = TestCoroutineScheduler() 80 private val mainDispatcher = StandardTestDispatcher(scheduler) 81 private var backgroundDispatcher = StandardTestDispatcher(scheduler) 82 83 /** 84 * A fake lifecycle scope for collections that get cancelled when we recreate the recyclerview. 85 */ 86 private lateinit var lifecycleScope: TestScope 87 private lateinit var recyclerView: TestRecyclerView 88 private lateinit var layoutManager: RestoreAwareLayoutManager 89 private lateinit var adapter: TestAdapter 90 91 @Before initnull92 fun init() { 93 createRecyclerView() 94 } 95 96 @SdkSuppress(minSdkVersion = 21) // b/189492631 97 @Test restoreState_withPlaceholdersnull98 fun restoreState_withPlaceholders() { 99 runTest { 100 collectPagesAsync(createPager(pageSize = 100, enablePlaceholders = true).flow) 101 measureAndLayout() 102 val visible = recyclerView.captureSnapshot() 103 assertThat(visible).isNotEmpty() 104 scrollToPosition(50) 105 val expected = recyclerView.captureSnapshot() 106 saveAndRestore() 107 // make sure state is not restored before items are loaded 108 assertThat(layoutManager.restoredState).isFalse() 109 // pause item loads 110 val delayedJob = 111 launch(start = CoroutineStart.LAZY) { 112 collectPagesAsync(createPager(pageSize = 10, enablePlaceholders = true).flow) 113 } 114 measureAndLayout() 115 // item load is paused, still shouldn't restore state 116 assertThat(layoutManager.restoredState).isFalse() 117 118 // now load items 119 delayedJob.start() 120 121 measureAndLayout() 122 assertThat(layoutManager.restoredState).isTrue() 123 assertThat(recyclerView.captureSnapshot()).containsExactlyElementsIn(expected) 124 } 125 } 126 127 @SdkSuppress(minSdkVersion = 21) // b/189492631 128 @Test restoreState_withoutPlaceholders_cachedInnull129 fun restoreState_withoutPlaceholders_cachedIn() { 130 runTest { 131 val pager = createPager(pageSize = 60, enablePlaceholders = false) 132 val cacheScope = TestScope(Job() + scheduler) 133 val cachedFlow = pager.flow.cachedIn(cacheScope) 134 collectPagesAsync(cachedFlow) 135 measureAndLayout() 136 // now scroll 137 scrollToPosition(50) 138 val snapshot = recyclerView.captureSnapshot() 139 saveAndRestore() 140 assertThat(layoutManager.restoredState).isFalse() 141 collectPagesAsync(cachedFlow) 142 measureAndLayout() 143 assertThat(layoutManager.restoredState).isTrue() 144 val restoredSnapshot = recyclerView.captureSnapshot() 145 assertThat(restoredSnapshot).containsExactlyElementsIn(snapshot) 146 cacheScope.cancel() 147 } 148 } 149 150 @SdkSuppress(minSdkVersion = 21) // b/189492631 151 @Test emptyNewPage_allowRestorationnull152 fun emptyNewPage_allowRestoration() { 153 // check that we don't block restoration indefinitely if new pager is empty. 154 runTest { 155 val pager = createPager(pageSize = 60, enablePlaceholders = true) 156 collectPagesAsync(pager.flow) 157 measureAndLayout() 158 scrollToPosition(50) 159 saveAndRestore() 160 assertThat(layoutManager.restoredState).isFalse() 161 162 val emptyPager = createPager(pageSize = 10, itemCount = 0, enablePlaceholders = true) 163 collectPagesAsync(emptyPager.flow) 164 measureAndLayout() 165 assertThat(layoutManager.restoredState).isTrue() 166 } 167 } 168 169 @SdkSuppress(minSdkVersion = 21) // b/189492631 170 @Test userOverridesStateRestorationnull171 fun userOverridesStateRestoration() { 172 runTest { 173 val pager = createPager(pageSize = 40, enablePlaceholders = true) 174 collectPagesAsync(pager.flow) 175 measureAndLayout() 176 scrollToPosition(20) 177 val snapshot = recyclerView.captureSnapshot() 178 saveAndRestore() 179 val pager2 = createPager(pageSize = 40, enablePlaceholders = true) 180 // when user calls prevent, we should not trigger state restoration even after we 181 // receive the first page 182 adapter.stateRestorationPolicy = PREVENT 183 collectPagesAsync(pager2.flow) 184 measureAndLayout() 185 assertThat(layoutManager.restoredState).isFalse() 186 // make sure test did work as expected, that is, new items are loaded 187 assertThat(adapter.itemCount).isGreaterThan(0) 188 // now if user allows it, restoration should happen properly 189 adapter.stateRestorationPolicy = ALLOW 190 measureAndLayout() 191 assertThat(layoutManager.restoredState).isTrue() 192 assertThat(recyclerView.captureSnapshot()).isEqualTo(snapshot) 193 } 194 } 195 createRecyclerViewnull196 private fun createRecyclerView() { 197 // cancel previous lifecycle if it exists 198 if (this::lifecycleScope.isInitialized) { 199 this.lifecycleScope.cancel() 200 } 201 lifecycleScope = TestScope(SupervisorJob() + mainDispatcher) 202 val context = ApplicationProvider.getApplicationContext<Application>() 203 recyclerView = TestRecyclerView(context) 204 recyclerView.itemAnimator = null 205 adapter = TestAdapter() 206 recyclerView.adapter = adapter 207 layoutManager = RestoreAwareLayoutManager(context) 208 recyclerView.layoutManager = layoutManager 209 } 210 scrollToPositionnull211 private fun scrollToPosition(pos: Int) { 212 while (adapter.itemCount <= pos) { 213 val prevSize = adapter.itemCount 214 adapter.triggerItemLoad(prevSize - 1) 215 scheduler.runCurrent() 216 // this might be an issue with dropping but it is not the case here 217 assertWithMessage("more items should be loaded") 218 .that(adapter.itemCount) 219 .isGreaterThan(prevSize) 220 } 221 scheduler.runCurrent() 222 recyclerView.scrollToPosition(pos) 223 measureAndLayout() 224 val child = layoutManager.findViewByPosition(pos) 225 assertWithMessage("scrolled child $pos exists").that(child).isNotNull() 226 227 val vh = recyclerView.getChildViewHolder(child!!) as ItemViewHolder 228 assertWithMessage("scrolled child should be fully loaded").that(vh.item).isNotNull() 229 } 230 measureAndLayoutnull231 private fun measureAndLayout() { 232 scheduler.runCurrent() 233 while (recyclerView.isLayoutRequested) { 234 measure() 235 layout() 236 scheduler.runCurrent() 237 } 238 } 239 measurenull240 private fun measure() { 241 recyclerView.measure(EXACTLY or RV_WIDTH, EXACTLY or RV_HEIGHT) 242 } 243 layoutnull244 private fun layout() { 245 recyclerView.layout(0, 0, 100, 200) 246 } 247 saveAndRestorenull248 private fun saveAndRestore() { 249 val state = recyclerView.saveState() 250 createRecyclerView() 251 recyclerView.restoreState(state!!) 252 measureAndLayout() 253 } 254 runTestnull255 private fun runTest(block: TestScope.() -> Unit) = 256 runTest(UnconfinedTestDispatcher(scheduler)) { 257 try { 258 block() 259 } finally { 260 scheduler.runCurrent() 261 // always cancel the lifecycle scope to ensure any collection there ends 262 if (this@StateRestorationTest::lifecycleScope.isInitialized) { 263 lifecycleScope.cancel() 264 } 265 } 266 } 267 268 /** collects pages in the lifecycle scope and sends them to the adapter */ collectPagesAsyncnull269 private fun collectPagesAsync(flow: Flow<PagingData<Item>>) { 270 val targetAdapter = adapter 271 lifecycleScope.launch { flow.collectLatest { targetAdapter.submitData(it) } } 272 } 273 createPagernull274 private fun createPager( 275 pageSize: Int, 276 enablePlaceholders: Boolean, 277 itemCount: Int = 100, 278 initialKey: Int? = null 279 ): Pager<Int, Item> { 280 return Pager( 281 config = 282 PagingConfig( 283 pageSize = pageSize, 284 enablePlaceholders = enablePlaceholders, 285 ), 286 initialKey = initialKey, 287 pagingSourceFactory = { 288 ItemPagingSource( 289 context = backgroundDispatcher, 290 items = (0 until itemCount).map { Item(it) } 291 ) 292 } 293 ) 294 } 295 296 /** Returns the list of all visible items in the recyclerview including their locations. */ RecyclerViewnull297 private fun RecyclerView.captureSnapshot(): List<PositionSnapshot> { 298 return (0 until childCount).mapNotNull { 299 val child = getChildAt(it) 300 // if child is not visible, ignore it as RV might have extra views around the visible 301 // area. 302 if (child.top >= height || child.bottom <= 0) { 303 // not visible, ignore 304 null 305 } else { 306 val vh = getChildViewHolder(child) 307 (vh as ItemViewHolder).captureSnapshot() 308 } 309 } 310 } 311 312 class ItemView(context: Context) : View(context) 313 314 class ItemViewHolder(context: Context) : RecyclerView.ViewHolder(ItemView(context)) { 315 var item: Item? = null 316 private set 317 318 init { 319 itemView.layoutParams = RecyclerView.LayoutParams(0, 0) 320 } 321 captureSnapshotnull322 fun captureSnapshot(): PositionSnapshot { 323 val item = checkNotNull(item) 324 return PositionSnapshot(item = item, top = itemView.top, bottom = itemView.bottom) 325 } 326 bindTonull327 fun bindTo(item: Item?) { 328 this.item = item 329 // setting placeholder height to 0 creates a weird jumping bug, investigate 330 itemView.layoutParams.height = item?.height ?: RV_HEIGHT / 10 331 } 332 } 333 334 data class Item(val id: Int, val height: Int = (RV_HEIGHT / 10) + (1 + (id % 10))) { 335 companion object { 336 val DIFF_CALLBACK = 337 object : DiffUtil.ItemCallback<Item>() { areItemsTheSamenull338 override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { 339 return oldItem.id == newItem.id 340 } 341 areContentsTheSamenull342 override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { 343 return oldItem == newItem 344 } 345 } 346 } 347 } 348 349 inner class TestAdapter : 350 PagingDataAdapter<Item, ItemViewHolder>( 351 diffCallback = Item.DIFF_CALLBACK, 352 mainDispatcher = mainDispatcher, 353 workerDispatcher = backgroundDispatcher 354 ) { onCreateViewHoldernull355 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { 356 return ItemViewHolder(parent.context) 357 } 358 onBindViewHoldernull359 override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { 360 holder.bindTo(getItem(position)) 361 } 362 triggerItemLoadnull363 fun triggerItemLoad(pos: Int) = super.getItem(pos) 364 } 365 366 class ItemPagingSource(private val context: CoroutineContext, private val items: List<Item>) : 367 PagingSource<Int, Item>() { 368 override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> { 369 return withContext(context) { 370 val key = params.key ?: 0 371 val isPrepend = params is LoadParams.Prepend 372 val start = if (isPrepend) key - params.loadSize + 1 else key 373 val end = if (isPrepend) key + 1 else key + params.loadSize 374 375 LoadResult.Page( 376 data = items.subList(maxOf(0, start), minOf(end, items.size)), 377 prevKey = if (start > 0) start - 1 else null, 378 nextKey = if (end < items.size) end else null, 379 itemsBefore = maxOf(0, start), 380 itemsAfter = maxOf(0, items.size - end) 381 ) 382 } 383 } 384 385 override fun getRefreshKey(state: PagingState<Int, Item>): Int? = null 386 } 387 388 /** Snapshot of an item in RecyclerView. */ 389 data class PositionSnapshot(val item: Item, val top: Int, val bottom: Int) 390 391 /** RecyclerView class that allows saving and restoring state. */ 392 class TestRecyclerView(context: Context) : RecyclerView(context) { restoreStatenull393 fun restoreState(state: Parcelable) { 394 super.onRestoreInstanceState(state) 395 } 396 saveStatenull397 fun saveState(): Parcelable? { 398 return super.onSaveInstanceState() 399 } 400 } 401 402 /** 403 * A layout manager that tracks whether state is restored or not so that we can assert on it. 404 */ 405 class RestoreAwareLayoutManager(context: Context) : LinearLayoutManager(context) { 406 var restoredState = false 407 onRestoreInstanceStatenull408 override fun onRestoreInstanceState(state: Parcelable) { 409 super.onRestoreInstanceState(state) 410 restoredState = true 411 } 412 } 413 414 companion object { 415 private const val RV_HEIGHT = 200 416 private const val RV_WIDTH = 100 417 } 418 } 419