1 /*
<lambda>null2  * 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 package androidx.recyclerview.widget
18 
19 import android.content.Context
20 import android.view.View
21 import android.view.View.MeasureSpec.AT_MOST
22 import android.view.ViewGroup
23 import androidx.recyclerview.widget.ConcatAdapter.Config.Builder
24 import androidx.recyclerview.widget.ConcatAdapter.Config.StableIdMode.ISOLATED_STABLE_IDS
25 import androidx.recyclerview.widget.ConcatAdapter.Config.StableIdMode.NO_STABLE_IDS
26 import androidx.recyclerview.widget.ConcatAdapter.Config.StableIdMode.SHARED_STABLE_IDS
27 import androidx.recyclerview.widget.ConcatAdapterSubject.Companion.assertThat
28 import androidx.recyclerview.widget.ConcatAdapterTest.LoggingAdapterObserver.Event.Changed
29 import androidx.recyclerview.widget.ConcatAdapterTest.LoggingAdapterObserver.Event.DataSetChanged
30 import androidx.recyclerview.widget.ConcatAdapterTest.LoggingAdapterObserver.Event.Inserted
31 import androidx.recyclerview.widget.ConcatAdapterTest.LoggingAdapterObserver.Event.Moved
32 import androidx.recyclerview.widget.ConcatAdapterTest.LoggingAdapterObserver.Event.Removed
33 import androidx.recyclerview.widget.ConcatAdapterTest.LoggingAdapterObserver.Event.StateRestorationPolicy
34 import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy.ALLOW
35 import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy.PREVENT
36 import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY
37 import androidx.recyclerview.widget.RecyclerView.LayoutParams
38 import androidx.recyclerview.widget.RecyclerView.LayoutParams.MATCH_PARENT
39 import androidx.recyclerview.widget.RecyclerView.NO_POSITION
40 import androidx.test.annotation.UiThreadTest
41 import androidx.test.core.app.ApplicationProvider
42 import androidx.test.ext.junit.runners.AndroidJUnit4
43 import androidx.test.filters.SdkSuppress
44 import androidx.test.filters.SmallTest
45 import com.google.common.truth.Truth.assertThat
46 import com.google.common.truth.Truth.assertWithMessage
47 import java.lang.reflect.Method
48 import java.lang.reflect.Modifier
49 import org.junit.Assert.fail
50 import org.junit.Before
51 import org.junit.Test
52 import org.junit.runner.RunWith
53 
54 @RunWith(AndroidJUnit4::class)
55 @SmallTest
56 class ConcatAdapterTest {
57     private lateinit var recyclerView: RecyclerView
58 
59     @Before
60     fun init() {
61         val context = ApplicationProvider.getApplicationContext<Context>()
62         recyclerView =
63             RecyclerView(context).also {
64                 it.layoutManager = LinearLayoutManager(context)
65                 it.itemAnimator = null
66             }
67     }
68 
69     @Test(expected = UnsupportedOperationException::class)
70     fun cannotCallSetStableIds_true() {
71         val concatenated = ConcatAdapter()
72         concatenated.setHasStableIds(true)
73     }
74 
75     @Test(expected = UnsupportedOperationException::class)
76     fun cannotCallSetStableIds_false() {
77         val concatenated = ConcatAdapter()
78         concatenated.setHasStableIds(false)
79     }
80 
81     @UiThreadTest
82     @Test
83     fun attachAndDetachAll() {
84         val concatenated = ConcatAdapter()
85         val adapter1 = NestedTestAdapter(10, getLayoutParams = { LayoutParams(MATCH_PARENT, 3) })
86         concatenated.addAdapter(adapter1)
87         recyclerView.adapter = concatenated
88         measureAndLayout(100, 50)
89         assertThat(recyclerView.childCount).isEqualTo(10)
90         assertThat(adapter1.attachedViewHolders()).hasSize(10)
91         measureAndLayout(100, 0)
92         assertThat(recyclerView.childCount).isEqualTo(0)
93         assertThat(adapter1.attachedViewHolders()).isEmpty()
94 
95         val adapter2 = NestedTestAdapter(5, getLayoutParams = { LayoutParams(MATCH_PARENT, 3) })
96         concatenated.addAdapter(adapter2)
97         assertThat(recyclerView.isLayoutRequested).isTrue()
98         measureAndLayout(100, 200)
99         assertThat(recyclerView.childCount).isEqualTo(15)
100         assertThat(adapter1.attachedViewHolders()).hasSize(10)
101         assertThat(adapter2.attachedViewHolders()).hasSize(5)
102         concatenated.removeAdapter(adapter1)
103         assertThat(recyclerView.isLayoutRequested).isTrue()
104         measureAndLayout(100, 200)
105         assertThat(recyclerView.childCount).isEqualTo(5)
106         assertThat(adapter1.attachedViewHolders()).isEmpty()
107         assertThat(adapter2.attachedViewHolders()).hasSize(5)
108         measureAndLayout(100, 0)
109         assertThat(adapter2.attachedViewHolders()).isEmpty()
110     }
111 
112     @Test
113     @UiThreadTest
114     fun concatInsideConcat() {
115         val concatenated = ConcatAdapter()
116         val adapter1 = NestedTestAdapter(10)
117         concatenated.addAdapter(adapter1)
118         recyclerView.adapter = concatenated
119         measureAndLayout(100, 100)
120         assertThat(recyclerView.childCount).isEqualTo(10)
121         concatenated.removeAdapter(adapter1)
122         assertThat(recyclerView.isLayoutRequested).isTrue()
123         measureAndLayout(100, 100)
124         assertThat(adapter1.attachedViewHolders()).isEmpty()
125     }
126 
127     @UiThreadTest
128     @Test
129     fun recycleOnRemoval() {
130         val concatenated = ConcatAdapter()
131         val adapter1 = NestedTestAdapter(10)
132         concatenated.addAdapter(adapter1)
133         recyclerView.adapter = concatenated
134         measureAndLayout(100, 100)
135         assertThat(recyclerView.childCount).isEqualTo(10)
136         adapter1.removeItems(3, 2)
137         assertThat(recyclerView.isLayoutRequested).isTrue()
138         measureAndLayout(100, 100)
139         assertThat(adapter1.recycleEvents()).hasSize(2)
140         assertThat(adapter1.attachedViewHolders()).hasSize(8)
141         assertThat(adapter1.attachedViewHolders()).containsNoneIn(adapter1.recycleEvents())
142     }
143 
144     @UiThreadTest
145     @Test
146     fun checkAttachDetach_adapterAdditions() {
147         val concatenated = ConcatAdapter()
148         val adapter1 = NestedTestAdapter(0)
149         concatenated.addAdapter(adapter1)
150         recyclerView.adapter = concatenated
151         measureAndLayout(100, 100)
152         adapter1.addItems(0, 3)
153         assertThat(recyclerView.isLayoutRequested).isTrue()
154         measureAndLayout(100, 100)
155         assertThat(adapter1.attachedViewHolders()).hasSize(3)
156         assertThat(adapter1.recycleEvents()).hasSize(0)
157     }
158 
159     @UiThreadTest
160     @Test
161     fun failedToRecycleTest() {
162         val adapter1 = NestedTestAdapter(10)
163         val adapter2 = NestedTestAdapter(5)
164         val concatenated = ConcatAdapter(adapter1, adapter2)
165         recyclerView.adapter = concatenated
166         measureAndLayout(100, 200)
167         val viewHolder = recyclerView.findViewHolderForAdapterPosition(12)
168         check(viewHolder != null) { "should have that view holder for position 12" }
169         assertThat(adapter2.attachedViewHolders()).contains(viewHolder)
170         // give it transient state so that it won't be recycled
171         viewHolder.itemView.setHasTransientState(true)
172         adapter2.removeItems(2, 2)
173         assertThat(recyclerView.isLayoutRequested).isTrue()
174         measureAndLayout(100, 200)
175         assertThat(adapter2.attachedViewHolders()).hasSize(3)
176         assertThat(adapter2.failedToRecycleEvents())
177             .contains(
178                 RecycledViewHolderEvent(
179                     itemId = 12,
180                     absoluteAdapterPosition = NO_POSITION,
181                     bindingAdapterPosition = NO_POSITION
182                 )
183             )
184         assertThat(adapter2.failedToRecycleEvents()).hasSize(1)
185         assertThat(adapter2.attachedViewHolders()).doesNotContain(viewHolder)
186     }
187 
188     @Suppress("DEPRECATION")
189     @UiThreadTest
190     @Test
191     fun localAdapterPositions() {
192         val adapter1 = NestedTestAdapter(10)
193         val adapter2 = NestedTestAdapter(4)
194         val adapter3 = NestedTestAdapter(8)
195         val concatenated = ConcatAdapter(adapter1, adapter2, adapter3)
196         recyclerView.adapter = concatenated
197         measureAndLayout(100, 100)
198         assertThat(recyclerView.childCount).isEqualTo(22)
199         (0 until 22).forEach {
200             val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it))
201             assertThat(recyclerView.getChildAdapterPosition(viewHolder.itemView)).isEqualTo(it)
202             assertThat(viewHolder.absoluteAdapterPosition).isEqualTo(it)
203         }
204         (0 until 10).forEach {
205             val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it))
206             assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it)
207             assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter1)
208         }
209 
210         (10 until 14).forEach {
211             val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it))
212             assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it - 10)
213             assertThat(viewHolder.adapterPosition).isEqualTo(it - 10)
214             assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter2)
215         }
216 
217         (14 until 22).forEach {
218             val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it))
219             assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it - 14)
220             assertThat(viewHolder.adapterPosition).isEqualTo(it - 14)
221             assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter3)
222         }
223     }
224 
225     @Suppress("LocalVariableName")
226     @UiThreadTest
227     @Test
228     fun localAdapterPositions_nested() {
229         val adapter1_1 = NestedTestAdapter(10)
230         val adapter1_2 = NestedTestAdapter(5)
231         val adapter1 = ConcatAdapter(adapter1_1, adapter1_2)
232         val adapter2_1 = NestedTestAdapter(3)
233         val adapter2_2 = NestedTestAdapter(6)
234         val adapter2 = ConcatAdapter(adapter2_1, adapter2_2)
235         val concatenated = ConcatAdapter(adapter1, adapter2)
236         recyclerView.adapter = concatenated
237         measureAndLayout(100, 100)
238         assertThat(recyclerView.childCount).isEqualTo(24)
239         (0 until 24).forEach {
240             val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it))
241             assertThat(viewHolder.absoluteAdapterPosition).isEqualTo(it)
242             assertThat(recyclerView.getChildAdapterPosition(viewHolder.itemView)).isEqualTo(it)
243         }
244         (0 until 10).forEach {
245             val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it))
246             assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it)
247             assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter1_1)
248         }
249         (10 until 15).forEach {
250             val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it))
251             assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it - 10)
252             assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter1_2)
253         }
254         (15 until 18).forEach {
255             val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it))
256             assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it - 15)
257             assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter2_1)
258         }
259         (18 until 24).forEach {
260             val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it))
261             assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it - 18)
262             assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter2_2)
263         }
264     }
265 
266     @UiThreadTest
267     @Test
268     fun localAdapterPositions_notIncluded() {
269         val adapter1 = NestedTestAdapter(10)
270         val concatenated = ConcatAdapter(adapter1)
271         recyclerView.adapter = concatenated
272         measureAndLayout(100, 100)
273         assertThat(recyclerView.childCount).isEqualTo(10)
274         val vh = checkNotNull(recyclerView.findViewHolderForAdapterPosition(3))
275         assertThat(vh.bindingAdapterPosition).isEqualTo(3)
276 
277         val toBeRemoved = checkNotNull(recyclerView.findViewHolderForAdapterPosition(4))
278         adapter1.removeItems(4, 1)
279         assertThat(toBeRemoved.bindingAdapterPosition).isEqualTo(NO_POSITION)
280         assertThat(toBeRemoved.absoluteAdapterPosition).isEqualTo(NO_POSITION)
281         measureAndLayout(100, 100)
282         assertThat(toBeRemoved.bindingAdapter).isNull()
283 
284         recyclerView.adapter = null
285         measureAndLayout(100, 100)
286         assertThat(vh.bindingAdapterPosition).isEqualTo(NO_POSITION)
287         assertThat(vh.absoluteAdapterPosition).isEqualTo(NO_POSITION)
288         assertThat(vh.bindingAdapter).isNull()
289     }
290 
291     @UiThreadTest
292     @Test
293     fun attachDetachTest() {
294         val adapter1 = NestedTestAdapter(10)
295         val adapter2 = NestedTestAdapter(5)
296         val concatenated = ConcatAdapter(adapter1, adapter2)
297         recyclerView.adapter = concatenated
298         assertThat(adapter1.attachedRecyclerViews()).containsExactly(recyclerView)
299         assertThat(adapter2.attachedRecyclerViews()).containsExactly(recyclerView)
300         val adapter3 = NestedTestAdapter(3)
301         concatenated.addAdapter(adapter3)
302         assertThat(adapter3.attachedRecyclerViews()).containsExactly(recyclerView)
303         concatenated.removeAdapter(adapter3)
304         assertThat(adapter3.attachedRecyclerViews()).isEmpty()
305         recyclerView.adapter = null
306         assertThat(adapter1.attachedRecyclerViews()).isEmpty()
307         assertThat(adapter2.attachedRecyclerViews()).isEmpty()
308     }
309 
310     @UiThreadTest
311     @Test
312     public fun scrollView() {
313         val adapter1 =
314             NestedTestAdapter(count = 10, getLayoutParams = { LayoutParams(MATCH_PARENT, 40) })
315         val adapter2 =
316             NestedTestAdapter(count = 5, getLayoutParams = { LayoutParams(MATCH_PARENT, 10) })
317         val concatenated = ConcatAdapter(adapter1, adapter2)
318         recyclerView.adapter = concatenated
319         measureAndLayout(100, 100)
320         recyclerView.scrollBy(0, 90)
321         assertThat(adapter1.attachedViewHolders()).hasSize(3)
322         assertThat(adapter1.attachedViewHolders().map { it.bindingAdapterPosition })
323             .containsExactly(2, 3, 4)
324     }
325 
326     @UiThreadTest
327     @Test
328     public fun recycledViewPositions() {
329         val adapter1 =
330             NestedTestAdapter(count = 5, getLayoutParams = { LayoutParams(MATCH_PARENT, 40) })
331         val adapter2 =
332             NestedTestAdapter(count = 10, getLayoutParams = { LayoutParams(MATCH_PARENT, 10) })
333         val concatenated = ConcatAdapter(adapter1, adapter2)
334         recyclerView.setItemViewCacheSize(0)
335         recyclerView.adapter = concatenated
336         measureAndLayout(100, 100)
337         // trigger recycle
338         recyclerView.scrollBy(0, 90)
339         // two views are recycled
340         assertThat(adapter1.recycleEvents())
341             .containsExactly(
342                 RecycledViewHolderEvent(
343                     itemId = 0,
344                     absoluteAdapterPosition = 0,
345                     bindingAdapterPosition = 0
346                 ),
347                 RecycledViewHolderEvent(
348                     itemId = 1,
349                     absoluteAdapterPosition = 1,
350                     bindingAdapterPosition = 1
351                 )
352             )
353             .inOrder()
354     }
355 
356     @UiThreadTest
357     @Test
358     public fun recycledViewPositions_failedRecycle() {
359         val adapter1 =
360             NestedTestAdapter(count = 5, getLayoutParams = { LayoutParams(MATCH_PARENT, 40) })
361         val adapter2 =
362             NestedTestAdapter(count = 10, getLayoutParams = { LayoutParams(MATCH_PARENT, 10) })
363         val concatenated = ConcatAdapter(adapter1, adapter2)
364         recyclerView.setItemViewCacheSize(0)
365         recyclerView.adapter = concatenated
366         measureAndLayout(100, 100)
367         // give second view transient state
368         val viewHolder = recyclerView.findViewHolderForAdapterPosition(1)
369         check(viewHolder != null) { "should have that view holder for position 1" }
370         // give it transient state so that it won't be recycled
371         viewHolder.itemView.setHasTransientState(true)
372         // trigger recycle
373         recyclerView.scrollBy(0, 90)
374         // two views are recycled but one of them fails to recycle
375         assertThat(adapter1.failedToRecycleEvents())
376             .containsExactly(
377                 RecycledViewHolderEvent(
378                     itemId = 1,
379                     absoluteAdapterPosition = 1,
380                     bindingAdapterPosition = 1
381                 )
382             )
383         assertThat(adapter1.recycleEvents())
384             .containsExactly(
385                 RecycledViewHolderEvent(
386                     itemId = 0,
387                     absoluteAdapterPosition = 0,
388                     bindingAdapterPosition = 0
389                 )
390             )
391     }
392 
393     @UiThreadTest
394     @Test
395     public fun recycledViewPositions_withAdapterChanges() {
396         val adapter1 =
397             NestedTestAdapter(count = 5, getLayoutParams = { LayoutParams(MATCH_PARENT, 20) })
398         val adapter2 =
399             NestedTestAdapter(count = 10, getLayoutParams = { LayoutParams(MATCH_PARENT, 10) })
400         val concatenated = ConcatAdapter(adapter1, adapter2)
401         recyclerView.setItemViewCacheSize(0)
402         recyclerView.adapter = concatenated
403         val layoutManager = (recyclerView.layoutManager!! as LinearLayoutManager)
404         measureAndLayout(100, 100)
405         // remove items 3 and 1
406         adapter1.removeItems(3, 1)
407         adapter1.removeItems(1, 1)
408 
409         // scroll to the beginning of the second adapter to trigger recycle of all items in adapter
410         // 1
411         layoutManager.scrollToPositionWithOffset(3, 0)
412         measureAndLayout(100, 100)
413         assertThat(adapter1.recycleEvents())
414             .containsExactly(
415                 RecycledViewHolderEvent(
416                     itemId = 0,
417                     bindingAdapterPosition = 0,
418                     absoluteAdapterPosition = 0
419                 ),
420                 RecycledViewHolderEvent(
421                     itemId = 1,
422                     bindingAdapterPosition = NO_POSITION,
423                     absoluteAdapterPosition = NO_POSITION
424                 ),
425                 RecycledViewHolderEvent(
426                     itemId = 2,
427                     bindingAdapterPosition = 1,
428                     absoluteAdapterPosition = 1
429                 ),
430                 RecycledViewHolderEvent(
431                     itemId = 3,
432                     bindingAdapterPosition = NO_POSITION,
433                     absoluteAdapterPosition = NO_POSITION
434                 ),
435                 RecycledViewHolderEvent(
436                     itemId = 4,
437                     bindingAdapterPosition = 2,
438                     absoluteAdapterPosition = 2
439                 )
440             )
441     }
442 
443     @UiThreadTest
444     @Test
445     public fun recycledViewPositions_withAdapterChanges_secondAdapter() {
446         val adapter1 =
447             NestedTestAdapter(count = 5, getLayoutParams = { LayoutParams(MATCH_PARENT, 20) })
448         val adapter2 =
449             NestedTestAdapter(count = 10, getLayoutParams = { LayoutParams(MATCH_PARENT, 10) })
450         val concatenated = ConcatAdapter(adapter1, adapter2)
451         recyclerView.setItemViewCacheSize(0)
452         val layoutManager = (recyclerView.layoutManager!! as LinearLayoutManager)
453 
454         recyclerView.adapter = concatenated
455         // start from the second adapter
456         layoutManager.scrollToPositionWithOffset(adapter1.itemCount, 0)
457         measureAndLayout(100, 100)
458         assertThat(adapter1.attachedViewHolders()).isEmpty()
459         assertThat(adapter2.attachedViewHolders()).hasSize(10)
460         // remove items 3 and 1 from first adapter
461         adapter1.removeItems(3, 1)
462         adapter1.removeItems(1, 1)
463         // remove items 2, 4 from the second adapter
464         adapter2.removeItems(4, 1)
465         adapter2.removeItems(2, 1)
466         // scroll to the top of the list
467         layoutManager.scrollToPositionWithOffset(0, 0)
468         measureAndLayout(100, 100)
469         assertThat(adapter1.attachedViewHolders()).hasSize(3)
470         assertThat(adapter2.attachedViewHolders()).hasSize(4)
471         // the UI will have (item ids)
472         // 0, 2, 4, 5, 6
473         // nothing is recycled in adapter 1
474         assertThat(adapter1.recycleEvents()).isEmpty()
475         // all items but first two are recycled in adapter2
476         assertThat(adapter2.recycleEvents())
477             .containsExactly(
478                 // first two items, 5 and 6, are not recycled as they are still visible
479                 // items 7 and 9 are recycled (removed)
480                 // items 8, 10 are still visible
481                 RecycledViewHolderEvent(
482                     itemId = 7,
483                     bindingAdapterPosition = NO_POSITION,
484                     absoluteAdapterPosition = NO_POSITION
485                 ),
486                 // pos 4 (item id 9) is removed from adapter 2
487                 RecycledViewHolderEvent(
488                     itemId = 9,
489                     bindingAdapterPosition = NO_POSITION,
490                     absoluteAdapterPosition = NO_POSITION
491                 ),
492                 RecycledViewHolderEvent(
493                     itemId = 11,
494                     bindingAdapterPosition = 4,
495                     absoluteAdapterPosition = 7
496                 ),
497                 RecycledViewHolderEvent(
498                     itemId = 12,
499                     bindingAdapterPosition = 5,
500                     absoluteAdapterPosition = 8
501                 ),
502                 RecycledViewHolderEvent(
503                     itemId = 13,
504                     bindingAdapterPosition = 6,
505                     absoluteAdapterPosition = 9
506                 ),
507                 RecycledViewHolderEvent(
508                     itemId = 14,
509                     bindingAdapterPosition = 7,
510                     absoluteAdapterPosition = 10
511                 ),
512             )
513     }
514 
515     @UiThreadTest
516     @Test
517     fun attachDetachTest_multipleRecyclerViews() {
518         val recyclerView2 = RecyclerView(ApplicationProvider.getApplicationContext())
519         val adapter1 = NestedTestAdapter(10)
520         val adapter2 = NestedTestAdapter(5)
521         val concatenated = ConcatAdapter(adapter1, adapter2)
522         recyclerView.adapter = concatenated
523         recyclerView2.adapter = concatenated
524         assertThat(adapter1.attachedRecyclerViews()).containsExactly(recyclerView, recyclerView2)
525         assertThat(adapter2.attachedRecyclerViews()).containsExactly(recyclerView, recyclerView2)
526         val adapter3 = NestedTestAdapter(3)
527         concatenated.addAdapter(adapter3)
528         assertThat(adapter3.attachedRecyclerViews()).containsExactly(recyclerView, recyclerView2)
529         concatenated.removeAdapter(adapter3)
530         assertThat(adapter3.attachedRecyclerViews()).isEmpty()
531         recyclerView.adapter = null
532         assertThat(adapter1.attachedRecyclerViews()).containsExactly(recyclerView2)
533         assertThat(adapter2.attachedRecyclerViews()).containsExactly(recyclerView2)
534         recyclerView2.adapter = null
535         assertThat(adapter1.attachedRecyclerViews()).isEmpty()
536         assertThat(adapter2.attachedRecyclerViews()).isEmpty()
537         assertThat(adapter3.attachedRecyclerViews()).isEmpty()
538     }
539 
540     @Test
541     @UiThreadTest
542     fun adapterRemoval() {
543         val adapter1 = NestedTestAdapter(3)
544         val adapter2 = NestedTestAdapter(5)
545         val concatenated = ConcatAdapter(adapter1, adapter2)
546         recyclerView.adapter = concatenated
547         measureAndLayout(100, 100)
548         assertThat(recyclerView.childCount).isEqualTo(8)
549         assertThat(concatenated.removeAdapter(adapter1)).isTrue()
550         measureAndLayout(100, 100)
551         assertThat(recyclerView.childCount).isEqualTo(5)
552         assertThat(concatenated.removeAdapter(adapter1)).isFalse()
553         assertThat(concatenated.removeAdapter(adapter2)).isTrue()
554         measureAndLayout(100, 100)
555         assertThat(recyclerView.childCount).isEqualTo(0)
556     }
557 
558     @Test
559     @UiThreadTest
560     fun boundAdapter() {
561         val adapter1 = NestedTestAdapter(3)
562         val adapter2 = NestedTestAdapter(5)
563         val concatenated = ConcatAdapter(adapter1, adapter2)
564         recyclerView.adapter = concatenated
565         measureAndLayout(100, 100)
566         assertThat(recyclerView.childCount).isEqualTo(8)
567         val adapter1ViewHolders =
568             (0 until 3).map { checkNotNull(recyclerView.findViewHolderForAdapterPosition(it)) }
569         val adapter2ViewHolders =
570             (3 until 8).map { checkNotNull(recyclerView.findViewHolderForAdapterPosition(it)) }
571         adapter1ViewHolders.forEach { assertThat(it.bindingAdapter).isSameInstanceAs(adapter1) }
572         adapter2ViewHolders.forEach { assertThat(it.bindingAdapter).isSameInstanceAs(adapter2) }
573         assertThat(concatenated.removeAdapter(adapter1)).isTrue()
574         // even when position is invalid, we should still be able to find the bound adapter
575         adapter1ViewHolders.forEach { assertThat(it.bindingAdapter).isSameInstanceAs(adapter1) }
576         measureAndLayout(100, 100)
577         assertThat(recyclerView.childCount).isEqualTo(5)
578         adapter1ViewHolders.forEach { assertThat(it.bindingAdapter).isNull() }
579         assertThat(concatenated.removeAdapter(adapter1)).isFalse()
580         assertThat(concatenated.removeAdapter(adapter2)).isTrue()
581         measureAndLayout(100, 100)
582         assertThat(recyclerView.childCount).isEqualTo(0)
583         adapter2ViewHolders.forEach { assertThat(it.bindingAdapter).isNull() }
584     }
585 
586     private fun measureAndLayout(@Suppress("SameParameterValue") width: Int, height: Int) {
587         measure(width, height)
588         layout(width, height)
589     }
590 
591     private fun measure(width: Int, height: Int) {
592         recyclerView.measure(AT_MOST or width, AT_MOST or height)
593     }
594 
595     private fun layout(width: Int, height: Int) {
596         recyclerView.layout(0, 0, width, height)
597     }
598 
599     @Test
600     fun size() {
601         val concatenated = ConcatAdapter()
602         val observer = LoggingAdapterObserver(concatenated)
603         assertThat(concatenated).hasItemCount(0)
604         concatenated.addAdapter(NestedTestAdapter(0))
605         observer.assertEventsAndClear("Empty adapter shouldn't cause notify")
606 
607         val adapter1 = NestedTestAdapter(3)
608         concatenated.addAdapter(adapter1)
609         assertThat(concatenated).hasItemCount(3)
610         observer.assertEventsAndClear(
611             "adapter with count should trigger notify",
612             Inserted(positionStart = 0, itemCount = 3)
613         )
614 
615         val adapter2 = NestedTestAdapter(5)
616         concatenated.addAdapter(adapter2)
617         assertThat(concatenated).hasItemCount(8)
618         observer.assertEventsAndClear(
619             "appended non-empty adapter should trigger insert event",
620             Inserted(positionStart = 3, itemCount = 5)
621         )
622 
623         val adapter3 = NestedTestAdapter(2)
624         concatenated.addAdapter(2, adapter3)
625         assertThat(concatenated).hasItemCount(10)
626         observer.assertEventsAndClear(
627             "appended non-empty adapter should trigger insert event in right index",
628             Inserted(positionStart = 3, itemCount = 2)
629         )
630 
631         concatenated.addAdapter(NestedTestAdapter(0))
632         assertThat(concatenated).hasItemCount(10)
633         observer.assertEventsAndClear("empty new adapter shouldn't trigger events")
634     }
635 
636     @Test
637     fun nested_addition() {
638         val concatenated = ConcatAdapter()
639         val observer = LoggingAdapterObserver(concatenated)
640 
641         val adapter1 = NestedTestAdapter(0)
642         concatenated.addAdapter(adapter1)
643         observer.assertEventsAndClear("empty adapter triggers no events")
644 
645         adapter1.addItems(positionStart = 0, itemCount = 3)
646         observer.assertEventsAndClear(
647             "non-empty adapter triggers an event",
648             Inserted(positionStart = 0, itemCount = 3)
649         )
650         assertThat(concatenated).hasItemCount(3)
651         adapter1.addItems(positionStart = 1, itemCount = 2)
652         observer.assertEventsAndClear(
653             "inner adapter change should trigger an event",
654             Inserted(positionStart = 1, itemCount = 2)
655         )
656         assertThat(concatenated).hasItemCount(5)
657         val adapter2 = NestedTestAdapter(2)
658         concatenated.addAdapter(adapter2)
659         observer.assertEventsAndClear(
660             "added adapter should trigger an event",
661             Inserted(positionStart = 5, itemCount = 2)
662         )
663         assertThat(concatenated).hasItemCount(7)
664 
665         adapter2.addItems(positionStart = 0, itemCount = 3)
666         observer.assertEventsAndClear(
667             "nested adapter prepends data",
668             Inserted(positionStart = 5, itemCount = 3)
669         )
670         assertThat(concatenated).hasItemCount(10)
671 
672         adapter2.addItems(positionStart = 2, itemCount = 4)
673         observer.assertEventsAndClear(
674             "nested adapter adds items with inner offset",
675             Inserted(positionStart = 7, itemCount = 4)
676         )
677         assertThat(concatenated).hasItemCount(14)
678     }
679 
680     @Test
681     fun nested_removal() {
682         val adapter1 = NestedTestAdapter(10)
683         val adapter2 = NestedTestAdapter(15)
684         val adapter3 = NestedTestAdapter(20)
685 
686         val concatenated = ConcatAdapter(adapter1, adapter2, adapter3)
687         val observer = LoggingAdapterObserver(concatenated)
688         assertThat(concatenated).hasItemCount(45)
689 
690         adapter1.removeItems(positionStart = 0, itemCount = 2)
691         observer.assertEventsAndClear(
692             "removal from first adapter top",
693             Removed(positionStart = 0, itemCount = 2)
694         )
695         assertThat(concatenated).hasItemCount(43)
696         adapter1.removeItems(positionStart = 2, itemCount = 1)
697         observer.assertEventsAndClear(
698             "removal from first adapter inner",
699             Removed(positionStart = 2, itemCount = 1)
700         )
701         assertThat(concatenated).hasItemCount(42)
702         // now first adapter has size 7
703         adapter2.removeItems(positionStart = 0, itemCount = 3)
704         observer.assertEventsAndClear(
705             "removal from second adapter should be offset",
706             Removed(positionStart = adapter1.itemCount, itemCount = 3)
707         )
708         assertThat(concatenated).hasItemCount(39)
709         adapter2.removeItems(positionStart = 6, itemCount = 4)
710         observer.assertEventsAndClear(
711             "inner item removal from middle adapter should be offset",
712             Removed(positionStart = adapter1.itemCount + 6, itemCount = 4)
713         )
714         assertThat(concatenated).hasItemCount(35)
715 
716         adapter3.removeItems(positionStart = 0, itemCount = 3)
717         observer.assertEventsAndClear(
718             "removal from last adapter should be offset by adapter 1 and 2",
719             Removed(positionStart = adapter1.itemCount + adapter2.itemCount, itemCount = 3)
720         )
721 
722         adapter3.removeItems(positionStart = 2, itemCount = 5)
723         observer.assertEventsAndClear(
724             "removal from inner items from last adapter should be offset by adapter 1 & 2",
725             Removed(positionStart = adapter1.itemCount + adapter2.itemCount + 2, itemCount = 5)
726         )
727 
728         concatenated.removeAdapter(adapter2)
729         observer.assertEventsAndClear(
730             "removing an adapter should trigger removal",
731             Removed(positionStart = adapter1.itemCount, itemCount = adapter2.itemCount)
732         )
733         assertThat(concatenated).hasItemCount(adapter1.itemCount + adapter3.itemCount)
734         concatenated.removeAdapter(adapter1)
735         observer.assertEventsAndClear(
736             "removing first adapter should trigger removal",
737             Removed(positionStart = 0, itemCount = adapter1.itemCount)
738         )
739         assertThat(concatenated).hasItemCount(adapter3.itemCount)
740         concatenated.removeAdapter(adapter3)
741         observer.assertEventsAndClear(
742             "removing last adapter should trigger a removal",
743             Removed(positionStart = 0, itemCount = adapter3.itemCount)
744         )
745         assertThat(concatenated).hasItemCount(0)
746     }
747 
748     @Test
749     fun nested_move() {
750         val adapter1 = NestedTestAdapter(10)
751         val adapter2 = NestedTestAdapter(15)
752         val adapter3 = NestedTestAdapter(20)
753         val concatenated = ConcatAdapter(adapter1, adapter2, adapter3)
754         val observer = LoggingAdapterObserver(concatenated)
755         adapter1.moveItem(fromPosition = 3, toPosition = 5)
756         observer.assertEventsAndClear(
757             "move from first adapter should come as is",
758             Moved(fromPosition = 3, toPosition = 5)
759         )
760         assertThat(concatenated).hasItemCount(45)
761         adapter2.moveItem(fromPosition = 2, toPosition = 4)
762         observer.assertEventsAndClear(
763             "move in adapter 2 should be offset",
764             Moved(fromPosition = adapter1.itemCount + 2, toPosition = adapter1.itemCount + 4)
765         )
766         adapter3.moveItem(fromPosition = 7, toPosition = 2)
767         observer.assertEventsAndClear(
768             "move in adapter 3 should be offset by adapter 1 & 2",
769             Moved(
770                 fromPosition = adapter1.itemCount + adapter2.itemCount + 7,
771                 toPosition = adapter1.itemCount + adapter2.itemCount + 2
772             )
773         )
774         assertThat(concatenated).hasItemCount(45)
775     }
776 
777     @Test fun nested_itemChange_withPayload() = nested_itemChange("payload")
778 
779     @Test fun nested_itemChange_withoutPayload() = nested_itemChange(null)
780 
781     fun nested_itemChange(payload: Any? = null) {
782         val adapter1 = NestedTestAdapter(10)
783         val adapter2 = NestedTestAdapter(15)
784         val adapter3 = NestedTestAdapter(20)
785         val concatenated = ConcatAdapter(adapter1, adapter2, adapter3)
786         val observer = LoggingAdapterObserver(concatenated)
787 
788         adapter1.changeItems(positionStart = 3, itemCount = 5, payload = payload)
789         observer.assertEventsAndClear(
790             "change from first adapter should come as is",
791             Changed(positionStart = 3, itemCount = 5, payload = payload)
792         )
793         assertThat(concatenated).hasItemCount(45)
794         adapter2.changeItems(positionStart = 2, itemCount = 4, payload = payload)
795         observer.assertEventsAndClear(
796             "change in adapter 2 should be offset",
797             Changed(positionStart = adapter1.itemCount + 2, itemCount = 4, payload = payload)
798         )
799         adapter3.changeItems(positionStart = 7, itemCount = 2, payload = payload)
800         observer.assertEventsAndClear(
801             "change in adapter 3 should be offset by adapter 1 & 2",
802             Changed(
803                 positionStart = adapter1.itemCount + adapter2.itemCount + 7,
804                 itemCount = 2,
805                 payload = payload
806             )
807         )
808         assertThat(concatenated).hasItemCount(45)
809     }
810 
811     @Test
812     fun notifyDataSetChanged() {
813         // we could add some logic to make data set changes add/remove/itemChange events yet
814         // it is very hard to get right and might cause very undesired animations. Not doing it
815         // for V1.
816         val adapter1 = NestedTestAdapter(10)
817         val adapter2 = NestedTestAdapter(15)
818         val adapter3 = NestedTestAdapter(20)
819         val concatenated = ConcatAdapter(adapter1, adapter2, adapter3)
820         val observer = LoggingAdapterObserver(concatenated)
821 
822         adapter1.changeDataSet(3)
823         observer.assertEventsAndClear("data set change should come as is", DataSetChanged)
824         assertThat(concatenated).hasItemCount(38)
825         adapter2.changeDataSet(20)
826         observer.assertEventsAndClear(
827             "data set change in adapter 2 should become full data set change",
828             DataSetChanged
829         )
830         assertThat(concatenated).hasItemCount(43)
831         adapter3.changeDataSet(newSize = 0)
832         observer.assertEventsAndClear(
833             """when an adapter changes size to 0, it should still come as 0 as we cannot
834                 |rely on itemCount changing immediately. In theory we would but adapter might be
835                 |faulty and not update its size immediately, which would work fine in RV because
836                 |everything is delayed but not here if we immediately read the item count
837             """
838                 .trimMargin(),
839             DataSetChanged
840         )
841         assertThat(concatenated).hasItemCount(23)
842     }
843 
844     @Test
845     fun viewTypeMapping_allViewsHaveDifferentTypes() {
846         val adapter1 = NestedTestAdapter(10) { _, position -> position }
847         val concatenated = ConcatAdapter(adapter1)
848         val adapter1ViewTypes = (0 until 10).map { concatenated.getItemViewType(it) }.toSet()
849 
850         assertWithMessage("all items have unique types").that(adapter1ViewTypes).hasSize(10)
851         repeat(adapter1.itemCount) {
852             assertThat(concatenated)
853                 .bindView(recyclerView, it)
854                 .verifyBoundTo(adapter = adapter1, localPosition = it)
855         }
856         val adapter2 = NestedTestAdapter(5) { _, position -> position }
857         concatenated.addAdapter(adapter2)
858         repeat(adapter2.itemCount) {
859             assertThat(concatenated)
860                 .bindView(recyclerView, adapter1.itemCount + it)
861                 .verifyBoundTo(adapter = adapter2, localPosition = it)
862         }
863 
864         concatenated.removeAdapter(adapter1)
865         repeat(adapter2.itemCount) {
866             assertThat(concatenated)
867                 .bindView(recyclerView, it)
868                 .verifyBoundTo(adapter = adapter2, localPosition = it)
869         }
870     }
871 
872     @Test
873     fun viewTypeMapping_shareTypesWithinAdapter() {
874         val adapter1 = NestedTestAdapter(10) { item, _ -> item.id % 3 }
875         val adapter2 = NestedTestAdapter(20) { item, _ -> item.id % 4 }
876         val concatenated = ConcatAdapter(adapter1, adapter2)
877         val adapter1Types =
878             (0 until adapter1.itemCount).map { concatenated.getItemViewType(it) }.toSet()
879         assertThat(adapter1Types).hasSize(3)
880         val adapter2Types =
881             (adapter1.itemCount until adapter2.itemCount)
882                 .map { concatenated.getItemViewType(it) }
883                 .toSet()
884         assertThat(adapter2Types).hasSize(4)
885         adapter2Types.forEach { assertThat(adapter1Types).doesNotContain(it) }
886         (0 until adapter1.itemCount).forEach {
887             assertThat(concatenated)
888                 .bindView(recyclerView, it)
889                 .verifyBoundTo(adapter = adapter1, localPosition = it)
890         }
891 
892         (0 until adapter2.itemCount).forEach {
893             assertThat(concatenated)
894                 .bindView(recyclerView, adapter1.itemCount + it)
895                 .verifyBoundTo(adapter = adapter2, localPosition = it)
896         }
897 
898         concatenated.removeAdapter(adapter1)
899         repeat(adapter2.itemCount) {
900             assertThat(concatenated)
901                 .bindView(recyclerView, it)
902                 .verifyBoundTo(adapter = adapter2, localPosition = it)
903         }
904     }
905 
906     @Test(expected = java.lang.UnsupportedOperationException::class)
907     fun stateRestorationTest_callingOnTheConcatAdapterIsNotAllowed() {
908         val concatenated = ConcatAdapter()
909         concatenated.stateRestorationPolicy = PREVENT
910     }
911 
912     @Test
913     fun stateRestoration_subAdapterAllowsNonEmpty() {
914         val adapter1 = NestedTestAdapter(1).also { it.stateRestorationPolicy = ALLOW }
915         val adapter2 = NestedTestAdapter(0).also { it.stateRestorationPolicy = PREVENT_WHEN_EMPTY }
916         val concatenated = ConcatAdapter(adapter1, adapter2)
917         assertThat(concatenated).cannotRestoreState()
918         adapter2.addItems(0, 1)
919         assertThat(concatenated).canRestoreState()
920         adapter2.removeItems(0, 1)
921         assertThat(concatenated).cannotRestoreState()
922     }
923 
924     @Test
925     fun stateRestoration_subAdapterAllowsNonEmpty_viaNotifyChange() {
926         val adapter1 = NestedTestAdapter(1).also { it.stateRestorationPolicy = ALLOW }
927         val adapter2 = NestedTestAdapter(0).also { it.stateRestorationPolicy = PREVENT_WHEN_EMPTY }
928         val concatenated = ConcatAdapter(adapter1, adapter2)
929         assertThat(concatenated).cannotRestoreState()
930         adapter2.changeDataSet(1)
931         assertThat(concatenated).canRestoreState()
932         adapter2.changeDataSet(0)
933         assertThat(concatenated).cannotRestoreState()
934     }
935 
936     @Test
937     fun stateRestoration() {
938         val adapter1 = NestedTestAdapter(10)
939         val adapter2 = NestedTestAdapter(5)
940         val adapter3 = NestedTestAdapter(20)
941         val concatenated = ConcatAdapter(adapter1, adapter2, adapter3)
942         assertThat(concatenated).hasStateRestorationPolicy(ALLOW)
943         adapter2.stateRestorationPolicy = PREVENT
944         assertThat(concatenated).hasStateRestorationPolicy(PREVENT)
945 
946         adapter3.stateRestorationPolicy = PREVENT_WHEN_EMPTY
947         assertThat(concatenated).hasStateRestorationPolicy(PREVENT)
948 
949         adapter2.stateRestorationPolicy = ALLOW
950         assertThat(concatenated).hasStateRestorationPolicy(ALLOW)
951 
952         concatenated.removeAdapter(adapter3)
953         assertThat(concatenated).hasStateRestorationPolicy(ALLOW)
954 
955         val adapter4 =
956             NestedTestAdapter(3).also {
957                 it.stateRestorationPolicy = PREVENT
958                 concatenated.addAdapter(it)
959             }
960         assertThat(concatenated).hasStateRestorationPolicy(PREVENT)
961         adapter4.stateRestorationPolicy = PREVENT_WHEN_EMPTY
962         assertThat(concatenated).hasStateRestorationPolicy(ALLOW)
963         concatenated.removeAdapter(adapter1)
964         assertThat(concatenated).hasStateRestorationPolicy(ALLOW)
965         adapter4.stateRestorationPolicy = ALLOW
966         assertThat(concatenated).hasStateRestorationPolicy(ALLOW)
967     }
968 
969     @Test
970     fun disposal() {
971         val adapter1 = NestedTestAdapter(10)
972         val adapter2 = NestedTestAdapter(5)
973         val concatenated = ConcatAdapter(adapter1, adapter2)
974         assertThat(adapter1.observerCount()).isEqualTo(1)
975         assertThat(adapter2.observerCount()).isEqualTo(1)
976         concatenated.removeAdapter(adapter1)
977         assertThat(adapter1.observerCount()).isEqualTo(0)
978         assertThat(adapter2.observerCount()).isEqualTo(1)
979 
980         val adapter3 = NestedTestAdapter(2)
981         concatenated.addAdapter(adapter3)
982         assertThat(adapter3.observerCount()).isEqualTo(1)
983         concatenated.adapters.forEach { concatenated.removeAdapter(it) }
984         listOf(adapter1, adapter2, adapter3).forEachIndexed { index, adapter ->
985             assertWithMessage("adapter ${index + 1}").apply {
986                 that(adapter.observerCount()).isEqualTo(0)
987                 that(adapter.attachedRecyclerViews()).isEmpty()
988             }
989         }
990     }
991 
992     /**
993      * Running only on 26 due to the getParameters method call and this is not API version dependent
994      * test so it is fine to only run it on new devices.
995      */
996     @SdkSuppress(minSdkVersion = 26)
997     @Test
998     fun overrideTest() {
999         // custom method instead of using toGenericString to avoid having class name
1000         fun Method.describe() =
1001             """
1002             $name(${parameters.map {
1003             it.type.canonicalName
1004         }}) : ${returnType.canonicalName}
1005         """
1006                 .trimIndent()
1007 
1008         val excludedMethods =
1009             setOf(
1010                 "registerAdapterDataObserver(" +
1011                     "[androidx.recyclerview.widget.RecyclerView.AdapterDataObserver]) : void",
1012                 "unregisterAdapterDataObserver(" +
1013                     "[androidx.recyclerview.widget.RecyclerView.AdapterDataObserver]) : void",
1014                 "canRestoreState([]) : boolean",
1015                 "onBindViewHolder([androidx.recyclerview.widget.RecyclerView.ViewHolder, int, " +
1016                     "java.util.List]) : void"
1017             )
1018         val adapterMethods =
1019             RecyclerView.Adapter::class
1020                 .java
1021                 .declaredMethods
1022                 .filterNot { Modifier.isPrivate(it.modifiers) || Modifier.isFinal(it.modifiers) }
1023                 .map { it.describe() }
1024                 .filterNot { excludedMethods.contains(it) }
1025         val concatenatedAdapterMethods =
1026             ConcatAdapter::class.java.declaredMethods.map { it.describe() }
1027         assertWithMessage(
1028                 """
1029             ConcatAdapter should override all methods in RecyclerView.Adapter for future
1030             compatibility. If you want to exclude a method, update the test.
1031             """
1032                     .trimIndent()
1033             )
1034             .that(concatenatedAdapterMethods)
1035             .containsAtLeastElementsIn(adapterMethods)
1036     }
1037 
1038     @Test
1039     fun getAdapters() {
1040         val adapter1 = NestedTestAdapter(1)
1041         val adapter2 = NestedTestAdapter(2)
1042         val concatenated = ConcatAdapter(adapter1, adapter2)
1043         assertThat(concatenated.adapters).isEqualTo(listOf(adapter1, adapter2))
1044         concatenated.removeAdapter(adapter1)
1045         assertThat(concatenated.adapters).isEqualTo(listOf(adapter2))
1046     }
1047 
1048     @Test
1049     fun sharedTypes() {
1050         val adapter1 = NestedTestAdapter(3) { _, pos -> pos % 2 }
1051         val adapter2 = NestedTestAdapter(3) { _, pos -> pos % 3 }
1052         val concatenated =
1053             ConcatAdapter(Builder().setIsolateViewTypes(false).build(), adapter1, adapter2)
1054         assertThat(concatenated).bindView(recyclerView, 2).verifyBoundTo(adapter1, 2)
1055         assertThat(concatenated).bindView(recyclerView, 3).verifyBoundTo(adapter2, 0)
1056         assertThat(concatenated.getItemViewType(0)).isEqualTo(0)
1057         assertThat(concatenated.getItemViewType(1)).isEqualTo(1)
1058         assertThat(concatenated.getItemViewType(2)).isEqualTo(0)
1059         // notice that it resets to 0 because type is based on position
1060         assertThat(concatenated.getItemViewType(3)).isEqualTo(0)
1061         assertThat(concatenated.getItemViewType(4)).isEqualTo(1)
1062         assertThat(concatenated.getItemViewType(5)).isEqualTo(2)
1063         // ensure we bind via the correct adapter when a type is limited to a specific adapter
1064         assertThat(concatenated).bindView(recyclerView, 5).verifyBoundTo(adapter2, 2)
1065     }
1066 
1067     @Test
1068     fun sharedTypes_allUnique() {
1069         val adapter1 = NestedTestAdapter(3) { item, _ -> item.id }
1070         val adapter2 = NestedTestAdapter(3) { item, _ -> item.id }
1071         val concatenated =
1072             ConcatAdapter(Builder().setIsolateViewTypes(false).build(), adapter1, adapter2)
1073         assertThat(concatenated).bindView(recyclerView, 0).verifyBoundTo(adapter1, 0)
1074         assertThat(concatenated).bindView(recyclerView, 1).verifyBoundTo(adapter1, 1)
1075         assertThat(concatenated).bindView(recyclerView, 2).verifyBoundTo(adapter1, 2)
1076         assertThat(concatenated).bindView(recyclerView, 3).verifyBoundTo(adapter2, 0)
1077         assertThat(concatenated).bindView(recyclerView, 4).verifyBoundTo(adapter2, 1)
1078         assertThat(concatenated).bindView(recyclerView, 5).verifyBoundTo(adapter2, 2)
1079     }
1080 
1081     @Test
1082     fun stableIds_noStableId() {
1083         val concatenatedAdapter = ConcatAdapter(Builder().setStableIdMode(NO_STABLE_IDS).build())
1084         assertThat(concatenatedAdapter).doesNotHaveStableIds()
1085         // accept adapters with stable ids
1086         assertThat(concatenatedAdapter.addAdapter(PositionAsIdsNestedTestAdapter(10))).isTrue()
1087     }
1088 
1089     @Test
1090     fun stableIds_isolated_addAdapterWithoutStableId() {
1091         val concatenatedAdapter =
1092             ConcatAdapter(Builder().setStableIdMode(ISOLATED_STABLE_IDS).build())
1093         assertThat(concatenatedAdapter).hasStableIds()
1094         assertThat(concatenatedAdapter)
1095             .throwsException {
1096                 it.addAdapter(
1097                     NestedTestAdapter(10).also { nested -> nested.setHasStableIds(false) }
1098                 )
1099             }
1100             .hasMessageThat()
1101             .contains(
1102                 "All sub adapters must have stable ids when stable id mode" +
1103                     " is ISOLATED_STABLE_IDS or SHARED_STABLE_IDS"
1104             )
1105     }
1106 
1107     @Test
1108     fun stableIds_shared_addAdapterWithoutStableId() {
1109         val concatenatedAdapter =
1110             ConcatAdapter(Builder().setStableIdMode(SHARED_STABLE_IDS).build())
1111         assertThat(concatenatedAdapter).hasStableIds()
1112         assertThat(concatenatedAdapter)
1113             .throwsException {
1114                 it.addAdapter(
1115                     NestedTestAdapter(10).also { nested -> nested.setHasStableIds(false) }
1116                 )
1117             }
1118             .hasMessageThat()
1119             .contains(
1120                 "All sub adapters must have stable ids when stable id mode" +
1121                     " is ISOLATED_STABLE_IDS or SHARED_STABLE_IDS"
1122             )
1123     }
1124 
1125     @Test
1126     fun stableIds_isolated() {
1127         val concatenatedAdapter =
1128             ConcatAdapter(Builder().setStableIdMode(ISOLATED_STABLE_IDS).build())
1129         assertThat(concatenatedAdapter).hasStableIds()
1130         val adapter1 = PositionAsIdsNestedTestAdapter(10)
1131         val adapter2 = PositionAsIdsNestedTestAdapter(10)
1132         concatenatedAdapter.addAdapter(adapter1)
1133         concatenatedAdapter.addAdapter(adapter2)
1134         assertThat(concatenatedAdapter).hasItemIds((0..19))
1135         // call again, ensure we are not popping up new ids
1136         assertThat(concatenatedAdapter).hasItemIds((0..19))
1137         concatenatedAdapter.removeAdapter(adapter1)
1138         assertThat(concatenatedAdapter).hasItemIds((10..19))
1139 
1140         val adapter3 = PositionAsIdsNestedTestAdapter(5)
1141         concatenatedAdapter.addAdapter(adapter3)
1142         assertThat(concatenatedAdapter).hasItemIds((10..24))
1143 
1144         // add in between
1145         val adapter4 = PositionAsIdsNestedTestAdapter(5)
1146         concatenatedAdapter.addAdapter(1, adapter4)
1147         assertThat(concatenatedAdapter).hasItemIds((10..19) + (25..29) + (20..24))
1148     }
1149 
1150     @Test
1151     fun stableIds_shared() {
1152         val concatenatedAdapter =
1153             ConcatAdapter(Builder().setStableIdMode(SHARED_STABLE_IDS).build())
1154         assertThat(concatenatedAdapter).hasStableIds()
1155         val adapter1 = UniqueItemIdsNestedTestAdapter(10)
1156         val adapter2 = UniqueItemIdsNestedTestAdapter(10)
1157         concatenatedAdapter.addAdapter(adapter1)
1158         concatenatedAdapter.addAdapter(adapter2)
1159         assertThat(concatenatedAdapter).hasItemIds(adapter1.itemIds() + adapter2.itemIds())
1160         // call again, ensure we are not popping up new ids
1161         assertThat(concatenatedAdapter).hasItemIds(adapter1.itemIds() + adapter2.itemIds())
1162         concatenatedAdapter.removeAdapter(adapter1)
1163         assertThat(concatenatedAdapter).hasItemIds(adapter2.itemIds())
1164 
1165         val adapter3 = UniqueItemIdsNestedTestAdapter(5)
1166         concatenatedAdapter.addAdapter(adapter3)
1167         assertThat(concatenatedAdapter).hasItemIds(adapter2.itemIds() + adapter3.itemIds())
1168 
1169         // add in between
1170         val adapter4 = UniqueItemIdsNestedTestAdapter(5)
1171         concatenatedAdapter.addAdapter(1, adapter4)
1172         assertThat(concatenatedAdapter)
1173             .hasItemIds(adapter2.itemIds() + adapter4.itemIds() + adapter3.itemIds())
1174     }
1175 
1176     @Test
1177     fun builderDefaults() {
1178         val defaultBuilder = Builder().build()
1179         assertThat(defaultBuilder.isolateViewTypes)
1180             .isEqualTo(ConcatAdapter.Config.DEFAULT.isolateViewTypes)
1181         assertThat(defaultBuilder.stableIdMode).isEqualTo(ConcatAdapter.Config.DEFAULT.stableIdMode)
1182     }
1183 
1184     @Test
1185     fun getWrappedAdapterAndPositionTest() {
1186         val adapter1 = NestedTestAdapter(10)
1187         val adapter2 = NestedTestAdapter(10)
1188         val concatAdapter = ConcatAdapter(adapter1, adapter2)
1189         val result0 = concatAdapter.getWrappedAdapterAndPosition(0)
1190         assertThat(result0.first).isEqualTo(adapter1)
1191         assertThat(result0.second).isEqualTo(0)
1192         val result5 = concatAdapter.getWrappedAdapterAndPosition(5)
1193         assertThat(result5.first).isEqualTo(adapter1)
1194         assertThat(result5.second).isEqualTo(5)
1195         val result10 = concatAdapter.getWrappedAdapterAndPosition(10)
1196         assertThat(result10.first).isEqualTo(adapter2)
1197         assertThat(result10.second).isEqualTo(0)
1198         val result15 = concatAdapter.getWrappedAdapterAndPosition(15)
1199         assertThat(result15.first).isEqualTo(adapter2)
1200         assertThat(result15.second).isEqualTo(5)
1201         try {
1202             val result20 = concatAdapter.getWrappedAdapterAndPosition(20)
1203             fail("Should throw exception on invalid position, instead got $result20")
1204         } catch (e: IllegalArgumentException) {
1205             // Expected, pass
1206         }
1207     }
1208 
1209     private var itemCounter = 0
1210 
1211     private fun produceItem(): TestItem = (itemCounter++).let { TestItem(id = it, value = it) }
1212 
1213     internal open inner class PositionAsIdsNestedTestAdapter(count: Int) :
1214         NestedTestAdapter(count) {
1215         init {
1216             setHasStableIds(true)
1217         }
1218 
1219         override fun getItemId(position: Int): Long {
1220             return position.toLong()
1221         }
1222     }
1223 
1224     internal open inner class UniqueItemIdsNestedTestAdapter(count: Int) :
1225         NestedTestAdapter(count) {
1226         init {
1227             setHasStableIds(true)
1228         }
1229 
1230         override fun getItemId(position: Int): Long {
1231             return items[position].id.toLong()
1232         }
1233 
1234         fun itemIds() = items.map { it.id }
1235     }
1236 
1237     internal open inner class NestedTestAdapter(
1238         count: Int = 0,
1239         val getLayoutParams: ((ConcatAdapterViewHolder) -> LayoutParams)? = null,
1240         val itemTypeLookup: ((TestItem, position: Int) -> Int)? = null
1241     ) : RecyclerView.Adapter<ConcatAdapterViewHolder>() {
1242         private val attachedViewHolders = mutableListOf<ConcatAdapterViewHolder>()
1243         private val recycledViewHolderEvents = mutableListOf<RecycledViewHolderEvent>()
1244         private val failedToRecycleEvents = mutableListOf<RecycledViewHolderEvent>()
1245         private var attachedRecyclerViews = mutableListOf<RecyclerView>()
1246         private var observers = mutableListOf<RecyclerView.AdapterDataObserver>()
1247 
1248         val items =
1249             mutableListOf<TestItem>().also { list -> repeat(count) { list.add(produceItem()) } }
1250 
1251         fun attachedViewHolders(): List<ConcatAdapterViewHolder> = attachedViewHolders
1252 
1253         override fun onViewAttachedToWindow(holder: ConcatAdapterViewHolder) {
1254             assertThat(attachedViewHolders).doesNotContain(holder)
1255             attachedViewHolders.add(holder)
1256         }
1257 
1258         override fun onViewDetachedFromWindow(holder: ConcatAdapterViewHolder) {
1259             assertThat(attachedViewHolders).contains(holder)
1260             holder.onDetached()
1261             attachedViewHolders.remove(holder)
1262         }
1263 
1264         override fun registerAdapterDataObserver(observer: RecyclerView.AdapterDataObserver) {
1265             assertThat(observers).doesNotContain(observer)
1266             observers.add(observer)
1267             super.registerAdapterDataObserver(observer)
1268         }
1269 
1270         override fun unregisterAdapterDataObserver(observer: RecyclerView.AdapterDataObserver) {
1271             assertThat(observers).contains(observer)
1272             observers.remove(observer)
1273             super.unregisterAdapterDataObserver(observer)
1274         }
1275 
1276         fun observerCount() = observers.size
1277 
1278         override fun getItemViewType(position: Int): Int {
1279             itemTypeLookup?.let {
1280                 return it(items[position], position)
1281             }
1282             return super.getItemViewType(position)
1283         }
1284 
1285         override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
1286             assertThat(attachedRecyclerViews).doesNotContain(recyclerView)
1287             attachedRecyclerViews.add(recyclerView)
1288         }
1289 
1290         override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
1291             assertThat(attachedRecyclerViews).contains(recyclerView)
1292             attachedRecyclerViews.remove(recyclerView)
1293         }
1294 
1295         fun attachedRecyclerViews(): List<RecyclerView> = attachedRecyclerViews
1296 
1297         fun addItems(positionStart: Int, itemCount: Int = 1) {
1298             require(itemCount > 0)
1299             require(positionStart >= 0 && positionStart <= items.size)
1300             val newItems = (0 until itemCount).map { produceItem() }
1301             items.addAll(positionStart, newItems)
1302             notifyItemRangeInserted(positionStart, itemCount)
1303         }
1304 
1305         fun removeItems(positionStart: Int, itemCount: Int = 1) {
1306             require(positionStart >= 0)
1307             require(positionStart + itemCount <= items.size)
1308             require(itemCount > 0)
1309             repeat(itemCount) { items.removeAt(positionStart) }
1310             notifyItemRangeRemoved(positionStart, itemCount)
1311         }
1312 
1313         fun moveItem(fromPosition: Int, toPosition: Int) {
1314             require(fromPosition >= 0 && fromPosition < items.size)
1315             require(toPosition >= 0 && toPosition < items.size)
1316             if (fromPosition == toPosition) return
1317             items.add(toPosition, items.removeAt(fromPosition))
1318             notifyItemMoved(fromPosition, toPosition)
1319         }
1320 
1321         fun changeDataSet(newSize: Int = items.size) {
1322             require(newSize >= 0)
1323             val newItems = (0 until newSize).map { produceItem() }
1324             items.clear()
1325             items.addAll(newItems)
1326             notifyDataSetChanged()
1327         }
1328 
1329         fun changeItems(positionStart: Int, itemCount: Int, payload: Any? = null) {
1330             require(positionStart >= 0 && positionStart < items.size)
1331             require(positionStart + itemCount <= items.size)
1332             (positionStart until positionStart + itemCount).forEach {
1333                 val prev = items[it]
1334                 items[it] = prev.copy(value = prev.value + 1)
1335             }
1336             if (payload == null) {
1337                 notifyItemRangeChanged(positionStart, itemCount)
1338             } else {
1339                 notifyItemRangeChanged(positionStart, itemCount, payload)
1340             }
1341         }
1342 
1343         override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConcatAdapterViewHolder {
1344             return ConcatAdapterViewHolder(parent.context, viewType).also { holder ->
1345                 getLayoutParams?.invoke(holder)?.let { holder.itemView.layoutParams = it }
1346             }
1347         }
1348 
1349         override fun onBindViewHolder(holder: ConcatAdapterViewHolder, position: Int) {
1350             assertThat(getItemViewType(position)).isEqualTo(holder.localViewType)
1351             holder.bindTo(this, items[position], position)
1352         }
1353 
1354         override fun onViewRecycled(holder: ConcatAdapterViewHolder) {
1355             recycledViewHolderEvents.add(RecycledViewHolderEvent(holder))
1356             holder.onRecycled()
1357         }
1358 
1359         override fun getItemCount() = items.size
1360 
1361         override fun onFailedToRecycleView(holder: ConcatAdapterViewHolder): Boolean {
1362             failedToRecycleEvents.add(RecycledViewHolderEvent(holder))
1363             return super.onFailedToRecycleView(holder)
1364         }
1365 
1366         fun getItemAt(localPosition: Int) = items[localPosition]
1367 
1368         fun recycleEvents(): List<RecycledViewHolderEvent> = recycledViewHolderEvents
1369 
1370         fun failedToRecycleEvents(): List<RecycledViewHolderEvent> = failedToRecycleEvents
1371     }
1372 
1373     internal class ConcatAdapterViewHolder(context: Context, val localViewType: Int) :
1374         RecyclerView.ViewHolder(View(context)) {
1375         private var boundItem: TestItem? = null
1376         private var boundAdapter: RecyclerView.Adapter<*>? = null
1377         private var boundPosition: Int? = null
1378 
1379         fun bindTo(adapter: RecyclerView.Adapter<*>, item: TestItem, position: Int) {
1380             boundAdapter = adapter
1381             boundPosition = position
1382             boundItem = item
1383         }
1384 
1385         fun boundItem() = boundItem
1386 
1387         fun boundLocalPosition() = boundPosition
1388 
1389         fun boundAdapter() = boundAdapter
1390 
1391         fun onDetached() {
1392             assertPosition()
1393         }
1394 
1395         fun onRecycled() {
1396             assertPosition()
1397             boundItem = null
1398             boundPosition = -1
1399             boundAdapter = null
1400         }
1401 
1402         private fun assertPosition() {
1403             val shouldHavePosition =
1404                 !isRemoved() && isBound() && !isAdapterPositionUnknown() && !isInvalid()
1405             assertWithMessage("binding adapter position $this")
1406                 .that(shouldHavePosition)
1407                 .isEqualTo(bindingAdapterPosition != NO_POSITION)
1408             assertWithMessage("binding adapter position $this")
1409                 .that(shouldHavePosition)
1410                 .isEqualTo(absoluteAdapterPosition != NO_POSITION)
1411         }
1412     }
1413 
1414     class LoggingAdapterObserver(private val src: RecyclerView.Adapter<*>) :
1415         RecyclerView.AdapterDataObserver() {
1416         init {
1417             src.registerAdapterDataObserver(this)
1418         }
1419 
1420         private val events = mutableListOf<Event>()
1421 
1422         fun assertEventsAndClear(message: String, vararg expected: Event) {
1423             assertWithMessage(message).that(events).isEqualTo(expected.toList())
1424             events.clear()
1425         }
1426 
1427         override fun onChanged() {
1428             events.add(DataSetChanged)
1429         }
1430 
1431         override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
1432             events.add(Changed(positionStart = positionStart, itemCount = itemCount))
1433         }
1434 
1435         override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
1436             events.add(
1437                 Changed(positionStart = positionStart, itemCount = itemCount, payload = payload)
1438             )
1439         }
1440 
1441         override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
1442             events.add(Inserted(positionStart = positionStart, itemCount = itemCount))
1443         }
1444 
1445         override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
1446             events.add(Removed(positionStart = positionStart, itemCount = itemCount))
1447         }
1448 
1449         override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
1450             require(itemCount == 1) { "RV does not support moving more than 1 item at a time" }
1451             events.add(Moved(fromPosition = fromPosition, toPosition = toPosition))
1452         }
1453 
1454         override fun onStateRestorationPolicyChanged() {
1455             events.add(StateRestorationPolicy(newValue = src.stateRestorationPolicy))
1456         }
1457 
1458         sealed class Event {
1459             object DataSetChanged : Event()
1460 
1461             data class Changed(
1462                 val positionStart: Int,
1463                 val itemCount: Int,
1464                 val payload: Any? = null
1465             ) : Event()
1466 
1467             data class Inserted(val positionStart: Int, val itemCount: Int) : Event()
1468 
1469             data class Removed(val positionStart: Int, val itemCount: Int) : Event()
1470 
1471             data class Moved(val fromPosition: Int, val toPosition: Int) : Event()
1472 
1473             data class StateRestorationPolicy(
1474                 val newValue: RecyclerView.Adapter.StateRestorationPolicy
1475             ) : Event()
1476         }
1477     }
1478 
1479     internal data class TestItem(val id: Int, val value: Int, val viewType: Int = 0)
1480 
1481     internal data class RecycledViewHolderEvent(
1482         val itemId: Int?,
1483         val absoluteAdapterPosition: Int,
1484         val bindingAdapterPosition: Int,
1485     ) {
1486         constructor(
1487             viewHolder: ConcatAdapterViewHolder
1488         ) : this(
1489             itemId = viewHolder.boundItem()?.id,
1490             absoluteAdapterPosition = viewHolder.absoluteAdapterPosition,
1491             bindingAdapterPosition = viewHolder.bindingAdapterPosition
1492         )
1493     }
1494 }
1495