1 /*
2  * Copyright (C) 2014 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 android.support.v7.widget;
18 
19 import android.graphics.Rect;
20 import android.os.Looper;
21 import android.support.v4.view.ViewCompat;
22 import android.test.ActivityInstrumentationTestCase2;
23 import android.util.Log;
24 import android.view.View;
25 import android.view.ViewGroup;
26 import android.widget.FrameLayout;
27 import android.widget.TextView;
28 
29 import java.util.ArrayList;
30 import java.util.HashSet;
31 import java.util.List;
32 import java.util.Set;
33 import java.util.concurrent.CountDownLatch;
34 import java.util.concurrent.TimeUnit;
35 import java.util.concurrent.atomic.AtomicInteger;
36 
37 abstract public class BaseRecyclerViewInstrumentationTest extends
38         ActivityInstrumentationTestCase2<TestActivity> {
39 
40     private static final String TAG = "RecyclerViewTest";
41 
42     private boolean mDebug;
43 
44     protected RecyclerView mRecyclerView;
45 
46     protected AdapterHelper mAdapterHelper;
47 
48     Throwable mainThreadException;
49 
BaseRecyclerViewInstrumentationTest()50     public BaseRecyclerViewInstrumentationTest() {
51         this(false);
52     }
53 
BaseRecyclerViewInstrumentationTest(boolean debug)54     public BaseRecyclerViewInstrumentationTest(boolean debug) {
55         super("android.support.v7.recyclerview", TestActivity.class);
56         mDebug = debug;
57     }
58 
checkForMainThreadException()59     void checkForMainThreadException() throws Throwable {
60         if (mainThreadException != null) {
61             throw mainThreadException;
62         }
63     }
64 
setHasTransientState(final View view, final boolean value)65     void setHasTransientState(final View view, final boolean value) {
66         try {
67             runTestOnUiThread(new Runnable() {
68                 @Override
69                 public void run() {
70                     ViewCompat.setHasTransientState(view, value);
71                 }
72             });
73         } catch (Throwable throwable) {
74             Log.e(TAG, "", throwable);
75         }
76     }
77 
setAdapter(final RecyclerView.Adapter adapter)78     void setAdapter(final RecyclerView.Adapter adapter) throws Throwable {
79         runTestOnUiThread(new Runnable() {
80             @Override
81             public void run() {
82                 mRecyclerView.setAdapter(adapter);
83             }
84         });
85     }
86 
swapAdapter(final RecyclerView.Adapter adapter, final boolean removeAndRecycleExistingViews)87     void swapAdapter(final RecyclerView.Adapter adapter,
88             final boolean removeAndRecycleExistingViews) throws Throwable {
89         runTestOnUiThread(new Runnable() {
90             @Override
91             public void run() {
92                 try {
93                     mRecyclerView.swapAdapter(adapter, removeAndRecycleExistingViews);
94                 } catch (Throwable t) {
95                     postExceptionToInstrumentation(t);
96                 }
97             }
98         });
99         checkForMainThreadException();
100     }
101 
postExceptionToInstrumentation(Throwable t)102     void postExceptionToInstrumentation(Throwable t) {
103         if (mainThreadException != null) {
104             Log.e(TAG, "receiving another main thread exception. dropping.", t);
105         } else {
106             Log.e(TAG, "captured exception on main thread", t);
107             mainThreadException = t;
108         }
109 
110         if (mRecyclerView != null && mRecyclerView
111                 .getLayoutManager() instanceof TestLayoutManager) {
112             TestLayoutManager lm = (TestLayoutManager) mRecyclerView.getLayoutManager();
113             // finish all layouts so that we get the correct exception
114             while (lm.layoutLatch.getCount() > 0) {
115                 lm.layoutLatch.countDown();
116             }
117         }
118     }
119 
120     @Override
tearDown()121     protected void tearDown() throws Exception {
122         if (mRecyclerView != null) {
123             try {
124                 removeRecyclerView();
125             } catch (Throwable throwable) {
126                 throwable.printStackTrace();
127             }
128         }
129         getInstrumentation().waitForIdleSync();
130         super.tearDown();
131 
132         try {
133             checkForMainThreadException();
134         } catch (Exception e) {
135             throw e;
136         } catch (Throwable throwable) {
137             throw new Exception(throwable);
138         }
139     }
140 
getDecoratedRecyclerViewBounds()141     public Rect getDecoratedRecyclerViewBounds() {
142         return new Rect(
143                 mRecyclerView.getPaddingLeft(),
144                 mRecyclerView.getPaddingTop(),
145                 mRecyclerView.getPaddingLeft() + mRecyclerView.getWidth(),
146                 mRecyclerView.getPaddingTop() + mRecyclerView.getHeight()
147         );
148     }
149 
removeRecyclerView()150     public void removeRecyclerView() throws Throwable {
151         if (mRecyclerView == null) {
152             return;
153         }
154         if (!isMainThread()) {
155             getInstrumentation().waitForIdleSync();
156         }
157         runTestOnUiThread(new Runnable() {
158             @Override
159             public void run() {
160                 try {
161                     final RecyclerView.Adapter adapter = mRecyclerView.getAdapter();
162                     if (adapter instanceof AttachDetachCountingAdapter) {
163                         ((AttachDetachCountingAdapter) adapter).getCounter()
164                                 .validateRemaining(mRecyclerView);
165                     }
166                     getActivity().mContainer.removeAllViews();
167                 } catch (Throwable t) {
168                     postExceptionToInstrumentation(t);
169                 }
170             }
171         });
172         mRecyclerView = null;
173     }
174 
waitForAnimations(int seconds)175     void waitForAnimations(int seconds) throws InterruptedException {
176         final CountDownLatch latch = new CountDownLatch(2);
177         boolean running = mRecyclerView.mItemAnimator
178                 .isRunning(new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() {
179                     @Override
180                     public void onAnimationsFinished() {
181                         latch.countDown();
182                     }
183                 });
184         if (running) {
185             latch.await(seconds, TimeUnit.SECONDS);
186         }
187     }
188 
setRecyclerView(final RecyclerView recyclerView)189     public void setRecyclerView(final RecyclerView recyclerView) throws Throwable {
190         setRecyclerView(recyclerView, true);
191     }
setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool)192     public void setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool)
193             throws Throwable {
194         setRecyclerView(recyclerView, true, true);
195     }
setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool, boolean addPositionCheckItemAnimator)196     public void setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool,
197             boolean addPositionCheckItemAnimator)
198             throws Throwable {
199         mRecyclerView = recyclerView;
200         if (assignDummyPool) {
201             RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool() {
202                 @Override
203                 public RecyclerView.ViewHolder getRecycledView(int viewType) {
204                     RecyclerView.ViewHolder viewHolder = super.getRecycledView(viewType);
205                     if (viewHolder == null) {
206                         return null;
207                     }
208                     viewHolder.addFlags(RecyclerView.ViewHolder.FLAG_BOUND);
209                     viewHolder.mPosition = 200;
210                     viewHolder.mOldPosition = 300;
211                     viewHolder.mPreLayoutPosition = 500;
212                     return viewHolder;
213                 }
214 
215                 @Override
216                 public void putRecycledView(RecyclerView.ViewHolder scrap) {
217                     super.putRecycledView(scrap);
218                 }
219             };
220             mRecyclerView.setRecycledViewPool(pool);
221         }
222         if (addPositionCheckItemAnimator) {
223             mRecyclerView.addItemDecoration(new RecyclerView.ItemDecoration() {
224                 @Override
225                 public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
226                         RecyclerView.State state) {
227                     RecyclerView.ViewHolder vh = parent.getChildViewHolder(view);
228                     if (!vh.isRemoved()) {
229                         assertNotSame("If getItemOffsets is called, child should have a valid"
230                                             + " adapter position unless it is removed",
231                                     vh.getAdapterPosition(), RecyclerView.NO_POSITION);
232                     }
233                 }
234             });
235         }
236         mAdapterHelper = recyclerView.mAdapterHelper;
237         runTestOnUiThread(new Runnable() {
238             @Override
239             public void run() {
240                 getActivity().mContainer.addView(recyclerView);
241             }
242         });
243     }
244 
getRecyclerViewContainer()245     protected FrameLayout getRecyclerViewContainer() {
246         return getActivity().mContainer;
247     }
248 
requestLayoutOnUIThread(final View view)249     public void requestLayoutOnUIThread(final View view) {
250         try {
251             runTestOnUiThread(new Runnable() {
252                 @Override
253                 public void run() {
254                     view.requestLayout();
255                 }
256             });
257         } catch (Throwable throwable) {
258             Log.e(TAG, "", throwable);
259         }
260     }
261 
scrollBy(final int dt)262     public void scrollBy(final int dt) {
263         try {
264             runTestOnUiThread(new Runnable() {
265                 @Override
266                 public void run() {
267                     if (mRecyclerView.getLayoutManager().canScrollHorizontally()) {
268                         mRecyclerView.scrollBy(dt, 0);
269                     } else {
270                         mRecyclerView.scrollBy(0, dt);
271                     }
272 
273                 }
274             });
275         } catch (Throwable throwable) {
276             Log.e(TAG, "", throwable);
277         }
278     }
279 
scrollToPosition(final int position)280     void scrollToPosition(final int position) throws Throwable {
281         runTestOnUiThread(new Runnable() {
282             @Override
283             public void run() {
284                 mRecyclerView.getLayoutManager().scrollToPosition(position);
285             }
286         });
287     }
288 
smoothScrollToPosition(final int position)289     void smoothScrollToPosition(final int position)
290             throws Throwable {
291         Log.d(TAG, "SMOOTH scrolling to " + position);
292         runTestOnUiThread(new Runnable() {
293             @Override
294             public void run() {
295                 mRecyclerView.smoothScrollToPosition(position);
296             }
297         });
298         while (mRecyclerView.getLayoutManager().isSmoothScrolling() ||
299                 mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) {
300             if (mDebug) {
301                 Log.d(TAG, "SMOOTH scrolling step");
302             }
303             Thread.sleep(200);
304         }
305         Log.d(TAG, "SMOOTH scrolling done");
306         getInstrumentation().waitForIdleSync();
307     }
308 
309     class TestViewHolder extends RecyclerView.ViewHolder {
310 
311         Item mBoundItem;
312 
TestViewHolder(View itemView)313         public TestViewHolder(View itemView) {
314             super(itemView);
315             itemView.setFocusable(true);
316         }
317 
318         @Override
toString()319         public String toString() {
320             return super.toString() + " item:" + mBoundItem;
321         }
322     }
323 
324     class TestLayoutManager extends RecyclerView.LayoutManager {
325 
326         CountDownLatch layoutLatch;
327 
expectLayouts(int count)328         public void expectLayouts(int count) {
329             layoutLatch = new CountDownLatch(count);
330         }
331 
waitForLayout(long timeout, TimeUnit timeUnit)332         public void waitForLayout(long timeout, TimeUnit timeUnit) throws Throwable {
333             layoutLatch.await(timeout * (mDebug ? 100 : 1), timeUnit);
334             assertEquals("all expected layouts should be executed at the expected time",
335                     0, layoutLatch.getCount());
336             getInstrumentation().waitForIdleSync();
337         }
338 
assertLayoutCount(int count, String msg, long timeout)339         public void assertLayoutCount(int count, String msg, long timeout) throws Throwable {
340             layoutLatch.await(timeout, TimeUnit.SECONDS);
341             assertEquals(msg, count, layoutLatch.getCount());
342         }
343 
assertNoLayout(String msg, long timeout)344         public void assertNoLayout(String msg, long timeout) throws Throwable {
345             layoutLatch.await(timeout, TimeUnit.SECONDS);
346             assertFalse(msg, layoutLatch.getCount() == 0);
347         }
348 
waitForLayout(long timeout)349         public void waitForLayout(long timeout) throws Throwable {
350             waitForLayout(timeout * (mDebug ? 10000 : 1), TimeUnit.SECONDS);
351         }
352 
353         @Override
generateDefaultLayoutParams()354         public RecyclerView.LayoutParams generateDefaultLayoutParams() {
355             return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
356                     ViewGroup.LayoutParams.WRAP_CONTENT);
357         }
358 
assertVisibleItemPositions()359         void assertVisibleItemPositions() {
360             int i = getChildCount();
361             TestAdapter testAdapter = (TestAdapter) mRecyclerView.getAdapter();
362             while (i-- > 0) {
363                 View view = getChildAt(i);
364                 RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) view.getLayoutParams();
365                 Item item = ((TestViewHolder) lp.mViewHolder).mBoundItem;
366                 if (mDebug) {
367                     Log.d(TAG, "testing item " + i);
368                 }
369                 if (!lp.isItemRemoved()) {
370                     RecyclerView.ViewHolder vh = mRecyclerView.getChildViewHolder(view);
371                     assertSame("item position in LP should match adapter value :" + vh,
372                             testAdapter.mItems.get(vh.mPosition), item);
373                 }
374             }
375         }
376 
getLp(View v)377         RecyclerView.LayoutParams getLp(View v) {
378             return (RecyclerView.LayoutParams) v.getLayoutParams();
379         }
380 
layoutRange(RecyclerView.Recycler recycler, int start, int end)381         void layoutRange(RecyclerView.Recycler recycler, int start, int end) {
382             assertScrap(recycler);
383             if (mDebug) {
384                 Log.d(TAG, "will layout items from " + start + " to " + end);
385             }
386             int diff = end > start ? 1 : -1;
387             int top = 0;
388             for (int i = start; i != end; i+=diff) {
389                 if (mDebug) {
390                     Log.d(TAG, "laying out item " + i);
391                 }
392                 View view = recycler.getViewForPosition(i);
393                 assertNotNull("view should not be null for valid position. "
394                         + "got null view at position " + i, view);
395                 if (!mRecyclerView.mState.isPreLayout()) {
396                     RecyclerView.LayoutParams layoutParams = (RecyclerView.LayoutParams) view
397                             .getLayoutParams();
398                     assertFalse("In post layout, getViewForPosition should never return a view "
399                             + "that is removed", layoutParams != null
400                             && layoutParams.isItemRemoved());
401 
402                 }
403                 assertEquals("getViewForPosition should return correct position",
404                         i, getPosition(view));
405                 addView(view);
406 
407                 measureChildWithMargins(view, 0, 0);
408                 layoutDecorated(view, 0, top, getDecoratedMeasuredWidth(view)
409                         , top + getDecoratedMeasuredHeight(view));
410                 top += view.getMeasuredHeight();
411             }
412         }
413 
assertScrap(RecyclerView.Recycler recycler)414         private void assertScrap(RecyclerView.Recycler recycler) {
415             if (mRecyclerView.getAdapter() != null &&
416                     !mRecyclerView.getAdapter().hasStableIds()) {
417                 for (RecyclerView.ViewHolder viewHolder : recycler.getScrapList()) {
418                     assertFalse("Invalid scrap should be no kept", viewHolder.isInvalid());
419                 }
420             }
421         }
422 
423         @Override
canScrollHorizontally()424         public boolean canScrollHorizontally() {
425             return true;
426         }
427 
428         @Override
canScrollVertically()429         public boolean canScrollVertically() {
430             return true;
431         }
432 
433         @Override
scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state)434         public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler,
435                 RecyclerView.State state) {
436             return dx;
437         }
438 
439         @Override
scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state)440         public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
441                 RecyclerView.State state) {
442             return dy;
443         }
444     }
445 
446     static class Item {
447         final static AtomicInteger idCounter = new AtomicInteger(0);
448         final public int mId = idCounter.incrementAndGet();
449 
450         int mAdapterIndex;
451 
452         final String mText;
453 
Item(int adapterIndex, String text)454         Item(int adapterIndex, String text) {
455             mAdapterIndex = adapterIndex;
456             mText = text;
457         }
458 
459         @Override
toString()460         public String toString() {
461             return "Item{" +
462                     "mId=" + mId +
463                     ", originalIndex=" + mAdapterIndex +
464                     ", text='" + mText + '\'' +
465                     '}';
466         }
467     }
468 
469     class TestAdapter extends RecyclerView.Adapter<TestViewHolder>
470             implements AttachDetachCountingAdapter {
471 
472         ViewAttachDetachCounter mAttachmentCounter = new ViewAttachDetachCounter();
473         List<Item> mItems;
474 
TestAdapter(int count)475         TestAdapter(int count) {
476             mItems = new ArrayList<Item>(count);
477             for (int i = 0; i < count; i++) {
478                 mItems.add(new Item(i, "Item " + i));
479             }
480         }
481 
482         @Override
onViewAttachedToWindow(TestViewHolder holder)483         public void onViewAttachedToWindow(TestViewHolder holder) {
484             super.onViewAttachedToWindow(holder);
485             mAttachmentCounter.onViewAttached(holder);
486         }
487 
488         @Override
onViewDetachedFromWindow(TestViewHolder holder)489         public void onViewDetachedFromWindow(TestViewHolder holder) {
490             super.onViewDetachedFromWindow(holder);
491             mAttachmentCounter.onViewDetached(holder);
492         }
493 
494         @Override
onAttachedToRecyclerView(RecyclerView recyclerView)495         public void onAttachedToRecyclerView(RecyclerView recyclerView) {
496             super.onAttachedToRecyclerView(recyclerView);
497             mAttachmentCounter.onAttached(recyclerView);
498         }
499 
500         @Override
onDetachedFromRecyclerView(RecyclerView recyclerView)501         public void onDetachedFromRecyclerView(RecyclerView recyclerView) {
502             super.onDetachedFromRecyclerView(recyclerView);
503             mAttachmentCounter.onDetached(recyclerView);
504         }
505 
506         @Override
onCreateViewHolder(ViewGroup parent, int viewType)507         public TestViewHolder onCreateViewHolder(ViewGroup parent,
508                 int viewType) {
509             return new TestViewHolder(new TextView(parent.getContext()));
510         }
511 
512         @Override
onBindViewHolder(TestViewHolder holder, int position)513         public void onBindViewHolder(TestViewHolder holder, int position) {
514             final Item item = mItems.get(position);
515             ((TextView) (holder.itemView)).setText(item.mText + "(" + item.mAdapterIndex + ")");
516             holder.mBoundItem = item;
517         }
518 
deleteAndNotify(final int start, final int count)519         public void deleteAndNotify(final int start, final int count) throws Throwable {
520             deleteAndNotify(new int[]{start, count});
521         }
522 
523         /**
524          * Deletes items in the given ranges.
525          * <p>
526          * Note that each operation affects the one after so you should offset them properly.
527          * <p>
528          * For example, if adapter has 5 items (A,B,C,D,E), and then you call this method with
529          * <code>[1, 2],[2, 1]</code>, it will first delete items B,C and the new adapter will be
530          * A D E. Then it will delete 2,1 which means it will delete E.
531          */
deleteAndNotify(final int[]... startCountTuples)532         public void deleteAndNotify(final int[]... startCountTuples) throws Throwable {
533             for (int[] tuple : startCountTuples) {
534                 tuple[1] = -tuple[1];
535             }
536             new AddRemoveRunnable(startCountTuples).runOnMainThread();
537         }
538 
539         @Override
getItemId(int position)540         public long getItemId(int position) {
541             return hasStableIds() ? mItems.get(position).mId : super.getItemId(position);
542         }
543 
offsetOriginalIndices(int start, int offset)544         public void offsetOriginalIndices(int start, int offset) {
545             for (int i = start; i < mItems.size(); i++) {
546                 mItems.get(i).mAdapterIndex += offset;
547             }
548         }
549 
550         /**
551          * @param start inclusive
552          * @param end exclusive
553          * @param offset
554          */
offsetOriginalIndicesBetween(int start, int end, int offset)555         public void offsetOriginalIndicesBetween(int start, int end, int offset) {
556             for (int i = start; i < end && i < mItems.size(); i++) {
557                 mItems.get(i).mAdapterIndex += offset;
558             }
559         }
560 
addAndNotify(final int start, final int count)561         public void addAndNotify(final int start, final int count) throws Throwable {
562             addAndNotify(new int[]{start, count});
563         }
564 
addAndNotify(final int[]... startCountTuples)565         public void addAndNotify(final int[]... startCountTuples) throws Throwable {
566             new AddRemoveRunnable(startCountTuples).runOnMainThread();
567         }
568 
dispatchDataSetChanged()569         public void dispatchDataSetChanged() throws Throwable {
570             runTestOnUiThread(new Runnable() {
571                 @Override
572                 public void run() {
573                     notifyDataSetChanged();
574                 }
575             });
576         }
577 
changeAndNotify(final int start, final int count)578         public void changeAndNotify(final int start, final int count) throws Throwable {
579             runTestOnUiThread(new Runnable() {
580                 @Override
581                 public void run() {
582                     notifyItemRangeChanged(start, count);
583                 }
584             });
585         }
586 
changePositionsAndNotify(final int... positions)587         public void changePositionsAndNotify(final int... positions) throws Throwable {
588             runTestOnUiThread(new Runnable() {
589                 @Override
590                 public void run() {
591                     for (int i = 0; i < positions.length; i += 1) {
592                         TestAdapter.super.notifyItemRangeChanged(positions[i], 1);
593                     }
594                 }
595             });
596         }
597 
598         /**
599          * Similar to other methods but negative count means delete and position count means add.
600          * <p>
601          * For instance, calling this method with <code>[1,1], [2,-1]</code> it will first add an
602          * item to index 1, then remove an item from index 2 (updated index 2)
603          */
addDeleteAndNotify(final int[]... startCountTuples)604         public void addDeleteAndNotify(final int[]... startCountTuples) throws Throwable {
605             new AddRemoveRunnable(startCountTuples).runOnMainThread();
606         }
607 
608         @Override
getItemCount()609         public int getItemCount() {
610             return mItems.size();
611         }
612 
613         /**
614          * Uses notifyDataSetChanged
615          */
moveItems(boolean notifyChange, int[]... fromToTuples)616         public void moveItems(boolean notifyChange, int[]... fromToTuples) throws Throwable {
617             for (int i = 0; i < fromToTuples.length; i += 1) {
618                 int[] tuple = fromToTuples[i];
619                 moveItem(tuple[0], tuple[1], false);
620             }
621             if (notifyChange) {
622                 dispatchDataSetChanged();
623             }
624         }
625 
626         /**
627          * Uses notifyDataSetChanged
628          */
moveItem(final int from, final int to, final boolean notifyChange)629         public void moveItem(final int from, final int to, final boolean notifyChange)
630                 throws Throwable {
631             runTestOnUiThread(new Runnable() {
632                 @Override
633                 public void run() {
634                     Item item = mItems.remove(from);
635                     mItems.add(to, item);
636                     offsetOriginalIndices(from, to - 1);
637                     item.mAdapterIndex = to;
638                     if (notifyChange) {
639                         notifyDataSetChanged();
640                     }
641                 }
642             });
643         }
644 
645         /**
646          * Uses notifyItemMoved
647          */
moveAndNotify(final int from, final int to)648         public void moveAndNotify(final int from, final int to) throws Throwable {
649             runTestOnUiThread(new Runnable() {
650                 @Override
651                 public void run() {
652                     Item item = mItems.remove(from);
653                     mItems.add(to, item);
654                     offsetOriginalIndices(from, to - 1);
655                     item.mAdapterIndex = to;
656                     notifyItemMoved(from, to);
657                 }
658             });
659         }
660 
661 
662 
663         @Override
getCounter()664         public ViewAttachDetachCounter getCounter() {
665             return mAttachmentCounter;
666         }
667 
668 
669         private class AddRemoveRunnable implements Runnable {
670             final int[][] mStartCountTuples;
671 
AddRemoveRunnable(int[][] startCountTuples)672             public AddRemoveRunnable(int[][] startCountTuples) {
673                 mStartCountTuples = startCountTuples;
674             }
675 
runOnMainThread()676             public void runOnMainThread() throws Throwable {
677                 if (Looper.myLooper() == Looper.getMainLooper()) {
678                     run();
679                 } else {
680                     runTestOnUiThread(this);
681                 }
682             }
683 
684             @Override
run()685             public void run() {
686                 for (int[] tuple : mStartCountTuples) {
687                     if (tuple[1] < 0) {
688                         delete(tuple);
689                     } else {
690                         add(tuple);
691                     }
692                 }
693             }
694 
add(int[] tuple)695             private void add(int[] tuple) {
696                 // offset others
697                 offsetOriginalIndices(tuple[0], tuple[1]);
698                 for (int i = 0; i < tuple[1]; i++) {
699                     mItems.add(tuple[0], new Item(i, "new item " + i));
700                 }
701                 notifyItemRangeInserted(tuple[0], tuple[1]);
702             }
703 
delete(int[] tuple)704             private void delete(int[] tuple) {
705                 final int count = -tuple[1];
706                 offsetOriginalIndices(tuple[0] + count, tuple[1]);
707                 for (int i = 0; i < count; i++) {
708                     mItems.remove(tuple[0]);
709                 }
710                 notifyItemRangeRemoved(tuple[0], count);
711             }
712         }
713     }
714 
isMainThread()715     public boolean isMainThread() {
716         return Looper.myLooper() == Looper.getMainLooper();
717     }
718 
719     @Override
runTestOnUiThread(Runnable r)720     public void runTestOnUiThread(Runnable r) throws Throwable {
721         if (Looper.myLooper() == Looper.getMainLooper()) {
722             r.run();
723         } else {
724             super.runTestOnUiThread(r);
725         }
726     }
727 
728     static class TargetTuple {
729 
730         final int mPosition;
731 
732         final int mLayoutDirection;
733 
TargetTuple(int position, int layoutDirection)734         TargetTuple(int position, int layoutDirection) {
735             this.mPosition = position;
736             this.mLayoutDirection = layoutDirection;
737         }
738 
739         @Override
toString()740         public String toString() {
741             return "TargetTuple{" +
742                     "mPosition=" + mPosition +
743                     ", mLayoutDirection=" + mLayoutDirection +
744                     '}';
745         }
746     }
747 
748     public interface AttachDetachCountingAdapter {
749 
getCounter()750         ViewAttachDetachCounter getCounter();
751     }
752 
753     public class ViewAttachDetachCounter {
754 
755         Set<RecyclerView.ViewHolder> mAttachedSet = new HashSet<RecyclerView.ViewHolder>();
756 
validateRemaining(RecyclerView recyclerView)757         public void validateRemaining(RecyclerView recyclerView) {
758             final int childCount = recyclerView.getChildCount();
759             for (int i = 0; i < childCount; i++) {
760                 View view = recyclerView.getChildAt(i);
761                 RecyclerView.ViewHolder vh = recyclerView.getChildViewHolder(view);
762                 assertTrue("remaining view should be in attached set " + vh,
763                         mAttachedSet.contains(vh));
764             }
765             assertEquals("there should not be any views left in attached set",
766                     childCount, mAttachedSet.size());
767         }
768 
onViewDetached(RecyclerView.ViewHolder viewHolder)769         public void onViewDetached(RecyclerView.ViewHolder viewHolder) {
770             try {
771                 assertTrue("view holder should be in attached set",
772                         mAttachedSet.remove(viewHolder));
773             } catch (Throwable t) {
774                 postExceptionToInstrumentation(t);
775             }
776         }
777 
onViewAttached(RecyclerView.ViewHolder viewHolder)778         public void onViewAttached(RecyclerView.ViewHolder viewHolder) {
779             try {
780                 assertTrue("view holder should not be in attached set",
781                         mAttachedSet.add(viewHolder));
782             } catch (Throwable t) {
783                 postExceptionToInstrumentation(t);
784             }
785         }
786 
onAttached(RecyclerView recyclerView)787         public void onAttached(RecyclerView recyclerView) {
788             // when a new RV is attached, clear the set and add all view holders
789             mAttachedSet.clear();
790             final int childCount = recyclerView.getChildCount();
791             for (int i = 0; i < childCount; i ++) {
792                 View view = recyclerView.getChildAt(i);
793                 mAttachedSet.add(recyclerView.getChildViewHolder(view));
794             }
795         }
796 
onDetached(RecyclerView recyclerView)797         public void onDetached(RecyclerView recyclerView) {
798             validateRemaining(recyclerView);
799         }
800     }
801 }
802