• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 static android.support.v7.widget.RecyclerView.SCROLL_STATE_IDLE;
20 
21 import org.hamcrest.CoreMatchers;
22 import org.hamcrest.MatcherAssert;
23 import org.junit.After;
24 import org.junit.Before;
25 import org.junit.Rule;
26 
27 import android.app.Instrumentation;
28 import android.graphics.Rect;
29 import android.os.Looper;
30 import android.support.test.InstrumentationRegistry;
31 import android.support.test.rule.ActivityTestRule;
32 import android.support.v4.view.ViewCompat;
33 import android.support.v7.recyclerview.test.SameActivityTestRule;
34 import android.util.Log;
35 import android.view.LayoutInflater;
36 import android.view.View;
37 import android.view.ViewGroup;
38 import android.widget.FrameLayout;
39 import android.widget.TextView;
40 
41 import java.lang.reflect.InvocationTargetException;
42 import java.lang.reflect.Method;
43 import java.util.ArrayList;
44 import java.util.HashSet;
45 import java.util.List;
46 import java.util.Set;
47 import java.util.concurrent.CountDownLatch;
48 import java.util.concurrent.TimeUnit;
49 import java.util.concurrent.atomic.AtomicBoolean;
50 import java.util.concurrent.atomic.AtomicInteger;
51 import java.util.concurrent.locks.ReentrantLock;
52 import android.support.v7.recyclerview.test.R;
53 
54 import static org.junit.Assert.*;
55 
56 import static java.util.concurrent.TimeUnit.SECONDS;
57 
58 abstract 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 
BaseRecyclerViewInstrumentationTest()82     public BaseRecyclerViewInstrumentationTest() {
83         this(false);
84     }
85 
BaseRecyclerViewInstrumentationTest(boolean debug)86     public BaseRecyclerViewInstrumentationTest(boolean debug) {
87         mDebug = debug;
88     }
89 
checkForMainThreadException()90     void checkForMainThreadException() throws Throwable {
91         if (!mIgnoreMainThreadException && mMainThreadException != null) {
92             throw mMainThreadException;
93         }
94     }
95 
setIgnoreMainThreadException(boolean ignoreMainThreadException)96     public void setIgnoreMainThreadException(boolean ignoreMainThreadException) {
97         mIgnoreMainThreadException = ignoreMainThreadException;
98     }
99 
getMainThreadException()100     public Throwable getMainThreadException() {
101         return mMainThreadException;
102     }
103 
getActivity()104     protected TestActivity getActivity() {
105         return mActivityRule.getActivity();
106     }
107 
108     @Before
setUpInsThread()109     public final void setUpInsThread() throws Exception {
110         mInstrumentationThread = Thread.currentThread();
111     }
112 
setHasTransientState(final View view, final boolean value)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 
canReUseActivity()126     public boolean canReUseActivity() {
127         return true;
128     }
129 
enableAccessibility()130     protected void enableAccessibility()
131             throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
132         Method getUIAutomation = Instrumentation.class.getMethod("getUiAutomation");
133         getUIAutomation.invoke(InstrumentationRegistry.getInstrumentation());
134     }
135 
setAdapter(final RecyclerView.Adapter adapter)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 
focusSearch(final View focused, final int direction)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 
inflateWrappedRV()161     protected WrappedRecyclerView inflateWrappedRV() {
162         return (WrappedRecyclerView)
163                 LayoutInflater.from(getActivity()).inflate(R.layout.wrapped_test_rv,
164                         getRecyclerViewContainer(), false);
165     }
166 
swapAdapter(final RecyclerView.Adapter adapter, final boolean removeAndRecycleExistingViews)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 
postExceptionToInstrumentation(Throwable t)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 
getInstrumentation()205     public Instrumentation getInstrumentation() {
206         return InstrumentationRegistry.getInstrumentation();
207     }
208 
209     @After
tearDown()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 
getDecoratedRecyclerViewBounds()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 
removeRecyclerView()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 
waitForAnimations(int seconds)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 
waitForIdleScroll(final RecyclerView recyclerView)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 
requestFocus(final View view, boolean waitForScroll)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 
setRecyclerView(final RecyclerView recyclerView)327     public void setRecyclerView(final RecyclerView recyclerView) throws Throwable {
328         setRecyclerView(recyclerView, true);
329     }
setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool)330     public void setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool)
331             throws Throwable {
332         setRecyclerView(recyclerView, assignDummyPool, true);
333     }
setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool, boolean addPositionCheckItemAnimator)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 
getRecyclerViewContainer()384     protected FrameLayout getRecyclerViewContainer() {
385         return getActivity().getContainer();
386     }
387 
requestLayoutOnUIThread(final View view)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 
scrollBy(final int dt)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 
scrollToPosition(final int position)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 
smoothScrollToPosition(final int position)428     void smoothScrollToPosition(final int position) throws Throwable {
429         smoothScrollToPosition(position, true);
430     }
431 
smoothScrollToPosition(final int position, boolean assertArrival)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 
freezeLayout(final boolean freeze)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 
setVisibility(final View view, final int visibility)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 
TestViewHolder(View itemView)511         public TestViewHolder(View itemView) {
512             super(itemView);
513             itemView.setFocusable(true);
514         }
515 
516         @Override
toString()517         public String toString() {
518             return super.toString() + " item:" + mBoundItem;
519         }
520     }
521     class DumbLayoutManager extends TestLayoutManager {
522         @Override
onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state)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 
expectLayouts(int count)537         public void expectLayouts(int count) {
538             layoutLatch = new CountDownLatch(count);
539         }
540 
waitForLayout(int seconds)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 
isSupportsPredictive()554         public boolean isSupportsPredictive() {
555             return mSupportsPredictive;
556         }
557 
setSupportsPredictive(boolean supportsPredictive)558         public void setSupportsPredictive(boolean supportsPredictive) {
559             mSupportsPredictive = supportsPredictive;
560         }
561 
562         @Override
supportsPredictiveItemAnimations()563         public boolean supportsPredictiveItemAnimations() {
564             return mSupportsPredictive;
565         }
566 
assertLayoutCount(int count, String msg, long timeout)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 
assertNoLayout(String msg, long timeout)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
generateDefaultLayoutParams()578         public RecyclerView.LayoutParams generateDefaultLayoutParams() {
579             return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
580                     ViewGroup.LayoutParams.WRAP_CONTENT);
581         }
582 
assertVisibleItemPositions()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 
getLp(View v)601         RecyclerView.LayoutParams getLp(View v) {
602             return (RecyclerView.LayoutParams) v.getLayoutParams();
603         }
604 
layoutRange(RecyclerView.Recycler recycler, int start, int end)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 
assertScrap(RecyclerView.Recycler recycler)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
canScrollHorizontally()653         public boolean canScrollHorizontally() {
654             return true;
655         }
656 
657         @Override
canScrollVertically()658         public boolean canScrollVertically() {
659             return true;
660         }
661 
662         @Override
scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state)663         public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler,
664                 RecyclerView.State state) {
665             mScrollHorizontallyAmount += dx;
666             return dx;
667         }
668 
669         @Override
scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state)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 
Item(int adapterIndex, String text)686         Item(int adapterIndex, String text) {
687             mAdapterIndex = adapterIndex;
688             mText = text;
689         }
690 
691         @Override
toString()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 
TestAdapter(int count)709         public TestAdapter(int count) {
710             mItems = new ArrayList<Item>(count);
711             addItems(0, count, DEFAULT_ITEM_PREFIX);
712         }
713 
addItems(int pos, int count, String prefix)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
getItemViewType(int position)721         public int getItemViewType(int position) {
722             return getItemAt(position).mType;
723         }
724 
725         @Override
onViewAttachedToWindow(TestViewHolder holder)726         public void onViewAttachedToWindow(TestViewHolder holder) {
727             super.onViewAttachedToWindow(holder);
728             mAttachmentCounter.onViewAttached(holder);
729         }
730 
731         @Override
onViewDetachedFromWindow(TestViewHolder holder)732         public void onViewDetachedFromWindow(TestViewHolder holder) {
733             super.onViewDetachedFromWindow(holder);
734             mAttachmentCounter.onViewDetached(holder);
735         }
736 
737         @Override
onAttachedToRecyclerView(RecyclerView recyclerView)738         public void onAttachedToRecyclerView(RecyclerView recyclerView) {
739             super.onAttachedToRecyclerView(recyclerView);
740             mAttachmentCounter.onAttached(recyclerView);
741         }
742 
743         @Override
onDetachedFromRecyclerView(RecyclerView recyclerView)744         public void onDetachedFromRecyclerView(RecyclerView recyclerView) {
745             super.onDetachedFromRecyclerView(recyclerView);
746             mAttachmentCounter.onDetached(recyclerView);
747         }
748 
749         @Override
onCreateViewHolder(ViewGroup parent, int viewType)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
onBindViewHolder(TestViewHolder holder, int position)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 
getItemAt(int position)767         public Item getItemAt(int position) {
768             return mItems.get(position);
769         }
770 
771         @Override
onViewRecycled(TestViewHolder holder)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 
deleteAndNotify(final int start, final int count)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          */
deleteAndNotify(final int[]... startCountTuples)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
getItemId(int position)806         public long getItemId(int position) {
807             return hasStableIds() ? mItems.get(position).mId : super.getItemId(position);
808         }
809 
offsetOriginalIndices(int start, int offset)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          */
offsetOriginalIndicesBetween(int start, int end, int offset)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 
addAndNotify(final int count)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 
resetItemsTo(final List<Item> testItems)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 
addAndNotify(final int start, final int count)845         public void addAndNotify(final int start, final int count) throws Throwable {
846             addAndNotify(new int[]{start, count});
847         }
848 
addAndNotify(final int[]... startCountTuples)849         public void addAndNotify(final int[]... startCountTuples) throws Throwable {
850             new AddRemoveRunnable(startCountTuples).runOnMainThread();
851         }
852 
dispatchDataSetChanged()853         public void dispatchDataSetChanged() throws Throwable {
854             runTestOnUiThread(new Runnable() {
855                 @Override
856                 public void run() {
857                     notifyDataSetChanged();
858                 }
859             });
860         }
861 
changeAndNotify(final int start, final int count)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 
changeAndNotifyWithPayload(final int start, final int count, final Object payload)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 
changePositionsAndNotify(final int... positions)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          */
addDeleteAndNotify(final int[]... startCountTuples)898         public void addDeleteAndNotify(final int[]... startCountTuples) throws Throwable {
899             new AddRemoveRunnable(startCountTuples).runOnMainThread();
900         }
901 
902         @Override
getItemCount()903         public int getItemCount() {
904             return mItems.size();
905         }
906 
907         /**
908          * Uses notifyDataSetChanged
909          */
moveItems(boolean notifyChange, int[]... fromToTuples)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          */
moveItem(final int from, final int to, final boolean notifyChange)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          */
moveAndNotify(final int from, final int to)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 
clearOnUIThread()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 
moveInUIThread(int from, int to)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
getCounter()966         public ViewAttachDetachCounter getCounter() {
967             return mAttachmentCounter;
968         }
969 
970         private class AddRemoveRunnable implements Runnable {
971             final String mNewItemPrefix;
972             final int[][] mStartCountTuples;
973 
AddRemoveRunnable(String newItemPrefix, int[]... startCountTuples)974             public AddRemoveRunnable(String newItemPrefix, int[]... startCountTuples) {
975                 mNewItemPrefix = newItemPrefix;
976                 mStartCountTuples = startCountTuples;
977             }
978 
AddRemoveRunnable(int[][] startCountTuples)979             public AddRemoveRunnable(int[][] startCountTuples) {
980                 this("new item ", startCountTuples);
981             }
982 
runOnMainThread()983             public void runOnMainThread() throws Throwable {
984                 if (Looper.myLooper() == Looper.getMainLooper()) {
985                     run();
986                 } else {
987                     runTestOnUiThread(this);
988                 }
989             }
990 
991             @Override
run()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 
add(int[] tuple)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 
delete(int[] tuple)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 
isMainThread()1020     public boolean isMainThread() {
1021         return Looper.myLooper() == Looper.getMainLooper();
1022     }
1023 
runTestOnUiThread(Runnable r)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 
TargetTuple(int position, int layoutDirection)1038         TargetTuple(int position, int layoutDirection) {
1039             this.mPosition = position;
1040             this.mLayoutDirection = layoutDirection;
1041         }
1042 
1043         @Override
toString()1044         public String toString() {
1045             return "TargetTuple{" +
1046                     "mPosition=" + mPosition +
1047                     ", mLayoutDirection=" + mLayoutDirection +
1048                     '}';
1049         }
1050     }
1051 
1052     public interface AttachDetachCountingAdapter {
1053 
getCounter()1054         ViewAttachDetachCounter getCounter();
1055     }
1056 
1057     public class ViewAttachDetachCounter {
1058 
1059         Set<RecyclerView.ViewHolder> mAttachedSet = new HashSet<RecyclerView.ViewHolder>();
1060 
validateRemaining(RecyclerView recyclerView)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 
onViewDetached(RecyclerView.ViewHolder viewHolder)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 
onViewAttached(RecyclerView.ViewHolder viewHolder)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 
onAttached(RecyclerView recyclerView)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 
onDetached(RecyclerView recyclerView)1101         public void onDetached(RecyclerView recyclerView) {
1102             validateRemaining(recyclerView);
1103         }
1104     }
1105 }
1106