BaseRecyclerViewInstrumentationTest.java revision 9f5e74d12c89334c2bd439b1bf19fdc5ebfbc137
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
17package android.support.v7.widget;
18
19import static android.support.v7.widget.RecyclerView.SCROLL_STATE_IDLE;
20
21import org.hamcrest.CoreMatchers;
22import org.hamcrest.MatcherAssert;
23import org.junit.After;
24import org.junit.Before;
25import org.junit.Rule;
26
27import android.app.Instrumentation;
28import android.graphics.Rect;
29import android.os.Looper;
30import android.support.test.InstrumentationRegistry;
31import android.support.test.rule.ActivityTestRule;
32import android.support.v4.view.ViewCompat;
33import android.support.v7.recyclerview.test.SameActivityTestRule;
34import android.util.Log;
35import android.view.LayoutInflater;
36import android.view.View;
37import android.view.ViewGroup;
38import android.widget.FrameLayout;
39import android.widget.TextView;
40
41import java.lang.reflect.InvocationTargetException;
42import java.lang.reflect.Method;
43import java.util.ArrayList;
44import java.util.HashSet;
45import java.util.List;
46import java.util.Set;
47import java.util.concurrent.CountDownLatch;
48import java.util.concurrent.TimeUnit;
49import java.util.concurrent.atomic.AtomicBoolean;
50import java.util.concurrent.atomic.AtomicInteger;
51import java.util.concurrent.locks.ReentrantLock;
52import android.support.v7.recyclerview.test.R;
53
54import static org.junit.Assert.*;
55
56import static java.util.concurrent.TimeUnit.SECONDS;
57
58abstract public class BaseRecyclerViewInstrumentationTest {
59
60    private static final String TAG = "RecyclerViewTest";
61
62    private boolean mDebug;
63
64    protected RecyclerView mRecyclerView;
65
66    protected AdapterHelper mAdapterHelper;
67
68    private Throwable mMainThreadException;
69
70    private boolean mIgnoreMainThreadException = false;
71
72    Thread mInstrumentationThread;
73
74    @Rule
75    public ActivityTestRule<TestActivity> mActivityRule = new SameActivityTestRule() {
76        @Override
77        public boolean canReUseActivity() {
78            return BaseRecyclerViewInstrumentationTest.this.canReUseActivity();
79        }
80    };
81
82    public BaseRecyclerViewInstrumentationTest() {
83        this(false);
84    }
85
86    public BaseRecyclerViewInstrumentationTest(boolean debug) {
87        mDebug = debug;
88    }
89
90    void checkForMainThreadException() throws Throwable {
91        if (!mIgnoreMainThreadException && mMainThreadException != null) {
92            throw mMainThreadException;
93        }
94    }
95
96    public void setIgnoreMainThreadException(boolean ignoreMainThreadException) {
97        mIgnoreMainThreadException = ignoreMainThreadException;
98    }
99
100    public Throwable getMainThreadException() {
101        return mMainThreadException;
102    }
103
104    protected TestActivity getActivity() {
105        return mActivityRule.getActivity();
106    }
107
108    @Before
109    public final void setUpInsThread() throws Exception {
110        mInstrumentationThread = Thread.currentThread();
111        Item.idCounter.set(0);
112    }
113
114    void setHasTransientState(final View view, final boolean value) {
115        try {
116            runTestOnUiThread(new Runnable() {
117                @Override
118                public void run() {
119                    ViewCompat.setHasTransientState(view, value);
120                }
121            });
122        } catch (Throwable throwable) {
123            Log.e(TAG, "", throwable);
124        }
125    }
126
127    public boolean canReUseActivity() {
128        return true;
129    }
130
131    protected void enableAccessibility()
132            throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
133        Method getUIAutomation = Instrumentation.class.getMethod("getUiAutomation");
134        getUIAutomation.invoke(InstrumentationRegistry.getInstrumentation());
135    }
136
137    void setAdapter(final RecyclerView.Adapter adapter) throws Throwable {
138        runTestOnUiThread(new Runnable() {
139            @Override
140            public void run() {
141                mRecyclerView.setAdapter(adapter);
142            }
143        });
144    }
145
146    public View focusSearch(final View focused, final int direction)
147            throws Throwable {
148        final View[] result = new View[1];
149        runTestOnUiThread(new Runnable() {
150            @Override
151            public void run() {
152                View view = focused.focusSearch(direction);
153                if (view != null && view != focused) {
154                    view.requestFocus();
155                }
156                result[0] = view;
157            }
158        });
159        return result[0];
160    }
161
162    protected WrappedRecyclerView inflateWrappedRV() {
163        return (WrappedRecyclerView)
164                LayoutInflater.from(getActivity()).inflate(R.layout.wrapped_test_rv,
165                        getRecyclerViewContainer(), false);
166    }
167
168    void swapAdapter(final RecyclerView.Adapter adapter,
169            final boolean removeAndRecycleExistingViews) throws Throwable {
170        runTestOnUiThread(new Runnable() {
171            @Override
172            public void run() {
173                try {
174                    mRecyclerView.swapAdapter(adapter, removeAndRecycleExistingViews);
175                } catch (Throwable t) {
176                    postExceptionToInstrumentation(t);
177                }
178            }
179        });
180        checkForMainThreadException();
181    }
182
183    void postExceptionToInstrumentation(Throwable t) {
184        if (mInstrumentationThread == Thread.currentThread()) {
185            throw new RuntimeException(t);
186        }
187        if (mMainThreadException != null) {
188            Log.e(TAG, "receiving another main thread exception. dropping.", t);
189        } else {
190            Log.e(TAG, "captured exception on main thread", t);
191            mMainThreadException = t;
192        }
193
194        if (mRecyclerView != null && mRecyclerView
195                .getLayoutManager() instanceof TestLayoutManager) {
196            TestLayoutManager lm = (TestLayoutManager) mRecyclerView.getLayoutManager();
197            // finish all layouts so that we get the correct exception
198            if (lm.layoutLatch != null) {
199                while (lm.layoutLatch.getCount() > 0) {
200                    lm.layoutLatch.countDown();
201                }
202            }
203        }
204    }
205
206    public Instrumentation getInstrumentation() {
207        return InstrumentationRegistry.getInstrumentation();
208    }
209
210    @After
211    public final void tearDown() throws Exception {
212        if (mRecyclerView != null) {
213            try {
214                removeRecyclerView();
215            } catch (Throwable throwable) {
216                throwable.printStackTrace();
217            }
218        }
219        getInstrumentation().waitForIdleSync();
220
221        try {
222            checkForMainThreadException();
223        } catch (Exception e) {
224            throw e;
225        } catch (Throwable throwable) {
226            throw new Exception(Log.getStackTraceString(throwable));
227        }
228    }
229
230    public Rect getDecoratedRecyclerViewBounds() {
231        return new Rect(
232                mRecyclerView.getPaddingLeft(),
233                mRecyclerView.getPaddingTop(),
234                mRecyclerView.getWidth() - mRecyclerView.getPaddingRight(),
235                mRecyclerView.getHeight() - mRecyclerView.getPaddingBottom()
236        );
237    }
238
239    public void removeRecyclerView() throws Throwable {
240        if (mRecyclerView == null) {
241            return;
242        }
243        if (!isMainThread()) {
244            getInstrumentation().waitForIdleSync();
245        }
246        runTestOnUiThread(new Runnable() {
247            @Override
248            public void run() {
249                try {
250                    // do not run validation if we already have an error
251                    if (mMainThreadException == null) {
252                        final RecyclerView.Adapter adapter = mRecyclerView.getAdapter();
253                        if (adapter instanceof AttachDetachCountingAdapter) {
254                            ((AttachDetachCountingAdapter) adapter).getCounter()
255                                    .validateRemaining(mRecyclerView);
256                        }
257                    }
258                    getActivity().getContainer().removeAllViews();
259                } catch (Throwable t) {
260                    postExceptionToInstrumentation(t);
261                }
262            }
263        });
264        mRecyclerView = null;
265    }
266
267    void waitForAnimations(int seconds) throws Throwable {
268        final CountDownLatch latch = new CountDownLatch(1);
269        runTestOnUiThread(new Runnable() {
270            @Override
271            public void run() {
272                mRecyclerView.mItemAnimator
273                        .isRunning(new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() {
274                            @Override
275                            public void onAnimationsFinished() {
276                                latch.countDown();
277                            }
278                        });
279            }
280        });
281
282        assertTrue("animations didn't finish on expected time of " + seconds + " seconds",
283                latch.await(seconds, TimeUnit.SECONDS));
284    }
285
286    public void waitForIdleScroll(final RecyclerView recyclerView) throws Throwable {
287        final CountDownLatch latch = new CountDownLatch(1);
288        runTestOnUiThread(new Runnable() {
289            @Override
290            public void run() {
291                RecyclerView.OnScrollListener listener = new RecyclerView.OnScrollListener() {
292                    @Override
293                    public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
294                        if (newState == SCROLL_STATE_IDLE) {
295                            latch.countDown();
296                            recyclerView.removeOnScrollListener(this);
297                        }
298                    }
299                };
300                if (recyclerView.getScrollState() == SCROLL_STATE_IDLE) {
301                    latch.countDown();
302                } else {
303                    recyclerView.addOnScrollListener(listener);
304                }
305            }
306        });
307        assertTrue("should go idle in 10 seconds", latch.await(10, TimeUnit.SECONDS));
308    }
309
310    public boolean requestFocus(final View view, boolean waitForScroll) throws Throwable {
311        final boolean[] result = new boolean[1];
312        try {
313            runTestOnUiThread(new Runnable() {
314                @Override
315                public void run() {
316                    result[0] = view.requestFocus();
317                }
318            });
319        } catch (Throwable throwable) {
320            fail(throwable.getMessage());
321        }
322        if (waitForScroll && result[0]) {
323            waitForIdleScroll(mRecyclerView);
324        }
325        return result[0];
326    }
327
328    public void setRecyclerView(final RecyclerView recyclerView) throws Throwable {
329        setRecyclerView(recyclerView, true);
330    }
331    public void setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool)
332            throws Throwable {
333        setRecyclerView(recyclerView, assignDummyPool, true);
334    }
335    public void setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool,
336            boolean addPositionCheckItemAnimator)
337            throws Throwable {
338        mRecyclerView = recyclerView;
339        if (assignDummyPool) {
340            RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool() {
341                @Override
342                public RecyclerView.ViewHolder getRecycledView(int viewType) {
343                    RecyclerView.ViewHolder viewHolder = super.getRecycledView(viewType);
344                    if (viewHolder == null) {
345                        return null;
346                    }
347                    viewHolder.addFlags(RecyclerView.ViewHolder.FLAG_BOUND);
348                    viewHolder.mPosition = 200;
349                    viewHolder.mOldPosition = 300;
350                    viewHolder.mPreLayoutPosition = 500;
351                    return viewHolder;
352                }
353
354                @Override
355                public void putRecycledView(RecyclerView.ViewHolder scrap) {
356                    assertNull(scrap.mOwnerRecyclerView);
357                    super.putRecycledView(scrap);
358                }
359            };
360            mRecyclerView.setRecycledViewPool(pool);
361        }
362        if (addPositionCheckItemAnimator) {
363            mRecyclerView.addItemDecoration(new RecyclerView.ItemDecoration() {
364                @Override
365                public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
366                        RecyclerView.State state) {
367                    RecyclerView.ViewHolder vh = parent.getChildViewHolder(view);
368                    if (!vh.isRemoved()) {
369                        assertNotSame("If getItemOffsets is called, child should have a valid"
370                                        + " adapter position unless it is removed : " + vh,
371                                vh.getAdapterPosition(), RecyclerView.NO_POSITION);
372                    }
373                }
374            });
375        }
376        mAdapterHelper = recyclerView.mAdapterHelper;
377        runTestOnUiThread(new Runnable() {
378            @Override
379            public void run() {
380                getActivity().getContainer().addView(recyclerView);
381            }
382        });
383    }
384
385    protected FrameLayout getRecyclerViewContainer() {
386        return getActivity().getContainer();
387    }
388
389    public void requestLayoutOnUIThread(final View view) {
390        try {
391            runTestOnUiThread(new Runnable() {
392                @Override
393                public void run() {
394                    view.requestLayout();
395                }
396            });
397        } catch (Throwable throwable) {
398            Log.e(TAG, "", throwable);
399        }
400    }
401
402    public void scrollBy(final int dt) {
403        try {
404            runTestOnUiThread(new Runnable() {
405                @Override
406                public void run() {
407                    if (mRecyclerView.getLayoutManager().canScrollHorizontally()) {
408                        mRecyclerView.scrollBy(dt, 0);
409                    } else {
410                        mRecyclerView.scrollBy(0, dt);
411                    }
412
413                }
414            });
415        } catch (Throwable throwable) {
416            Log.e(TAG, "", throwable);
417        }
418    }
419
420    public void smoothScrollBy(final int dt) {
421        try {
422            runTestOnUiThread(new Runnable() {
423                @Override
424                public void run() {
425                    if (mRecyclerView.getLayoutManager().canScrollHorizontally()) {
426                        mRecyclerView.smoothScrollBy(dt, 0);
427                    } else {
428                        mRecyclerView.smoothScrollBy(0, dt);
429                    }
430
431                }
432            });
433        } catch (Throwable throwable) {
434            Log.e(TAG, "", throwable);
435        }
436        getInstrumentation().waitForIdleSync();
437    }
438
439    void scrollToPosition(final int position) throws Throwable {
440        runTestOnUiThread(new Runnable() {
441            @Override
442            public void run() {
443                mRecyclerView.getLayoutManager().scrollToPosition(position);
444            }
445        });
446    }
447
448    void smoothScrollToPosition(final int position) throws Throwable {
449        smoothScrollToPosition(position, true);
450    }
451
452    void smoothScrollToPosition(final int position, boolean assertArrival) throws Throwable {
453        if (mDebug) {
454            Log.d(TAG, "SMOOTH scrolling to " + position);
455        }
456        final CountDownLatch viewAdded = new CountDownLatch(1);
457        final RecyclerView.OnChildAttachStateChangeListener listener =
458                new RecyclerView.OnChildAttachStateChangeListener() {
459                    @Override
460                    public void onChildViewAttachedToWindow(View view) {
461                        if (position == mRecyclerView.getChildAdapterPosition(view)) {
462                            viewAdded.countDown();
463                        }
464                    }
465                    @Override
466                    public void onChildViewDetachedFromWindow(View view) {
467                    }
468                };
469        final AtomicBoolean addedListener = new AtomicBoolean(false);
470        runTestOnUiThread(new Runnable() {
471            @Override
472            public void run() {
473                RecyclerView.ViewHolder viewHolderForAdapterPosition =
474                        mRecyclerView.findViewHolderForAdapterPosition(position);
475                if (viewHolderForAdapterPosition != null) {
476                    viewAdded.countDown();
477                } else {
478                    mRecyclerView.addOnChildAttachStateChangeListener(listener);
479                    addedListener.set(true);
480                }
481
482            }
483        });
484        runTestOnUiThread(new Runnable() {
485            @Override
486            public void run() {
487                mRecyclerView.smoothScrollToPosition(position);
488            }
489        });
490        getInstrumentation().waitForIdleSync();
491        assertThat("should be able to scroll in 10 seconds", !assertArrival ||
492                viewAdded.await(10, TimeUnit.SECONDS),
493                CoreMatchers.is(true));
494        waitForIdleScroll(mRecyclerView);
495        if (mDebug) {
496            Log.d(TAG, "SMOOTH scrolling done");
497        }
498        if (addedListener.get()) {
499            runTestOnUiThread(new Runnable() {
500                @Override
501                public void run() {
502                    mRecyclerView.removeOnChildAttachStateChangeListener(listener);
503                }
504            });
505        }
506        getInstrumentation().waitForIdleSync();
507    }
508
509    void freezeLayout(final boolean freeze) throws Throwable {
510        runTestOnUiThread(new Runnable() {
511            @Override
512            public void run() {
513                mRecyclerView.setLayoutFrozen(freeze);
514            }
515        });
516    }
517
518    public void setVisibility(final View view, final int visibility) throws Throwable {
519        runTestOnUiThread(new Runnable() {
520            @Override
521            public void run() {
522                view.setVisibility(visibility);
523            }
524        });
525    }
526
527    public class TestViewHolder extends RecyclerView.ViewHolder {
528
529        Item mBoundItem;
530
531        public TestViewHolder(View itemView) {
532            super(itemView);
533            itemView.setFocusable(true);
534        }
535
536        @Override
537        public String toString() {
538            return super.toString() + " item:" + mBoundItem;
539        }
540    }
541    class DumbLayoutManager extends TestLayoutManager {
542        @Override
543        public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
544            detachAndScrapAttachedViews(recycler);
545            layoutRange(recycler, 0, state.getItemCount());
546            if (layoutLatch != null) {
547                layoutLatch.countDown();
548            }
549        }
550    }
551    public class TestLayoutManager extends RecyclerView.LayoutManager {
552        int mScrollVerticallyAmount;
553        int mScrollHorizontallyAmount;
554        protected CountDownLatch layoutLatch;
555        private boolean mSupportsPredictive = false;
556
557        public void expectLayouts(int count) {
558            layoutLatch = new CountDownLatch(count);
559        }
560
561        public void waitForLayout(int seconds) throws Throwable {
562            layoutLatch.await(seconds * (mDebug ? 1000 : 1), SECONDS);
563            checkForMainThreadException();
564            MatcherAssert.assertThat("all layouts should complete on time",
565                    layoutLatch.getCount(), CoreMatchers.is(0L));
566            // use a runnable to ensure RV layout is finished
567            getInstrumentation().runOnMainSync(new Runnable() {
568                @Override
569                public void run() {
570                }
571            });
572        }
573
574        public boolean isSupportsPredictive() {
575            return mSupportsPredictive;
576        }
577
578        public void setSupportsPredictive(boolean supportsPredictive) {
579            mSupportsPredictive = supportsPredictive;
580        }
581
582        @Override
583        public boolean supportsPredictiveItemAnimations() {
584            return mSupportsPredictive;
585        }
586
587        public void assertLayoutCount(int count, String msg, long timeout) throws Throwable {
588            layoutLatch.await(timeout, TimeUnit.SECONDS);
589            assertEquals(msg, count, layoutLatch.getCount());
590        }
591
592        public void assertNoLayout(String msg, long timeout) throws Throwable {
593            layoutLatch.await(timeout, TimeUnit.SECONDS);
594            assertFalse(msg, layoutLatch.getCount() == 0);
595        }
596
597        @Override
598        public RecyclerView.LayoutParams generateDefaultLayoutParams() {
599            return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
600                    ViewGroup.LayoutParams.WRAP_CONTENT);
601        }
602
603        void assertVisibleItemPositions() {
604            int i = getChildCount();
605            TestAdapter testAdapter = (TestAdapter) mRecyclerView.getAdapter();
606            while (i-- > 0) {
607                View view = getChildAt(i);
608                RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) view.getLayoutParams();
609                Item item = ((TestViewHolder) lp.mViewHolder).mBoundItem;
610                if (mDebug) {
611                    Log.d(TAG, "testing item " + i);
612                }
613                if (!lp.isItemRemoved()) {
614                    RecyclerView.ViewHolder vh = mRecyclerView.getChildViewHolder(view);
615                    assertSame("item position in LP should match adapter value :" + vh,
616                            testAdapter.mItems.get(vh.mPosition), item);
617                }
618            }
619        }
620
621        RecyclerView.LayoutParams getLp(View v) {
622            return (RecyclerView.LayoutParams) v.getLayoutParams();
623        }
624
625        protected void layoutRange(RecyclerView.Recycler recycler, int start, int end) {
626            assertScrap(recycler);
627            if (mDebug) {
628                Log.d(TAG, "will layout items from " + start + " to " + end);
629            }
630            int diff = end > start ? 1 : -1;
631            int top = 0;
632            for (int i = start; i != end; i+=diff) {
633                if (mDebug) {
634                    Log.d(TAG, "laying out item " + i);
635                }
636                View view = recycler.getViewForPosition(i);
637                assertNotNull("view should not be null for valid position. "
638                        + "got null view at position " + i, view);
639                if (!mRecyclerView.mState.isPreLayout()) {
640                    RecyclerView.LayoutParams layoutParams = (RecyclerView.LayoutParams) view
641                            .getLayoutParams();
642                    assertFalse("In post layout, getViewForPosition should never return a view "
643                            + "that is removed", layoutParams != null
644                            && layoutParams.isItemRemoved());
645
646                }
647                assertEquals("getViewForPosition should return correct position",
648                        i, getPosition(view));
649                addView(view);
650                measureChildWithMargins(view, 0, 0);
651                if (getLayoutDirection() == ViewCompat.LAYOUT_DIRECTION_RTL) {
652                    layoutDecorated(view, getWidth() - getDecoratedMeasuredWidth(view), top,
653                            getWidth(), top + getDecoratedMeasuredHeight(view));
654                } else {
655                    layoutDecorated(view, 0, top, getDecoratedMeasuredWidth(view)
656                            , top + getDecoratedMeasuredHeight(view));
657                }
658
659                top += view.getMeasuredHeight();
660            }
661        }
662
663        private void assertScrap(RecyclerView.Recycler recycler) {
664            if (mRecyclerView.getAdapter() != null &&
665                    !mRecyclerView.getAdapter().hasStableIds()) {
666                for (RecyclerView.ViewHolder viewHolder : recycler.getScrapList()) {
667                    assertFalse("Invalid scrap should be no kept", viewHolder.isInvalid());
668                }
669            }
670        }
671
672        @Override
673        public boolean canScrollHorizontally() {
674            return true;
675        }
676
677        @Override
678        public boolean canScrollVertically() {
679            return true;
680        }
681
682        @Override
683        public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler,
684                RecyclerView.State state) {
685            mScrollHorizontallyAmount += dx;
686            return dx;
687        }
688
689        @Override
690        public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
691                RecyclerView.State state) {
692            mScrollVerticallyAmount += dy;
693            return dy;
694        }
695    }
696
697    static class Item {
698        final static AtomicInteger idCounter = new AtomicInteger(0);
699        final public int mId = idCounter.incrementAndGet();
700
701        int mAdapterIndex;
702
703        final String mText;
704        int mType = 0;
705
706        Item(int adapterIndex, String text) {
707            mAdapterIndex = adapterIndex;
708            mText = text;
709        }
710
711        @Override
712        public String toString() {
713            return "Item{" +
714                    "mId=" + mId +
715                    ", originalIndex=" + mAdapterIndex +
716                    ", text='" + mText + '\'' +
717                    '}';
718        }
719    }
720
721    public class TestAdapter extends RecyclerView.Adapter<TestViewHolder>
722            implements AttachDetachCountingAdapter {
723
724        public static final String DEFAULT_ITEM_PREFIX = "Item ";
725
726        ViewAttachDetachCounter mAttachmentCounter = new ViewAttachDetachCounter();
727        List<Item> mItems;
728
729        public TestAdapter(int count) {
730            mItems = new ArrayList<Item>(count);
731            addItems(0, count, DEFAULT_ITEM_PREFIX);
732        }
733
734        private void addItems(int pos, int count, String prefix) {
735            for (int i = 0; i < count; i++, pos++) {
736                mItems.add(pos, new Item(pos, prefix));
737            }
738        }
739
740        @Override
741        public int getItemViewType(int position) {
742            return getItemAt(position).mType;
743        }
744
745        @Override
746        public void onViewAttachedToWindow(TestViewHolder holder) {
747            super.onViewAttachedToWindow(holder);
748            mAttachmentCounter.onViewAttached(holder);
749        }
750
751        @Override
752        public void onViewDetachedFromWindow(TestViewHolder holder) {
753            super.onViewDetachedFromWindow(holder);
754            mAttachmentCounter.onViewDetached(holder);
755        }
756
757        @Override
758        public void onAttachedToRecyclerView(RecyclerView recyclerView) {
759            super.onAttachedToRecyclerView(recyclerView);
760            mAttachmentCounter.onAttached(recyclerView);
761        }
762
763        @Override
764        public void onDetachedFromRecyclerView(RecyclerView recyclerView) {
765            super.onDetachedFromRecyclerView(recyclerView);
766            mAttachmentCounter.onDetached(recyclerView);
767        }
768
769        @Override
770        public TestViewHolder onCreateViewHolder(ViewGroup parent,
771                int viewType) {
772            TextView itemView = new TextView(parent.getContext());
773            itemView.setFocusableInTouchMode(true);
774            itemView.setFocusable(true);
775            return new TestViewHolder(itemView);
776        }
777
778        @Override
779        public void onBindViewHolder(TestViewHolder holder, int position) {
780            assertNotNull(holder.mOwnerRecyclerView);
781            assertEquals(position, holder.getAdapterPosition());
782            final Item item = mItems.get(position);
783            ((TextView) (holder.itemView)).setText(item.mText + "(" + item.mId + ")");
784            holder.mBoundItem = item;
785        }
786
787        public Item getItemAt(int position) {
788            return mItems.get(position);
789        }
790
791        @Override
792        public void onViewRecycled(TestViewHolder holder) {
793            super.onViewRecycled(holder);
794            final int adapterPosition = holder.getAdapterPosition();
795            final boolean shouldHavePosition = !holder.isRemoved() && holder.isBound() &&
796                    !holder.isAdapterPositionUnknown() && !holder.isInvalid();
797            String log = "Position check for " + holder.toString();
798            assertEquals(log, shouldHavePosition, adapterPosition != RecyclerView.NO_POSITION);
799            if (shouldHavePosition) {
800                assertTrue(log, mItems.size() > adapterPosition);
801                assertSame(log, holder.mBoundItem, mItems.get(adapterPosition));
802            }
803        }
804
805        public void deleteAndNotify(final int start, final int count) throws Throwable {
806            deleteAndNotify(new int[]{start, count});
807        }
808
809        /**
810         * Deletes items in the given ranges.
811         * <p>
812         * Note that each operation affects the one after so you should offset them properly.
813         * <p>
814         * For example, if adapter has 5 items (A,B,C,D,E), and then you call this method with
815         * <code>[1, 2],[2, 1]</code>, it will first delete items B,C and the new adapter will be
816         * A D E. Then it will delete 2,1 which means it will delete E.
817         */
818        public void deleteAndNotify(final int[]... startCountTuples) throws Throwable {
819            for (int[] tuple : startCountTuples) {
820                tuple[1] = -tuple[1];
821            }
822            new AddRemoveRunnable(startCountTuples).runOnMainThread();
823        }
824
825        @Override
826        public long getItemId(int position) {
827            return hasStableIds() ? mItems.get(position).mId : super.getItemId(position);
828        }
829
830        public void offsetOriginalIndices(int start, int offset) {
831            for (int i = start; i < mItems.size(); i++) {
832                mItems.get(i).mAdapterIndex += offset;
833            }
834        }
835
836        /**
837         * @param start inclusive
838         * @param end exclusive
839         * @param offset
840         */
841        public void offsetOriginalIndicesBetween(int start, int end, int offset) {
842            for (int i = start; i < end && i < mItems.size(); i++) {
843                mItems.get(i).mAdapterIndex += offset;
844            }
845        }
846
847        public void addAndNotify(final int count) throws Throwable {
848            assertEquals(0, mItems.size());
849            new AddRemoveRunnable(DEFAULT_ITEM_PREFIX, new int[]{0, count}).runOnMainThread();
850        }
851
852        public void resetItemsTo(final List<Item> testItems) throws Throwable {
853            if (!mItems.isEmpty()) {
854                deleteAndNotify(0, mItems.size());
855            }
856            mItems = testItems;
857            runTestOnUiThread(new Runnable() {
858                @Override
859                public void run() {
860                    notifyItemRangeInserted(0, testItems.size());
861                }
862            });
863        }
864
865        public void addAndNotify(final int start, final int count) throws Throwable {
866            addAndNotify(new int[]{start, count});
867        }
868
869        public void addAndNotify(final int[]... startCountTuples) throws Throwable {
870            new AddRemoveRunnable(startCountTuples).runOnMainThread();
871        }
872
873        public void dispatchDataSetChanged() throws Throwable {
874            runTestOnUiThread(new Runnable() {
875                @Override
876                public void run() {
877                    notifyDataSetChanged();
878                }
879            });
880        }
881
882        public void changeAndNotify(final int start, final int count) throws Throwable {
883            runTestOnUiThread(new Runnable() {
884                @Override
885                public void run() {
886                    notifyItemRangeChanged(start, count);
887                }
888            });
889        }
890
891        public void changeAndNotifyWithPayload(final int start, final int count,
892                final Object payload) throws Throwable {
893            runTestOnUiThread(new Runnable() {
894                @Override
895                public void run() {
896                    notifyItemRangeChanged(start, count, payload);
897                }
898            });
899        }
900
901        public void changePositionsAndNotify(final int... positions) throws Throwable {
902            runTestOnUiThread(new Runnable() {
903                @Override
904                public void run() {
905                    for (int i = 0; i < positions.length; i += 1) {
906                        TestAdapter.super.notifyItemRangeChanged(positions[i], 1);
907                    }
908                }
909            });
910        }
911
912        /**
913         * Similar to other methods but negative count means delete and position count means add.
914         * <p>
915         * For instance, calling this method with <code>[1,1], [2,-1]</code> it will first add an
916         * item to index 1, then remove an item from index 2 (updated index 2)
917         */
918        public void addDeleteAndNotify(final int[]... startCountTuples) throws Throwable {
919            new AddRemoveRunnable(startCountTuples).runOnMainThread();
920        }
921
922        @Override
923        public int getItemCount() {
924            return mItems.size();
925        }
926
927        /**
928         * Uses notifyDataSetChanged
929         */
930        public void moveItems(boolean notifyChange, int[]... fromToTuples) throws Throwable {
931            for (int i = 0; i < fromToTuples.length; i += 1) {
932                int[] tuple = fromToTuples[i];
933                moveItem(tuple[0], tuple[1], false);
934            }
935            if (notifyChange) {
936                dispatchDataSetChanged();
937            }
938        }
939
940        /**
941         * Uses notifyDataSetChanged
942         */
943        public void moveItem(final int from, final int to, final boolean notifyChange)
944                throws Throwable {
945            runTestOnUiThread(new Runnable() {
946                @Override
947                public void run() {
948                    moveInUIThread(from, to);
949                    if (notifyChange) {
950                        notifyDataSetChanged();
951                    }
952                }
953            });
954        }
955
956        /**
957         * Uses notifyItemMoved
958         */
959        public void moveAndNotify(final int from, final int to) throws Throwable {
960            runTestOnUiThread(new Runnable() {
961                @Override
962                public void run() {
963                    moveInUIThread(from, to);
964                    notifyItemMoved(from, to);
965                }
966            });
967        }
968
969        public void clearOnUIThread() {
970            assertEquals("clearOnUIThread called from a wrong thread",
971                    Looper.getMainLooper(), Looper.myLooper());
972            mItems = new ArrayList<Item>();
973            notifyDataSetChanged();
974        }
975
976        protected void moveInUIThread(int from, int to) {
977            Item item = mItems.remove(from);
978            offsetOriginalIndices(from, -1);
979            mItems.add(to, item);
980            offsetOriginalIndices(to + 1, 1);
981            item.mAdapterIndex = to;
982        }
983
984
985        @Override
986        public ViewAttachDetachCounter getCounter() {
987            return mAttachmentCounter;
988        }
989
990        private class AddRemoveRunnable implements Runnable {
991            final String mNewItemPrefix;
992            final int[][] mStartCountTuples;
993
994            public AddRemoveRunnable(String newItemPrefix, int[]... startCountTuples) {
995                mNewItemPrefix = newItemPrefix;
996                mStartCountTuples = startCountTuples;
997            }
998
999            public AddRemoveRunnable(int[][] startCountTuples) {
1000                this("new item ", startCountTuples);
1001            }
1002
1003            public void runOnMainThread() throws Throwable {
1004                if (Looper.myLooper() == Looper.getMainLooper()) {
1005                    run();
1006                } else {
1007                    runTestOnUiThread(this);
1008                }
1009            }
1010
1011            @Override
1012            public void run() {
1013                for (int[] tuple : mStartCountTuples) {
1014                    if (tuple[1] < 0) {
1015                        delete(tuple);
1016                    } else {
1017                        add(tuple);
1018                    }
1019                }
1020            }
1021
1022            private void add(int[] tuple) {
1023                // offset others
1024                offsetOriginalIndices(tuple[0], tuple[1]);
1025                addItems(tuple[0], tuple[1], mNewItemPrefix);
1026                notifyItemRangeInserted(tuple[0], tuple[1]);
1027            }
1028
1029            private void delete(int[] tuple) {
1030                final int count = -tuple[1];
1031                offsetOriginalIndices(tuple[0] + count, tuple[1]);
1032                for (int i = 0; i < count; i++) {
1033                    mItems.remove(tuple[0]);
1034                }
1035                notifyItemRangeRemoved(tuple[0], count);
1036            }
1037        }
1038    }
1039
1040    public boolean isMainThread() {
1041        return Looper.myLooper() == Looper.getMainLooper();
1042    }
1043
1044    public void runTestOnUiThread(Runnable r) throws Throwable {
1045        if (Looper.myLooper() == Looper.getMainLooper()) {
1046            r.run();
1047        } else {
1048            InstrumentationRegistry.getInstrumentation().runOnMainSync(r);
1049        }
1050    }
1051
1052    static class TargetTuple {
1053
1054        final int mPosition;
1055
1056        final int mLayoutDirection;
1057
1058        TargetTuple(int position, int layoutDirection) {
1059            this.mPosition = position;
1060            this.mLayoutDirection = layoutDirection;
1061        }
1062
1063        @Override
1064        public String toString() {
1065            return "TargetTuple{" +
1066                    "mPosition=" + mPosition +
1067                    ", mLayoutDirection=" + mLayoutDirection +
1068                    '}';
1069        }
1070    }
1071
1072    public interface AttachDetachCountingAdapter {
1073
1074        ViewAttachDetachCounter getCounter();
1075    }
1076
1077    public class ViewAttachDetachCounter {
1078
1079        Set<RecyclerView.ViewHolder> mAttachedSet = new HashSet<RecyclerView.ViewHolder>();
1080
1081        public void validateRemaining(RecyclerView recyclerView) {
1082            final int childCount = recyclerView.getChildCount();
1083            for (int i = 0; i < childCount; i++) {
1084                View view = recyclerView.getChildAt(i);
1085                RecyclerView.ViewHolder vh = recyclerView.getChildViewHolder(view);
1086                assertTrue("remaining view should be in attached set " + vh,
1087                        mAttachedSet.contains(vh));
1088            }
1089            assertEquals("there should not be any views left in attached set",
1090                    childCount, mAttachedSet.size());
1091        }
1092
1093        public void onViewDetached(RecyclerView.ViewHolder viewHolder) {
1094            try {
1095                assertTrue("view holder should be in attached set",
1096                        mAttachedSet.remove(viewHolder));
1097            } catch (Throwable t) {
1098                postExceptionToInstrumentation(t);
1099            }
1100        }
1101
1102        public void onViewAttached(RecyclerView.ViewHolder viewHolder) {
1103            try {
1104                assertTrue("view holder should not be in attached set",
1105                        mAttachedSet.add(viewHolder));
1106            } catch (Throwable t) {
1107                postExceptionToInstrumentation(t);
1108            }
1109        }
1110
1111        public void onAttached(RecyclerView recyclerView) {
1112            // when a new RV is attached, clear the set and add all view holders
1113            mAttachedSet.clear();
1114            final int childCount = recyclerView.getChildCount();
1115            for (int i = 0; i < childCount; i ++) {
1116                View view = recyclerView.getChildAt(i);
1117                mAttachedSet.add(recyclerView.getChildViewHolder(view));
1118            }
1119        }
1120
1121        public void onDetached(RecyclerView recyclerView) {
1122            validateRemaining(recyclerView);
1123        }
1124    }
1125}
1126