1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.support.v7.widget;
18
19import org.junit.Test;
20import org.junit.runner.RunWith;
21import org.junit.runners.Parameterized;
22
23import android.graphics.Rect;
24import android.os.Parcel;
25import android.os.Parcelable;
26import android.support.test.InstrumentationRegistry;
27import android.test.suitebuilder.annotation.LargeTest;
28import android.util.Log;
29
30import java.util.ArrayList;
31import java.util.List;
32import java.util.Map;
33import java.util.UUID;
34import static org.junit.Assert.*;
35
36@RunWith(Parameterized.class)
37@LargeTest
38public class LinearLayoutManagerSavedStateTest extends BaseLinearLayoutManagerTest {
39    final Config mConfig;
40    final boolean mWaitForLayout;
41    final boolean mLoadDataAfterRestore;
42    final PostLayoutRunnable mPostLayoutOperation;
43    final PostRestoreRunnable mPostRestoreOperation;
44
45    public LinearLayoutManagerSavedStateTest(Config config, boolean waitForLayout,
46            boolean loadDataAfterRestore, PostLayoutRunnable postLayoutOperation,
47            PostRestoreRunnable postRestoreOperation) {
48        mConfig = config;
49        mWaitForLayout = waitForLayout;
50        mLoadDataAfterRestore = loadDataAfterRestore;
51        mPostLayoutOperation = postLayoutOperation;
52        mPostRestoreOperation = postRestoreOperation;
53        mPostLayoutOperation.mLayoutManagerDelegate = new Delegate<WrappedLinearLayoutManager>() {
54            @Override
55            public WrappedLinearLayoutManager get() {
56                return mLayoutManager;
57            }
58        };
59        mPostLayoutOperation.mTestAdapterDelegate = new Delegate<TestAdapter>() {
60            @Override
61            public TestAdapter get() {
62                return mTestAdapter;
63            }
64        };
65        mPostRestoreOperation.mLayoutManagerDelegate = new Delegate<WrappedLinearLayoutManager>() {
66            @Override
67            public WrappedLinearLayoutManager get() {
68                return mLayoutManager;
69            }
70        };
71        mPostRestoreOperation.mTestAdapterDelegate = new Delegate<TestAdapter>() {
72            @Override
73            public TestAdapter get() {
74                return mTestAdapter;
75            }
76        };
77    }
78
79    @Parameterized.Parameters(name = "{0}_waitForLayout:{1}_loadDataAfterRestore:{2}"
80            + "_postLayout:{3}_postRestore:{4}")
81    public static Iterable<Object[]> params()
82            throws IllegalAccessException, CloneNotSupportedException, NoSuchFieldException {
83        PostLayoutRunnable[] postLayoutOptions = new PostLayoutRunnable[]{
84                new PostLayoutRunnable() {
85                    @Override
86                    public void run() throws Throwable {
87                        // do nothing
88                    }
89
90                    @Override
91                    public String describe() {
92                        return "doing nothing";
93                    }
94                },
95                new PostLayoutRunnable() {
96                    @Override
97                    public void run() throws Throwable {
98                        layoutManager().expectLayouts(1);
99                        scrollToPosition(testAdapter().getItemCount() * 3 / 4);
100                        layoutManager().waitForLayout(2);
101                    }
102
103                    @Override
104                    public String describe() {
105                        return "scroll to position";
106                    }
107                },
108                new PostLayoutRunnable() {
109                    @Override
110                    public void run() throws Throwable {
111                        layoutManager().expectLayouts(1);
112                        scrollToPositionWithOffset(testAdapter().getItemCount() / 3,
113                                50);
114                        layoutManager().waitForLayout(2);
115                    }
116
117                    @Override
118                    public String describe() {
119                        return "scroll to position with positive offset";
120                    }
121                },
122                new PostLayoutRunnable() {
123                    @Override
124                    public void run() throws Throwable {
125                        layoutManager().expectLayouts(1);
126                        scrollToPositionWithOffset(testAdapter().getItemCount() * 2 / 3,
127                                -10);  // Some tests break if this value is below the item height.
128                        layoutManager().waitForLayout(2);
129                    }
130
131                    @Override
132                    public String describe() {
133                        return "scroll to position with negative offset";
134                    }
135                }
136        };
137
138        PostRestoreRunnable[] postRestoreOptions = new PostRestoreRunnable[]{
139                new PostRestoreRunnable() {
140                    @Override
141                    public String describe() {
142                        return "Doing nothing";
143                    }
144                },
145                new PostRestoreRunnable() {
146                    @Override
147                    void onAfterRestore(Config config) throws Throwable {
148                        // update config as well so that restore assertions will work
149                        config.mOrientation = 1 - config.mOrientation;
150                        layoutManager().setOrientation(config.mOrientation);
151                    }
152
153                    @Override
154                    boolean shouldLayoutMatch(Config config) {
155                        return config.mItemCount == 0;
156                    }
157
158                    @Override
159                    public String describe() {
160                        return "Changing orientation";
161                    }
162                },
163                new PostRestoreRunnable() {
164                    @Override
165                    void onAfterRestore(Config config) throws Throwable {
166                        config.mStackFromEnd = !config.mStackFromEnd;
167                        layoutManager().setStackFromEnd(config.mStackFromEnd);
168                    }
169
170                    @Override
171                    boolean shouldLayoutMatch(Config config) {
172                        return true; //stack from end should not move items on change
173                    }
174
175                    @Override
176                    public String describe() {
177                        return "Changing stack from end";
178                    }
179                },
180                new PostRestoreRunnable() {
181                    @Override
182                    void onAfterRestore(Config config) throws Throwable {
183                        config.mReverseLayout = !config.mReverseLayout;
184                        layoutManager().setReverseLayout(config.mReverseLayout);
185                    }
186
187                    @Override
188                    boolean shouldLayoutMatch(Config config) {
189                        return config.mItemCount == 0;
190                    }
191
192                    @Override
193                    public String describe() {
194                        return "Changing reverse layout";
195                    }
196                },
197                new PostRestoreRunnable() {
198                    @Override
199                    void onAfterRestore(Config config) throws Throwable {
200                        config.mRecycleChildrenOnDetach = !config.mRecycleChildrenOnDetach;
201                        layoutManager().setRecycleChildrenOnDetach(config.mRecycleChildrenOnDetach);
202                    }
203
204                    @Override
205                    boolean shouldLayoutMatch(Config config) {
206                        return true;
207                    }
208
209                    @Override
210                    String describe() {
211                        return "Change should recycle children";
212                    }
213                },
214                new PostRestoreRunnable() {
215                    int position;
216                    @Override
217                    void onAfterRestore(Config config) throws Throwable {
218                        position = testAdapter().getItemCount() / 2;
219                        layoutManager().scrollToPosition(position);
220                    }
221
222                    @Override
223                    boolean shouldLayoutMatch(Config config) {
224                        return testAdapter().getItemCount() == 0;
225                    }
226
227                    @Override
228                    String describe() {
229                        return "Scroll to position " + position ;
230                    }
231
232                    @Override
233                    void onAfterReLayout(Config config) {
234                        if (testAdapter().getItemCount() > 0) {
235                            assertEquals(config + ":scrolled view should be last completely visible",
236                                    position,
237                                    config.mStackFromEnd ?
238                                            layoutManager().findLastCompletelyVisibleItemPosition()
239                                            : layoutManager().findFirstCompletelyVisibleItemPosition());
240                        }
241                    }
242                }
243        };
244        boolean[] waitForLayoutOptions = new boolean[]{true, false};
245        boolean[] loadDataAfterRestoreOptions = new boolean[]{true, false};
246        List<Config> variations = addConfigVariation(createBaseVariations(), "mItemCount", 0, 300);
247        variations = addConfigVariation(variations, "mRecycleChildrenOnDetach", true);
248
249        List<Object[]> params = new ArrayList<>();
250        for (Config config : variations) {
251            for (PostLayoutRunnable postLayoutRunnable : postLayoutOptions) {
252                for (boolean waitForLayout : waitForLayoutOptions) {
253                    for (PostRestoreRunnable postRestoreRunnable : postRestoreOptions) {
254                        for (boolean loadDataAfterRestore : loadDataAfterRestoreOptions) {
255                            params.add(new Object[]{
256                                    config.clone(), waitForLayout,
257                                    loadDataAfterRestore, postLayoutRunnable, postRestoreRunnable
258                            });
259                        }
260                    }
261
262                }
263            }
264        }
265        return params;
266    }
267
268    @Test
269    public void savedStateTest()
270            throws Throwable {
271        if (DEBUG) {
272            Log.d(TAG, "testing saved state with wait for layout = " + mWaitForLayout + " config " +
273                    mConfig + " post layout action " + mPostLayoutOperation.describe() +
274                    "post restore action " + mPostRestoreOperation.describe());
275        }
276        setupByConfig(mConfig, false);
277
278        if (mWaitForLayout) {
279            waitForFirstLayout();
280            mPostLayoutOperation.run();
281        }
282        Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
283        Parcelable savedState = mRecyclerView.onSaveInstanceState();
284        // we append a suffix to the parcelable to test out of bounds
285        String parcelSuffix = UUID.randomUUID().toString();
286        Parcel parcel = Parcel.obtain();
287        savedState.writeToParcel(parcel, 0);
288        parcel.writeString(parcelSuffix);
289        removeRecyclerView();
290        // reset for reading
291        parcel.setDataPosition(0);
292        // re-create
293        savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel);
294
295        final int itemCount = mTestAdapter.getItemCount();
296        List<Item> testItems = new ArrayList<>();
297        if (mLoadDataAfterRestore) {
298            // we cannot delete and re-add since new items may have different sizes. We need the
299            // exact same adapter.
300            testItems.addAll(mTestAdapter.mItems);
301            mTestAdapter.deleteAndNotify(0, itemCount);
302        }
303
304        RecyclerView restored = new RecyclerView(getActivity());
305        // this config should be no op.
306        mLayoutManager = new WrappedLinearLayoutManager(getActivity(),
307                mConfig.mOrientation, mConfig.mReverseLayout);
308        mLayoutManager.setStackFromEnd(mConfig.mStackFromEnd);
309        restored.setLayoutManager(mLayoutManager);
310        // use the same adapter for Rect matching
311        restored.setAdapter(mTestAdapter);
312        restored.onRestoreInstanceState(savedState);
313
314        if (mLoadDataAfterRestore) {
315            // add the same items back
316            mTestAdapter.resetItemsTo(testItems);
317        }
318
319        mPostRestoreOperation.onAfterRestore(mConfig);
320        assertEquals("Parcel reading should not go out of bounds", parcelSuffix,
321                parcel.readString());
322        mLayoutManager.expectLayouts(1);
323        setRecyclerView(restored);
324        mLayoutManager.waitForLayout(2);
325        // calculate prefix here instead of above to include post restore changes
326        final String logPrefix = mConfig + "\npostLayout:" + mPostLayoutOperation.describe() +
327                "\npostRestore:" + mPostRestoreOperation.describe() + "\n";
328        assertEquals(logPrefix + " on saved state, reverse layout should be preserved",
329                mConfig.mReverseLayout, mLayoutManager.getReverseLayout());
330        assertEquals(logPrefix + " on saved state, orientation should be preserved",
331                mConfig.mOrientation, mLayoutManager.getOrientation());
332        assertEquals(logPrefix + " on saved state, stack from end should be preserved",
333                mConfig.mStackFromEnd, mLayoutManager.getStackFromEnd());
334        if (mWaitForLayout) {
335            final boolean strictItemEquality = !mLoadDataAfterRestore;
336            if (mPostRestoreOperation.shouldLayoutMatch(mConfig)) {
337                assertRectSetsEqual(
338                        logPrefix + ": on restore, previous view positions should be preserved",
339                        before, mLayoutManager.collectChildCoordinates(), strictItemEquality);
340            } else {
341                assertRectSetsNotEqual(
342                        logPrefix
343                                + ": on restore with changes, previous view positions should NOT "
344                                + "be preserved",
345                        before, mLayoutManager.collectChildCoordinates(), strictItemEquality);
346            }
347            mPostRestoreOperation.onAfterReLayout(mConfig);
348        }
349    }
350
351    protected static abstract class PostLayoutRunnable {
352        private Delegate<WrappedLinearLayoutManager> mLayoutManagerDelegate;
353        private Delegate<TestAdapter> mTestAdapterDelegate;
354        protected WrappedLinearLayoutManager layoutManager() {
355            return mLayoutManagerDelegate.get();
356        }
357        protected TestAdapter testAdapter() {
358            return mTestAdapterDelegate.get();
359        }
360
361        abstract void run() throws Throwable;
362        void scrollToPosition(final int position) {
363            InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
364                @Override
365                public void run() {
366                    layoutManager().scrollToPosition(position);
367                }
368            });
369        }
370        void scrollToPositionWithOffset(final int position, final int offset) {
371            InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
372                @Override
373                public void run() {
374                    layoutManager().scrollToPositionWithOffset(position, offset);
375                }
376            });
377        }
378        abstract String describe();
379
380        @Override
381        public String toString() {
382            return describe();
383        }
384    }
385
386    protected static abstract class PostRestoreRunnable {
387        private Delegate<WrappedLinearLayoutManager> mLayoutManagerDelegate;
388        private Delegate<TestAdapter> mTestAdapterDelegate;
389        protected WrappedLinearLayoutManager layoutManager() {
390            return mLayoutManagerDelegate.get();
391        }
392        protected TestAdapter testAdapter() {
393            return mTestAdapterDelegate.get();
394        }
395
396        void onAfterRestore(Config config) throws Throwable {
397        }
398
399        abstract String describe();
400
401        boolean shouldLayoutMatch(Config config) {
402            return true;
403        }
404
405        void onAfterReLayout(Config config) {
406
407        };
408
409        @Override
410        public String toString() {
411            return describe();
412        }
413    }
414
415    private interface Delegate<T> {
416        T get();
417    }
418}
419