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