Home | History | Annotate | Download | only in widget
      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 
     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