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