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