diff options
Diffstat (limited to 'android/arch')
79 files changed, 3293 insertions, 2723 deletions
diff --git a/android/arch/lifecycle/ActivityFullLifecycleTest.java b/android/arch/lifecycle/ActivityFullLifecycleTest.java index ee4e661a..78dd0150 100644 --- a/android/arch/lifecycle/ActivityFullLifecycleTest.java +++ b/android/arch/lifecycle/ActivityFullLifecycleTest.java @@ -16,48 +16,43 @@ package android.arch.lifecycle; -import static android.arch.lifecycle.Lifecycle.Event.ON_CREATE; -import static android.arch.lifecycle.Lifecycle.Event.ON_DESTROY; -import static android.arch.lifecycle.Lifecycle.Event.ON_PAUSE; -import static android.arch.lifecycle.Lifecycle.Event.ON_RESUME; -import static android.arch.lifecycle.Lifecycle.Event.ON_START; -import static android.arch.lifecycle.Lifecycle.Event.ON_STOP; -import static android.arch.lifecycle.testapp.TestEvent.ACTIVITY_CALLBACK; -import static android.arch.lifecycle.testapp.TestEvent.LIFECYCLE_EVENT; +import static android.arch.lifecycle.TestUtils.OrderedTuples.CREATE; +import static android.arch.lifecycle.TestUtils.OrderedTuples.DESTROY; +import static android.arch.lifecycle.TestUtils.OrderedTuples.PAUSE; +import static android.arch.lifecycle.TestUtils.OrderedTuples.RESUME; +import static android.arch.lifecycle.TestUtils.OrderedTuples.START; +import static android.arch.lifecycle.TestUtils.OrderedTuples.STOP; +import static android.arch.lifecycle.TestUtils.flatMap; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import android.app.Activity; import android.arch.lifecycle.Lifecycle.Event; -import android.arch.lifecycle.testapp.CollectingActivity; +import android.arch.lifecycle.testapp.CollectingLifecycleOwner; +import android.arch.lifecycle.testapp.CollectingSupportActivity; import android.arch.lifecycle.testapp.FrameworkLifecycleRegistryActivity; -import android.arch.lifecycle.testapp.FullLifecycleTestActivity; -import android.arch.lifecycle.testapp.SupportLifecycleRegistryActivity; import android.arch.lifecycle.testapp.TestEvent; import android.support.test.filters.SmallTest; import android.support.test.rule.ActivityTestRule; -import android.util.Pair; +import android.support.v4.util.Pair; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; -import java.util.ArrayList; import java.util.List; @SmallTest @RunWith(Parameterized.class) public class ActivityFullLifecycleTest { @Rule - public ActivityTestRule activityTestRule = - new ActivityTestRule<>(FullLifecycleTestActivity.class); + public final ActivityTestRule<? extends CollectingLifecycleOwner> activityTestRule; @Parameterized.Parameters public static Class[] params() { - return new Class[]{FullLifecycleTestActivity.class, - SupportLifecycleRegistryActivity.class, + return new Class[]{CollectingSupportActivity.class, FrameworkLifecycleRegistryActivity.class}; } @@ -68,28 +63,13 @@ public class ActivityFullLifecycleTest { @Test - public void testFullLifecycle() throws InterruptedException { - Activity activity = activityTestRule.getActivity(); - List<Pair<TestEvent, Event>> results = ((CollectingActivity) activity) - .waitForCollectedEvents(); + public void testFullLifecycle() throws Throwable { + CollectingLifecycleOwner owner = activityTestRule.getActivity(); + TestUtils.waitTillResumed(owner, activityTestRule); + activityTestRule.finishActivity(); - Event[] expectedEvents = - new Event[]{ON_CREATE, ON_START, ON_RESUME, ON_PAUSE, ON_STOP, ON_DESTROY}; - - List<Pair<TestEvent, Event>> expected = new ArrayList<>(); - boolean beforeResume = true; - for (Event i : expectedEvents) { - if (beforeResume) { - expected.add(new Pair<>(ACTIVITY_CALLBACK, i)); - expected.add(new Pair<>(LIFECYCLE_EVENT, i)); - } else { - expected.add(new Pair<>(LIFECYCLE_EVENT, i)); - expected.add(new Pair<>(ACTIVITY_CALLBACK, i)); - } - if (i == ON_RESUME) { - beforeResume = false; - } - } - assertThat(results, is(expected)); + TestUtils.waitTillDestroyed(owner, activityTestRule); + List<Pair<TestEvent, Event>> results = owner.copyCollectedEvents(); + assertThat(results, is(flatMap(CREATE, START, RESUME, PAUSE, STOP, DESTROY))); } } diff --git a/android/arch/lifecycle/AndroidViewModel.java b/android/arch/lifecycle/AndroidViewModel.java index 2c7e1739..106b2ef0 100644 --- a/android/arch/lifecycle/AndroidViewModel.java +++ b/android/arch/lifecycle/AndroidViewModel.java @@ -16,7 +16,9 @@ package android.arch.lifecycle; +import android.annotation.SuppressLint; import android.app.Application; +import android.support.annotation.NonNull; /** * Application context aware {@link ViewModel}. @@ -25,16 +27,19 @@ import android.app.Application; * <p> */ public class AndroidViewModel extends ViewModel { + @SuppressLint("StaticFieldLeak") private Application mApplication; - public AndroidViewModel(Application application) { + public AndroidViewModel(@NonNull Application application) { mApplication = application; } /** * Return the application. */ + @NonNull public <T extends Application> T getApplication() { + //noinspection unchecked return (T) mApplication; } } diff --git a/android/arch/lifecycle/ClassesInfoCache.java b/android/arch/lifecycle/ClassesInfoCache.java index f077daed..d88e2762 100644 --- a/android/arch/lifecycle/ClassesInfoCache.java +++ b/android/arch/lifecycle/ClassesInfoCache.java @@ -46,7 +46,7 @@ class ClassesInfoCache { return mHasLifecycleMethods.get(klass); } - Method[] methods = klass.getDeclaredMethods(); + Method[] methods = getDeclaredMethods(klass); for (Method method : methods) { OnLifecycleEvent annotation = method.getAnnotation(OnLifecycleEvent.class); if (annotation != null) { @@ -64,6 +64,18 @@ class ClassesInfoCache { return false; } + private Method[] getDeclaredMethods(Class klass) { + try { + return klass.getDeclaredMethods(); + } catch (NoClassDefFoundError e) { + throw new IllegalArgumentException("The observer class has some methods that use " + + "newer APIs which are not available in the current OS version. Lifecycles " + + "cannot access even other methods so you should make sure that your " + + "observer classes only access framework classes that are available " + + "in your min API level OR use lifecycle:compiler annotation processor.", e); + } + } + CallbackInfo getInfo(Class klass) { CallbackInfo existing = mCallbackMap.get(klass); if (existing != null) { @@ -106,7 +118,7 @@ class ClassesInfoCache { } } - Method[] methods = declaredMethods != null ? declaredMethods : klass.getDeclaredMethods(); + Method[] methods = declaredMethods != null ? declaredMethods : getDeclaredMethods(klass); boolean hasLifecycleMethods = false; for (Method method : methods) { OnLifecycleEvent annotation = method.getAnnotation(OnLifecycleEvent.class); diff --git a/android/arch/lifecycle/Lifecycle.java b/android/arch/lifecycle/Lifecycle.java index 02db5ff9..c0a2090c 100644 --- a/android/arch/lifecycle/Lifecycle.java +++ b/android/arch/lifecycle/Lifecycle.java @@ -17,6 +17,7 @@ package android.arch.lifecycle; import android.support.annotation.MainThread; +import android.support.annotation.NonNull; /** * Defines an object that has an Android Lifecycle. {@link android.support.v4.app.Fragment Fragment} @@ -83,7 +84,7 @@ public abstract class Lifecycle { * @param observer The observer to notify. */ @MainThread - public abstract void addObserver(LifecycleObserver observer); + public abstract void addObserver(@NonNull LifecycleObserver observer); /** * Removes the given observer from the observers list. @@ -99,7 +100,7 @@ public abstract class Lifecycle { * @param observer The observer to be removed. */ @MainThread - public abstract void removeObserver(LifecycleObserver observer); + public abstract void removeObserver(@NonNull LifecycleObserver observer); /** * Returns the current state of the Lifecycle. @@ -193,7 +194,7 @@ public abstract class Lifecycle { * @param state State to compare with * @return true if this State is greater or equal to the given {@code state} */ - public boolean isAtLeast(State state) { + public boolean isAtLeast(@NonNull State state) { return compareTo(state) >= 0; } } diff --git a/android/arch/lifecycle/LifecycleOwner.java b/android/arch/lifecycle/LifecycleOwner.java index 934cf3a2..068bac1b 100644 --- a/android/arch/lifecycle/LifecycleOwner.java +++ b/android/arch/lifecycle/LifecycleOwner.java @@ -16,6 +16,8 @@ package android.arch.lifecycle; +import android.support.annotation.NonNull; + /** * A class that has an Android lifecycle. These events can be used by custom components to * handle lifecycle changes without implementing any code inside the Activity or the Fragment. @@ -29,5 +31,6 @@ public interface LifecycleOwner { * * @return The lifecycle of the provider. */ + @NonNull Lifecycle getLifecycle(); } diff --git a/android/arch/lifecycle/LifecycleRegistry.java b/android/arch/lifecycle/LifecycleRegistry.java index b83e6b8a..bf8aff79 100644 --- a/android/arch/lifecycle/LifecycleRegistry.java +++ b/android/arch/lifecycle/LifecycleRegistry.java @@ -29,9 +29,12 @@ import static android.arch.lifecycle.Lifecycle.State.RESUMED; import static android.arch.lifecycle.Lifecycle.State.STARTED; import android.arch.core.internal.FastSafeIterableMap; +import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.util.Log; +import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Iterator; import java.util.Map.Entry; @@ -44,6 +47,8 @@ import java.util.Map.Entry; */ public class LifecycleRegistry extends Lifecycle { + private static final String LOG_TAG = "LifecycleRegistry"; + /** * Custom list that keeps observers and can handle removals / additions during traversal. * @@ -59,8 +64,12 @@ public class LifecycleRegistry extends Lifecycle { private State mState; /** * The provider that owns this Lifecycle. + * Only WeakReference on LifecycleOwner is kept, so if somebody leaks Lifecycle, they won't leak + * the whole Fragment / Activity. However, to leak Lifecycle object isn't great idea neither, + * because it keeps strong references on all other listeners, so you'll leak all of them as + * well. */ - private final LifecycleOwner mLifecycleOwner; + private final WeakReference<LifecycleOwner> mLifecycleOwner; private int mAddingObserverCounter = 0; @@ -86,19 +95,19 @@ public class LifecycleRegistry extends Lifecycle { * @param provider The owner LifecycleOwner */ public LifecycleRegistry(@NonNull LifecycleOwner provider) { - mLifecycleOwner = provider; + mLifecycleOwner = new WeakReference<>(provider); mState = INITIALIZED; } /** - * Only marks the current state as the given value. It doesn't dispatch any event to its - * listeners. + * Moves the Lifecycle to the given state and dispatches necessary events to the observers. * * @param state new state */ @SuppressWarnings("WeakerAccess") - public void markState(State state) { - mState = state; + @MainThread + public void markState(@NonNull State state) { + moveToState(state); } /** @@ -109,8 +118,16 @@ public class LifecycleRegistry extends Lifecycle { * * @param event The event that was received */ - public void handleLifecycleEvent(Lifecycle.Event event) { - mState = getStateAfter(event); + public void handleLifecycleEvent(@NonNull Lifecycle.Event event) { + State next = getStateAfter(event); + moveToState(next); + } + + private void moveToState(State next) { + if (mState == next) { + return; + } + mState = next; if (mHandlingEvent || mAddingObserverCounter != 0) { mNewEventOccurred = true; // we will figure out what to do on upper level. @@ -140,7 +157,7 @@ public class LifecycleRegistry extends Lifecycle { } @Override - public void addObserver(LifecycleObserver observer) { + public void addObserver(@NonNull LifecycleObserver observer) { State initialState = mState == DESTROYED ? DESTROYED : INITIALIZED; ObserverWithState statefulObserver = new ObserverWithState(observer, initialState); ObserverWithState previous = mObserverMap.putIfAbsent(observer, statefulObserver); @@ -148,15 +165,19 @@ public class LifecycleRegistry extends Lifecycle { if (previous != null) { return; } + LifecycleOwner lifecycleOwner = mLifecycleOwner.get(); + if (lifecycleOwner == null) { + // it is null we should be destroyed. Fallback quickly + return; + } boolean isReentrance = mAddingObserverCounter != 0 || mHandlingEvent; - State targetState = calculateTargetState(observer); mAddingObserverCounter++; while ((statefulObserver.mState.compareTo(targetState) < 0 && mObserverMap.contains(observer))) { pushParentState(statefulObserver.mState); - statefulObserver.dispatchEvent(mLifecycleOwner, upEvent(statefulObserver.mState)); + statefulObserver.dispatchEvent(lifecycleOwner, upEvent(statefulObserver.mState)); popParentState(); // mState / subling may have been changed recalculate targetState = calculateTargetState(observer); @@ -178,7 +199,7 @@ public class LifecycleRegistry extends Lifecycle { } @Override - public void removeObserver(LifecycleObserver observer) { + public void removeObserver(@NonNull LifecycleObserver observer) { // we consciously decided not to send destruction events here in opposition to addObserver. // Our reasons for that: // 1. These events haven't yet happened at all. In contrast to events in addObservers, that @@ -258,7 +279,7 @@ public class LifecycleRegistry extends Lifecycle { throw new IllegalArgumentException("Unexpected state value " + state); } - private void forwardPass() { + private void forwardPass(LifecycleOwner lifecycleOwner) { Iterator<Entry<LifecycleObserver, ObserverWithState>> ascendingIterator = mObserverMap.iteratorWithAdditions(); while (ascendingIterator.hasNext() && !mNewEventOccurred) { @@ -267,13 +288,13 @@ public class LifecycleRegistry extends Lifecycle { while ((observer.mState.compareTo(mState) < 0 && !mNewEventOccurred && mObserverMap.contains(entry.getKey()))) { pushParentState(observer.mState); - observer.dispatchEvent(mLifecycleOwner, upEvent(observer.mState)); + observer.dispatchEvent(lifecycleOwner, upEvent(observer.mState)); popParentState(); } } } - private void backwardPass() { + private void backwardPass(LifecycleOwner lifecycleOwner) { Iterator<Entry<LifecycleObserver, ObserverWithState>> descendingIterator = mObserverMap.descendingIterator(); while (descendingIterator.hasNext() && !mNewEventOccurred) { @@ -283,7 +304,7 @@ public class LifecycleRegistry extends Lifecycle { && mObserverMap.contains(entry.getKey()))) { Event event = downEvent(observer.mState); pushParentState(getStateAfter(event)); - observer.dispatchEvent(mLifecycleOwner, event); + observer.dispatchEvent(lifecycleOwner, event); popParentState(); } } @@ -292,16 +313,22 @@ public class LifecycleRegistry extends Lifecycle { // happens only on the top of stack (never in reentrance), // so it doesn't have to take in account parents private void sync() { + LifecycleOwner lifecycleOwner = mLifecycleOwner.get(); + if (lifecycleOwner == null) { + Log.w(LOG_TAG, "LifecycleOwner is garbage collected, you shouldn't try dispatch " + + "new events from it."); + return; + } while (!isSynced()) { mNewEventOccurred = false; // no need to check eldest for nullability, because isSynced does it for us. if (mState.compareTo(mObserverMap.eldest().getValue().mState) < 0) { - backwardPass(); + backwardPass(lifecycleOwner); } Entry<LifecycleObserver, ObserverWithState> newest = mObserverMap.newest(); if (!mNewEventOccurred && newest != null && mState.compareTo(newest.getValue().mState) > 0) { - forwardPass(); + forwardPass(lifecycleOwner); } } mNewEventOccurred = false; diff --git a/android/arch/lifecycle/LifecycleRegistryOwner.java b/android/arch/lifecycle/LifecycleRegistryOwner.java index 38eeb6d3..0c67fefe 100644 --- a/android/arch/lifecycle/LifecycleRegistryOwner.java +++ b/android/arch/lifecycle/LifecycleRegistryOwner.java @@ -16,6 +16,8 @@ package android.arch.lifecycle; +import android.support.annotation.NonNull; + /** * @deprecated Use {@code android.support.v7.app.AppCompatActivity} * which extends {@link LifecycleOwner}, so there are no use cases for this class. @@ -23,6 +25,7 @@ package android.arch.lifecycle; @SuppressWarnings({"WeakerAccess", "unused"}) @Deprecated public interface LifecycleRegistryOwner extends LifecycleOwner { + @NonNull @Override LifecycleRegistry getLifecycle(); } diff --git a/android/arch/lifecycle/LifecycleRegistryTest.java b/android/arch/lifecycle/LifecycleRegistryTest.java index 6506454d..2a7bbad2 100644 --- a/android/arch/lifecycle/LifecycleRegistryTest.java +++ b/android/arch/lifecycle/LifecycleRegistryTest.java @@ -566,6 +566,25 @@ public class LifecycleRegistryTest { verify(observer).onCreate(); } + private static void forceGc() { + Runtime.getRuntime().gc(); + Runtime.getRuntime().runFinalization(); + Runtime.getRuntime().gc(); + Runtime.getRuntime().runFinalization(); + } + + @Test + public void goneLifecycleOwner() { + fullyInitializeRegistry(); + mLifecycleOwner = null; + forceGc(); + TestObserver observer = mock(TestObserver.class); + mRegistry.addObserver(observer); + verify(observer, never()).onCreate(); + verify(observer, never()).onStart(); + verify(observer, never()).onResume(); + } + private void dispatchEvent(Lifecycle.Event event) { when(mLifecycle.getCurrentState()).thenReturn(LifecycleRegistry.getStateAfter(event)); mRegistry.handleLifecycleEvent(event); diff --git a/android/arch/lifecycle/LiveDataOnSaveInstanceStateTest.java b/android/arch/lifecycle/LiveDataOnSaveInstanceStateTest.java new file mode 100644 index 00000000..836cfff0 --- /dev/null +++ b/android/arch/lifecycle/LiveDataOnSaveInstanceStateTest.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.arch.lifecycle; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import android.app.Instrumentation; +import android.arch.lifecycle.testapp.CollectingSupportActivity; +import android.arch.lifecycle.testapp.CollectingSupportFragment; +import android.arch.lifecycle.testapp.NavigationDialogActivity; +import android.content.Intent; +import android.os.Build; +import android.support.test.InstrumentationRegistry; +import android.support.test.filters.SdkSuppress; +import android.support.test.filters.SmallTest; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; +import android.support.v4.app.FragmentActivity; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.atomic.AtomicInteger; + +@SmallTest +@RunWith(AndroidJUnit4.class) +public class LiveDataOnSaveInstanceStateTest { + @Rule + public ActivityTestRule<CollectingSupportActivity> mActivityTestRule = + new ActivityTestRule<>(CollectingSupportActivity.class); + + @Test + @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.M) + public void liveData_partiallyObscuredActivity_maxSdkM() throws Throwable { + CollectingSupportActivity activity = mActivityTestRule.getActivity(); + + liveData_partiallyObscuredLifecycleOwner_maxSdkM(activity); + } + + @Test + @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.M) + public void liveData_partiallyObscuredActivityWithFragment_maxSdkM() throws Throwable { + CollectingSupportActivity activity = mActivityTestRule.getActivity(); + CollectingSupportFragment fragment = new CollectingSupportFragment(); + mActivityTestRule.runOnUiThread(() -> activity.replaceFragment(fragment)); + + liveData_partiallyObscuredLifecycleOwner_maxSdkM(fragment); + } + + @Test + @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.M) + public void liveData_partiallyObscuredActivityFragmentInFragment_maxSdkM() throws Throwable { + CollectingSupportActivity activity = mActivityTestRule.getActivity(); + CollectingSupportFragment fragment = new CollectingSupportFragment(); + CollectingSupportFragment fragment2 = new CollectingSupportFragment(); + mActivityTestRule.runOnUiThread(() -> { + activity.replaceFragment(fragment); + fragment.replaceFragment(fragment2); + }); + + liveData_partiallyObscuredLifecycleOwner_maxSdkM(fragment2); + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) + public void liveData_partiallyObscuredActivity_minSdkN() throws Throwable { + CollectingSupportActivity activity = mActivityTestRule.getActivity(); + + liveData_partiallyObscuredLifecycleOwner_minSdkN(activity); + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) + public void liveData_partiallyObscuredActivityWithFragment_minSdkN() throws Throwable { + CollectingSupportActivity activity = mActivityTestRule.getActivity(); + CollectingSupportFragment fragment = new CollectingSupportFragment(); + mActivityTestRule.runOnUiThread(() -> activity.replaceFragment(fragment)); + + liveData_partiallyObscuredLifecycleOwner_minSdkN(fragment); + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) + public void liveData_partiallyObscuredActivityFragmentInFragment_minSdkN() throws Throwable { + CollectingSupportActivity activity = mActivityTestRule.getActivity(); + CollectingSupportFragment fragment = new CollectingSupportFragment(); + CollectingSupportFragment fragment2 = new CollectingSupportFragment(); + mActivityTestRule.runOnUiThread(() -> { + activity.replaceFragment(fragment); + fragment.replaceFragment(fragment2); + }); + + liveData_partiallyObscuredLifecycleOwner_minSdkN(fragment2); + } + + private void liveData_partiallyObscuredLifecycleOwner_maxSdkM(LifecycleOwner lifecycleOwner) + throws Throwable { + final AtomicInteger atomicInteger = new AtomicInteger(0); + MutableLiveData<Integer> mutableLiveData = new MutableLiveData<>(); + mActivityTestRule.runOnUiThread(() -> mutableLiveData.setValue(0)); + + TestUtils.waitTillResumed(lifecycleOwner, mActivityTestRule); + + mutableLiveData.observe(lifecycleOwner, atomicInteger::set); + + final FragmentActivity dialogActivity = launchDialog(); + + TestUtils.waitTillCreated(lifecycleOwner, mActivityTestRule); + + // Change the LiveData value and assert that the observer is not called given that the + // lifecycle is in the CREATED state. + mActivityTestRule.runOnUiThread(() -> mutableLiveData.setValue(1)); + assertThat(atomicInteger.get(), is(0)); + + // Finish the dialog Activity, wait for the main activity to be resumed, and assert that + // the observer's onChanged method is called. + mActivityTestRule.runOnUiThread(dialogActivity::finish); + TestUtils.waitTillResumed(lifecycleOwner, mActivityTestRule); + assertThat(atomicInteger.get(), is(1)); + } + + private void liveData_partiallyObscuredLifecycleOwner_minSdkN(LifecycleOwner lifecycleOwner) + throws Throwable { + final AtomicInteger atomicInteger = new AtomicInteger(0); + MutableLiveData<Integer> mutableLiveData = new MutableLiveData<>(); + mActivityTestRule.runOnUiThread(() -> mutableLiveData.setValue(0)); + + TestUtils.waitTillResumed(lifecycleOwner, mActivityTestRule); + + mutableLiveData.observe(lifecycleOwner, atomicInteger::set); + + // Launch the NavigationDialogActivity, partially obscuring the activity, and wait for the + // lifecycleOwner to hit onPause (or enter the STARTED state). On API 24 and above, this + // onPause should be the last lifecycle method called (and the STARTED state should be the + // final resting state). + launchDialog(); + TestUtils.waitTillStarted(lifecycleOwner, mActivityTestRule); + + // Change the LiveData's value and verify that the observer's onChanged method is called + // since we are in the STARTED state. + mActivityTestRule.runOnUiThread(() -> mutableLiveData.setValue(1)); + assertThat(atomicInteger.get(), is(1)); + } + + private FragmentActivity launchDialog() throws Throwable { + Instrumentation.ActivityMonitor monitor = new Instrumentation.ActivityMonitor( + NavigationDialogActivity.class.getCanonicalName(), null, false); + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + instrumentation.addMonitor(monitor); + + FragmentActivity activity = mActivityTestRule.getActivity(); + // helps with less flaky API 16 tests + Intent intent = new Intent(activity, NavigationDialogActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + activity.startActivity(intent); + FragmentActivity fragmentActivity = (FragmentActivity) monitor.waitForActivity(); + TestUtils.waitTillResumed(fragmentActivity, mActivityTestRule); + return fragmentActivity; + } +} diff --git a/android/arch/lifecycle/LiveDataTest.java b/android/arch/lifecycle/LiveDataTest.java index 9f0b4257..647d5d7a 100644 --- a/android/arch/lifecycle/LiveDataTest.java +++ b/android/arch/lifecycle/LiveDataTest.java @@ -53,18 +53,29 @@ import org.mockito.Mockito; public class LiveDataTest { private PublicLiveData<String> mLiveData; private LifecycleOwner mOwner; + private LifecycleOwner mOwner2; private LifecycleRegistry mRegistry; + private LifecycleRegistry mRegistry2; private MethodExec mActiveObserversChanged; private boolean mInObserver; @Before public void init() { mLiveData = new PublicLiveData<>(); + + mActiveObserversChanged = mock(MethodExec.class); + mLiveData.activeObserversChanged = mActiveObserversChanged; + mOwner = mock(LifecycleOwner.class); + mRegistry = new LifecycleRegistry(mOwner); when(mOwner.getLifecycle()).thenReturn(mRegistry); - mActiveObserversChanged = mock(MethodExec.class); - mLiveData.activeObserversChanged = mActiveObserversChanged; + + mOwner2 = mock(LifecycleOwner.class); + + mRegistry2 = new LifecycleRegistry(mOwner2); + when(mOwner2.getLifecycle()).thenReturn(mRegistry2); + mInObserver = false; } @@ -159,14 +170,11 @@ public class LiveDataTest { @Test public void testAddSameObserverIn2LifecycleOwners() { Observer<String> observer = (Observer<String>) mock(Observer.class); - LifecycleOwner owner2 = mock(LifecycleOwner.class); - LifecycleRegistry registry2 = new LifecycleRegistry(owner2); - when(owner2.getLifecycle()).thenReturn(registry2); mLiveData.observe(mOwner, observer); Throwable throwable = null; try { - mLiveData.observe(owner2, observer); + mLiveData.observe(mOwner2, observer); } catch (Throwable t) { throwable = t; } @@ -456,6 +464,210 @@ public class LiveDataTest { inOrder.verifyNoMoreInteractions(); } + @Test + public void setValue_neverActive_observerOnChangedNotCalled() { + Observer<String> observer = (Observer<String>) mock(Observer.class); + mLiveData.observe(mOwner, observer); + + mLiveData.setValue("1"); + + verify(observer, never()).onChanged(anyString()); + } + + @Test + public void setValue_twoObserversTwoStartedOwners_onChangedCalledOnBoth() { + Observer<String> observer1 = mock(Observer.class); + Observer<String> observer2 = mock(Observer.class); + + mLiveData.observe(mOwner, observer1); + mLiveData.observe(mOwner2, observer2); + + mRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); + mRegistry2.handleLifecycleEvent(Lifecycle.Event.ON_START); + + mLiveData.setValue("1"); + + verify(observer1).onChanged("1"); + verify(observer2).onChanged("1"); + } + + @Test + public void setValue_twoObserversOneStartedOwner_onChangedCalledOnOneCorrectObserver() { + Observer<String> observer1 = mock(Observer.class); + Observer<String> observer2 = mock(Observer.class); + + mLiveData.observe(mOwner, observer1); + mLiveData.observe(mOwner2, observer2); + + mRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); + + mLiveData.setValue("1"); + + verify(observer1).onChanged("1"); + verify(observer2, never()).onChanged(anyString()); + } + + @Test + public void setValue_twoObserversBothStartedAfterSetValue_onChangedCalledOnBoth() { + Observer<String> observer1 = mock(Observer.class); + Observer<String> observer2 = mock(Observer.class); + + mLiveData.observe(mOwner, observer1); + mLiveData.observe(mOwner2, observer2); + + mLiveData.setValue("1"); + + mRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); + mRegistry2.handleLifecycleEvent(Lifecycle.Event.ON_START); + + verify(observer1).onChanged("1"); + verify(observer1).onChanged("1"); + } + + @Test + public void setValue_twoObserversOneStartedAfterSetValue_onChangedCalledOnCorrectObserver() { + Observer<String> observer1 = mock(Observer.class); + Observer<String> observer2 = mock(Observer.class); + + mLiveData.observe(mOwner, observer1); + mLiveData.observe(mOwner2, observer2); + + mLiveData.setValue("1"); + + mRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); + + verify(observer1).onChanged("1"); + verify(observer2, never()).onChanged(anyString()); + } + + @Test + public void setValue_twoObserversOneStarted_liveDataBecomesActive() { + Observer<String> observer1 = mock(Observer.class); + Observer<String> observer2 = mock(Observer.class); + + mLiveData.observe(mOwner, observer1); + mLiveData.observe(mOwner2, observer2); + + mRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); + + verify(mActiveObserversChanged).onCall(true); + } + + @Test + public void setValue_twoObserversOneStopped_liveDataStaysActive() { + Observer<String> observer1 = mock(Observer.class); + Observer<String> observer2 = mock(Observer.class); + + mLiveData.observe(mOwner, observer1); + mLiveData.observe(mOwner2, observer2); + + mRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); + mRegistry2.handleLifecycleEvent(Lifecycle.Event.ON_START); + + verify(mActiveObserversChanged).onCall(true); + + reset(mActiveObserversChanged); + mRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP); + + verify(mActiveObserversChanged, never()).onCall(anyBoolean()); + } + + /** + * Verifies that if a lifecycle's state changes without an event, and changes to something that + * LiveData would become inactive in response to, LiveData will detect the change upon new data + * being set and become inactive. Also verifies that once the lifecycle enters into a state + * that LiveData should become active to, that it does indeed become active. + */ + @Test + public void liveDataActiveStateIsManagedCorrectlyWithoutEvent_oneObserver() { + Observer<String> observer = (Observer<String>) mock(Observer.class); + mLiveData.observe(mOwner, observer); + + mRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); + + // Marking state as CREATED should call onInactive. + reset(mActiveObserversChanged); + mRegistry.markState(Lifecycle.State.CREATED); + verify(mActiveObserversChanged).onCall(false); + reset(mActiveObserversChanged); + + // Setting a new value should trigger LiveData to realize the Lifecycle it is observing + // is in a state where the LiveData should be inactive, so the LiveData will call onInactive + // and the Observer shouldn't be affected. + mLiveData.setValue("1"); + + // state is already CREATED so should not call again + verify(mActiveObserversChanged, never()).onCall(anyBoolean()); + verify(observer, never()).onChanged(anyString()); + + // Sanity check. Because we've only marked the state as CREATED, sending ON_START + // should re-dispatch events. + reset(mActiveObserversChanged); + reset(observer); + mRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); + verify(mActiveObserversChanged).onCall(true); + verify(observer).onChanged("1"); + } + + /** + * This test verifies that LiveData will detect changes in LifecycleState that would make it + * inactive upon the setting of new data, but only if all of the Lifecycles it's observing + * are all in those states. It also makes sure that once it is inactive, that it will become + * active again once one of the lifecycles it's observing moves to an appropriate state. + */ + @Test + public void liveDataActiveStateIsManagedCorrectlyWithoutEvent_twoObservers() { + Observer<String> observer1 = mock(Observer.class); + Observer<String> observer2 = mock(Observer.class); + + mLiveData.observe(mOwner, observer1); + mLiveData.observe(mOwner2, observer2); + + mRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); + mRegistry2.handleLifecycleEvent(Lifecycle.Event.ON_START); + + // Marking the state to created won't change LiveData to be inactive. + reset(mActiveObserversChanged); + mRegistry.markState(Lifecycle.State.CREATED); + verify(mActiveObserversChanged, never()).onCall(anyBoolean()); + + // After setting a value, the LiveData will stay active because there is still a STARTED + // lifecycle being observed. The one Observer associated with the STARTED lifecycle will + // also have been called, but the other Observer will not have been called. + reset(observer1); + reset(observer2); + mLiveData.setValue("1"); + verify(mActiveObserversChanged, never()).onCall(anyBoolean()); + verify(observer1, never()).onChanged(anyString()); + verify(observer2).onChanged("1"); + + // Now we set the other Lifecycle to be inactive, live data should become inactive. + reset(observer1); + reset(observer2); + mRegistry2.markState(Lifecycle.State.CREATED); + verify(mActiveObserversChanged).onCall(false); + verify(observer1, never()).onChanged(anyString()); + verify(observer2, never()).onChanged(anyString()); + + // Now we post another value, because both lifecycles are in the Created state, live data + // will not dispatch any values + reset(mActiveObserversChanged); + mLiveData.setValue("2"); + verify(mActiveObserversChanged, never()).onCall(anyBoolean()); + verify(observer1, never()).onChanged(anyString()); + verify(observer2, never()).onChanged(anyString()); + + // Now that the first Lifecycle has been moved back to the Resumed state, the LiveData will + // be made active and it's associated Observer will be called with the new value, but the + // Observer associated with the Lifecycle that is still in the Created state won't be + // called. + reset(mActiveObserversChanged); + mRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME); + verify(mActiveObserversChanged).onCall(true); + verify(observer1).onChanged("2"); + verify(observer2, never()).onChanged(anyString()); + } + @SuppressWarnings("WeakerAccess") static class PublicLiveData<T> extends LiveData<T> { // cannot spy due to internal calls diff --git a/android/arch/lifecycle/MediatorLiveData.java b/android/arch/lifecycle/MediatorLiveData.java index 672b3a3b..58647394 100644 --- a/android/arch/lifecycle/MediatorLiveData.java +++ b/android/arch/lifecycle/MediatorLiveData.java @@ -19,16 +19,49 @@ package android.arch.lifecycle; import android.arch.core.internal.SafeIterableMap; import android.support.annotation.CallSuper; import android.support.annotation.MainThread; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import java.util.Map; /** - * {@link LiveData} subclass which may observer other {@code LiveData} objects and react on + * {@link LiveData} subclass which may observe other {@code LiveData} objects and react on * {@code OnChanged} events from them. * <p> * This class correctly propagates its active/inactive states down to source {@code LiveData} * objects. + * <p> + * Consider the following scenario: we have 2 instances of {@code LiveData}, let's name them + * {@code liveData1} and {@code liveData2}, and we want to merge their emissions in one object: + * {@code liveDataMerger}. Then, {@code liveData1} and {@code liveData2} will become sources for + * the {@code MediatorLiveData liveDataMerger} and every time {@code onChanged} callback + * is called for either of them, we set a new value in {@code liveDataMerger}. + * + * <pre> + * LiveData<Integer> liveData1 = ...; + * LiveData<Integer> liveData2 = ...; + * + * MediatorLiveData<Integer> liveDataMerger = new MediatorLiveData<>(); + * liveDataMerger.addSource(liveData1, value -> liveDataMerger.setValue(value)); + * liveDataMerger.addSource(liveData2, value -> liveDataMerger.setValue(value)); + * </pre> + * <p> + * Let's consider that we only want 10 values emitted by {@code liveData1}, to be + * merged in the {@code liveDataMerger}. Then, after 10 values, we can stop listening to {@code + * liveData1} and remove it as a source. + * <pre> + * liveDataMerger.addSource(liveData1, new Observer<Integer>() { + * private int count = 1; + * + * {@literal @}Override public void onChanged(@Nullable Integer s) { + * count++; + * liveDataMerger.setValue(s); + * if (count > 10) { + * liveDataMerger.removeSource(liveData1); + * } + * } + * }); + * </pre> * * @param <T> The type of data hold by this instance */ @@ -49,7 +82,7 @@ public class MediatorLiveData<T> extends MutableLiveData<T> { * @param <S> The type of data hold by {@code source} LiveData */ @MainThread - public <S> void addSource(LiveData<S> source, Observer<S> onChanged) { + public <S> void addSource(@NonNull LiveData<S> source, @NonNull Observer<S> onChanged) { Source<S> e = new Source<>(source, onChanged); Source<?> existing = mSources.putIfAbsent(source, e); if (existing != null && existing.mObserver != onChanged) { @@ -71,7 +104,7 @@ public class MediatorLiveData<T> extends MutableLiveData<T> { * @param <S> the type of data hold by {@code source} LiveData */ @MainThread - public <S> void removeSource(LiveData<S> toRemote) { + public <S> void removeSource(@NonNull LiveData<S> toRemote) { Source<?> source = mSources.remove(toRemote); if (source != null) { source.unplug(); diff --git a/android/arch/lifecycle/MissingClassTest.java b/android/arch/lifecycle/MissingClassTest.java new file mode 100644 index 00000000..81a07564 --- /dev/null +++ b/android/arch/lifecycle/MissingClassTest.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.arch.lifecycle; + +import android.app.PictureInPictureParams; +import android.os.Build; +import android.support.test.filters.SdkSuppress; +import android.support.test.filters.SmallTest; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +@SdkSuppress(maxSdkVersion = Build.VERSION_CODES.N_MR1) +@SmallTest +public class MissingClassTest { + public static class ObserverWithMissingClasses { + @SuppressWarnings("unused") + public void newApiMethod(PictureInPictureParams params) {} + + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) + public void onResume() {} + } + + @Test(expected = IllegalArgumentException.class) + public void testMissingApi() { + new ReflectiveGenericLifecycleObserver(new ObserverWithMissingClasses()); + } +} diff --git a/android/arch/lifecycle/PartiallyCoveredActivityTest.java b/android/arch/lifecycle/PartiallyCoveredActivityTest.java new file mode 100644 index 00000000..07a9dc5a --- /dev/null +++ b/android/arch/lifecycle/PartiallyCoveredActivityTest.java @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.arch.lifecycle; + +import static android.arch.lifecycle.Lifecycle.Event.ON_RESUME; +import static android.arch.lifecycle.Lifecycle.Event.ON_START; +import static android.arch.lifecycle.Lifecycle.Event.ON_STOP; +import static android.arch.lifecycle.TestUtils.OrderedTuples.CREATE; +import static android.arch.lifecycle.TestUtils.OrderedTuples.DESTROY; +import static android.arch.lifecycle.TestUtils.OrderedTuples.PAUSE; +import static android.arch.lifecycle.TestUtils.OrderedTuples.RESUME; +import static android.arch.lifecycle.TestUtils.OrderedTuples.START; +import static android.arch.lifecycle.TestUtils.OrderedTuples.STOP; +import static android.arch.lifecycle.TestUtils.flatMap; +import static android.arch.lifecycle.testapp.TestEvent.LIFECYCLE_EVENT; +import static android.arch.lifecycle.testapp.TestEvent.OWNER_CALLBACK; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; + +import android.app.Instrumentation; +import android.arch.lifecycle.testapp.CollectingLifecycleOwner; +import android.arch.lifecycle.testapp.CollectingSupportActivity; +import android.arch.lifecycle.testapp.CollectingSupportFragment; +import android.arch.lifecycle.testapp.NavigationDialogActivity; +import android.arch.lifecycle.testapp.TestEvent; +import android.content.Intent; +import android.os.Build; +import android.support.test.InstrumentationRegistry; +import android.support.test.filters.LargeTest; +import android.support.test.rule.ActivityTestRule; +import android.support.v4.app.FragmentActivity; +import android.support.v4.util.Pair; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.List; +import java.util.concurrent.ExecutionException; + +/** + * Runs tests about the state when an activity is partially covered by another activity. Pre + * API 24, framework behavior changes so the test rely on whether state is saved or not and makes + * assertions accordingly. + */ +@SuppressWarnings("unchecked") +@RunWith(Parameterized.class) +@LargeTest +public class PartiallyCoveredActivityTest { + private static final List[] IF_SAVED = new List[]{ + // when overlaid + flatMap(CREATE, START, RESUME, PAUSE, + singletonList(new Pair<>(LIFECYCLE_EVENT, ON_STOP))), + // post dialog dismiss + asList(new Pair<>(OWNER_CALLBACK, ON_RESUME), + new Pair<>(LIFECYCLE_EVENT, ON_START), + new Pair<>(LIFECYCLE_EVENT, ON_RESUME)), + // post finish + flatMap(PAUSE, STOP, DESTROY)}; + + private static final List[] IF_NOT_SAVED = new List[]{ + // when overlaid + flatMap(CREATE, START, RESUME, PAUSE), + // post dialog dismiss + flatMap(RESUME), + // post finish + flatMap(PAUSE, STOP, DESTROY)}; + + private static final boolean sShouldSave = Build.VERSION.SDK_INT < Build.VERSION_CODES.N; + private static final List<Pair<TestEvent, Lifecycle.Event>>[] EXPECTED = + sShouldSave ? IF_SAVED : IF_NOT_SAVED; + + @Rule + public ActivityTestRule<CollectingSupportActivity> activityRule = + new ActivityTestRule<CollectingSupportActivity>( + CollectingSupportActivity.class) { + @Override + protected Intent getActivityIntent() { + // helps with less flaky API 16 tests + Intent intent = new Intent(InstrumentationRegistry.getTargetContext(), + CollectingSupportActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + return intent; + } + }; + private final boolean mDismissDialog; + + @Parameterized.Parameters(name = "dismissDialog_{0}") + public static List<Boolean> dismissDialog() { + return asList(true, false); + } + + public PartiallyCoveredActivityTest(boolean dismissDialog) { + mDismissDialog = dismissDialog; + } + + @Test + public void coveredWithDialog_activity() throws Throwable { + final CollectingSupportActivity activity = activityRule.getActivity(); + runTest(activity); + } + + @Test + public void coveredWithDialog_fragment() throws Throwable { + CollectingSupportFragment fragment = new CollectingSupportFragment(); + activityRule.runOnUiThread(() -> activityRule.getActivity().replaceFragment(fragment)); + runTest(fragment); + } + + @Test + public void coveredWithDialog_childFragment() throws Throwable { + CollectingSupportFragment parentFragment = new CollectingSupportFragment(); + CollectingSupportFragment childFragment = new CollectingSupportFragment(); + activityRule.runOnUiThread(() -> { + activityRule.getActivity().replaceFragment(parentFragment); + parentFragment.replaceFragment(childFragment); + }); + runTest(childFragment); + } + + private void runTest(CollectingLifecycleOwner owner) throws Throwable { + TestUtils.waitTillResumed(owner, activityRule); + FragmentActivity dialog = launchDialog(); + assertStateSaving(); + waitForIdle(); + assertThat(owner.copyCollectedEvents(), is(EXPECTED[0])); + List<Pair<TestEvent, Lifecycle.Event>> expected; + if (mDismissDialog) { + dialog.finish(); + TestUtils.waitTillResumed(activityRule.getActivity(), activityRule); + assertThat(owner.copyCollectedEvents(), is(flatMap(EXPECTED[0], EXPECTED[1]))); + expected = flatMap(EXPECTED[0], EXPECTED[1], EXPECTED[2]); + } else { + expected = flatMap(CREATE, START, RESUME, PAUSE, STOP, DESTROY); + } + CollectingSupportActivity activity = activityRule.getActivity(); + activityRule.finishActivity(); + TestUtils.waitTillDestroyed(activity, activityRule); + assertThat(owner.copyCollectedEvents(), is(expected)); + } + + // test sanity + private void assertStateSaving() throws ExecutionException, InterruptedException { + final CollectingSupportActivity activity = activityRule.getActivity(); + if (sShouldSave) { + // state should be saved. wait for it to be saved + assertThat("test sanity", + activity.waitForStateSave(20), is(true)); + assertThat("test sanity", activity.getSupportFragmentManager() + .isStateSaved(), is(true)); + } else { + // should should not be saved + assertThat("test sanity", activity.getSupportFragmentManager() + .isStateSaved(), is(false)); + } + } + + private void waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + private FragmentActivity launchDialog() throws Throwable { + Instrumentation.ActivityMonitor monitor = new Instrumentation.ActivityMonitor( + NavigationDialogActivity.class.getCanonicalName(), null, false); + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + instrumentation.addMonitor(monitor); + + FragmentActivity activity = activityRule.getActivity(); + + Intent intent = new Intent(activity, NavigationDialogActivity.class); + // disabling animations helps with less flaky API 16 tests + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + activity.startActivity(intent); + FragmentActivity fragmentActivity = (FragmentActivity) monitor.waitForActivity(); + TestUtils.waitTillResumed(fragmentActivity, activityRule); + return fragmentActivity; + } +} diff --git a/android/arch/lifecycle/ProcessLifecycleOwner.java b/android/arch/lifecycle/ProcessLifecycleOwner.java index e2a12563..74ea97f7 100644 --- a/android/arch/lifecycle/ProcessLifecycleOwner.java +++ b/android/arch/lifecycle/ProcessLifecycleOwner.java @@ -22,6 +22,7 @@ import android.arch.lifecycle.ReportFragment.ActivityInitializationListener; import android.content.Context; import android.os.Bundle; import android.os.Handler; +import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; /** @@ -156,7 +157,7 @@ public class ProcessLifecycleOwner implements LifecycleOwner { app.registerActivityLifecycleCallbacks(new EmptyActivityLifecycleCallbacks() { @Override public void onActivityCreated(Activity activity, Bundle savedInstanceState) { - ReportFragment .get(activity).setProcessListener(mInitializationListener); + ReportFragment.get(activity).setProcessListener(mInitializationListener); } @Override @@ -171,6 +172,7 @@ public class ProcessLifecycleOwner implements LifecycleOwner { }); } + @NonNull @Override public Lifecycle getLifecycle() { return mRegistry; diff --git a/android/arch/lifecycle/LifecycleRuntimeTrojanProvider.java b/android/arch/lifecycle/ProcessLifecycleOwnerInitializer.java index ac278c0c..6cf80d2d 100644 --- a/android/arch/lifecycle/LifecycleRuntimeTrojanProvider.java +++ b/android/arch/lifecycle/ProcessLifecycleOwnerInitializer.java @@ -29,7 +29,7 @@ import android.support.annotation.RestrictTo; * @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -public class LifecycleRuntimeTrojanProvider extends ContentProvider { +public class ProcessLifecycleOwnerInitializer extends ContentProvider { @Override public boolean onCreate() { LifecycleDispatcher.init(getContext()); diff --git a/android/arch/lifecycle/ProcessOwnerTest.java b/android/arch/lifecycle/ProcessOwnerTest.java index 37bdcdb4..77baf94c 100644 --- a/android/arch/lifecycle/ProcessOwnerTest.java +++ b/android/arch/lifecycle/ProcessOwnerTest.java @@ -31,6 +31,7 @@ import android.arch.lifecycle.Lifecycle.Event; import android.arch.lifecycle.testapp.NavigationDialogActivity; import android.arch.lifecycle.testapp.NavigationTestActivityFirst; import android.arch.lifecycle.testapp.NavigationTestActivitySecond; +import android.arch.lifecycle.testapp.NonSupportActivity; import android.content.Context; import android.content.Intent; import android.support.test.InstrumentationRegistry; @@ -95,6 +96,22 @@ public class ProcessOwnerTest { } @Test + public void testNavigationToNonSupport() throws Throwable { + FragmentActivity firstActivity = setupObserverOnResume(); + Instrumentation.ActivityMonitor monitor = new Instrumentation.ActivityMonitor( + NonSupportActivity.class.getCanonicalName(), null, false); + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + instrumentation.addMonitor(monitor); + + Intent intent = new Intent(firstActivity, NonSupportActivity.class); + firstActivity.finish(); + firstActivity.startActivity(intent); + NonSupportActivity secondActivity = (NonSupportActivity) monitor.waitForActivity(); + assertThat("Failed to navigate", secondActivity, notNullValue()); + checkProcessObserverSilent(secondActivity); + } + + @Test public void testRecreation() throws Throwable { FragmentActivity activity = setupObserverOnResume(); FragmentActivity recreated = TestUtils.recreateActivity(activity, activityTestRule); @@ -164,4 +181,11 @@ public class ProcessOwnerTest { activityTestRule.runOnUiThread(() -> ProcessLifecycleOwner.get().getLifecycle().removeObserver(mObserver)); } + + private void checkProcessObserverSilent(NonSupportActivity activity) throws Throwable { + assertThat(activity.awaitResumedState(), is(true)); + assertThat(mObserver.mChangedState, is(false)); + activityTestRule.runOnUiThread(() -> + ProcessLifecycleOwner.get().getLifecycle().removeObserver(mObserver)); + } } diff --git a/android/arch/lifecycle/ReportFragment.java b/android/arch/lifecycle/ReportFragment.java index 3e4ece82..16a89ce8 100644 --- a/android/arch/lifecycle/ReportFragment.java +++ b/android/arch/lifecycle/ReportFragment.java @@ -28,7 +28,6 @@ import android.support.annotation.RestrictTo; */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public class ReportFragment extends Fragment { - private static final String REPORT_FRAGMENT_TAG = "android.arch.lifecycle" + ".LifecycleDispatcher.report_fragment_tag"; diff --git a/android/arch/lifecycle/TestUtils.java b/android/arch/lifecycle/TestUtils.java index f0214bfb..f7f9bbe5 100644 --- a/android/arch/lifecycle/TestUtils.java +++ b/android/arch/lifecycle/TestUtils.java @@ -16,16 +16,35 @@ package android.arch.lifecycle; +import static android.arch.lifecycle.Lifecycle.Event.ON_CREATE; +import static android.arch.lifecycle.Lifecycle.Event.ON_DESTROY; +import static android.arch.lifecycle.Lifecycle.Event.ON_PAUSE; +import static android.arch.lifecycle.Lifecycle.Event.ON_RESUME; +import static android.arch.lifecycle.Lifecycle.Event.ON_START; +import static android.arch.lifecycle.Lifecycle.Event.ON_STOP; +import static android.arch.lifecycle.Lifecycle.State.CREATED; +import static android.arch.lifecycle.Lifecycle.State.DESTROYED; import static android.arch.lifecycle.Lifecycle.State.RESUMED; +import static android.arch.lifecycle.Lifecycle.State.STARTED; +import static android.arch.lifecycle.testapp.TestEvent.LIFECYCLE_EVENT; +import static android.arch.lifecycle.testapp.TestEvent.OWNER_CALLBACK; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; import android.app.Activity; import android.app.Instrumentation; import android.app.Instrumentation.ActivityMonitor; +import android.arch.lifecycle.testapp.TestEvent; import android.support.test.InstrumentationRegistry; import android.support.test.rule.ActivityTestRule; -import android.support.v4.app.FragmentActivity; +import android.support.v4.util.Pair; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; class TestUtils { @@ -61,23 +80,88 @@ class TestUtils { return result; } - static void waitTillResumed(final FragmentActivity a, ActivityTestRule<?> activityRule) + static void waitTillCreated(final LifecycleOwner owner, ActivityTestRule<?> activityRule) + throws Throwable { + waitTillState(owner, activityRule, CREATED); + } + + static void waitTillStarted(final LifecycleOwner owner, ActivityTestRule<?> activityRule) + throws Throwable { + waitTillState(owner, activityRule, STARTED); + } + + static void waitTillResumed(final LifecycleOwner owner, ActivityTestRule<?> activityRule) + throws Throwable { + waitTillState(owner, activityRule, RESUMED); + } + + static void waitTillDestroyed(final LifecycleOwner owner, ActivityTestRule<?> activityRule) + throws Throwable { + waitTillState(owner, activityRule, DESTROYED); + } + + static void waitTillState(final LifecycleOwner owner, ActivityTestRule<?> activityRule, + Lifecycle.State state) throws Throwable { final CountDownLatch latch = new CountDownLatch(1); activityRule.runOnUiThread(() -> { - Lifecycle.State currentState = a.getLifecycle().getCurrentState(); - if (currentState == RESUMED) { + Lifecycle.State currentState = owner.getLifecycle().getCurrentState(); + if (currentState == state) { latch.countDown(); + } else { + owner.getLifecycle().addObserver(new LifecycleObserver() { + @OnLifecycleEvent(Lifecycle.Event.ON_ANY) + public void onStateChanged(LifecycleOwner provider) { + if (provider.getLifecycle().getCurrentState() == state) { + latch.countDown(); + provider.getLifecycle().removeObserver(this); + } + } + }); } - a.getLifecycle().addObserver(new LifecycleObserver() { - @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) - public void onStateChanged(LifecycleOwner provider) { - latch.countDown(); - provider.getLifecycle().removeObserver(this); - } - }); }); - latch.await(); + boolean latchResult = latch.await(1, TimeUnit.MINUTES); + assertThat("expected " + state + " never happened. Current state:" + + owner.getLifecycle().getCurrentState(), latchResult, is(true)); + + // wait for another loop to ensure all observers are called + activityRule.runOnUiThread(() -> { + // do nothing + }); } + @SafeVarargs + static <T> List<T> flatMap(List<T>... items) { + ArrayList<T> result = new ArrayList<>(); + for (List<T> item : items) { + result.addAll(item); + } + return result; + } + + /** + * Event tuples of {@link TestEvent} and {@link Lifecycle.Event} + * in the order they should arrive. + */ + @SuppressWarnings("unchecked") + static class OrderedTuples { + static final List<Pair<TestEvent, Lifecycle.Event>> CREATE = + Arrays.asList(new Pair(OWNER_CALLBACK, ON_CREATE), + new Pair(LIFECYCLE_EVENT, ON_CREATE)); + static final List<Pair<TestEvent, Lifecycle.Event>> START = + Arrays.asList(new Pair(OWNER_CALLBACK, ON_START), + new Pair(LIFECYCLE_EVENT, ON_START)); + static final List<Pair<TestEvent, Lifecycle.Event>> RESUME = + Arrays.asList(new Pair(OWNER_CALLBACK, ON_RESUME), + new Pair(LIFECYCLE_EVENT, ON_RESUME)); + static final List<Pair<TestEvent, Lifecycle.Event>> PAUSE = + Arrays.asList(new Pair(LIFECYCLE_EVENT, ON_PAUSE), + new Pair(OWNER_CALLBACK, ON_PAUSE)); + static final List<Pair<TestEvent, Lifecycle.Event>> STOP = + Arrays.asList(new Pair(LIFECYCLE_EVENT, ON_STOP), + new Pair(OWNER_CALLBACK, ON_STOP)); + static final List<Pair<TestEvent, Lifecycle.Event>> DESTROY = + Arrays.asList(new Pair(LIFECYCLE_EVENT, ON_DESTROY), + new Pair(OWNER_CALLBACK, ON_DESTROY)); + } } diff --git a/android/arch/lifecycle/Transformations.java b/android/arch/lifecycle/Transformations.java index 9ce9cbb7..c735f8ba 100644 --- a/android/arch/lifecycle/Transformations.java +++ b/android/arch/lifecycle/Transformations.java @@ -18,6 +18,7 @@ package android.arch.lifecycle; import android.arch.core.util.Function; import android.support.annotation.MainThread; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; /** @@ -60,7 +61,8 @@ public class Transformations { * @return a LiveData which emits resulting values */ @MainThread - public static <X, Y> LiveData<Y> map(LiveData<X> source, final Function<X, Y> func) { + public static <X, Y> LiveData<Y> map(@NonNull LiveData<X> source, + @NonNull final Function<X, Y> func) { final MediatorLiveData<Y> result = new MediatorLiveData<>(); result.addSource(source, new Observer<X>() { @Override @@ -120,8 +122,8 @@ public class Transformations { * @param <Y> a type of resulting LiveData */ @MainThread - public static <X, Y> LiveData<Y> switchMap(LiveData<X> trigger, - final Function<X, LiveData<Y>> func) { + public static <X, Y> LiveData<Y> switchMap(@NonNull LiveData<X> trigger, + @NonNull final Function<X, LiveData<Y>> func) { final MediatorLiveData<Y> result = new MediatorLiveData<>(); result.addSource(trigger, new Observer<X>() { LiveData<Y> mSource; diff --git a/android/arch/lifecycle/ViewModelProvider.java b/android/arch/lifecycle/ViewModelProvider.java index 7ef591f3..29cbab8e 100644 --- a/android/arch/lifecycle/ViewModelProvider.java +++ b/android/arch/lifecycle/ViewModelProvider.java @@ -43,7 +43,8 @@ public class ViewModelProvider { * @param <T> The type parameter for the ViewModel. * @return a newly created ViewModel */ - <T extends ViewModel> T create(Class<T> modelClass); + @NonNull + <T extends ViewModel> T create(@NonNull Class<T> modelClass); } private final Factory mFactory; @@ -70,7 +71,7 @@ public class ViewModelProvider { * @param factory factory a {@code Factory} which will be used to instantiate * new {@code ViewModels} */ - public ViewModelProvider(ViewModelStore store, Factory factory) { + public ViewModelProvider(@NonNull ViewModelStore store, @NonNull Factory factory) { mFactory = factory; this.mViewModelStore = store; } @@ -88,7 +89,8 @@ public class ViewModelProvider { * @param <T> The type parameter for the ViewModel. * @return A ViewModel that is an instance of the given type {@code T}. */ - public <T extends ViewModel> T get(Class<T> modelClass) { + @NonNull + public <T extends ViewModel> T get(@NonNull Class<T> modelClass) { String canonicalName = modelClass.getCanonicalName(); if (canonicalName == null) { throw new IllegalArgumentException("Local and anonymous classes can not be ViewModels"); @@ -136,8 +138,9 @@ public class ViewModelProvider { */ public static class NewInstanceFactory implements Factory { + @NonNull @Override - public <T extends ViewModel> T create(Class<T> modelClass) { + public <T extends ViewModel> T create(@NonNull Class<T> modelClass) { //noinspection TryWithIdenticalCatches try { return modelClass.newInstance(); diff --git a/android/arch/lifecycle/ViewModelProviders.java b/android/arch/lifecycle/ViewModelProviders.java index 746162a9..b4b20aa4 100644 --- a/android/arch/lifecycle/ViewModelProviders.java +++ b/android/arch/lifecycle/ViewModelProviders.java @@ -139,8 +139,9 @@ public class ViewModelProviders { mApplication = application; } + @NonNull @Override - public <T extends ViewModel> T create(Class<T> modelClass) { + public <T extends ViewModel> T create(@NonNull Class<T> modelClass) { if (AndroidViewModel.class.isAssignableFrom(modelClass)) { //noinspection TryWithIdenticalCatches try { diff --git a/android/arch/lifecycle/ViewModelStoreOwner.java b/android/arch/lifecycle/ViewModelStoreOwner.java index 50583056..e26fa325 100644 --- a/android/arch/lifecycle/ViewModelStoreOwner.java +++ b/android/arch/lifecycle/ViewModelStoreOwner.java @@ -16,6 +16,8 @@ package android.arch.lifecycle; +import android.support.annotation.NonNull; + /** * A scope that owns {@link ViewModelStore}. * <p> @@ -30,5 +32,6 @@ public interface ViewModelStoreOwner { * * @return a {@code ViewModelStore} */ + @NonNull ViewModelStore getViewModelStore(); } diff --git a/android/arch/lifecycle/ViewModelStores.java b/android/arch/lifecycle/ViewModelStores.java index 8c17dd98..d7d769d6 100644 --- a/android/arch/lifecycle/ViewModelStores.java +++ b/android/arch/lifecycle/ViewModelStores.java @@ -19,6 +19,7 @@ package android.arch.lifecycle; import static android.arch.lifecycle.HolderFragment.holderFragmentFor; import android.support.annotation.MainThread; +import android.support.annotation.NonNull; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentActivity; @@ -38,7 +39,7 @@ public class ViewModelStores { * @return a {@code ViewModelStore} */ @MainThread - public static ViewModelStore of(FragmentActivity activity) { + public static ViewModelStore of(@NonNull FragmentActivity activity) { return holderFragmentFor(activity).getViewModelStore(); } @@ -49,7 +50,7 @@ public class ViewModelStores { * @return a {@code ViewModelStore} */ @MainThread - public static ViewModelStore of(Fragment fragment) { + public static ViewModelStore of(@NonNull Fragment fragment) { return holderFragmentFor(fragment).getViewModelStore(); } } diff --git a/android/arch/lifecycle/testapp/CollectingActivity.java b/android/arch/lifecycle/testapp/CollectingLifecycleOwner.java index 6e243b6c..4213cab9 100644 --- a/android/arch/lifecycle/testapp/CollectingActivity.java +++ b/android/arch/lifecycle/testapp/CollectingLifecycleOwner.java @@ -17,21 +17,20 @@ package android.arch.lifecycle.testapp; import android.arch.lifecycle.Lifecycle; -import android.util.Pair; +import android.arch.lifecycle.LifecycleOwner; +import android.support.v4.util.Pair; import java.util.List; /** * For activities that collect their events. */ -public interface CollectingActivity { - long TIMEOUT = 5; - +public interface CollectingLifecycleOwner extends LifecycleOwner { /** - * Return collected events + * Return a copy of currently collected events * * @return The list of collected events. * @throws InterruptedException */ - List<Pair<TestEvent, Lifecycle.Event>> waitForCollectedEvents() throws InterruptedException; + List<Pair<TestEvent, Lifecycle.Event>> copyCollectedEvents(); } diff --git a/android/arch/lifecycle/testapp/CollectingSupportActivity.java b/android/arch/lifecycle/testapp/CollectingSupportActivity.java new file mode 100644 index 00000000..f38d4224 --- /dev/null +++ b/android/arch/lifecycle/testapp/CollectingSupportActivity.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.arch.lifecycle.testapp; + +import static android.arch.lifecycle.testapp.TestEvent.OWNER_CALLBACK; + +import android.arch.lifecycle.Lifecycle.Event; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentActivity; +import android.support.v4.util.Pair; +import android.widget.FrameLayout; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * LifecycleRegistryOwner that extends FragmentActivity. + */ +public class CollectingSupportActivity extends FragmentActivity implements + CollectingLifecycleOwner { + + private final List<Pair<TestEvent, Event>> mCollectedEvents = new ArrayList<>(); + private TestObserver mTestObserver = new TestObserver(mCollectedEvents); + private CountDownLatch mSavedStateLatch = new CountDownLatch(1); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + FrameLayout layout = new FrameLayout(this); + layout.setId(R.id.fragment_container); + setContentView(layout); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Event.ON_CREATE)); + getLifecycle().addObserver(mTestObserver); + } + + /** + * replaces the main content fragment w/ the given fragment. + */ + public void replaceFragment(Fragment fragment) { + getSupportFragmentManager() + .beginTransaction() + .add(R.id.fragment_container, fragment) + .commitNow(); + } + + @Override + protected void onStart() { + super.onStart(); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Event.ON_START)); + } + + @Override + protected void onResume() { + super.onResume(); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Event.ON_RESUME)); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Event.ON_DESTROY)); + } + + @Override + protected void onStop() { + super.onStop(); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Event.ON_STOP)); + } + + @Override + protected void onPause() { + super.onPause(); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Event.ON_PAUSE)); + // helps with less flaky API 16 tests. + overridePendingTransition(0, 0); + } + + @Override + public List<Pair<TestEvent, Event>> copyCollectedEvents() { + return new ArrayList<>(mCollectedEvents); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + mSavedStateLatch.countDown(); + } + + /** + * Waits for onSaveInstanceState to be called. + */ + public boolean waitForStateSave(@SuppressWarnings("SameParameterValue") int seconds) + throws InterruptedException { + return mSavedStateLatch.await(seconds, TimeUnit.SECONDS); + } +} diff --git a/android/arch/lifecycle/testapp/CollectingSupportFragment.java b/android/arch/lifecycle/testapp/CollectingSupportFragment.java new file mode 100644 index 00000000..9bbbe165 --- /dev/null +++ b/android/arch/lifecycle/testapp/CollectingSupportFragment.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package android.arch.lifecycle.testapp; + +import static android.arch.lifecycle.testapp.TestEvent.OWNER_CALLBACK; + +import android.annotation.SuppressLint; +import android.arch.lifecycle.Lifecycle; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.util.Pair; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import java.util.ArrayList; +import java.util.List; + +/** + * A support fragment that collects all of its events. + */ +@SuppressLint("ValidFragment") +public class CollectingSupportFragment extends Fragment implements CollectingLifecycleOwner { + private final List<Pair<TestEvent, Lifecycle.Event>> mCollectedEvents = + new ArrayList<>(); + private TestObserver mTestObserver = new TestObserver(mCollectedEvents); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Lifecycle.Event.ON_CREATE)); + getLifecycle().addObserver(mTestObserver); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + //noinspection ConstantConditions + FrameLayout layout = new FrameLayout(container.getContext()); + layout.setId(R.id.child_fragment_container); + return layout; + } + + /** + * Runs a replace fragment transaction with 'fragment' on this Fragment. + */ + public void replaceFragment(Fragment fragment) { + getChildFragmentManager() + .beginTransaction() + .add(R.id.child_fragment_container, fragment) + .commitNow(); + } + + @Override + public void onStart() { + super.onStart(); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Lifecycle.Event.ON_START)); + } + + @Override + public void onResume() { + super.onResume(); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Lifecycle.Event.ON_RESUME)); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Lifecycle.Event.ON_DESTROY)); + } + + @Override + public void onStop() { + super.onStop(); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Lifecycle.Event.ON_STOP)); + } + + @Override + public void onPause() { + super.onPause(); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Lifecycle.Event.ON_PAUSE)); + } + + @Override + public List<Pair<TestEvent, Lifecycle.Event>> copyCollectedEvents() { + return new ArrayList<>(mCollectedEvents); + } +} diff --git a/android/arch/lifecycle/testapp/FrameworkLifecycleRegistryActivity.java b/android/arch/lifecycle/testapp/FrameworkLifecycleRegistryActivity.java index d8f4fb39..cdf577c1 100644 --- a/android/arch/lifecycle/testapp/FrameworkLifecycleRegistryActivity.java +++ b/android/arch/lifecycle/testapp/FrameworkLifecycleRegistryActivity.java @@ -16,27 +16,29 @@ package android.arch.lifecycle.testapp; -import static android.arch.lifecycle.testapp.TestEvent.ACTIVITY_CALLBACK; +import static android.arch.lifecycle.testapp.TestEvent.OWNER_CALLBACK; import android.app.Activity; import android.arch.lifecycle.Lifecycle; import android.arch.lifecycle.LifecycleRegistry; import android.arch.lifecycle.LifecycleRegistryOwner; import android.os.Bundle; -import android.util.Pair; +import android.support.annotation.NonNull; +import android.support.v4.util.Pair; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; /** * LifecycleRegistryOwner that extends framework activity. */ +@SuppressWarnings("deprecation") public class FrameworkLifecycleRegistryActivity extends Activity implements - LifecycleRegistryOwner, CollectingActivity { + LifecycleRegistryOwner, CollectingLifecycleOwner { private LifecycleRegistry mLifecycleRegistry = new LifecycleRegistry(this); + @NonNull @Override public LifecycleRegistry getLifecycle() { return mLifecycleRegistry; @@ -49,49 +51,43 @@ public class FrameworkLifecycleRegistryActivity extends Activity implements @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Lifecycle.Event.ON_CREATE)); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Lifecycle.Event.ON_CREATE)); getLifecycle().addObserver(mTestObserver); } @Override protected void onStart() { super.onStart(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Lifecycle.Event.ON_START)); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Lifecycle.Event.ON_START)); } @Override protected void onResume() { super.onResume(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Lifecycle.Event.ON_RESUME)); - finish(); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Lifecycle.Event.ON_RESUME)); } @Override protected void onDestroy() { super.onDestroy(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Lifecycle.Event.ON_DESTROY)); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Lifecycle.Event.ON_DESTROY)); mLatch.countDown(); } @Override protected void onStop() { super.onStop(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Lifecycle.Event.ON_STOP)); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Lifecycle.Event.ON_STOP)); } @Override protected void onPause() { super.onPause(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Lifecycle.Event.ON_PAUSE)); + mCollectedEvents.add(new Pair<>(OWNER_CALLBACK, Lifecycle.Event.ON_PAUSE)); } - /** - * awaits for all events and returns them. - */ @Override - public List<Pair<TestEvent, Lifecycle.Event>> waitForCollectedEvents() - throws InterruptedException { - mLatch.await(TIMEOUT, TimeUnit.SECONDS); - return mCollectedEvents; + public List<Pair<TestEvent, Lifecycle.Event>> copyCollectedEvents() { + return new ArrayList<>(mCollectedEvents); } } diff --git a/android/arch/lifecycle/testapp/FullLifecycleTestActivity.java b/android/arch/lifecycle/testapp/FullLifecycleTestActivity.java deleted file mode 100644 index 5f33c282..00000000 --- a/android/arch/lifecycle/testapp/FullLifecycleTestActivity.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.lifecycle.testapp; - -import static android.arch.lifecycle.testapp.TestEvent.ACTIVITY_CALLBACK; - -import android.arch.lifecycle.Lifecycle; -import android.os.Bundle; -import android.support.v4.app.FragmentActivity; -import android.util.Pair; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -/** - * Activity for testing full lifecycle - */ -public class FullLifecycleTestActivity extends FragmentActivity implements CollectingActivity { - - private List<Pair<TestEvent, Lifecycle.Event>> mCollectedEvents = new ArrayList<>(); - private TestObserver mTestObserver = new TestObserver(mCollectedEvents); - private CountDownLatch mLatch = new CountDownLatch(1); - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Lifecycle.Event.ON_CREATE)); - getLifecycle().addObserver(mTestObserver); - } - - @Override - protected void onStart() { - super.onStart(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Lifecycle.Event.ON_START)); - } - - @Override - protected void onResume() { - super.onResume(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Lifecycle.Event.ON_RESUME)); - finish(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Lifecycle.Event.ON_DESTROY)); - mLatch.countDown(); - } - - @Override - protected void onStop() { - super.onStop(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Lifecycle.Event.ON_STOP)); - } - - @Override - protected void onPause() { - super.onPause(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Lifecycle.Event.ON_PAUSE)); - } - - /** - * awaits for all events and returns them. - */ - @Override - public List<Pair<TestEvent, Lifecycle.Event>> waitForCollectedEvents() - throws InterruptedException { - mLatch.await(TIMEOUT, TimeUnit.SECONDS); - return mCollectedEvents; - } -} diff --git a/android/arch/lifecycle/testapp/MainActivity.java b/android/arch/lifecycle/testapp/MainActivity.java deleted file mode 100644 index b9d59142..00000000 --- a/android/arch/lifecycle/testapp/MainActivity.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.lifecycle.testapp; - -import android.os.Bundle; -import android.support.v4.app.FragmentActivity; - -/** - * Simple test activity - */ -public class MainActivity extends FragmentActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity); - } -} diff --git a/android/arch/lifecycle/testapp/NavigationDialogActivity.java b/android/arch/lifecycle/testapp/NavigationDialogActivity.java index 0ae94033..7d53528f 100644 --- a/android/arch/lifecycle/testapp/NavigationDialogActivity.java +++ b/android/arch/lifecycle/testapp/NavigationDialogActivity.java @@ -22,4 +22,10 @@ import android.support.v4.app.FragmentActivity; * an activity with Dialog theme. */ public class NavigationDialogActivity extends FragmentActivity { + @Override + protected void onPause() { + super.onPause(); + // helps with less flaky API 16 tests + overridePendingTransition(0, 0); + } } diff --git a/android/arch/lifecycle/testapp/NonSupportActivity.java b/android/arch/lifecycle/testapp/NonSupportActivity.java new file mode 100644 index 00000000..835d846a --- /dev/null +++ b/android/arch/lifecycle/testapp/NonSupportActivity.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.arch.lifecycle.testapp; + +import android.app.Activity; +import android.os.Bundle; +import android.support.annotation.Nullable; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Activity which doesn't extend FragmentActivity, to test ProcessLifecycleOwner because it + * should work anyway. + */ +public class NonSupportActivity extends Activity { + + private static final int TIMEOUT = 1; //secs + private final Lock mLock = new ReentrantLock(); + private Condition mIsResumedCondition = mLock.newCondition(); + private boolean mIsResumed = false; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + protected void onResume() { + super.onResume(); + mLock.lock(); + try { + mIsResumed = true; + mIsResumedCondition.signalAll(); + } finally { + mLock.unlock(); + } + } + + @Override + protected void onPause() { + super.onPause(); + mLock.lock(); + try { + mIsResumed = false; + } finally { + mLock.unlock(); + } + } + + /** + * awaits resumed state + * @return + * @throws InterruptedException + */ + public boolean awaitResumedState() throws InterruptedException { + mLock.lock(); + try { + while (!mIsResumed) { + if (!mIsResumedCondition.await(TIMEOUT, TimeUnit.SECONDS)) { + return false; + } + } + return true; + } finally { + mLock.unlock(); + } + } +} diff --git a/android/arch/lifecycle/testapp/SupportLifecycleRegistryActivity.java b/android/arch/lifecycle/testapp/SupportLifecycleRegistryActivity.java deleted file mode 100644 index c46c6d3e..00000000 --- a/android/arch/lifecycle/testapp/SupportLifecycleRegistryActivity.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.lifecycle.testapp; - -import static android.arch.lifecycle.testapp.TestEvent.ACTIVITY_CALLBACK; - -import android.arch.lifecycle.Lifecycle.Event; -import android.arch.lifecycle.LifecycleRegistry; -import android.arch.lifecycle.LifecycleRegistryOwner; -import android.os.Bundle; -import android.support.v4.app.FragmentActivity; -import android.util.Pair; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -/** - * LifecycleRegistryOwner that extends FragmentActivity. - */ -public class SupportLifecycleRegistryActivity extends FragmentActivity implements - LifecycleRegistryOwner, CollectingActivity { - private LifecycleRegistry mLifecycleRegistry = new LifecycleRegistry(this); - @Override - public LifecycleRegistry getLifecycle() { - return mLifecycleRegistry; - } - - private List<Pair<TestEvent, Event>> mCollectedEvents = new ArrayList<>(); - private TestObserver mTestObserver = new TestObserver(mCollectedEvents); - private CountDownLatch mLatch = new CountDownLatch(1); - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Event.ON_CREATE)); - getLifecycle().addObserver(mTestObserver); - } - - @Override - protected void onStart() { - super.onStart(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Event.ON_START)); - } - - @Override - protected void onResume() { - super.onResume(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Event.ON_RESUME)); - finish(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Event.ON_DESTROY)); - mLatch.countDown(); - } - - @Override - protected void onStop() { - super.onStop(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Event.ON_STOP)); - } - - @Override - protected void onPause() { - super.onPause(); - mCollectedEvents.add(new Pair<>(ACTIVITY_CALLBACK, Event.ON_PAUSE)); - } - - /** - * awaits for all events and returns them. - */ - @Override - public List<Pair<TestEvent, Event>> waitForCollectedEvents() throws InterruptedException { - mLatch.await(TIMEOUT, TimeUnit.SECONDS); - return mCollectedEvents; - } -} diff --git a/android/arch/lifecycle/testapp/TestEvent.java b/android/arch/lifecycle/testapp/TestEvent.java index 0929f84a..788045a2 100644 --- a/android/arch/lifecycle/testapp/TestEvent.java +++ b/android/arch/lifecycle/testapp/TestEvent.java @@ -17,6 +17,6 @@ package android.arch.lifecycle.testapp; public enum TestEvent { - ACTIVITY_CALLBACK, - LIFECYCLE_EVENT + OWNER_CALLBACK, + LIFECYCLE_EVENT, } diff --git a/android/arch/lifecycle/testapp/TestObserver.java b/android/arch/lifecycle/testapp/TestObserver.java index c6112396..00b8e16d 100644 --- a/android/arch/lifecycle/testapp/TestObserver.java +++ b/android/arch/lifecycle/testapp/TestObserver.java @@ -28,7 +28,7 @@ import android.arch.lifecycle.Lifecycle.Event; import android.arch.lifecycle.LifecycleObserver; import android.arch.lifecycle.LifecycleOwner; import android.arch.lifecycle.OnLifecycleEvent; -import android.util.Pair; +import android.support.v4.util.Pair; import java.util.List; diff --git a/android/arch/paging/BoundedDataSource.java b/android/arch/paging/BoundedDataSource.java index 664ab16c..06564907 100644 --- a/android/arch/paging/BoundedDataSource.java +++ b/android/arch/paging/BoundedDataSource.java @@ -21,7 +21,6 @@ import android.support.annotation.RestrictTo; import android.support.annotation.WorkerThread; import java.util.ArrayList; -import java.util.Collections; import java.util.List; /** @@ -75,7 +74,6 @@ public abstract class BoundedDataSource<Value> extends PositionalDataSource<Valu if (result.size() != loadSize) { throw new IllegalStateException("invalid number of items returned."); } - Collections.reverse(result); } return result; } diff --git a/android/arch/paging/ContiguousDataSource.java b/android/arch/paging/ContiguousDataSource.java index afcc208c..be9da200 100644 --- a/android/arch/paging/ContiguousDataSource.java +++ b/android/arch/paging/ContiguousDataSource.java @@ -26,21 +26,65 @@ import java.util.List; /** @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public abstract class ContiguousDataSource<Key, Value> extends DataSource<Key, Value> { - /** - * Number of items that this DataSource can provide in total, or COUNT_UNDEFINED. - * - * @return number of items that this DataSource can provide in total, or COUNT_UNDEFINED - * if difficult or undesired to compute. - */ - public int countItems() { - return COUNT_UNDEFINED; - } - @Override boolean isContiguous() { return true; } + void loadInitial(Key key, int pageSize, boolean enablePlaceholders, + PageResult.Receiver<Key, Value> receiver) { + NullPaddedList<Value> initial = loadInitial(key, pageSize, enablePlaceholders); + if (initial != null) { + receiver.onPageResult(new PageResult<>( + PageResult.INIT, + new Page<Key, Value>(initial.mList), + initial.getLeadingNullCount(), + initial.getTrailingNullCount(), + initial.getPositionOffset())); + } else { + receiver.onPageResult(new PageResult<Key, Value>( + PageResult.INIT, null, 0, 0, 0)); + } + } + + void loadAfter(int currentEndIndex, @NonNull Value currentEndItem, int pageSize, + PageResult.Receiver<Key, Value> receiver) { + List<Value> list = loadAfter(currentEndIndex, currentEndItem, pageSize); + + Page<Key, Value> page = list != null + ? new Page<Key, Value>(list) : null; + + receiver.postOnPageResult(new PageResult<>( + PageResult.APPEND, page, 0, 0, 0)); + } + + void loadBefore(int currentBeginIndex, @NonNull Value currentBeginItem, int pageSize, + PageResult.Receiver<Key, Value> receiver) { + List<Value> list = loadBefore(currentBeginIndex, currentBeginItem, pageSize); + + Page<Key, Value> page = list != null + ? new Page<Key, Value>(list) : null; + + receiver.postOnPageResult(new PageResult<>( + PageResult.PREPEND, page, 0, 0, 0)); + } + + /** + * Get the key from either the position, or item, or null if position/item invalid. + * <p> + * Position may not match passed item's position - if trying to query the key from a position + * that isn't yet loaded, a fallback item (last loaded item accessed) will be passed. + */ + abstract Key getKey(int position, Value item); + + @Nullable + abstract List<Value> loadAfterImpl(int currentEndIndex, + @NonNull Value currentEndItem, int pageSize); + + @Nullable + abstract List<Value> loadBeforeImpl(int currentBeginIndex, + @NonNull Value currentBeginItem, int pageSize); + /** @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @WorkerThread @@ -48,21 +92,7 @@ public abstract class ContiguousDataSource<Key, Value> extends DataSource<Key, V public abstract NullPaddedList<Value> loadInitial( Key key, int initialLoadSize, boolean enablePlaceholders); - /** - * Load data after the given position / item. - * <p> - * It's valid to return a different list size than the page size, if it's easier for this data - * source. It is generally safer to increase number loaded than reduce. - * - * @param currentEndIndex Load items after this index, starting with currentEndIndex + 1. - * @param currentEndItem Load items after this item, can be used for precise querying based on - * item contents. - * @param pageSize Suggested number of items to load. - * @return List of items, starting at position currentEndIndex + 1. Null if the data source is - * no longer valid, and should not be queried again. - * - * @hide - */ + /** @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @WorkerThread @Nullable @@ -78,24 +108,7 @@ public abstract class ContiguousDataSource<Key, Value> extends DataSource<Key, V return list; } - @Nullable - abstract List<Value> loadAfterImpl(int currentEndIndex, - @NonNull Value currentEndItem, int pageSize); - - /** - * Load data before the given position / item. - * <p> - * It's valid to return a different list size than the page size, if it's easier for this data - * source. It is generally safer to increase number loaded than reduce. - * - * @param currentBeginIndex Load items before this index, starting with currentBeginIndex - 1. - * @param currentBeginItem Load items after this item, can be used for precise querying based - * on item contents. - * @param pageSize Suggested number of items to load. - * @return List of items, in descending order, starting at position currentBeginIndex - 1. - * - * @hide - */ + /** @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @WorkerThread @Nullable @@ -111,15 +124,4 @@ public abstract class ContiguousDataSource<Key, Value> extends DataSource<Key, V return list; } - - @Nullable - abstract List<Value> loadBeforeImpl(int currentBeginIndex, - @NonNull Value currentBeginItem, int pageSize); - - /** - * Get the key from either the position, or item. Position may not match passed item's position, - * if trying to query the key from a position that isn't yet loaded, so a fallback item must be - * used. - */ - abstract Key getKey(int position, Value item); } diff --git a/android/arch/paging/ContiguousDiffHelperTest.java b/android/arch/paging/ContiguousDiffHelperTest.java deleted file mode 100644 index 4f221b34..00000000 --- a/android/arch/paging/ContiguousDiffHelperTest.java +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.paging; - -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.verifyZeroInteractions; - -import android.support.annotation.NonNull; -import android.support.test.filters.SmallTest; -import android.support.v7.recyclerview.extensions.DiffCallback; -import android.support.v7.util.DiffUtil; -import android.support.v7.util.ListUpdateCallback; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; -import org.mockito.Mockito; - -@SmallTest -@RunWith(JUnit4.class) -public class ContiguousDiffHelperTest { - private interface CallbackValidator { - void validate(ListUpdateCallback callback); - } - - private static final DiffCallback<String> DIFF_CALLBACK = new DiffCallback<String>() { - @Override - public boolean areItemsTheSame(@NonNull String oldItem, @NonNull String newItem) { - // first char means same item - return oldItem.charAt(0) == newItem.charAt(0); - } - - @Override - public boolean areContentsTheSame(@NonNull String oldItem, @NonNull String newItem) { - return oldItem.equals(newItem); - } - }; - - private void validateTwoListDiff(StringPagedList oldList, StringPagedList newList, - CallbackValidator callbackValidator) { - DiffUtil.DiffResult diffResult = ContiguousDiffHelper.computeDiff(oldList, newList, - DIFF_CALLBACK, false); - - ListUpdateCallback listUpdateCallback = Mockito.mock(ListUpdateCallback.class); - ContiguousDiffHelper.dispatchDiff(listUpdateCallback, oldList, newList, diffResult); - - callbackValidator.validate(listUpdateCallback); - } - - @Test - public void sameListNoUpdates() { - validateTwoListDiff( - new StringPagedList(5, 5, "a", "b", "c"), - new StringPagedList(5, 5, "a", "b", "c"), - new CallbackValidator() { - @Override - public void validate(ListUpdateCallback callback) { - verifyZeroInteractions(callback); - } - } - ); - } - - @Test - public void appendFill() { - validateTwoListDiff( - new StringPagedList(5, 5, "a", "b"), - new StringPagedList(5, 4, "a", "b", "c"), - new CallbackValidator() { - @Override - public void validate(ListUpdateCallback callback) { - verify(callback).onRemoved(11, 1); - verify(callback).onInserted(7, 1); - // NOTE: ideally would be onChanged(7, 1, null) - verifyNoMoreInteractions(callback); - } - } - ); - } - - @Test - public void prependFill() { - validateTwoListDiff( - new StringPagedList(5, 5, "b", "c"), - new StringPagedList(4, 5, "a", "b", "c"), - new CallbackValidator() { - @Override - public void validate(ListUpdateCallback callback) { - verify(callback).onRemoved(0, 1); - verify(callback).onInserted(4, 1); - //NOTE: ideally would be onChanged(4, 1, null); - verifyNoMoreInteractions(callback); - } - } - ); - } - - @Test - public void change() { - validateTwoListDiff( - new StringPagedList(5, 5, "a1", "b1", "c1"), - new StringPagedList(5, 5, "a2", "b1", "c2"), - new CallbackValidator() { - @Override - public void validate(ListUpdateCallback callback) { - verify(callback).onChanged(5, 1, null); - verify(callback).onChanged(7, 1, null); - verifyNoMoreInteractions(callback); - } - } - ); - } -} diff --git a/android/arch/paging/ContiguousPagedList.java b/android/arch/paging/ContiguousPagedList.java index d8907c3b..2a5cd42f 100644 --- a/android/arch/paging/ContiguousPagedList.java +++ b/android/arch/paging/ContiguousPagedList.java @@ -16,101 +16,136 @@ package android.arch.paging; +import android.support.annotation.AnyThread; import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.support.annotation.RestrictTo; -import android.support.annotation.WorkerThread; -import java.lang.ref.WeakReference; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicBoolean; - -/** @hide */ -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class ContiguousPagedList<T> extends NullPaddedList<T> { - - private final ContiguousDataSource<?, T> mDataSource; - private final Executor mMainThreadExecutor; - private final Executor mBackgroundThreadExecutor; - private final Config mConfig; +class ContiguousPagedList<K, V> extends PagedList<V> implements PagedStorage.Callback { + private final ContiguousDataSource<K, V> mDataSource; private boolean mPrependWorkerRunning = false; private boolean mAppendWorkerRunning = false; private int mPrependItemsRequested = 0; private int mAppendItemsRequested = 0; - private int mLastLoad = 0; - private T mLastItem = null; + @SuppressWarnings("unchecked") + private final PagedStorage<K, V> mKeyedStorage = (PagedStorage<K, V>) mStorage; + + private final PageResult.Receiver<K, V> mReceiver = new PageResult.Receiver<K, V>() { + @AnyThread + @Override + public void postOnPageResult(@NonNull final PageResult<K, V> pageResult) { + // NOTE: if we're already on main thread, this can delay page receive by a frame + mMainThreadExecutor.execute(new Runnable() { + @Override + public void run() { + onPageResult(pageResult); + } + }); + } - private AtomicBoolean mDetached = new AtomicBoolean(false); + @MainThread + @Override + public void onPageResult(@NonNull PageResult<K, V> pageResult) { + if (pageResult.page == null) { + detach(); + return; + } - private ArrayList<WeakReference<Callback>> mCallbacks = new ArrayList<>(); + if (isDetached()) { + // No op, have detached + return; + } - @WorkerThread - <K> ContiguousPagedList(@NonNull ContiguousDataSource<K, T> dataSource, + Page<K, V> page = pageResult.page; + if (pageResult.type == PageResult.INIT) { + mKeyedStorage.init(pageResult.leadingNulls, page, pageResult.trailingNulls, + pageResult.positionOffset, ContiguousPagedList.this); + notifyInserted(0, mKeyedStorage.size()); + } else if (pageResult.type == PageResult.APPEND) { + mKeyedStorage.appendPage(page, ContiguousPagedList.this); + } else if (pageResult.type == PageResult.PREPEND) { + mKeyedStorage.prependPage(page, ContiguousPagedList.this); + } + } + }; + + ContiguousPagedList( + @NonNull ContiguousDataSource<K, V> dataSource, @NonNull Executor mainThreadExecutor, @NonNull Executor backgroundThreadExecutor, - Config config, - @Nullable K key) { - super(); - + @NonNull Config config, + final @Nullable K key) { + super(new PagedStorage<K, V>(), mainThreadExecutor, backgroundThreadExecutor, config); mDataSource = dataSource; - mMainThreadExecutor = mainThreadExecutor; - mBackgroundThreadExecutor = backgroundThreadExecutor; - mConfig = config; - NullPaddedList<T> initialState = dataSource.loadInitial( - key, config.mInitialLoadSizeHint, config.mEnablePlaceholders); - - if (initialState != null) { - mPositionOffset = initialState.getPositionOffset(); - - mLeadingNullCount = initialState.getLeadingNullCount(); - mList = new ArrayList<>(initialState.mList); - mTrailingNullCount = initialState.getTrailingNullCount(); - - if (initialState.getLeadingNullCount() == 0 - && initialState.getTrailingNullCount() == 0 - && config.mPrefetchDistance < 1) { - throw new IllegalArgumentException("Null padding is required to support the 0" - + " prefetch case - require either null items or prefetching to fetch" - + " beyond initial load."); - } - if (initialState.size() != 0) { - mLastLoad = mLeadingNullCount + mList.size() / 2; - mLastItem = mList.get(mList.size() / 2); - } - } else { - mList = new ArrayList<>(); - detach(); - } - if (mList.size() == 0) { - // Empty initial state, so don't try and fetch data. - mPrependWorkerRunning = true; - mAppendWorkerRunning = true; - } + // blocking init just triggers the initial load on the construction thread - + // Could still be posted with callback, if desired. + mDataSource.loadInitial(key, + mConfig.initialLoadSizeHint, + mConfig.enablePlaceholders, + mReceiver); } + @MainThread @Override - public T get(int index) { - T item = super.get(index); - if (item != null) { - mLastItem = item; + void dispatchUpdatesSinceSnapshot( + @NonNull PagedList<V> pagedListSnapshot, @NonNull Callback callback) { + + final PagedStorage<?, V> snapshot = pagedListSnapshot.mStorage; + + final int newlyAppended = mStorage.getNumberAppended() - snapshot.getNumberAppended(); + final int newlyPrepended = mStorage.getNumberPrepended() - snapshot.getNumberPrepended(); + + final int previousTrailing = snapshot.getTrailingNullCount(); + final int previousLeading = snapshot.getLeadingNullCount(); + + // Validate that the snapshot looks like a previous version of this list - if it's not, + // we can't be sure we'll dispatch callbacks safely + if (newlyAppended < 0 + || newlyPrepended < 0 + || mStorage.getTrailingNullCount() != Math.max(previousTrailing - newlyAppended, 0) + || mStorage.getLeadingNullCount() != Math.max(previousLeading - newlyPrepended, 0) + || (mStorage.getStorageCount() + != snapshot.getStorageCount() + newlyAppended + newlyPrepended)) { + throw new IllegalArgumentException("Invalid snapshot provided - doesn't appear" + + " to be a snapshot of this PagedList"); + } + + if (newlyAppended != 0) { + final int changedCount = Math.min(previousTrailing, newlyAppended); + final int addedCount = newlyAppended - changedCount; + + final int endPosition = snapshot.getLeadingNullCount() + snapshot.getStorageCount(); + if (changedCount != 0) { + callback.onChanged(endPosition, changedCount); + } + if (addedCount != 0) { + callback.onInserted(endPosition + changedCount, addedCount); + } + } + if (newlyPrepended != 0) { + final int changedCount = Math.min(previousLeading, newlyPrepended); + final int addedCount = newlyPrepended - changedCount; + + if (changedCount != 0) { + callback.onChanged(previousLeading, changedCount); + } + if (addedCount != 0) { + callback.onInserted(0, addedCount); + } } - return item; } + @MainThread @Override - public void loadAround(int index) { - mLastLoad = index + mPositionOffset; - - int prependItems = mConfig.mPrefetchDistance - (index - mLeadingNullCount); - int appendItems = index + mConfig.mPrefetchDistance - (mLeadingNullCount + mList.size()); + protected void loadAroundInternal(int index) { + int prependItems = mConfig.prefetchDistance - (index - mStorage.getLeadingNullCount()); + int appendItems = index + mConfig.prefetchDistance + - (mStorage.getLeadingNullCount() + mStorage.getStorageCount()); mPrependItemsRequested = Math.max(prependItems, mPrependItemsRequested); if (mPrependItemsRequested > 0) { @@ -123,21 +158,6 @@ class ContiguousPagedList<T> extends NullPaddedList<T> { } } - @Override - public int getLoadedCount() { - return mList.size(); - } - - @Override - public int getLeadingNullCount() { - return mLeadingNullCount; - } - - @Override - public int getTrailingNullCount() { - return mTrailingNullCount; - } - @MainThread private void schedulePrepend() { if (mPrependWorkerRunning) { @@ -145,29 +165,17 @@ class ContiguousPagedList<T> extends NullPaddedList<T> { } mPrependWorkerRunning = true; - final int position = mLeadingNullCount + mPositionOffset; - final T item = mList.get(0); + final int position = mStorage.getLeadingNullCount() + mStorage.getPositionOffset(); + + // safe to access first item here - mStorage can't be empty if we're prepending + final V item = mStorage.getFirstContiguousItem(); mBackgroundThreadExecutor.execute(new Runnable() { @Override public void run() { - if (mDetached.get()) { + if (isDetached()) { return; } - - final List<T> data = mDataSource.loadBefore(position, item, mConfig.mPageSize); - if (data != null) { - mMainThreadExecutor.execute(new Runnable() { - @Override - public void run() { - if (mDetached.get()) { - return; - } - prependImpl(data); - } - }); - } else { - detach(); - } + mDataSource.loadBefore(position, item, mConfig.pageSize, mReceiver); } }); } @@ -179,56 +187,44 @@ class ContiguousPagedList<T> extends NullPaddedList<T> { } mAppendWorkerRunning = true; - final int position = mLeadingNullCount + mList.size() - 1 + mPositionOffset; - final T item = mList.get(mList.size() - 1); + final int position = mStorage.getLeadingNullCount() + + mStorage.getStorageCount() - 1 + mStorage.getPositionOffset(); + + // safe to access first item here - mStorage can't be empty if we're appending + final V item = mStorage.getLastContiguousItem(); mBackgroundThreadExecutor.execute(new Runnable() { @Override public void run() { - if (mDetached.get()) { + if (isDetached()) { return; } - - final List<T> data = mDataSource.loadAfter(position, item, mConfig.mPageSize); - if (data != null) { - mMainThreadExecutor.execute(new Runnable() { - @Override - public void run() { - if (mDetached.get()) { - return; - } - appendImpl(data); - } - }); - } else { - detach(); - } + mDataSource.loadAfter(position, item, mConfig.pageSize, mReceiver); } }); } - @MainThread - private void prependImpl(List<T> before) { - final int count = before.size(); - if (count == 0) { - // Nothing returned from source, stop loading in this direction - return; - } - - Collections.reverse(before); - mList.addAll(0, before); - - final int changedCount = Math.min(mLeadingNullCount, count); - final int addedCount = count - changedCount; + @Override + boolean isContiguous() { + return true; + } - if (changedCount != 0) { - mLeadingNullCount -= changedCount; - } - mPositionOffset -= addedCount; - mNumberPrepended += count; + @Nullable + @Override + public Object getLastKey() { + return mDataSource.getKey(mLastLoad, mLastItem); + } + @MainThread + @Override + public void onInitialized(int count) { + notifyInserted(0, count); + } - // only try to post more work after fully prepended (with offsets / null counts updated) - mPrependItemsRequested -= count; + @MainThread + @Override + public void onPagePrepended(int leadingNulls, int changedCount, int addedCount) { + // consider whether to post more work, now that a page is fully prepended + mPrependItemsRequested = mPrependItemsRequested - changedCount - addedCount; mPrependWorkerRunning = false; if (mPrependItemsRequested > 0) { // not done prepending, keep going @@ -236,39 +232,16 @@ class ContiguousPagedList<T> extends NullPaddedList<T> { } // finally dispatch callbacks, after prepend may have already been scheduled - for (WeakReference<Callback> weakRef : mCallbacks) { - Callback callback = weakRef.get(); - if (callback != null) { - if (changedCount != 0) { - callback.onChanged(mLeadingNullCount, changedCount); - } - if (addedCount != 0) { - callback.onInserted(0, addedCount); - } - } - } + notifyChanged(leadingNulls, changedCount); + notifyInserted(0, addedCount); } @MainThread - private void appendImpl(List<T> after) { - final int count = after.size(); - if (count == 0) { - // Nothing returned from source, stop loading in this direction - return; - } - - mList.addAll(after); - - final int changedCount = Math.min(mTrailingNullCount, count); - final int addedCount = count - changedCount; - - if (changedCount != 0) { - mTrailingNullCount -= changedCount; - } - mNumberAppended += count; + @Override + public void onPageAppended(int endPosition, int changedCount, int addedCount) { + // consider whether to post more work, now that a page is fully appended - // only try to post more work after fully appended (with null counts updated) - mAppendItemsRequested -= count; + mAppendItemsRequested = mAppendItemsRequested - changedCount - addedCount; mAppendWorkerRunning = false; if (mAppendItemsRequested > 0) { // not done appending, keep going @@ -276,100 +249,19 @@ class ContiguousPagedList<T> extends NullPaddedList<T> { } // finally dispatch callbacks, after append may have already been scheduled - for (WeakReference<Callback> weakRef : mCallbacks) { - Callback callback = weakRef.get(); - if (callback != null) { - final int endPosition = mLeadingNullCount + mList.size() - count; - if (changedCount != 0) { - callback.onChanged(endPosition, changedCount); - } - if (addedCount != 0) { - callback.onInserted(endPosition + changedCount, addedCount); - } - } - } - } - - @Override - public boolean isImmutable() { - // TODO: return true if had nulls, and now getLoadedCount() == size(). Is that safe? - // Currently we don't prevent DataSources from returning more items than their null counts - return isDetached(); - } - - @Override - public void addWeakCallback(@Nullable PagedList<T> previousSnapshot, - @NonNull Callback callback) { - NullPaddedList<T> snapshot = (NullPaddedList<T>) previousSnapshot; - if (snapshot != this && snapshot != null) { - final int newlyAppended = mNumberAppended - snapshot.getNumberAppended(); - final int newlyPrepended = mNumberPrepended - snapshot.getNumberPrepended(); - - final int previousTrailing = snapshot.getTrailingNullCount(); - final int previousLeading = snapshot.getLeadingNullCount(); - - // Validate that the snapshot looks like a previous version of this list - if it's not, - // we can't be sure we'll dispatch callbacks safely - if (newlyAppended < 0 - || newlyPrepended < 0 - || mTrailingNullCount != Math.max(previousTrailing - newlyAppended, 0) - || mLeadingNullCount != Math.max(previousLeading - newlyPrepended, 0) - || snapshot.getLoadedCount() + newlyAppended + newlyPrepended != mList.size()) { - throw new IllegalArgumentException("Invalid snapshot provided - doesn't appear" - + " to be a snapshot of this list"); - } - - if (newlyAppended != 0) { - final int changedCount = Math.min(previousTrailing, newlyAppended); - final int addedCount = newlyAppended - changedCount; - - final int endPosition = - snapshot.getLeadingNullCount() + snapshot.getLoadedCount(); - if (changedCount != 0) { - callback.onChanged(endPosition, changedCount); - } - if (addedCount != 0) { - callback.onInserted(endPosition + changedCount, addedCount); - } - } - if (newlyPrepended != 0) { - final int changedCount = Math.min(previousLeading, newlyPrepended); - final int addedCount = newlyPrepended - changedCount; - - if (changedCount != 0) { - callback.onChanged(previousLeading, changedCount); - } - if (addedCount != 0) { - callback.onInserted(0, addedCount); - } - } - } - mCallbacks.add(new WeakReference<>(callback)); - } - - @Override - public void removeWeakCallback(@NonNull Callback callback) { - for (int i = mCallbacks.size() - 1; i >= 0; i--) { - Callback currentCallback = mCallbacks.get(i).get(); - if (currentCallback == null || currentCallback == callback) { - mCallbacks.remove(i); - } - } + notifyChanged(endPosition, changedCount); + notifyInserted(endPosition + changedCount, addedCount); } + @MainThread @Override - public boolean isDetached() { - return mDetached.get(); + public void onPagePlaceholderInserted(int pageIndex) { + throw new IllegalStateException("Tiled callback on ContiguousPagedList"); } - @SuppressWarnings("WeakerAccess") - public void detach() { - mDetached.set(true); - } - - @Nullable + @MainThread @Override - public Object getLastKey() { - return mDataSource.getKey(mLastLoad, mLastItem); + public void onPageInserted(int start, int count) { + throw new IllegalStateException("Tiled callback on ContiguousPagedList"); } } diff --git a/android/arch/paging/ContiguousPagedListTest.java b/android/arch/paging/ContiguousPagedListTest.java deleted file mode 100644 index ee7ea6a4..00000000 --- a/android/arch/paging/ContiguousPagedListTest.java +++ /dev/null @@ -1,333 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.paging; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertSame; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.verifyZeroInteractions; - -import android.support.annotation.Nullable; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -@RunWith(Parameterized.class) -public class ContiguousPagedListTest { - - @Parameterized.Parameters(name = "counted:{0}") - public static List<Object[]> parameters() { - return Arrays.asList(new Object[][]{{true}, {false}}); - } - - public ContiguousPagedListTest(boolean counted) { - mCounted = counted; - } - - private final boolean mCounted; - private TestExecutor mMainThread = new TestExecutor(); - private TestExecutor mBackgroundThread = new TestExecutor(); - - private static final ArrayList<Item> ITEMS = new ArrayList<>(); - - static { - for (int i = 0; i < 100; i++) { - ITEMS.add(new Item(i)); - } - } - - @SuppressWarnings("WeakerAccess") - private static class Item { - private Item(int position) { - this.position = position; - this.name = "Item " + position; - } - - public final int position; - public final String name; - - @Override - public String toString() { - return name; - } - } - - private class TestSource extends PositionalDataSource<Item> { - @Override - public int countItems() { - if (mCounted) { - return ITEMS.size(); - } else { - return COUNT_UNDEFINED; - } - } - - private List<Item> getClampedRange(int startInc, int endExc, boolean reverse) { - startInc = Math.max(0, startInc); - endExc = Math.min(ITEMS.size(), endExc); - List<Item> list = ITEMS.subList(startInc, endExc); - if (reverse) { - Collections.reverse(list); - } - return list; - } - - @Nullable - @Override - public List<Item> loadAfter(int startIndex, int pageSize) { - return getClampedRange(startIndex, startIndex + pageSize, false); - } - - @Nullable - @Override - public List<Item> loadBefore(int startIndex, int pageSize) { - return getClampedRange(startIndex - pageSize + 1, startIndex + 1, true); - } - } - - private void verifyRange(int start, int count, NullPaddedList<Item> actual) { - if (mCounted) { - int expectedLeading = start; - int expectedTrailing = ITEMS.size() - start - count; - assertEquals(ITEMS.size(), actual.size()); - assertEquals(ITEMS.size() - expectedLeading - expectedTrailing, - actual.getLoadedCount()); - assertEquals(expectedLeading, actual.getLeadingNullCount()); - assertEquals(expectedTrailing, actual.getTrailingNullCount()); - - for (int i = 0; i < actual.getLoadedCount(); i++) { - assertSame(ITEMS.get(i + start), actual.get(i + start)); - } - } else { - assertEquals(count, actual.size()); - assertEquals(actual.size(), actual.getLoadedCount()); - assertEquals(0, actual.getLeadingNullCount()); - assertEquals(0, actual.getTrailingNullCount()); - - for (int i = 0; i < actual.getLoadedCount(); i++) { - assertSame(ITEMS.get(i + start), actual.get(i)); - } - } - } - - private void verifyCallback(PagedList.Callback callback, int countedPosition, - int uncountedPosition) { - if (mCounted) { - verify(callback).onChanged(countedPosition, 20); - } else { - verify(callback).onInserted(uncountedPosition, 20); - } - } - - @Test - public void initialLoad() { - verifyRange(30, 40, - new TestSource().loadInitial(50, 40, true)); - - verifyRange(0, 10, - new TestSource().loadInitial(5, 10, true)); - - verifyRange(90, 10, - new TestSource().loadInitial(95, 10, true)); - } - - - private ContiguousPagedList<Item> createCountedPagedList( - PagedList.Config config, int initialPosition) { - TestSource source = new TestSource(); - return new ContiguousPagedList<>( - source, mMainThread, mBackgroundThread, - config, - initialPosition); - } - - private ContiguousPagedList<Item> createCountedPagedList(int initialPosition) { - return createCountedPagedList( - new PagedList.Config.Builder() - .setInitialLoadSizeHint(40) - .setPageSize(20) - .setPrefetchDistance(20) - .build(), - initialPosition); - } - - @Test - public void append() { - ContiguousPagedList<Item> pagedList = createCountedPagedList(0); - PagedList.Callback callback = mock(PagedList.Callback.class); - pagedList.addWeakCallback(null, callback); - verifyRange(0, 40, pagedList); - verifyZeroInteractions(callback); - - pagedList.loadAround(35); - drain(); - - verifyRange(0, 60, pagedList); - verifyCallback(callback, 40, 40); - verifyNoMoreInteractions(callback); - } - - - @Test - public void prepend() { - ContiguousPagedList<Item> pagedList = createCountedPagedList(80); - PagedList.Callback callback = mock(PagedList.Callback.class); - pagedList.addWeakCallback(null, callback); - verifyRange(60, 40, pagedList); - verifyZeroInteractions(callback); - - pagedList.loadAround(mCounted ? 65 : 5); - drain(); - - verifyRange(40, 60, pagedList); - verifyCallback(callback, 40, 0); - verifyNoMoreInteractions(callback); - } - - @Test - public void outwards() { - ContiguousPagedList<Item> pagedList = createCountedPagedList(50); - PagedList.Callback callback = mock(PagedList.Callback.class); - pagedList.addWeakCallback(null, callback); - verifyRange(30, 40, pagedList); - verifyZeroInteractions(callback); - - pagedList.loadAround(mCounted ? 65 : 35); - drain(); - - verifyRange(30, 60, pagedList); - verifyCallback(callback, 70, 40); - verifyNoMoreInteractions(callback); - - pagedList.loadAround(mCounted ? 35 : 5); - drain(); - - verifyRange(10, 80, pagedList); - verifyCallback(callback, 10, 0); - verifyNoMoreInteractions(callback); - } - - @Test - public void multiAppend() { - ContiguousPagedList<Item> pagedList = createCountedPagedList(0); - PagedList.Callback callback = mock(PagedList.Callback.class); - pagedList.addWeakCallback(null, callback); - verifyRange(0, 40, pagedList); - verifyZeroInteractions(callback); - - pagedList.loadAround(55); - drain(); - - verifyRange(0, 80, pagedList); - verifyCallback(callback, 40, 40); - verifyCallback(callback, 60, 60); - verifyNoMoreInteractions(callback); - } - - @Test - public void distantPrefetch() { - ContiguousPagedList<Item> pagedList = createCountedPagedList( - new PagedList.Config.Builder() - .setInitialLoadSizeHint(10) - .setPageSize(10) - .setPrefetchDistance(30) - .build(), - 0); - PagedList.Callback callback = mock(PagedList.Callback.class); - pagedList.addWeakCallback(null, callback); - verifyRange(0, 10, pagedList); - verifyZeroInteractions(callback); - - pagedList.loadAround(5); - drain(); - - verifyRange(0, 40, pagedList); - - pagedList.loadAround(6); - drain(); - - // although our prefetch window moves forward, no new load triggered - verifyRange(0, 40, pagedList); - } - - @Test - public void appendCallbackAddedLate() { - ContiguousPagedList<Item> pagedList = createCountedPagedList(0); - verifyRange(0, 40, pagedList); - - pagedList.loadAround(35); - drain(); - verifyRange(0, 60, pagedList); - - // snapshot at 60 items - NullPaddedList<Item> snapshot = (NullPaddedList<Item>) pagedList.snapshot(); - verifyRange(0, 60, snapshot); - - - pagedList.loadAround(55); - drain(); - verifyRange(0, 80, pagedList); - verifyRange(0, 60, snapshot); - - PagedList.Callback callback = mock(PagedList.Callback.class); - pagedList.addWeakCallback(snapshot, callback); - verifyCallback(callback, 60, 60); - verifyNoMoreInteractions(callback); - } - - - @Test - public void prependCallbackAddedLate() { - ContiguousPagedList<Item> pagedList = createCountedPagedList(80); - verifyRange(60, 40, pagedList); - - pagedList.loadAround(mCounted ? 65 : 5); - drain(); - verifyRange(40, 60, pagedList); - - // snapshot at 60 items - NullPaddedList<Item> snapshot = (NullPaddedList<Item>) pagedList.snapshot(); - verifyRange(40, 60, snapshot); - - - pagedList.loadAround(mCounted ? 45 : 5); - drain(); - verifyRange(20, 80, pagedList); - verifyRange(40, 60, snapshot); - - PagedList.Callback callback = mock(PagedList.Callback.class); - pagedList.addWeakCallback(snapshot, callback); - verifyCallback(callback, 40, 0); - verifyNoMoreInteractions(callback); - } - - private void drain() { - boolean executed; - do { - executed = mBackgroundThread.executeAll(); - executed |= mMainThread.executeAll(); - } while (executed); - } -} diff --git a/android/arch/paging/DataSource.java b/android/arch/paging/DataSource.java index 48fbec5f..524e570a 100644 --- a/android/arch/paging/DataSource.java +++ b/android/arch/paging/DataSource.java @@ -17,6 +17,7 @@ package android.arch.paging; import android.support.annotation.AnyThread; +import android.support.annotation.NonNull; import android.support.annotation.WorkerThread; import java.util.concurrent.CopyOnWriteArrayList; @@ -60,15 +61,6 @@ public abstract class DataSource<Key, Value> { public static int COUNT_UNDEFINED = -1; /** - * Number of items that this DataSource can provide in total, or {@link #COUNT_UNDEFINED}. - * - * @return number of items that this DataSource can provide in total, or - * {@link #COUNT_UNDEFINED} if expensive or undesired to compute. - */ - @WorkerThread - public abstract int countItems(); - - /** * Returns true if the data source guaranteed to produce a contiguous set of items, * never producing gaps. */ @@ -111,7 +103,7 @@ public abstract class DataSource<Key, Value> { */ @AnyThread @SuppressWarnings("WeakerAccess") - public void addInvalidatedCallback(InvalidatedCallback onInvalidatedCallback) { + public void addInvalidatedCallback(@NonNull InvalidatedCallback onInvalidatedCallback) { mOnInvalidatedCallbacks.add(onInvalidatedCallback); } @@ -122,7 +114,7 @@ public abstract class DataSource<Key, Value> { */ @AnyThread @SuppressWarnings("WeakerAccess") - public void removeInvalidatedCallback(InvalidatedCallback onInvalidatedCallback) { + public void removeInvalidatedCallback(@NonNull InvalidatedCallback onInvalidatedCallback) { mOnInvalidatedCallbacks.remove(onInvalidatedCallback); } diff --git a/android/arch/paging/KeyedDataSource.java b/android/arch/paging/KeyedDataSource.java index 8cf6829c..0d452946 100644 --- a/android/arch/paging/KeyedDataSource.java +++ b/android/arch/paging/KeyedDataSource.java @@ -103,10 +103,6 @@ import java.util.List; * @param <Value> Type of items being loaded by the DataSource. */ public abstract class KeyedDataSource<Key, Value> extends ContiguousDataSource<Key, Value> { - @Override - public final int countItems() { - return 0; // method not called, can't be overridden - } @Nullable @Override @@ -118,7 +114,14 @@ public abstract class KeyedDataSource<Key, Value> extends ContiguousDataSource<K @Override List<Value> loadBeforeImpl( int currentBeginIndex, @NonNull Value currentBeginItem, int pageSize) { - return loadBefore(getKey(currentBeginItem), pageSize); + List<Value> list = loadBefore(getKey(currentBeginItem), pageSize); + + if (list != null && list.size() > 1) { + // TODO: move out of keyed entirely, into the DB DataSource. + list = new ArrayList<>(list); + Collections.reverse(list); + } + return list; } @Nullable @@ -191,6 +194,8 @@ public abstract class KeyedDataSource<Key, Value> extends ContiguousDataSource<K /** @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + @WorkerThread + @Override public NullPaddedList<Value> loadInitial( @Nullable Key key, int initialLoadSize, boolean enablePlaceholders) { if (isInvalid()) { diff --git a/android/arch/paging/ListDataSource.java b/android/arch/paging/ListDataSource.java deleted file mode 100644 index f3e83d03..00000000 --- a/android/arch/paging/ListDataSource.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.paging; - -import java.util.List; - -public class ListDataSource<T> extends TiledDataSource<T> { - private List<T> mList; - - ListDataSource(List<T> data) { - mList = data; - } - - @Override - public int countItems() { - return mList.size(); - } - - @Override - public List<T> loadRange(int startPosition, int count) { - int endExclusive = Math.min(mList.size(), startPosition + count); - return mList.subList(startPosition, endExclusive); - } -} diff --git a/android/arch/paging/LivePagedListProvider.java b/android/arch/paging/LivePagedListProvider.java index b7c68dd6..07dd84bf 100644 --- a/android/arch/paging/LivePagedListProvider.java +++ b/android/arch/paging/LivePagedListProvider.java @@ -16,5 +16,133 @@ package android.arch.paging; -abstract public class LivePagedListProvider<K, T> { -}
\ No newline at end of file +import android.arch.core.executor.ArchTaskExecutor; +import android.arch.lifecycle.ComputableLiveData; +import android.arch.lifecycle.LiveData; +import android.support.annotation.AnyThread; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; + +/** + * Provides a {@code LiveData<PagedList>}, given a means to construct a DataSource. + * <p> + * Return type for data-loading system of an application or library to produce a + * {@code LiveData<PagedList>}, while leaving the details of the paging mechanism up to the + * consumer. + * <p> + * If you're using Room, it can generate a LivePagedListProvider from a query: + * <pre> + * {@literal @}Dao + * interface UserDao { + * {@literal @}Query("SELECT * FROM user ORDER BY lastName ASC") + * public abstract LivePagedListProvider<Integer, User> usersByLastName(); + * }</pre> + * In the above sample, {@code Integer} is used because it is the {@code Key} type of + * {@link TiledDataSource}. Currently, Room can only generate a {@code LIMIT}/{@code OFFSET}, + * position based loader that uses TiledDataSource under the hood, and specifying {@code Integer} + * here lets you pass an initial loading position as an integer. + * <p> + * In the future, Room plans to offer other key types to support paging content with a + * {@link KeyedDataSource}. + * + * @param <Key> Type of input valued used to load data from the DataSource. Must be integer if + * you're using TiledDataSource. + * @param <Value> Data type produced by the DataSource, and held by the PagedLists. + * + * @see PagedListAdapter + * @see DataSource + * @see PagedList + */ +public abstract class LivePagedListProvider<Key, Value> { + + /** + * Construct a new data source to be wrapped in a new PagedList, which will be returned + * through the LiveData. + * + * @return The data source. + */ + @WorkerThread + protected abstract DataSource<Key, Value> createDataSource(); + + /** + * Creates a LiveData of PagedLists, given the page size. + * <p> + * This LiveData can be passed to a {@link PagedListAdapter} to be displayed with a + * {@link android.support.v7.widget.RecyclerView}. + * + * @param initialLoadKey Initial key used to load initial data from the data source. + * @param pageSize Page size defining how many items are loaded from a data source at a time. + * Recommended to be multiple times the size of item displayed at once. + * + * @return The LiveData of PagedLists. + */ + @AnyThread + @NonNull + public LiveData<PagedList<Value>> create(@Nullable Key initialLoadKey, int pageSize) { + return create(initialLoadKey, + new PagedList.Config.Builder() + .setPageSize(pageSize) + .build()); + } + + /** + * Creates a LiveData of PagedLists, given the PagedList.Config. + * <p> + * This LiveData can be passed to a {@link PagedListAdapter} to be displayed with a + * {@link android.support.v7.widget.RecyclerView}. + * + * @param initialLoadKey Initial key to pass to the data source to initialize data with. + * @param config PagedList.Config to use with created PagedLists. This specifies how the + * lists will load data. + * + * @return The LiveData of PagedLists. + */ + @AnyThread + @NonNull + public LiveData<PagedList<Value>> create(@Nullable final Key initialLoadKey, + final PagedList.Config config) { + return new ComputableLiveData<PagedList<Value>>() { + @Nullable + private PagedList<Value> mList; + @Nullable + private DataSource<Key, Value> mDataSource; + + private final DataSource.InvalidatedCallback mCallback = + new DataSource.InvalidatedCallback() { + @Override + public void onInvalidated() { + invalidate(); + } + }; + + @Override + protected PagedList<Value> compute() { + @Nullable Key initializeKey = initialLoadKey; + if (mList != null) { + //noinspection unchecked + initializeKey = (Key) mList.getLastKey(); + } + + do { + if (mDataSource != null) { + mDataSource.removeInvalidatedCallback(mCallback); + } + + mDataSource = createDataSource(); + mDataSource.addInvalidatedCallback(mCallback); + + mList = new PagedList.Builder<Key, Value>() + .setDataSource(mDataSource) + .setMainThreadExecutor(ArchTaskExecutor.getMainThreadExecutor()) + .setBackgroundThreadExecutor( + ArchTaskExecutor.getIOThreadExecutor()) + .setConfig(config) + .setInitialKey(initializeKey) + .build(); + } while (mList.isDetached()); + return mList; + } + }.getLiveData(); + } +} diff --git a/android/arch/paging/NullPaddedList.java b/android/arch/paging/NullPaddedList.java index 43000302..c7b0b231 100644 --- a/android/arch/paging/NullPaddedList.java +++ b/android/arch/paging/NullPaddedList.java @@ -16,11 +16,9 @@ package android.arch.paging; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; import android.support.annotation.RestrictTo; -import java.util.ArrayList; +import java.util.AbstractList; import java.util.List; /** @@ -31,18 +29,11 @@ import java.util.List; * @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -public class NullPaddedList<Type> extends PagedList<Type> { +public class NullPaddedList<Type> extends AbstractList<Type> { List<Type> mList; - int mTrailingNullCount; - int mLeadingNullCount; - int mPositionOffset; - - // track the items prepended/appended since the PagedList was initialized - int mNumberPrepended; - int mNumberAppended; - - NullPaddedList() { - } + private int mTrailingNullCount; + private int mLeadingNullCount; + private int mPositionOffset; @Override public String toString() { @@ -91,20 +82,6 @@ public class NullPaddedList<Type> extends PagedList<Type> { mPositionOffset = positionOffset; } - /** - * Create a copy of the passed NullPaddedList. - * - * @param other Other list to copy. - */ - NullPaddedList(NullPaddedList<Type> other) { - mLeadingNullCount = other.getLeadingNullCount(); - mList = other.isImmutable() ? other.mList : new ArrayList<>(other.mList); - mTrailingNullCount = other.getTrailingNullCount(); - - mNumberPrepended = other.getNumberPrepended(); - mNumberAppended = other.getNumberAppended(); - } - // --------------- PagedList API --------------- @Override @@ -124,46 +101,12 @@ public class NullPaddedList<Type> extends PagedList<Type> { } @Override - public void loadAround(int index) { - // do nothing - immutable, so no fetching will be done - } - - @Override public final int size() { return getLoadedCount() + getLeadingNullCount() + getTrailingNullCount(); } - public boolean isImmutable() { - return true; - } - - @Override - public PagedList<Type> snapshot() { - if (isImmutable()) { - return this; - } - return new NullPaddedList<>(this); - } - - @Override - boolean isContiguous() { - return true; - } - - @Override - public void addWeakCallback(@Nullable PagedList<Type> previousSnapshot, - @NonNull Callback callback) { - // no op, immutable - } - - @Override - public void removeWeakCallback(Callback callback) { - // no op, immutable - } - // --------------- Contiguous API --------------- - @Override public int getPositionOffset() { return mPositionOffset; } @@ -194,12 +137,4 @@ public class NullPaddedList<Type> extends PagedList<Type> { public int getTrailingNullCount() { return mTrailingNullCount; } - - int getNumberPrepended() { - return mNumberPrepended; - } - - int getNumberAppended() { - return mNumberAppended; - } } diff --git a/android/arch/paging/NullPaddedListTest.java b/android/arch/paging/NullPaddedListTest.java deleted file mode 100644 index 0c38485c..00000000 --- a/android/arch/paging/NullPaddedListTest.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.paging; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -@RunWith(JUnit4.class) -public class NullPaddedListTest { - @Test - public void simple() { - List<String> data = Arrays.asList("A", "B", "C", "D", "E", "F"); - NullPaddedList<String> list = new NullPaddedList<>( - 2, data.subList(2, 4), 2); - - assertNull(list.get(0)); - assertNull(list.get(1)); - assertSame(data.get(2), list.get(2)); - assertSame(data.get(3), list.get(3)); - assertNull(list.get(4)); - assertNull(list.get(5)); - - assertEquals(6, list.size()); - assertEquals(2, list.getLeadingNullCount()); - assertEquals(2, list.getTrailingNullCount()); - } - - @Test(expected = IndexOutOfBoundsException.class) - public void getEmpty() { - NullPaddedList<String> list = new NullPaddedList<>(0, new ArrayList<String>(), 0); - list.get(0); - } - - @Test(expected = IndexOutOfBoundsException.class) - public void getNegative() { - NullPaddedList<String> list = new NullPaddedList<>(0, Arrays.asList("a", "b"), 0); - list.get(-1); - } - - @Test(expected = IndexOutOfBoundsException.class) - public void getPastEnd() { - NullPaddedList<String> list = new NullPaddedList<>(0, Arrays.asList("a", "b"), 0); - list.get(2); - } -} diff --git a/android/arch/paging/Page.java b/android/arch/paging/Page.java new file mode 100644 index 00000000..e9890ed4 --- /dev/null +++ b/android/arch/paging/Page.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.arch.paging; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.util.List; + +/** + * Immutable class representing a page of data loaded from a DataSource. + * <p> + * Optionally stores before/after keys for cases where they cannot be computed, but the DataSource + * can provide them as part of loading a page. + * <p> + * A page's list must never be modified. + */ +class Page<K, V> { + @SuppressWarnings("WeakerAccess") + @Nullable + public final K beforeKey; + @NonNull + public final List<V> items; + @SuppressWarnings("WeakerAccess") + @Nullable + public K afterKey; + + Page(@NonNull List<V> items) { + this(null, items, null); + } + + Page(@Nullable K beforeKey, @NonNull List<V> items, @Nullable K afterKey) { + this.beforeKey = beforeKey; + this.items = items; + this.afterKey = afterKey; + } +} diff --git a/android/arch/paging/PageArrayList.java b/android/arch/paging/PageArrayList.java deleted file mode 100644 index b90d055a..00000000 --- a/android/arch/paging/PageArrayList.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.paging; - -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.annotation.RestrictTo; - -import java.util.ArrayList; -import java.util.List; - -/** @hide */ -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class PageArrayList<T> extends PagedList<T> { - // partial list of pages, doesn't include pages below the lowest accessed, or above the highest - final ArrayList<List<T>> mPages; - - // to access page at index N, do mPages.get(N - mPageIndexOffset), but do bounds checking first! - int mPageIndexOffset; - - final int mPageSize; - final int mCount; - final int mMaxPageCount; - - PageArrayList(int pageSize, int count) { - mPages = new ArrayList<>(); - mPageSize = pageSize; - mCount = count; - mMaxPageCount = (mCount + mPageSize - 1) / mPageSize; - } - - private PageArrayList(PageArrayList<T> other) { - mPages = other.isImmutable() ? other.mPages : new ArrayList<>(other.mPages); - mPageIndexOffset = other.mPageIndexOffset; - mPageSize = other.mPageSize; - mCount = other.size(); - mMaxPageCount = other.mMaxPageCount; - } - - @Override - public T get(int index) { - if (index < 0 || index >= mCount) { - throw new IllegalArgumentException(); - } - - int localPageIndex = getLocalPageIndex(index); - - List<T> page = getPage(localPageIndex); - - if (page == null) { - // page empty - return null; - } - - return page.get(index % mPageSize); - } - - @Nullable - private List<T> getPage(int localPageIndex) { - if (localPageIndex < 0 || localPageIndex >= mPages.size()) { - // page not present - return null; - } - - return mPages.get(localPageIndex); - } - - private int getLocalPageIndex(int index) { - return index / mPageSize - mPageIndexOffset; - } - - @Override - public void loadAround(int index) { - // do nothing - immutable, so no fetching will be done - } - - @Override - public int size() { - return mCount; - } - - @Override - public boolean isImmutable() { - return true; - } - - boolean hasPage(int pageIndex) { - final int localPageIndex = pageIndex - mPageIndexOffset; - List<T> page = getPage(localPageIndex); - return page != null && page.size() != 0; - } - - @Override - public PagedList<T> snapshot() { - if (isImmutable()) { - return this; - } - return new PageArrayList<>(this); - } - - @Override - boolean isContiguous() { - return false; - } - - @Override - public void addWeakCallback(@Nullable PagedList<T> previousSnapshot, - @NonNull Callback callback) { - // no op, immutable - } - - @Override - public void removeWeakCallback(Callback callback) { - // no op, immutable - } -} diff --git a/android/arch/paging/PageArrayListTest.java b/android/arch/paging/PageArrayListTest.java deleted file mode 100644 index 135e640d..00000000 --- a/android/arch/paging/PageArrayListTest.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.paging; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -import java.util.Arrays; -import java.util.List; - -@RunWith(JUnit4.class) -public class PageArrayListTest { - @Test - public void simple() { - List<String> data = Arrays.asList("A", "B", "C", "D", "E", "F"); - PageArrayList<String> list = new PageArrayList<>(2, data.size()); - - assertEquals(2, list.mPageSize); - assertEquals(data.size(), list.size()); - assertEquals(3, list.mMaxPageCount); - - for (int i = 0; i < data.size(); i++) { - assertEquals(null, list.get(i)); - } - for (int i = 0; i < data.size(); i += list.mPageSize) { - list.mPages.add(data.subList(i, i + 2)); - } - for (int i = 0; i < data.size(); i++) { - assertEquals(data.get(i), list.get(i)); - } - } -} diff --git a/android/arch/paging/PageResult.java b/android/arch/paging/PageResult.java new file mode 100644 index 00000000..a4090f61 --- /dev/null +++ b/android/arch/paging/PageResult.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.arch.paging; + +import android.support.annotation.AnyThread; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; + +class PageResult<K, V> { + static final int INIT = 0; + + // contiguous results + static final int APPEND = 1; + static final int PREPEND = 2; + + // non-contiguous, tile result + static final int TILE = 3; + + public final int type; + public final Page<K, V> page; + @SuppressWarnings("WeakerAccess") + public final int leadingNulls; + @SuppressWarnings("WeakerAccess") + public final int trailingNulls; + @SuppressWarnings("WeakerAccess") + public final int positionOffset; + + PageResult(int type, Page<K, V> page, int leadingNulls, int trailingNulls, int positionOffset) { + this.type = type; + this.page = page; + this.leadingNulls = leadingNulls; + this.trailingNulls = trailingNulls; + this.positionOffset = positionOffset; + } + + interface Receiver<K, V> { + @AnyThread + void postOnPageResult(@NonNull PageResult<K, V> pageResult); + @MainThread + void onPageResult(@NonNull PageResult<K, V> pageResult); + } +} diff --git a/android/arch/paging/PagedList.java b/android/arch/paging/PagedList.java index 6a31b689..51f524af 100644 --- a/android/arch/paging/PagedList.java +++ b/android/arch/paging/PagedList.java @@ -20,9 +20,12 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.WorkerThread; +import java.lang.ref.WeakReference; import java.util.AbstractList; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; /** * Lazy loading list that pages in content from a {@link DataSource}. @@ -90,9 +93,30 @@ import java.util.concurrent.Executor; * @param <T> The type of the entries in the list. */ public abstract class PagedList<T> extends AbstractList<T> { - // Since we currently rely on implementation details of two implementations, - // prevent external subclassing - PagedList() { + @NonNull + final Executor mMainThreadExecutor; + @NonNull + final Executor mBackgroundThreadExecutor; + @NonNull + final Config mConfig; + @NonNull + final PagedStorage<?, T> mStorage; + + int mLastLoad = 0; + T mLastItem = null; + + private final AtomicBoolean mDetached = new AtomicBoolean(false); + + protected final ArrayList<WeakReference<Callback>> mCallbacks = new ArrayList<>(); + + PagedList(@NonNull PagedStorage<?, T> storage, + @NonNull Executor mainThreadExecutor, + @NonNull Executor backgroundThreadExecutor, + @NonNull Config config) { + mStorage = storage; + mMainThreadExecutor = mainThreadExecutor; + mBackgroundThreadExecutor = backgroundThreadExecutor; + mConfig = config; } /** @@ -117,7 +141,7 @@ public abstract class PagedList<T> extends AbstractList<T> { @NonNull Executor backgroundThreadExecutor, @NonNull Config config, @Nullable K key) { - if (dataSource.isContiguous() || !config.mEnablePlaceholders) { + if (dataSource.isContiguous() || !config.enablePlaceholders) { if (!dataSource.isContiguous()) { //noinspection unchecked dataSource = (DataSource<K, T>) ((TiledDataSource<T>) dataSource).getAsContiguous(); @@ -280,7 +304,13 @@ public abstract class PagedList<T> extends AbstractList<T> { */ @Override @Nullable - public abstract T get(int index); + public T get(int index) { + T item = mStorage.get(index); + if (item != null) { + mLastItem = item; + } + return item; + } /** @@ -288,7 +318,10 @@ public abstract class PagedList<T> extends AbstractList<T> { * * @param index Index at which to load. */ - public abstract void loadAround(int index); + public void loadAround(int index) { + mLastLoad = index + getPositionOffset(); + loadAroundInternal(index); + } /** @@ -297,7 +330,9 @@ public abstract class PagedList<T> extends AbstractList<T> { * @return Current total size of the list. */ @Override - public abstract int size(); + public int size() { + return mStorage.size(); + } /** * Returns whether the list is immutable. Immutable lists may not become mutable again, and may @@ -305,19 +340,39 @@ public abstract class PagedList<T> extends AbstractList<T> { * * @return True if the PagedList is immutable. */ - public abstract boolean isImmutable(); + @SuppressWarnings("WeakerAccess") + public boolean isImmutable() { + return isDetached(); + } /** * Returns an immutable snapshot of the PagedList. If this PagedList is already * immutable, it will be returned. * - * @return Immutable snapshot of PagedList, which may be the PagedList itself. + * @return Immutable snapshot of PagedList data. */ - public abstract List<T> snapshot(); + @NonNull + public List<T> snapshot() { + if (isImmutable()) { + return this; + } + + return new SnapshotPagedList<>(this); + } abstract boolean isContiguous(); /** + * Return the Config used to construct this PagedList. + * + * @return the Config of this PagedList + */ + @NonNull + public Config getConfig() { + return mConfig; + } + + /** * Return the key for the position passed most recently to {@link #loadAround(int)}. * <p> * When a PagedList is invalidated, you can pass the key returned by this function to initialize @@ -328,9 +383,7 @@ public abstract class PagedList<T> extends AbstractList<T> { * @return Key of position most recently passed to {@link #loadAround(int)}. */ @Nullable - public Object getLastKey() { - return null; - } + public abstract Object getLastKey(); /** * True if the PagedList has detached the DataSource it was loading from, and will no longer @@ -338,8 +391,9 @@ public abstract class PagedList<T> extends AbstractList<T> { * * @return True if the data source is detached. */ + @SuppressWarnings("WeakerAccess") public boolean isDetached() { - return true; + return mDetached.get(); } /** @@ -349,7 +403,9 @@ public abstract class PagedList<T> extends AbstractList<T> { * signal to stop loading. The PagedList will continue to present existing data, but will not * initiate new loads. */ + @SuppressWarnings("WeakerAccess") public void detach() { + mDetached.set(true); } /** @@ -361,7 +417,7 @@ public abstract class PagedList<T> extends AbstractList<T> { * If the DataSource is a {@link KeyedDataSource}, and thus doesn't use positions, returns 0. */ public int getPositionOffset() { - return 0; + return mStorage.getPositionOffset(); } /** @@ -385,16 +441,69 @@ public abstract class PagedList<T> extends AbstractList<T> { * @param callback Callback to dispatch to. * @see #removeWeakCallback(Callback) */ - public abstract void addWeakCallback(@Nullable PagedList<T> previousSnapshot, - @NonNull Callback callback); + @SuppressWarnings("WeakerAccess") + public void addWeakCallback(@Nullable List<T> previousSnapshot, @NonNull Callback callback) { + if (previousSnapshot != null && previousSnapshot != this) { + PagedList<T> storageSnapshot = (PagedList<T>) previousSnapshot; + //noinspection unchecked + dispatchUpdatesSinceSnapshot(storageSnapshot, callback); + } + + // first, clean up any empty weak refs + for (int i = mCallbacks.size() - 1; i >= 0; i--) { + Callback currentCallback = mCallbacks.get(i).get(); + if (currentCallback == null) { + mCallbacks.remove(i); + } + } + // then add the new one + mCallbacks.add(new WeakReference<>(callback)); + } /** * Removes a previously added callback. * * @param callback Callback, previously added. - * @see #addWeakCallback(PagedList, Callback) + * @see #addWeakCallback(List, Callback) */ - public abstract void removeWeakCallback(Callback callback); + @SuppressWarnings("WeakerAccess") + public void removeWeakCallback(@NonNull Callback callback) { + for (int i = mCallbacks.size() - 1; i >= 0; i--) { + Callback currentCallback = mCallbacks.get(i).get(); + if (currentCallback == null || currentCallback == callback) { + // found callback, or empty weak ref + mCallbacks.remove(i); + } + } + } + + void notifyInserted(int position, int count) { + if (count != 0) { + for (int i = mCallbacks.size() - 1; i >= 0; i--) { + Callback callback = mCallbacks.get(i).get(); + if (callback != null) { + callback.onInserted(position, count); + } + } + } + } + + void notifyChanged(int position, int count) { + if (count != 0) { + for (int i = mCallbacks.size() - 1; i >= 0; i--) { + Callback callback = mCallbacks.get(i).get(); + + if (callback != null) { + callback.onChanged(position, count); + } + } + } + } + + abstract void dispatchUpdatesSinceSnapshot(@NonNull PagedList<T> snapshot, + @NonNull Callback callback); + + abstract void loadAroundInternal(int index); /** * Callback signaling when content is loaded into the list. @@ -442,17 +551,41 @@ public abstract class PagedList<T> extends AbstractList<T> { * {@link Builder#setPageSize(int)}, which defines number of items loaded at a time}. */ public static class Config { - final int mPageSize; - final int mPrefetchDistance; - final boolean mEnablePlaceholders; - final int mInitialLoadSizeHint; + /** + * Size of each page loaded by the PagedList. + */ + public final int pageSize; + + /** + * Prefetch distance which defines how far ahead to load. + * <p> + * If this value is set to 50, the paged list will attempt to load 50 items in advance of + * data that's already been accessed. + * + * @see PagedList#loadAround(int) + */ + @SuppressWarnings("WeakerAccess") + public final int prefetchDistance; + + /** + * Defines whether the PagedList may display null placeholders, if the DataSource provides + * them. + */ + @SuppressWarnings("WeakerAccess") + public final boolean enablePlaceholders; + + /** + * Size hint for initial load of PagedList, often larger than a regular page. + */ + @SuppressWarnings("WeakerAccess") + public final int initialLoadSizeHint; private Config(int pageSize, int prefetchDistance, boolean enablePlaceholders, int initialLoadSizeHint) { - mPageSize = pageSize; - mPrefetchDistance = prefetchDistance; - mEnablePlaceholders = enablePlaceholders; - mInitialLoadSizeHint = initialLoadSizeHint; + this.pageSize = pageSize; + this.prefetchDistance = prefetchDistance; + this.enablePlaceholders = enablePlaceholders; + this.initialLoadSizeHint = initialLoadSizeHint; } /** @@ -545,10 +678,15 @@ public abstract class PagedList<T> extends AbstractList<T> { * Defines how many items to load when first load occurs, if you are using a * {@link KeyedDataSource}. * <p> - * If you are using an {@link TiledDataSource}, this value is currently ignored. - * Otherwise, this value will be passed to - * {@link KeyedDataSource#loadInitial(int)} to load a (typically) larger amount - * of data on first load. + * This value is typically larger than page size, so on first load data there's a large + * enough range of content loaded to cover small scrolls. + * <p> + * If used with a {@link TiledDataSource}, this value is rounded to the nearest number + * of pages, with a minimum of two pages, and loaded with a single call to + * {@link TiledDataSource#loadRange(int, int)}. + * <p> + * If used with a {@link KeyedDataSource}, this value will be passed to + * {@link KeyedDataSource#loadInitial(int)}. * <p> * If not set, defaults to three times page size. * diff --git a/android/arch/paging/PagedListAdapterHelper.java b/android/arch/paging/PagedListAdapterHelper.java index c7b61d9f..abcff415 100644 --- a/android/arch/paging/PagedListAdapterHelper.java +++ b/android/arch/paging/PagedListAdapterHelper.java @@ -25,8 +25,6 @@ import android.support.v7.util.DiffUtil; import android.support.v7.util.ListUpdateCallback; import android.support.v7.widget.RecyclerView; -import java.util.List; - /** * Helper object for mapping a {@link PagedList} into a * {@link android.support.v7.widget.RecyclerView.Adapter RecyclerView.Adapter}. @@ -120,15 +118,15 @@ import java.util.List; * @param <T> Type of the PagedLists this helper will receive. */ public class PagedListAdapterHelper<T> { + // updateCallback notifications must only be notified *after* new data and item count are stored + // this ensures Adapter#notifyItemRangeInserted etc are accessing the new data private final ListUpdateCallback mUpdateCallback; private final ListAdapterConfig<T> mConfig; - // true if our listener is detached from mList, because it's been snapshotted - private boolean mUpdateScheduled; - private boolean mIsContiguous; - private PagedList<T> mList; + private PagedList<T> mPagedList; + private PagedList<T> mSnapshot; // Max generation of currently scheduled runnable private int mMaxScheduledGeneration; @@ -182,12 +180,17 @@ public class PagedListAdapterHelper<T> { @SuppressWarnings("WeakerAccess") @Nullable public T getItem(int index) { - if (mList == null) { - throw new IndexOutOfBoundsException("Item count is zero, getItem() call is invalid"); + if (mPagedList == null) { + if (mSnapshot == null) { + throw new IndexOutOfBoundsException( + "Item count is zero, getItem() call is invalid"); + } else { + return mSnapshot.get(index); + } } - mList.loadAround(index); - return mList.get(index); + mPagedList.loadAround(index); + return mPagedList.get(index); } /** @@ -198,7 +201,11 @@ public class PagedListAdapterHelper<T> { */ @SuppressWarnings("WeakerAccess") public int getItemCount() { - return mList == null ? 0 : mList.size(); + if (mPagedList != null) { + return mPagedList.size(); + } + + return mSnapshot == null ? 0 : mSnapshot.size(); } /** @@ -212,7 +219,7 @@ public class PagedListAdapterHelper<T> { */ public void setList(final PagedList<T> pagedList) { if (pagedList != null) { - if (mList == null) { + if (mPagedList == null && mSnapshot == null) { mIsContiguous = pagedList.isContiguous(); } else { if (pagedList.isContiguous() != mIsContiguous) { @@ -222,7 +229,7 @@ public class PagedListAdapterHelper<T> { } } - if (pagedList == mList) { + if (pagedList == mPagedList) { // nothing to do return; } @@ -231,49 +238,55 @@ public class PagedListAdapterHelper<T> { final int runGeneration = ++mMaxScheduledGeneration; if (pagedList == null) { - mUpdateCallback.onRemoved(0, mList.size()); - mList.removeWeakCallback(mPagedListCallback); - mList = null; + int removedCount = getItemCount(); + if (mPagedList != null) { + mPagedList.removeWeakCallback(mPagedListCallback); + mPagedList = null; + } else if (mSnapshot != null) { + mSnapshot = null; + } + // dispatch update callback after updating mPagedList/mSnapshot + mUpdateCallback.onRemoved(0, removedCount); return; } - if (mList == null) { + if (mPagedList == null && mSnapshot == null) { // fast simple first insert - mUpdateCallback.onInserted(0, pagedList.size()); - mList = pagedList; + mPagedList = pagedList; pagedList.addWeakCallback(null, mPagedListCallback); + + // dispatch update callback after updating mPagedList/mSnapshot + mUpdateCallback.onInserted(0, pagedList.size()); return; } - if (!mList.isImmutable()) { + if (mPagedList != null) { // first update scheduled on this list, so capture mPages as a snapshot, removing // callbacks so we don't have resolve updates against a moving target - mList.removeWeakCallback(mPagedListCallback); - mList = (PagedList<T>) mList.snapshot(); + mPagedList.removeWeakCallback(mPagedListCallback); + mSnapshot = (PagedList<T>) mPagedList.snapshot(); + mPagedList = null; + } + + if (mSnapshot == null || mPagedList != null) { + throw new IllegalStateException("must be in snapshot state to diff"); } - final PagedList<T> oldSnapshot = mList; - final List<T> newSnapshot = pagedList.snapshot(); - mUpdateScheduled = true; + final PagedList<T> oldSnapshot = mSnapshot; + final PagedList<T> newSnapshot = (PagedList<T>) pagedList.snapshot(); mConfig.getBackgroundThreadExecutor().execute(new Runnable() { @Override public void run() { final DiffUtil.DiffResult result; - if (mIsContiguous) { - result = ContiguousDiffHelper.computeDiff( - (NullPaddedList<T>) oldSnapshot, (NullPaddedList<T>) newSnapshot, - mConfig.getDiffCallback(), true); - } else { - result = SparseDiffHelper.computeDiff( - (PageArrayList<T>) oldSnapshot, (PageArrayList<T>) newSnapshot, - mConfig.getDiffCallback(), true); - } + result = PagedStorageDiffHelper.computeDiff( + oldSnapshot.mStorage, + newSnapshot.mStorage, + mConfig.getDiffCallback()); mConfig.getMainThreadExecutor().execute(new Runnable() { @Override public void run() { if (mMaxScheduledGeneration == runGeneration) { - mUpdateScheduled = false; latchPagedList(pagedList, newSnapshot, result); } } @@ -283,16 +296,21 @@ public class PagedListAdapterHelper<T> { } private void latchPagedList( - PagedList<T> newList, List<T> diffSnapshot, + PagedList<T> newList, PagedList<T> diffSnapshot, DiffUtil.DiffResult diffResult) { - if (mIsContiguous) { - ContiguousDiffHelper.dispatchDiff(mUpdateCallback, - (NullPaddedList<T>) mList, (ContiguousPagedList<T>) newList, diffResult); - } else { - SparseDiffHelper.dispatchDiff(mUpdateCallback, diffResult); + if (mSnapshot == null || mPagedList != null) { + throw new IllegalStateException("must be in snapshot state to apply diff"); } - mList = newList; - newList.addWeakCallback((PagedList<T>) diffSnapshot, mPagedListCallback); + + PagedList<T> previousSnapshot = mSnapshot; + mPagedList = newList; + mSnapshot = null; + + // dispatch update callback after updating mPagedList/mSnapshot + PagedStorageDiffHelper.dispatchDiff(mUpdateCallback, + previousSnapshot.mStorage, newList.mStorage, diffResult); + + newList.addWeakCallback(diffSnapshot, mPagedListCallback); } /** @@ -307,6 +325,9 @@ public class PagedListAdapterHelper<T> { @SuppressWarnings("WeakerAccess") @Nullable public PagedList<T> getCurrentList() { - return mList; + if (mSnapshot != null) { + return mSnapshot; + } + return mPagedList; } } diff --git a/android/arch/paging/PagedListAdapterHelperTest.java b/android/arch/paging/PagedListAdapterHelperTest.java deleted file mode 100644 index 3518540c..00000000 --- a/android/arch/paging/PagedListAdapterHelperTest.java +++ /dev/null @@ -1,308 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.paging; - -import static junit.framework.Assert.assertEquals; -import static junit.framework.Assert.assertFalse; -import static junit.framework.Assert.assertNotNull; -import static junit.framework.Assert.assertNull; -import static junit.framework.Assert.assertTrue; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.verifyZeroInteractions; - -import android.support.annotation.NonNull; -import android.support.test.filters.SmallTest; -import android.support.v7.recyclerview.extensions.DiffCallback; -import android.support.v7.recyclerview.extensions.ListAdapterConfig; -import android.support.v7.util.ListUpdateCallback; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -import java.util.ArrayList; -import java.util.List; - -@SmallTest -@RunWith(JUnit4.class) -public class PagedListAdapterHelperTest { - private TestExecutor mMainThread = new TestExecutor(); - private TestExecutor mDiffThread = new TestExecutor(); - private TestExecutor mPageLoadingThread = new TestExecutor(); - - private static final ArrayList<String> ALPHABET_LIST = new ArrayList<>(); - static { - for (int i = 0; i < 26; i++) { - ALPHABET_LIST.add("" + 'a' + i); - } - } - - private static final DiffCallback<String> STRING_DIFF_CALLBACK = new DiffCallback<String>() { - @Override - public boolean areItemsTheSame(@NonNull String oldItem, @NonNull String newItem) { - return oldItem.equals(newItem); - } - - @Override - public boolean areContentsTheSame(@NonNull String oldItem, @NonNull String newItem) { - return oldItem.equals(newItem); - } - }; - - private static final ListUpdateCallback IGNORE_CALLBACK = new ListUpdateCallback() { - @Override - public void onInserted(int position, int count) { - } - - @Override - public void onRemoved(int position, int count) { - } - - @Override - public void onMoved(int fromPosition, int toPosition) { - } - - @Override - public void onChanged(int position, int count, Object payload) { - } - }; - - - private <T> PagedListAdapterHelper<T> createHelper( - ListUpdateCallback listUpdateCallback, DiffCallback<T> diffCallback) { - return new PagedListAdapterHelper<T>(listUpdateCallback, - new ListAdapterConfig.Builder<T>() - .setDiffCallback(diffCallback) - .setMainThreadExecutor(mMainThread) - .setBackgroundThreadExecutor(mDiffThread) - .build()); - } - - private <V> PagedList<V> createPagedListFromListAndPos( - PagedList.Config config, List<V> data, int initialKey) { - return new PagedList.Builder<Integer, V>() - .setInitialKey(initialKey) - .setConfig(config) - .setMainThreadExecutor(mMainThread) - .setBackgroundThreadExecutor(mPageLoadingThread) - .setDataSource(new ListDataSource<>(data)) - .build(); - } - - @Test - public void initialState() { - ListUpdateCallback callback = mock(ListUpdateCallback.class); - PagedListAdapterHelper<String> helper = createHelper(callback, STRING_DIFF_CALLBACK); - assertEquals(null, helper.getCurrentList()); - assertEquals(0, helper.getItemCount()); - verifyZeroInteractions(callback); - } - - @Test - public void setFullList() { - ListUpdateCallback callback = mock(ListUpdateCallback.class); - PagedListAdapterHelper<String> helper = createHelper(callback, STRING_DIFF_CALLBACK); - helper.setList(new StringPagedList(0, 0, "a", "b")); - - assertEquals(2, helper.getItemCount()); - assertEquals("a", helper.getItem(0)); - assertEquals("b", helper.getItem(1)); - - verify(callback).onInserted(0, 2); - verifyNoMoreInteractions(callback); - drain(); - verifyNoMoreInteractions(callback); - } - - @Test(expected = IndexOutOfBoundsException.class) - public void getEmpty() { - PagedListAdapterHelper<String> helper = createHelper(IGNORE_CALLBACK, STRING_DIFF_CALLBACK); - helper.getItem(0); - } - - @Test(expected = IndexOutOfBoundsException.class) - public void getNegative() { - PagedListAdapterHelper<String> helper = createHelper(IGNORE_CALLBACK, STRING_DIFF_CALLBACK); - helper.setList(new StringPagedList(0, 0, "a", "b")); - helper.getItem(-1); - } - - @Test(expected = IndexOutOfBoundsException.class) - public void getPastEnd() { - PagedListAdapterHelper<String> helper = createHelper(IGNORE_CALLBACK, STRING_DIFF_CALLBACK); - helper.setList(new StringPagedList(0, 0, "a", "b")); - helper.getItem(2); - } - - @Test - public void simpleStatic() { - ListUpdateCallback callback = mock(ListUpdateCallback.class); - PagedListAdapterHelper<String> helper = createHelper(callback, STRING_DIFF_CALLBACK); - - assertEquals(0, helper.getItemCount()); - - helper.setList(new StringPagedList(2, 2, "a", "b")); - - verify(callback).onInserted(0, 6); - verifyNoMoreInteractions(callback); - assertEquals(6, helper.getItemCount()); - - assertNull(helper.getItem(0)); - assertNull(helper.getItem(1)); - assertEquals("a", helper.getItem(2)); - assertEquals("b", helper.getItem(3)); - assertNull(helper.getItem(4)); - assertNull(helper.getItem(5)); - } - - @Test - public void pagingInContent() { - PagedList.Config config = new PagedList.Config.Builder() - .setInitialLoadSizeHint(4) - .setPageSize(2) - .setPrefetchDistance(2) - .build(); - - final ListUpdateCallback callback = mock(ListUpdateCallback.class); - PagedListAdapterHelper<String> helper = createHelper(callback, STRING_DIFF_CALLBACK); - - helper.setList(createPagedListFromListAndPos(config, ALPHABET_LIST, 2)); - verify(callback).onInserted(0, ALPHABET_LIST.size()); - verifyNoMoreInteractions(callback); - drain(); - verifyNoMoreInteractions(callback); - - // get without triggering prefetch... - helper.getItem(1); - verifyNoMoreInteractions(callback); - drain(); - verifyNoMoreInteractions(callback); - - // get triggering prefetch... - helper.getItem(2); - verifyNoMoreInteractions(callback); - drain(); - verify(callback).onChanged(4, 2, null); - verifyNoMoreInteractions(callback); - - // get with no data loaded nearby... - helper.getItem(12); - verifyNoMoreInteractions(callback); - drain(); - verify(callback).onChanged(10, 2, null); - verify(callback).onChanged(12, 2, null); - verify(callback).onChanged(14, 2, null); - verifyNoMoreInteractions(callback); - - // finally, clear - helper.setList(null); - verify(callback).onRemoved(0, 26); - drain(); - verifyNoMoreInteractions(callback); - } - - @Test - public void simpleSwap() { - // Page size large enough to load - PagedList.Config config = new PagedList.Config.Builder() - .setPageSize(50) - .build(); - - final ListUpdateCallback callback = mock(ListUpdateCallback.class); - PagedListAdapterHelper<String> helper = createHelper(callback, STRING_DIFF_CALLBACK); - - // initial list missing one item (immediate) - helper.setList(createPagedListFromListAndPos(config, ALPHABET_LIST.subList(0, 25), 0)); - verify(callback).onInserted(0, 25); - verifyNoMoreInteractions(callback); - assertEquals(helper.getItemCount(), 25); - drain(); - verifyNoMoreInteractions(callback); - - // pass second list with full data - helper.setList(createPagedListFromListAndPos(config, ALPHABET_LIST, 0)); - verifyNoMoreInteractions(callback); - drain(); - verify(callback).onInserted(25, 1); - verifyNoMoreInteractions(callback); - assertEquals(helper.getItemCount(), 26); - - // finally, clear (immediate) - helper.setList(null); - verify(callback).onRemoved(0, 26); - verifyNoMoreInteractions(callback); - drain(); - verifyNoMoreInteractions(callback); - } - - @Test - public void newPageWhileDiffing() { - PagedList.Config config = new PagedList.Config.Builder() - .setInitialLoadSizeHint(4) - .setPageSize(2) - .setPrefetchDistance(2) - .build(); - - final ListUpdateCallback callback = mock(ListUpdateCallback.class); - PagedListAdapterHelper<String> helper = createHelper(callback, STRING_DIFF_CALLBACK); - - helper.setList(createPagedListFromListAndPos(config, ALPHABET_LIST, 2)); - verify(callback).onInserted(0, ALPHABET_LIST.size()); - verifyNoMoreInteractions(callback); - drain(); - verifyNoMoreInteractions(callback); - assertNotNull(helper.getCurrentList()); - assertFalse(helper.getCurrentList().isImmutable()); - - // trigger page loading - helper.getItem(10); - helper.setList(createPagedListFromListAndPos(config, ALPHABET_LIST, 2)); - verifyNoMoreInteractions(callback); - - // drain page fetching, but list became immutable, page will be ignored - drainExceptDiffThread(); - verifyNoMoreInteractions(callback); - assertNotNull(helper.getCurrentList()); - assertTrue(helper.getCurrentList().isImmutable()); - - // finally full drain, which signals nothing, since 1st pagedlist == 2nd pagedlist - drain(); - verifyNoMoreInteractions(callback); - assertNotNull(helper.getCurrentList()); - assertFalse(helper.getCurrentList().isImmutable()); - } - - private void drainExceptDiffThread() { - boolean executed; - do { - executed = mPageLoadingThread.executeAll(); - executed |= mMainThread.executeAll(); - } while (executed); - } - - private void drain() { - boolean executed; - do { - executed = mPageLoadingThread.executeAll(); - executed |= mDiffThread.executeAll(); - executed |= mMainThread.executeAll(); - } while (executed); - } -} diff --git a/android/arch/paging/PagedStorage.java b/android/arch/paging/PagedStorage.java new file mode 100644 index 00000000..7f91290d --- /dev/null +++ b/android/arch/paging/PagedStorage.java @@ -0,0 +1,433 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.arch.paging; + +import android.support.annotation.NonNull; + +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +final class PagedStorage<K, V> extends AbstractList<V> { + // Always set + private int mLeadingNullCount; + /** + * List of pages in storage. + * + * Two storage modes: + * + * Contiguous - all content in mPages is valid and loaded, but may return false from isTiled(). + * Safe to access any item in any page. + * + * Non-contiguous - mPages may have nulls or a placeholder page, isTiled() always returns true. + * mPages may have nulls, or placeholder (empty) pages while content is loading. + */ + private final ArrayList<Page<K, V>> mPages; + private int mTrailingNullCount; + + private int mPositionOffset; + /** + * Number of items represented by {@link #mPages}. If tiling is enabled, unloaded items in + * {@link #mPages} may be null, but this value still counts them. + */ + private int mStorageCount; + + // If mPageSize > 0, tiling is enabled, 'mPages' may have gaps, and leadingPages is set + private int mPageSize; + + private int mNumberPrepended; + private int mNumberAppended; + + // only used in tiling case + private Page<K, V> mPlaceholderPage; + + PagedStorage() { + mLeadingNullCount = 0; + mPages = new ArrayList<>(); + mTrailingNullCount = 0; + mPositionOffset = 0; + mStorageCount = 0; + mPageSize = 1; + mNumberPrepended = 0; + mNumberAppended = 0; + } + + PagedStorage(int leadingNulls, Page<K, V> page, int trailingNulls) { + this(); + init(leadingNulls, page, trailingNulls, 0); + } + + private PagedStorage(PagedStorage<K, V> other) { + mLeadingNullCount = other.mLeadingNullCount; + mPages = new ArrayList<>(other.mPages); + mTrailingNullCount = other.mTrailingNullCount; + mPositionOffset = other.mPositionOffset; + mStorageCount = other.mStorageCount; + mPageSize = other.mPageSize; + mNumberPrepended = other.mNumberPrepended; + mNumberAppended = other.mNumberAppended; + + // preserve placeholder page so we can locate placeholder pages if needed later + mPlaceholderPage = other.mPlaceholderPage; + } + + PagedStorage<K, V> snapshot() { + return new PagedStorage<>(this); + } + + private void init(int leadingNulls, Page<K, V> page, int trailingNulls, int positionOffset) { + mLeadingNullCount = leadingNulls; + mPages.clear(); + mPages.add(page); + mTrailingNullCount = trailingNulls; + + mPositionOffset = positionOffset; + mStorageCount = page.items.size(); + + // initialized as tiled. There may be 3 nulls, 2 items, but we still call this tiled + // even if it will break if nulls convert. + mPageSize = page.items.size(); + + mNumberPrepended = 0; + mNumberAppended = 0; + } + + void init(int leadingNulls, Page<K, V> page, int trailingNulls, int positionOffset, + @NonNull Callback callback) { + init(leadingNulls, page, trailingNulls, positionOffset); + callback.onInitialized(size()); + } + + @Override + public V get(int i) { + if (i < 0 || i >= size()) { + throw new IndexOutOfBoundsException("Index: " + i + ", Size: " + size()); + } + + // is it definitely outside 'mPages'? + int localIndex = i - mLeadingNullCount; + if (localIndex < 0 || localIndex >= mStorageCount) { + return null; + } + + int localPageIndex; + int pageInternalIndex; + + if (isTiled()) { + // it's inside mPages, and we're tiled. Jump to correct tile. + localPageIndex = localIndex / mPageSize; + pageInternalIndex = localIndex % mPageSize; + } else { + // it's inside mPages, but page sizes aren't regular. Walk to correct tile. + // Pages can only be null while tiled, so accessing page count is safe. + pageInternalIndex = localIndex; + final int localPageCount = mPages.size(); + for (localPageIndex = 0; localPageIndex < localPageCount; localPageIndex++) { + int pageSize = mPages.get(localPageIndex).items.size(); + if (pageSize > pageInternalIndex) { + // stop, found the page + break; + } + pageInternalIndex -= pageSize; + } + } + + Page<?, V> page = mPages.get(localPageIndex); + if (page == null || page.items.size() == 0) { + // can only occur in tiled case, with untouched inner/placeholder pages + return null; + } + return page.items.get(pageInternalIndex); + } + + /** + * Returns true if all pages are the same size, except for the last, which may be smaller + */ + boolean isTiled() { + return mPageSize > 0; + } + + int getLeadingNullCount() { + return mLeadingNullCount; + } + + int getTrailingNullCount() { + return mTrailingNullCount; + } + + int getStorageCount() { + return mStorageCount; + } + + int getNumberAppended() { + return mNumberAppended; + } + + int getNumberPrepended() { + return mNumberPrepended; + } + + int getPageCount() { + return mPages.size(); + } + + interface Callback { + void onInitialized(int count); + void onPagePrepended(int leadingNulls, int changed, int added); + void onPageAppended(int endPosition, int changed, int added); + void onPagePlaceholderInserted(int pageIndex); + void onPageInserted(int start, int count); + } + + int getPositionOffset() { + return mPositionOffset; + } + + @Override + public int size() { + return mLeadingNullCount + mStorageCount + mTrailingNullCount; + } + + int computeLeadingNulls() { + int total = mLeadingNullCount; + final int pageCount = mPages.size(); + for (int i = 0; i < pageCount; i++) { + Page page = mPages.get(i); + if (page != null && page != mPlaceholderPage) { + break; + } + total += mPageSize; + } + return total; + } + + int computeTrailingNulls() { + int total = mTrailingNullCount; + for (int i = mPages.size() - 1; i >= 0; i--) { + Page page = mPages.get(i); + if (page != null && page != mPlaceholderPage) { + break; + } + total += mPageSize; + } + return total; + } + + // ---------------- Contiguous API ------------------- + + V getFirstContiguousItem() { + // safe to access first page's first item here: + // If contiguous, mPages can't be empty, can't hold null Pages, and items can't be empty + return mPages.get(0).items.get(0); + } + + V getLastContiguousItem() { + // safe to access last page's last item here: + // If contiguous, mPages can't be empty, can't hold null Pages, and items can't be empty + Page<K, V> page = mPages.get(mPages.size() - 1); + return page.items.get(page.items.size() - 1); + } + + public void prependPage(@NonNull Page<K, V> page, @NonNull Callback callback) { + final int count = page.items.size(); + if (count == 0) { + // Nothing returned from source, stop loading in this direction + return; + } + if (mPageSize > 0 && count != mPageSize) { + if (mPages.size() == 1 && count > mPageSize) { + // prepending to a single item - update current page size to that of 'inner' page + mPageSize = count; + } else { + // no longer tiled + mPageSize = -1; + } + } + + mPages.add(0, page); + mStorageCount += count; + + final int changedCount = Math.min(mLeadingNullCount, count); + final int addedCount = count - changedCount; + + if (changedCount != 0) { + mLeadingNullCount -= changedCount; + } + mPositionOffset -= addedCount; + mNumberPrepended += count; + + callback.onPagePrepended(mLeadingNullCount, changedCount, addedCount); + } + + public void appendPage(@NonNull Page<K, V> page, @NonNull Callback callback) { + final int count = page.items.size(); + if (count == 0) { + // Nothing returned from source, stop loading in this direction + return; + } + + if (mPageSize > 0) { + // if the previous page was smaller than mPageSize, + // or if this page is larger than the previous, disable tiling + if (mPages.get(mPages.size() - 1).items.size() != mPageSize + || count > mPageSize) { + mPageSize = -1; + } + } + + mPages.add(page); + mStorageCount += count; + + final int changedCount = Math.min(mTrailingNullCount, count); + final int addedCount = count - changedCount; + + if (changedCount != 0) { + mTrailingNullCount -= changedCount; + } + mNumberAppended += count; + callback.onPageAppended(mLeadingNullCount + mStorageCount - count, + changedCount, addedCount); + } + + // ------------------ Non-Contiguous API (tiling required) ---------------------- + + public void insertPage(int position, @NonNull Page<K, V> page, Callback callback) { + final int newPageSize = page.items.size(); + if (newPageSize != mPageSize) { + // differing page size is OK in 2 cases, when the page is being added: + // 1) to the end (in which case, ignore new smaller size) + // 2) only the last page has been added so far (in which case, adopt new bigger size) + + int size = size(); + boolean addingLastPage = position == (size - size % mPageSize) + && newPageSize < mPageSize; + boolean onlyEndPagePresent = mTrailingNullCount == 0 && mPages.size() == 1 + && newPageSize > mPageSize; + + // OK only if existing single page, and it's the last one + if (!onlyEndPagePresent && !addingLastPage) { + throw new IllegalArgumentException("page introduces incorrect tiling"); + } + if (onlyEndPagePresent) { + mPageSize = newPageSize; + } + } + + int pageIndex = position / mPageSize; + + allocatePageRange(pageIndex, pageIndex); + + int localPageIndex = pageIndex - mLeadingNullCount / mPageSize; + + Page<K, V> oldPage = mPages.get(localPageIndex); + if (oldPage != null && oldPage != mPlaceholderPage) { + throw new IllegalArgumentException( + "Invalid position " + position + ": data already loaded"); + } + mPages.set(localPageIndex, page); + callback.onPageInserted(position, page.items.size()); + } + + private Page<K, V> getPlaceholderPage() { + if (mPlaceholderPage == null) { + @SuppressWarnings("unchecked") + List<V> list = Collections.emptyList(); + mPlaceholderPage = new Page<>(null, list, null); + } + return mPlaceholderPage; + } + + private void allocatePageRange(final int minimumPage, final int maximumPage) { + int leadingNullPages = mLeadingNullCount / mPageSize; + + if (minimumPage < leadingNullPages) { + for (int i = 0; i < leadingNullPages - minimumPage; i++) { + mPages.add(0, null); + } + int newStorageAllocated = (leadingNullPages - minimumPage) * mPageSize; + mStorageCount += newStorageAllocated; + mLeadingNullCount -= newStorageAllocated; + + leadingNullPages = minimumPage; + } + if (maximumPage >= leadingNullPages + mPages.size()) { + int newStorageAllocated = Math.min(mTrailingNullCount, + (maximumPage + 1 - (leadingNullPages + mPages.size())) * mPageSize); + for (int i = mPages.size(); i <= maximumPage - leadingNullPages; i++) { + mPages.add(mPages.size(), null); + } + mStorageCount += newStorageAllocated; + mTrailingNullCount -= newStorageAllocated; + } + } + + public void allocatePlaceholders(int index, int prefetchDistance, + int pageSize, Callback callback) { + if (pageSize != mPageSize) { + if (pageSize < mPageSize) { + throw new IllegalArgumentException("Page size cannot be reduced"); + } + if (mPages.size() != 1 || mTrailingNullCount != 0) { + // not in single, last page allocated case - can't change page size + throw new IllegalArgumentException( + "Page size can change only if last page is only one present"); + } + mPageSize = pageSize; + } + + final int maxPageCount = (size() + mPageSize - 1) / mPageSize; + int minimumPage = Math.max((index - prefetchDistance) / mPageSize, 0); + int maximumPage = Math.min((index + prefetchDistance) / mPageSize, maxPageCount - 1); + + allocatePageRange(minimumPage, maximumPage); + int leadingNullPages = mLeadingNullCount / mPageSize; + for (int pageIndex = minimumPage; pageIndex <= maximumPage; pageIndex++) { + int localPageIndex = pageIndex - leadingNullPages; + if (mPages.get(localPageIndex) == null) { + mPages.set(localPageIndex, getPlaceholderPage()); + callback.onPagePlaceholderInserted(pageIndex); + } + } + } + + public boolean hasPage(int pageSize, int index) { + // NOTE: we pass pageSize here to avoid in case mPageSize + // not fully initialized (when last page only one loaded) + int leadingNullPages = mLeadingNullCount / pageSize; + + if (index < leadingNullPages || index >= leadingNullPages + mPages.size()) { + return false; + } + + Page<K, V> page = mPages.get(index - leadingNullPages); + + return page != null && page != mPlaceholderPage; + } + + @Override + public String toString() { + StringBuilder ret = new StringBuilder("leading " + mLeadingNullCount + + ", storage " + mStorageCount + + ", trailing " + getTrailingNullCount()); + + for (int i = 0; i < mPages.size(); i++) { + ret.append(" ").append(mPages.get(i)); + } + return ret.toString(); + } +} diff --git a/android/arch/paging/ContiguousDiffHelper.java b/android/arch/paging/PagedStorageDiffHelper.java index 7dd194b2..6fc70390 100644 --- a/android/arch/paging/ContiguousDiffHelper.java +++ b/android/arch/paging/PagedStorageDiffHelper.java @@ -16,36 +16,31 @@ package android.arch.paging; -import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.support.annotation.RestrictTo; import android.support.v7.recyclerview.extensions.DiffCallback; import android.support.v7.util.DiffUtil; import android.support.v7.util.ListUpdateCallback; -/** @hide */ -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class ContiguousDiffHelper { - private ContiguousDiffHelper() { +class PagedStorageDiffHelper { + private PagedStorageDiffHelper() { } - @NonNull static <T> DiffUtil.DiffResult computeDiff( - final NullPaddedList<T> oldList, final NullPaddedList<T> newList, - final DiffCallback<T> diffCallback, boolean detectMoves) { + final PagedStorage<?, T> oldList, + final PagedStorage<?, T> newList, + final DiffCallback<T> diffCallback) { + final int oldOffset = oldList.computeLeadingNulls(); + final int newOffset = newList.computeLeadingNulls(); + + final int oldSize = oldList.size() - oldOffset - oldList.computeTrailingNulls(); + final int newSize = newList.size() - newOffset - newList.computeTrailingNulls(); - if (!oldList.isImmutable()) { - throw new IllegalArgumentException("list must be immutable to safely perform diff"); - } - if (!newList.isImmutable()) { - throw new IllegalArgumentException("list must be immutable to safely perform diff"); - } return DiffUtil.calculateDiff(new DiffUtil.Callback() { @Nullable @Override public Object getChangePayload(int oldItemPosition, int newItemPosition) { - T oldItem = oldList.mList.get(oldItemPosition); - T newItem = newList.mList.get(newItemPosition); + T oldItem = oldList.get(oldItemPosition + oldOffset); + T newItem = newList.get(newItemPosition + newList.getLeadingNullCount()); if (oldItem == null || newItem == null) { return null; } @@ -54,21 +49,22 @@ class ContiguousDiffHelper { @Override public int getOldListSize() { - return oldList.mList.size(); + return oldSize; } @Override public int getNewListSize() { - return newList.mList.size(); + return newSize; } @Override public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { - T oldItem = oldList.mList.get(oldItemPosition); - T newItem = newList.mList.get(newItemPosition); + T oldItem = oldList.get(oldItemPosition + oldOffset); + T newItem = newList.get(newItemPosition + newList.getLeadingNullCount()); if (oldItem == newItem) { return true; } + //noinspection SimplifiableIfStatement if (oldItem == null || newItem == null) { return false; } @@ -77,18 +73,19 @@ class ContiguousDiffHelper { @Override public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { - T oldItem = oldList.mList.get(oldItemPosition); - T newItem = newList.mList.get(newItemPosition); + T oldItem = oldList.get(oldItemPosition + oldOffset); + T newItem = newList.get(newItemPosition + newList.getLeadingNullCount()); if (oldItem == newItem) { return true; } + //noinspection SimplifiableIfStatement if (oldItem == null || newItem == null) { return false; } return diffCallback.areContentsTheSame(oldItem, newItem); } - }, detectMoves); + }, true); } private static class OffsettingListUpdateCallback implements ListUpdateCallback { @@ -134,21 +131,25 @@ class ContiguousDiffHelper { * immediately after dispatching this diff. */ static <T> void dispatchDiff(ListUpdateCallback callback, - final NullPaddedList<T> oldList, final NullPaddedList<T> newList, + final PagedStorage<?, T> oldList, + final PagedStorage<?, T> newList, final DiffUtil.DiffResult diffResult) { - if (oldList.getLeadingNullCount() == 0 - && oldList.getTrailingNullCount() == 0 - && newList.getLeadingNullCount() == 0 - && newList.getTrailingNullCount() == 0) { + final int trailingOld = oldList.computeTrailingNulls(); + final int trailingNew = newList.computeTrailingNulls(); + final int leadingOld = oldList.computeLeadingNulls(); + final int leadingNew = newList.computeLeadingNulls(); + + if (trailingOld == 0 + && trailingNew == 0 + && leadingOld == 0 + && leadingNew == 0) { // Simple case, dispatch & return diffResult.dispatchUpdatesTo(callback); return; } // First, remove or insert trailing nulls - final int trailingOld = oldList.getTrailingNullCount(); - final int trailingNew = newList.getTrailingNullCount(); if (trailingOld > trailingNew) { int count = trailingOld - trailingNew; callback.onRemoved(oldList.size() - count, count); @@ -157,8 +158,6 @@ class ContiguousDiffHelper { } // Second, remove or insert leading nulls - final int leadingOld = oldList.getLeadingNullCount(); - final int leadingNew = newList.getLeadingNullCount(); if (leadingOld > leadingNew) { callback.onRemoved(0, leadingOld - leadingNew); } else if (leadingOld < leadingNew) { diff --git a/android/arch/paging/PositionalDataSource.java b/android/arch/paging/PositionalDataSource.java index deb51e94..c538cb60 100644 --- a/android/arch/paging/PositionalDataSource.java +++ b/android/arch/paging/PositionalDataSource.java @@ -42,6 +42,17 @@ import java.util.List; */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public abstract class PositionalDataSource<Value> extends ContiguousDataSource<Integer, Value> { + + /** + * Number of items that this DataSource can provide in total, or COUNT_UNDEFINED. + * + * @return number of items that this DataSource can provide in total, or COUNT_UNDEFINED + * if difficult or undesired to compute. + */ + public int countItems() { + return COUNT_UNDEFINED; + } + @Nullable @Override List<Value> loadAfterImpl(int currentEndIndex, @NonNull Value currentEndItem, int pageSize) { @@ -55,16 +66,7 @@ public abstract class PositionalDataSource<Value> extends ContiguousDataSource<I return loadBefore(currentBeginIndex - 1, pageSize); } - - /** - * Load initial data, starting after the passed position. - * - * @param position Index just before the data to be loaded. - * @param initialLoadSize Suggested number of items to load. - * @return List of initial items, representing data starting at position. Null if the - * DataSource is no longer valid, and should not be queried again. - * @hide - */ + /** @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @WorkerThread @Nullable @@ -118,6 +120,9 @@ public abstract class PositionalDataSource<Value> extends ContiguousDataSource<I @Override Integer getKey(int position, Value item) { + if (position < 0) { + return null; + } return position; } } diff --git a/android/arch/paging/SnapshotPagedList.java b/android/arch/paging/SnapshotPagedList.java new file mode 100644 index 00000000..7e965a0f --- /dev/null +++ b/android/arch/paging/SnapshotPagedList.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.arch.paging; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +class SnapshotPagedList<T> extends PagedList<T> { + private final boolean mContiguous; + private final Object mLastKey; + + SnapshotPagedList(@NonNull PagedList<T> pagedList) { + super(pagedList.mStorage.snapshot(), + pagedList.mMainThreadExecutor, + pagedList.mBackgroundThreadExecutor, + pagedList.mConfig); + mContiguous = pagedList.isContiguous(); + mLastKey = pagedList.getLastKey(); + } + + @Override + public boolean isImmutable() { + return true; + } + + @Override + public boolean isDetached() { + return true; + } + + @Override + boolean isContiguous() { + return mContiguous; + } + + @Nullable + @Override + public Object getLastKey() { + return mLastKey; + } + + @Override + void dispatchUpdatesSinceSnapshot(@NonNull PagedList<T> storageSnapshot, + @NonNull Callback callback) { + } + + @Override + void loadAroundInternal(int index) { + } +} diff --git a/android/arch/paging/SparseDiffHelper.java b/android/arch/paging/SparseDiffHelper.java deleted file mode 100644 index fe478973..00000000 --- a/android/arch/paging/SparseDiffHelper.java +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.paging; - -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.annotation.RestrictTo; -import android.support.v7.recyclerview.extensions.DiffCallback; -import android.support.v7.util.DiffUtil; -import android.support.v7.util.ListUpdateCallback; - -/** @hide */ -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class SparseDiffHelper { - private SparseDiffHelper() { - } - - @NonNull - static <T> DiffUtil.DiffResult computeDiff( - final PageArrayList<T> oldList, final PageArrayList<T> newList, - final DiffCallback<T> diffCallback, boolean detectMoves) { - - if (!oldList.isImmutable()) { - throw new IllegalArgumentException("list must be immutable to safely perform diff"); - } - if (!newList.isImmutable()) { - throw new IllegalArgumentException("list must be immutable to safely perform diff"); - } - return DiffUtil.calculateDiff(new DiffUtil.Callback() { - @Nullable - @Override - public Object getChangePayload(int oldItemPosition, int newItemPosition) { - T oldItem = oldList.get(oldItemPosition); - T newItem = newList.get(newItemPosition); - if (oldItem == null || newItem == null) { - return null; - } - return diffCallback.getChangePayload(oldItem, newItem); - } - - @Override - public int getOldListSize() { - return oldList.size(); - } - - @Override - public int getNewListSize() { - return newList.size(); - } - - @Override - public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { - T oldItem = oldList.get(oldItemPosition); - T newItem = newList.get(newItemPosition); - if (oldItem == newItem) { - return true; - } - if (oldItem == null || newItem == null) { - return false; - } - return diffCallback.areItemsTheSame(oldItem, newItem); - } - - @Override - public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { - T oldItem = oldList.get(oldItemPosition); - T newItem = newList.get(newItemPosition); - if (oldItem == newItem) { - return true; - } - if (oldItem == null || newItem == null) { - return false; - } - - return diffCallback.areContentsTheSame(oldItem, newItem); - } - }, detectMoves); - } - - static <T> void dispatchDiff(ListUpdateCallback callback, - final DiffUtil.DiffResult diffResult) { - // Simple case, dispatch & return - diffResult.dispatchUpdatesTo(callback); - } -} diff --git a/android/arch/paging/StringPagedList.java b/android/arch/paging/StringPagedList.java deleted file mode 100644 index 5318d38c..00000000 --- a/android/arch/paging/StringPagedList.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.paging; - -import java.util.Arrays; - -public class StringPagedList extends NullPaddedList<String> { - public StringPagedList(int leadingNulls, int trailingNulls, String... items) { - super(leadingNulls, Arrays.asList(items), trailingNulls); - } -} diff --git a/android/arch/paging/TestExecutor.java b/android/arch/paging/TestExecutor.java deleted file mode 100644 index 30809c3e..00000000 --- a/android/arch/paging/TestExecutor.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.paging; - -import android.support.annotation.NonNull; - -import java.util.LinkedList; -import java.util.Queue; -import java.util.concurrent.Executor; - -public class TestExecutor implements Executor { - private Queue<Runnable> mTasks = new LinkedList<>(); - - @Override - public void execute(@NonNull Runnable command) { - mTasks.add(command); - } - - boolean executeAll() { - boolean consumed = !mTasks.isEmpty(); - Runnable task; - while ((task = mTasks.poll()) != null) { - task.run(); - } - return consumed; - } -} diff --git a/android/arch/paging/TiledDataSource.java b/android/arch/paging/TiledDataSource.java index 36be423d..61dead3a 100644 --- a/android/arch/paging/TiledDataSource.java +++ b/android/arch/paging/TiledDataSource.java @@ -19,6 +19,7 @@ package android.arch.paging; import android.support.annotation.Nullable; import android.support.annotation.WorkerThread; +import java.util.Collections; import java.util.List; /** @@ -92,7 +93,6 @@ public abstract class TiledDataSource<Type> extends DataSource<Integer, Type> { * @return Number of items this DataSource can provide. Must be <code>0</code> or greater. */ @WorkerThread - @Override public abstract int countItems(); @Override @@ -118,7 +118,61 @@ public abstract class TiledDataSource<Type> extends DataSource<Integer, Type> { @WorkerThread public abstract List<Type> loadRange(int startPosition, int count); - final List<Type> loadRangeWrapper(int startPosition, int count) { + /** + * blocking, and splits pages + */ + void loadRangeInitial(int startPosition, int count, int pageSize, int itemCount, + PageResult.Receiver<Integer, Type> receiver) { + + if (itemCount == 0) { + // no data to load, just immediately return empty + receiver.onPageResult(new PageResult<>( + PageResult.INIT, new Page<Integer, Type>(Collections.<Type>emptyList()), + 0, 0, startPosition)); + return; + } + + + List<Type> list = loadRangeWrapper(startPosition, count); + + count = Math.min(count, itemCount - startPosition); + + if (list == null) { + // invalid data, pass to receiver + receiver.onPageResult(new PageResult<Integer, Type>( + PageResult.INIT, null, 0, 0, startPosition)); + return; + } + + if (list.size() != count) { + throw new IllegalStateException("Invalid list, requested size: " + count + + ", returned size: " + list.size()); + } + + // emit the results as multiple pages + int pageCount = (count + (pageSize - 1)) / pageSize; + for (int i = 0; i < pageCount; i++) { + int beginInclusive = i * pageSize; + int endExclusive = Math.min(count, (i + 1) * pageSize); + + Page<Integer, Type> page = new Page<>(list.subList(beginInclusive, endExclusive)); + + int leadingNulls = startPosition + beginInclusive; + int trailingNulls = itemCount - leadingNulls - page.items.size(); + receiver.onPageResult(new PageResult<>( + PageResult.INIT, page, leadingNulls, trailingNulls, 0)); + } + } + + void loadRange(int startPosition, int count, PageResult.Receiver<Integer, Type> receiver) { + List<Type> list = loadRangeWrapper(startPosition, count); + + Page<Integer, Type> page = list != null ? new Page<Integer, Type>(list) : null; + receiver.postOnPageResult(new PageResult<>( + PageResult.TILE, page, startPosition, 0, 0)); + } + + private List<Type> loadRangeWrapper(int startPosition, int count) { if (isInvalid()) { return null; } diff --git a/android/arch/paging/TiledPagedList.java b/android/arch/paging/TiledPagedList.java index a2fc064b..934a0dd0 100644 --- a/android/arch/paging/TiledPagedList.java +++ b/android/arch/paging/TiledPagedList.java @@ -16,219 +16,173 @@ package android.arch.paging; +import android.support.annotation.AnyThread; +import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.support.annotation.RestrictTo; import android.support.annotation.WorkerThread; -import java.lang.ref.WeakReference; -import java.util.AbstractList; -import java.util.ArrayList; -import java.util.List; import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicBoolean; -/** @hide */ -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class TiledPagedList<T> extends PageArrayList<T> { +class TiledPagedList<T> extends PagedList<T> + implements PagedStorage.Callback { private final TiledDataSource<T> mDataSource; - private final Executor mMainThreadExecutor; - private final Executor mBackgroundThreadExecutor; - private final Config mConfig; - @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") - private final List<T> mLoadingPlaceholder = new AbstractList<T>() { - @Override - public T get(int i) { - return null; - } + @SuppressWarnings("unchecked") + private final PagedStorage<Integer, T> mKeyedStorage = (PagedStorage<Integer, T>) mStorage; + private final PageResult.Receiver<Integer, T> mReceiver = + new PageResult.Receiver<Integer, T>() { + @AnyThread @Override - public int size() { - return 0; + public void postOnPageResult(@NonNull final PageResult<Integer, T> pageResult) { + // NOTE: if we're already on main thread, this can delay page receive by a frame + mMainThreadExecutor.execute(new Runnable() { + @Override + public void run() { + onPageResult(pageResult); + } + }); } - }; - private int mLastLoad = -1; + @MainThread + @Override + public void onPageResult(@NonNull PageResult<Integer, T> pageResult) { + if (pageResult.page == null) { + detach(); + return; + } - private AtomicBoolean mDetached = new AtomicBoolean(false); + if (isDetached()) { + // No op, have detached + return; + } - private ArrayList<WeakReference<Callback>> mCallbacks = new ArrayList<>(); + if (mStorage.getPageCount() == 0) { + mKeyedStorage.init( + pageResult.leadingNulls, pageResult.page, pageResult.trailingNulls, + pageResult.positionOffset, TiledPagedList.this); + } else { + mKeyedStorage.insertPage(pageResult.leadingNulls, pageResult.page, + TiledPagedList.this); + } + } + }; @WorkerThread TiledPagedList(@NonNull TiledDataSource<T> dataSource, @NonNull Executor mainThreadExecutor, @NonNull Executor backgroundThreadExecutor, - Config config, + @NonNull Config config, int position) { - super(config.mPageSize, dataSource.countItems()); - + super(new PagedStorage<Integer, T>(), + mainThreadExecutor, backgroundThreadExecutor, config); mDataSource = dataSource; - mMainThreadExecutor = mainThreadExecutor; - mBackgroundThreadExecutor = backgroundThreadExecutor; - mConfig = config; - - position = Math.min(Math.max(0, position), mCount); - - int firstPage = position / mPageSize; - List<T> firstPageData = dataSource.loadRangeWrapper(firstPage * mPageSize, mPageSize); - if (firstPageData != null) { - mPageIndexOffset = firstPage; - mPages.add(firstPageData); - mLastLoad = position; - } else { - detach(); - return; - } - int secondPage = (position % mPageSize < mPageSize / 2) ? firstPage - 1 : firstPage + 1; - if (secondPage < 0 || secondPage > mMaxPageCount) { - // no second page to load - return; - } - List<T> secondPageData = dataSource.loadRangeWrapper(secondPage * mPageSize, mPageSize); - if (secondPageData != null) { - boolean before = secondPage < firstPage; - mPages.add(before ? 0 : 1, secondPageData); - if (before) { - mPageIndexOffset--; - } - return; - } - detach(); - } + final int pageSize = mConfig.pageSize; - @Override - public void loadAround(int index) { - mLastLoad = index; - - int minimumPage = Math.max((index - mConfig.mPrefetchDistance) / mPageSize, 0); - int maximumPage = Math.min((index + mConfig.mPrefetchDistance) / mPageSize, - mMaxPageCount - 1); + final int itemCount = mDataSource.countItems(); + final int firstLoadSize = Math.min(itemCount, + (Math.max(mConfig.initialLoadSizeHint / pageSize, 2)) * pageSize); + final int firstLoadPosition = computeFirstLoadPosition( + position, firstLoadSize, pageSize, itemCount); - if (minimumPage < mPageIndexOffset) { - for (int i = 0; i < mPageIndexOffset - minimumPage; i++) { - mPages.add(0, null); - } - mPageIndexOffset = minimumPage; - } - if (maximumPage >= mPageIndexOffset + mPages.size()) { - for (int i = mPages.size(); i <= maximumPage - mPageIndexOffset; i++) { - mPages.add(mPages.size(), null); - } - } - for (int i = minimumPage; i <= maximumPage; i++) { - scheduleLoadPage(i); - } + mDataSource.loadRangeInitial(firstLoadPosition, firstLoadSize, pageSize, + itemCount, mReceiver); } - private void scheduleLoadPage(final int pageIndex) { - final int localPageIndex = pageIndex - mPageIndexOffset; + static int computeFirstLoadPosition(int position, int firstLoadSize, int pageSize, int size) { + int idealStart = position - firstLoadSize / 2; - if (mPages.get(localPageIndex) != null) { - // page is present in list, and non-null - don't need to load - return; - } - mPages.set(localPageIndex, mLoadingPlaceholder); + int roundedPageStart = Math.round(idealStart / pageSize) * pageSize; - mBackgroundThreadExecutor.execute(new Runnable() { - @Override - public void run() { - if (mDetached.get()) { - return; - } - final List<T> data = mDataSource.loadRangeWrapper( - pageIndex * mPageSize, mPageSize); - if (data != null) { - mMainThreadExecutor.execute(new Runnable() { - @Override - public void run() { - if (mDetached.get()) { - return; - } - loadPageImpl(pageIndex, data); - } - }); - } else { - detach(); - } - } - }); + // minimum start position is 0 + roundedPageStart = Math.max(0, roundedPageStart); - } + // maximum start pos is that which will encompass end of list + int maximumLoadPage = ((size - firstLoadSize + pageSize - 1) / pageSize) * pageSize; + roundedPageStart = Math.min(maximumLoadPage, roundedPageStart); - private void loadPageImpl(int pageIndex, List<T> data) { - int localPageIndex = pageIndex - mPageIndexOffset; + return roundedPageStart; + } - if (mPages.get(localPageIndex) != mLoadingPlaceholder) { - throw new IllegalStateException("Data inserted before requested."); - } - mPages.set(localPageIndex, data); - for (WeakReference<Callback> weakRef : mCallbacks) { - Callback callback = weakRef.get(); - if (callback != null) { - callback.onChanged(pageIndex * mPageSize, data.size()); - } - } + @Override + boolean isContiguous() { + return false; } + @Nullable @Override - public boolean isImmutable() { - // TODO: consider counting loaded pages, return true if mLoadedPages == mMaxPageCount - // Note: could at some point want to support growing past max count, or grow dynamically - return isDetached(); + public Object getLastKey() { + return mLastLoad; } @Override - public void addWeakCallback(@Nullable PagedList<T> previousSnapshot, + protected void dispatchUpdatesSinceSnapshot(@NonNull PagedList<T> pagedListSnapshot, @NonNull Callback callback) { - PageArrayList<T> snapshot = (PageArrayList<T>) previousSnapshot; - if (snapshot != this && snapshot != null) { - // loop through each page and signal the callback for any pages that are present now, - // but not in the snapshot. - for (int i = 0; i < mPages.size(); i++) { - int pageIndex = i + mPageIndexOffset; - int pageCount = 0; - // count number of consecutive pages that were added since the snapshot... - while (pageCount < mPages.size() - && hasPage(pageIndex + pageCount) - && !snapshot.hasPage(pageIndex + pageCount)) { - pageCount++; - } - // and signal them all at once to the callback - if (pageCount > 0) { - callback.onChanged(pageIndex * mPageSize, mPageSize * pageCount); - i += pageCount - 1; - } + //noinspection UnnecessaryLocalVariable + final PagedStorage<?, T> snapshot = pagedListSnapshot.mStorage; + + // loop through each page and signal the callback for any pages that are present now, + // but not in the snapshot. + final int pageSize = mConfig.pageSize; + final int leadingNullPages = mStorage.getLeadingNullCount() / pageSize; + final int pageCount = mStorage.getPageCount(); + for (int i = 0; i < pageCount; i++) { + int pageIndex = i + leadingNullPages; + int updatedPages = 0; + // count number of consecutive pages that were added since the snapshot... + while (updatedPages < mStorage.getPageCount() + && mStorage.hasPage(pageSize, pageIndex + updatedPages) + && !snapshot.hasPage(pageSize, pageIndex + updatedPages)) { + updatedPages++; + } + // and signal them all at once to the callback + if (updatedPages > 0) { + callback.onChanged(pageIndex * pageSize, pageSize * updatedPages); + i += updatedPages - 1; } } - mCallbacks.add(new WeakReference<>(callback)); } @Override - public void removeWeakCallback(@NonNull Callback callback) { - for (int i = mCallbacks.size() - 1; i >= 0; i--) { - Callback currentCallback = mCallbacks.get(i).get(); - if (currentCallback == null || currentCallback == callback) { - mCallbacks.remove(i); - } - } + protected void loadAroundInternal(int index) { + mStorage.allocatePlaceholders(index, mConfig.prefetchDistance, mConfig.pageSize, this); } @Override - public boolean isDetached() { - return mDetached.get(); + public void onInitialized(int count) { + notifyInserted(0, count); } @Override - public void detach() { - mDetached.set(true); + public void onPagePrepended(int leadingNulls, int changed, int added) { + throw new IllegalStateException("Contiguous callback on TiledPagedList"); } - @Nullable @Override - public Object getLastKey() { - return mLastLoad; + public void onPageAppended(int endPosition, int changed, int added) { + throw new IllegalStateException("Contiguous callback on TiledPagedList"); + } + + @Override + public void onPagePlaceholderInserted(final int pageIndex) { + // placeholder means initialize a load + mBackgroundThreadExecutor.execute(new Runnable() { + @Override + public void run() { + if (isDetached()) { + return; + } + final int pageSize = mConfig.pageSize; + mDataSource.loadRange(pageIndex * pageSize, pageSize, mReceiver); + } + }); + } + + @Override + public void onPageInserted(int start, int count) { + notifyChanged(start, count); } } diff --git a/android/arch/paging/TiledPagedListTest.java b/android/arch/paging/TiledPagedListTest.java deleted file mode 100644 index 4ad02e12..00000000 --- a/android/arch/paging/TiledPagedListTest.java +++ /dev/null @@ -1,288 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.paging; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.verifyZeroInteractions; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -@RunWith(JUnit4.class) -public class TiledPagedListTest { - - private TestExecutor mMainThread = new TestExecutor(); - private TestExecutor mBackgroundThread = new TestExecutor(); - - private static final ArrayList<Item> ITEMS = new ArrayList<>(); - - static { - for (int i = 0; i < 45; i++) { - ITEMS.add(new Item(i)); - } - } - - // use a page size that's not an even divisor of ITEMS.size() to test end conditions - private static final int PAGE_SIZE = 10; - - private static class Item { - private Item(int position) { - this.position = position; - this.name = "Item " + position; - } - - @SuppressWarnings("WeakerAccess") - public final int position; - public final String name; - - @Override - public String toString() { - return name; - } - } - - private static class TestTiledSource extends TiledDataSource<Item> { - @Override - public int countItems() { - return ITEMS.size(); - } - - @Override - public List<Item> loadRange(int startPosition, int count) { - int endPosition = Math.min(ITEMS.size(), startPosition + count); - return ITEMS.subList(startPosition, endPosition); - } - } - - private void verifyRange(PageArrayList<Item> list, Integer... loadedPages) { - List<Integer> loadedPageList = Arrays.asList(loadedPages); - assertEquals(ITEMS.size(), list.size()); - for (int i = 0; i < list.size(); i++) { - if (loadedPageList.contains(i / PAGE_SIZE)) { - assertSame(ITEMS.get(i), list.get(i)); - } else { - assertEquals(null, list.get(i)); - } - } - } - private TiledPagedList<Item> createTiledPagedList(int loadPosition) { - return createTiledPagedList(loadPosition, PAGE_SIZE); - } - - private TiledPagedList<Item> createTiledPagedList(int loadPosition, int prefetchDistance) { - TestTiledSource source = new TestTiledSource(); - return new TiledPagedList<>( - source, mMainThread, mBackgroundThread, - new PagedList.Config.Builder() - .setPageSize(PAGE_SIZE) - .setPrefetchDistance(prefetchDistance) - .build(), - loadPosition); - } - - @Test - public void initialLoad() { - TiledPagedList<Item> pagedList = createTiledPagedList(0); - verifyRange(pagedList, 0); - } - - @Test - public void initialLoad_end() { - TiledPagedList<Item> pagedList = createTiledPagedList(44); - verifyRange(pagedList, 3, 4); - } - - @Test - public void initialLoad_multiple() { - TiledPagedList<Item> pagedList = createTiledPagedList(9); - verifyRange(pagedList, 0, 1); - } - - @Test - public void initialLoad_offset() { - TiledPagedList<Item> pagedList = createTiledPagedList(41); - verifyRange(pagedList, 3, 4); - } - - @Test - public void append() { - TiledPagedList<Item> pagedList = createTiledPagedList(0); - PagedList.Callback callback = mock(PagedList.Callback.class); - pagedList.addWeakCallback(null, callback); - verifyRange(pagedList, 0); - verifyZeroInteractions(callback); - - pagedList.loadAround(5); - drain(); - - verifyRange(pagedList, 0, 1); - verify(callback).onChanged(10, 10); - verifyNoMoreInteractions(callback); - } - - @Test - public void prepend() { - TiledPagedList<Item> pagedList = createTiledPagedList(44); - PagedList.Callback callback = mock(PagedList.Callback.class); - pagedList.addWeakCallback(null, callback); - verifyRange(pagedList, 3, 4); - verifyZeroInteractions(callback); - - pagedList.loadAround(35); - drain(); - - verifyRange(pagedList, 2, 3, 4); - verify(callback).onChanged(20, 10); - verifyNoMoreInteractions(callback); - } - - @Test - public void loadWithGap() { - TiledPagedList<Item> pagedList = createTiledPagedList(0); - PagedList.Callback callback = mock(PagedList.Callback.class); - pagedList.addWeakCallback(null, callback); - verifyRange(pagedList, 0); - verifyZeroInteractions(callback); - - pagedList.loadAround(44); - drain(); - - verifyRange(pagedList, 0, 3, 4); - verify(callback).onChanged(30, 10); - verify(callback).onChanged(40, 5); - verifyNoMoreInteractions(callback); - } - - @Test - public void tinyPrefetchTest() { - TiledPagedList<Item> pagedList = createTiledPagedList(0, 1); - PagedList.Callback callback = mock(PagedList.Callback.class); - pagedList.addWeakCallback(null, callback); - verifyRange(pagedList, 0); // just 4 loaded - verifyZeroInteractions(callback); - - pagedList.loadAround(23); - drain(); - - verifyRange(pagedList, 0, 2); - verify(callback).onChanged(20, 10); - verifyNoMoreInteractions(callback); - - pagedList.loadAround(44); - drain(); - - verifyRange(pagedList, 0, 2, 4); - verify(callback).onChanged(40, 5); - verifyNoMoreInteractions(callback); - } - - @Test - public void appendCallbackAddedLate() { - TiledPagedList<Item> pagedList = createTiledPagedList(0, 0); - verifyRange(pagedList, 0); - - pagedList.loadAround(15); - drain(); - verifyRange(pagedList, 0, 1); - - // snapshot at 20 items - PageArrayList<Item> snapshot = (PageArrayList<Item>) pagedList.snapshot(); - verifyRange(snapshot, 0, 1); - - - pagedList.loadAround(25); - pagedList.loadAround(35); - drain(); - verifyRange(pagedList, 0, 1, 2, 3); - verifyRange(snapshot, 0, 1); - - PagedList.Callback callback = mock( - PagedList.Callback.class); - pagedList.addWeakCallback(snapshot, callback); - verify(callback).onChanged(20, 20); - verifyNoMoreInteractions(callback); - } - - - @Test - public void prependCallbackAddedLate() { - TiledPagedList<Item> pagedList = createTiledPagedList(44, 0); - verifyRange(pagedList, 3, 4); - - pagedList.loadAround(25); - drain(); - verifyRange(pagedList, 2, 3, 4); - - // snapshot at 30 items - PageArrayList<Item> snapshot = (PageArrayList<Item>) pagedList.snapshot(); - verifyRange(snapshot, 2, 3, 4); - - - pagedList.loadAround(15); - pagedList.loadAround(5); - drain(); - verifyRange(pagedList, 0, 1, 2, 3, 4); - verifyRange(snapshot, 2, 3, 4); - - PagedList.Callback callback = mock(PagedList.Callback.class); - pagedList.addWeakCallback(snapshot, callback); - verify(callback).onChanged(0, 20); - verifyNoMoreInteractions(callback); - } - - @Test - public void placeholdersDisabled() { - // disable placeholders with config, so we create a contiguous version of the pagedlist - PagedList<Item> pagedList = new PagedList.Builder<Integer, Item>() - .setDataSource(new TestTiledSource()) - .setMainThreadExecutor(mMainThread) - .setBackgroundThreadExecutor(mBackgroundThread) - .setConfig(new PagedList.Config.Builder() - .setPageSize(PAGE_SIZE) - .setPrefetchDistance(PAGE_SIZE) - .setInitialLoadSizeHint(PAGE_SIZE) - .setEnablePlaceholders(false) - .build()) - .setInitialKey(20) - .build(); - - assertTrue(pagedList.isContiguous()); - - ContiguousPagedList<Item> contiguousPagedList = (ContiguousPagedList<Item>) pagedList; - assertEquals(0, contiguousPagedList.getLeadingNullCount()); - assertEquals(PAGE_SIZE, contiguousPagedList.mList.size()); - assertEquals(0, contiguousPagedList.getTrailingNullCount()); - } - - private void drain() { - boolean executed; - do { - executed = mBackgroundThread.executeAll(); - executed |= mMainThread.executeAll(); - } while (executed); - } -} diff --git a/android/arch/paging/User.java b/android/arch/paging/User.java deleted file mode 100644 index a6d965a2..00000000 --- a/android/arch/paging/User.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.paging; - -public class User { - public final String name; - public final String info; - - public User(String name, String info) { - this.name = name; - this.info = info; - } - - @Override - public String toString() { - return name; - } -} diff --git a/android/arch/persistence/room/InvalidationTracker.java b/android/arch/persistence/room/InvalidationTracker.java index 45ec0289..b31dc13a 100644 --- a/android/arch/persistence/room/InvalidationTracker.java +++ b/android/arch/persistence/room/InvalidationTracker.java @@ -219,7 +219,7 @@ public class InvalidationTracker { * * @param observer The observer which listens the database for changes. */ - public void addObserver(Observer observer) { + public void addObserver(@NonNull Observer observer) { final String[] tableNames = observer.mTables; int[] tableIds = new int[tableNames.length]; final int size = tableNames.length; @@ -265,7 +265,7 @@ public class InvalidationTracker { * @param observer The observer to remove. */ @SuppressWarnings("WeakerAccess") - public void removeObserver(final Observer observer) { + public void removeObserver(@NonNull final Observer observer) { ObserverWrapper wrapper; synchronized (mObserverMap) { wrapper = mObserverMap.remove(observer); diff --git a/android/arch/persistence/room/Relation.java b/android/arch/persistence/room/Relation.java index 72066992..d55bbfe8 100644 --- a/android/arch/persistence/room/Relation.java +++ b/android/arch/persistence/room/Relation.java @@ -28,6 +28,8 @@ import java.lang.annotation.Target; * <pre> * {@literal @}Entity * public class Pet { + * {@literal @} PrimaryKey + * int id; * int userId; * String name; * // other fields @@ -41,8 +43,8 @@ import java.lang.annotation.Target; * * {@literal @}Dao * public interface UserPetDao { - * {@literal @}Query("SELECT id, name from User WHERE age > :minAge") - * public List<UserNameAndAllPets> loadUserAndPets(int minAge); + * {@literal @}Query("SELECT id, name from User") + * public List<UserNameAndAllPets> loadUserAndPets(); * } * </pre> * <p> @@ -63,16 +65,16 @@ import java.lang.annotation.Target; * {@literal @}Embedded * public User user; * {@literal @}Relation(parentColumn = "id", entityColumn = "userId", entity = Pet.class) - * public List<PetNameAndId> pets; + * public List<PetNameAndId> pets; * } * {@literal @}Dao * public interface UserPetDao { - * {@literal @}Query("SELECT * from User WHERE age > :minAge") - * public List<UserAllPets> loadUserAndPets(int minAge); + * {@literal @}Query("SELECT * from User") + * public List<UserAllPets> loadUserAndPets(); * } * </pre> * <p> - * In the example above, {@code PetNameAndId} is a regular but all of fields are fetched + * In the example above, {@code PetNameAndId} is a regular Pojo but all of fields are fetched * from the {@code entity} defined in the {@code @Relation} annotation (<i>Pet</i>). * {@code PetNameAndId} could also define its own relations all of which would also be fetched * automatically. @@ -85,7 +87,7 @@ import java.lang.annotation.Target; * public User user; * {@literal @}Relation(parentColumn = "id", entityColumn = "userId", entity = Pet.class, * projection = {"name"}) - * public List<String> petNames; + * public List<String> petNames; * } * </pre> * <p> @@ -93,7 +95,7 @@ import java.lang.annotation.Target; * cannot have relations. This is a design decision to avoid common pitfalls in {@link Entity} * setups. You can read more about it in the main Room documentation. When loading data, you can * simply work around this limitation by creating Pojo classes that extend the {@link Entity}. - * + * <p> * Note that the {@code @Relation} annotated field cannot be a constructor parameter, it must be * public or have a public setter. */ diff --git a/android/arch/persistence/room/Room.java b/android/arch/persistence/room/Room.java index 8ce4be0c..2850b55e 100644 --- a/android/arch/persistence/room/Room.java +++ b/android/arch/persistence/room/Room.java @@ -43,6 +43,7 @@ public class Room { * @return A {@code RoomDatabaseBuilder<T>} which you can use to create the database. */ @SuppressWarnings("WeakerAccess") + @NonNull public static <T extends RoomDatabase> RoomDatabase.Builder<T> databaseBuilder( @NonNull Context context, @NonNull Class<T> klass, @NonNull String name) { //noinspection ConstantConditions @@ -65,6 +66,7 @@ public class Room { * @param <T> The type of the database class. * @return A {@code RoomDatabaseBuilder<T>} which you can use to create the database. */ + @NonNull public static <T extends RoomDatabase> RoomDatabase.Builder<T> inMemoryDatabaseBuilder( @NonNull Context context, @NonNull Class<T> klass) { return new RoomDatabase.Builder<>(context, klass, null); diff --git a/android/arch/persistence/room/RoomDatabase.java b/android/arch/persistence/room/RoomDatabase.java index cdad868d..8c940246 100644 --- a/android/arch/persistence/room/RoomDatabase.java +++ b/android/arch/persistence/room/RoomDatabase.java @@ -49,7 +49,7 @@ import java.util.concurrent.locks.ReentrantLock; * * @see Database */ -@SuppressWarnings({"unused", "WeakerAccess"}) +//@SuppressWarnings({"unused", "WeakerAccess"}) public abstract class RoomDatabase { private static final String DB_IMPL_SUFFIX = "_Impl"; // set by the generated open helper. @@ -153,7 +153,9 @@ public abstract class RoomDatabase { * * @hide */ + @SuppressWarnings("WeakerAccess") @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + // used in generated code public void assertNotMainThread() { if (mAllowMainThreadQueries) { return; @@ -298,6 +300,7 @@ public abstract class RoomDatabase { * @return True if there is an active transaction in current thread, false otherwise. * @see SupportSQLiteDatabase#inTransaction() */ + @SuppressWarnings("WeakerAccess") public boolean inTransaction() { return mOpenHelper.getWritableDatabase().inTransaction(); } @@ -307,7 +310,6 @@ public abstract class RoomDatabase { * * @param <T> The type of the abstract database class. */ - @SuppressWarnings("unused") public static class Builder<T extends RoomDatabase> { private final Class<T> mDatabaseClass; private final String mName; @@ -337,7 +339,8 @@ public abstract class RoomDatabase { * @param factory The factory to use to access the database. * @return this */ - public Builder<T> openHelperFactory(SupportSQLiteOpenHelper.Factory factory) { + @NonNull + public Builder<T> openHelperFactory(@Nullable SupportSQLiteOpenHelper.Factory factory) { mFactory = factory; return this; } @@ -361,6 +364,7 @@ public abstract class RoomDatabase { * changes. * @return this */ + @NonNull public Builder<T> addMigrations(Migration... migrations) { mMigrationContainer.addMigrations(migrations); return this; @@ -378,6 +382,7 @@ public abstract class RoomDatabase { * * @return this */ + @NonNull public Builder<T> allowMainThreadQueries() { mAllowMainThreadQueries = true; return this; @@ -400,6 +405,7 @@ public abstract class RoomDatabase { * * @return this */ + @NonNull public Builder<T> fallbackToDestructiveMigration() { mRequireMigration = false; return this; @@ -411,6 +417,7 @@ public abstract class RoomDatabase { * @param callback The callback. * @return this */ + @NonNull public Builder<T> addCallback(@NonNull Callback callback) { if (mCallbacks == null) { mCallbacks = new ArrayList<>(); @@ -427,6 +434,7 @@ public abstract class RoomDatabase { * * @return A new database instance. */ + @NonNull public T build() { //noinspection ConstantConditions if (mContext == null) { @@ -493,6 +501,7 @@ public abstract class RoomDatabase { * @return An ordered list of {@link Migration} objects that should be run to migrate * between the given versions. If a migration path cannot be found, returns {@code null}. */ + @SuppressWarnings("WeakerAccess") @Nullable public List<Migration> findMigrationPath(int start, int end) { if (start == end) { diff --git a/android/arch/persistence/room/RoomWarnings.java b/android/arch/persistence/room/RoomWarnings.java index c64be967..f05e6be2 100644 --- a/android/arch/persistence/room/RoomWarnings.java +++ b/android/arch/persistence/room/RoomWarnings.java @@ -125,4 +125,12 @@ public class RoomWarnings { * annotation. */ public static final String DEFAULT_CONSTRUCTOR = "ROOM_DEFAULT_CONSTRUCTOR"; + + /** + * Reported when a @Query method returns a Pojo that has relations but the method is not + * annotated with @Transaction. Relations are run as separate queries and if the query is not + * run inside a transaction, it might return inconsistent results from the database. + */ + public static final String RELATION_QUERY_WITHOUT_TRANSACTION = + "ROOM_RELATION_QUERY_WITHOUT_TRANSACTION"; } diff --git a/android/arch/persistence/room/Transaction.java b/android/arch/persistence/room/Transaction.java index 914e4f41..3b6ede9c 100644 --- a/android/arch/persistence/room/Transaction.java +++ b/android/arch/persistence/room/Transaction.java @@ -22,9 +22,10 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Marks a method in an abstract {@link Dao} class as a transaction method. + * Marks a method in a {@link Dao} class as a transaction method. * <p> - * The derived implementation of the method will execute the super method in a database transaction. + * When used on a non-abstract method of an abstract {@link Dao} class, + * the derived implementation of the method will execute the super method in a database transaction. * All the parameters and return types are preserved. The transaction will be marked as successful * unless an exception is thrown in the method body. * <p> @@ -44,6 +45,38 @@ import java.lang.annotation.Target; * } * } * </pre> + * <p> + * When used on a {@link Query} method that has a {@code Select} statement, the generated code for + * the Query will be run in a transaction. There are 2 main cases where you may want to do that: + * <ol> + * <li>If the result of the query is fairly big, it is better to run it inside a transaction + * to receive a consistent result. Otherwise, if the query result does not fit into a single + * {@link android.database.CursorWindow CursorWindow}, the query result may be corrupted due to + * changes in the database in between cursor window swaps. + * <li>If the result of the query is a Pojo with {@link Relation} fields, these fields are + * queried separately. To receive consistent results between these queries, you probably want + * to run them in a single transaction. + * </ol> + * Example: + * <pre> + * class ProductWithReviews extends Product { + * {@literal @}Relation(parentColumn = "id", entityColumn = "productId", entity = Review.class) + * public List<Review> reviews; + * } + * {@literal @}Dao + * public interface ProductDao { + * {@literal @}Transaction {@literal @}Query("SELECT * from products") + * public List<ProductWithReviews> loadAll(); + * } + * </pre> + * If the query is an async query (e.g. returns a {@link android.arch.lifecycle.LiveData LiveData} + * or RxJava Flowable, the transaction is properly handled when the query is run, not when the + * method is called. + * <p> + * Putting this annotation on an {@link Insert}, {@link Update} or {@link Delete} method has no + * impact because they are always run inside a transaction. Similarly, if it is annotated with + * {@link Query} but runs an update or delete statement, it is automatically wrapped in a + * transaction. */ @Target({ElementType.METHOD}) @Retention(RetentionPolicy.CLASS) diff --git a/android/arch/persistence/room/integration/testapp/RoomPagedListActivity.java b/android/arch/persistence/room/integration/testapp/RoomPagedListActivity.java index 818c46b4..cdd464e4 100644 --- a/android/arch/persistence/room/integration/testapp/RoomPagedListActivity.java +++ b/android/arch/persistence/room/integration/testapp/RoomPagedListActivity.java @@ -86,6 +86,7 @@ public class RoomPagedListActivity extends AppCompatActivity { @Override protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); PagedList<Customer> list = mAdapter.getCurrentList(); if (list == null) { // Can't find anything to restore diff --git a/android/arch/persistence/room/integration/testapp/database/CustomerDao.java b/android/arch/persistence/room/integration/testapp/database/CustomerDao.java index 9d402370..b5df914a 100644 --- a/android/arch/persistence/room/integration/testapp/database/CustomerDao.java +++ b/android/arch/persistence/room/integration/testapp/database/CustomerDao.java @@ -59,7 +59,7 @@ public interface CustomerDao { // Keyed - @Query("SELECT * from customer ORDER BY mLastName ASC LIMIT :limit") + @Query("SELECT * from customer ORDER BY mLastName DESC LIMIT :limit") List<Customer> customerNameInitial(int limit); @Query("SELECT * from customer WHERE mLastName < :key ORDER BY mLastName DESC LIMIT :limit") diff --git a/android/arch/persistence/room/integration/testapp/db/JDBCOpenHelper.java b/android/arch/persistence/room/integration/testapp/db/JDBCOpenHelper.java deleted file mode 100644 index 3cbffc8b..00000000 --- a/android/arch/persistence/room/integration/testapp/db/JDBCOpenHelper.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.arch.persistence.room.integration.testapp.db; - -import android.arch.persistence.db.SupportSQLiteDatabase; -import android.arch.persistence.db.SupportSQLiteOpenHelper; - -public class JDBCOpenHelper implements SupportSQLiteOpenHelper { - @Override - public String getDatabaseName() { - return null; - } - - @Override - public void setWriteAheadLoggingEnabled(boolean enabled) { - - } - - @Override - public SupportSQLiteDatabase getWritableDatabase() { - return null; - } - - @Override - public SupportSQLiteDatabase getReadableDatabase() { - return null; - } - - @Override - public void close() { - - } -} diff --git a/android/arch/persistence/room/integration/testapp/test/InvalidationTest.java b/android/arch/persistence/room/integration/testapp/test/InvalidationTest.java index 84f20ec5..33f40183 100644 --- a/android/arch/persistence/room/integration/testapp/test/InvalidationTest.java +++ b/android/arch/persistence/room/integration/testapp/test/InvalidationTest.java @@ -17,20 +17,17 @@ package android.arch.persistence.room.integration.testapp.test; import static org.hamcrest.CoreMatchers.hasItem; -import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.collection.IsCollectionWithSize.hasSize; -import android.arch.core.executor.ArchTaskExecutor; -import android.arch.core.executor.TaskExecutor; +import android.arch.core.executor.testing.CountingTaskExecutorRule; import android.arch.persistence.room.InvalidationTracker; import android.arch.persistence.room.Room; import android.arch.persistence.room.integration.testapp.TestDatabase; import android.arch.persistence.room.integration.testapp.dao.UserDao; import android.arch.persistence.room.integration.testapp.vo.User; import android.content.Context; -import android.os.Handler; -import android.os.Looper; import android.support.annotation.NonNull; import android.support.test.InstrumentationRegistry; import android.support.test.filters.SmallTest; @@ -38,17 +35,13 @@ import android.support.test.runner.AndroidJUnit4; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import java.util.Set; -import java.util.concurrent.Callable; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; /** * Tests invalidation tracking. @@ -56,138 +49,97 @@ import java.util.concurrent.TimeUnit; @SmallTest @RunWith(AndroidJUnit4.class) public class InvalidationTest { + @Rule + public CountingTaskExecutorRule executorRule = new CountingTaskExecutorRule(); private UserDao mUserDao; private TestDatabase mDb; @Before - public void createDb() { + public void createDb() throws TimeoutException, InterruptedException { Context context = InstrumentationRegistry.getTargetContext(); mDb = Room.inMemoryDatabaseBuilder(context, TestDatabase.class).build(); mUserDao = mDb.getUserDao(); - } - - @Before - public void setSingleThreadedIO() { - ArchTaskExecutor.getInstance().setDelegate(new TaskExecutor() { - ExecutorService mIOExecutor = Executors.newSingleThreadExecutor(); - Handler mHandler = new Handler(Looper.getMainLooper()); - - @Override - public void executeOnDiskIO(Runnable runnable) { - mIOExecutor.execute(runnable); - } - - @Override - public void postToMainThread(Runnable runnable) { - mHandler.post(runnable); - } - - @Override - public boolean isMainThread() { - return Thread.currentThread() == Looper.getMainLooper().getThread(); - } - }); + drain(); } @After - public void clearExecutor() { - ArchTaskExecutor.getInstance().setDelegate(null); + public void closeDb() throws TimeoutException, InterruptedException { + mDb.close(); + drain(); } - private void waitUntilIOThreadIsIdle() { - FutureTask<Void> future = new FutureTask<>(new Callable<Void>() { - @Override - public Void call() throws Exception { - return null; - } - }); - ArchTaskExecutor.getInstance().executeOnDiskIO(future); - //noinspection TryWithIdenticalCatches - try { - future.get(); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } catch (ExecutionException e) { - throw new RuntimeException(e); - } + private void drain() throws TimeoutException, InterruptedException { + executorRule.drainTasks(1, TimeUnit.MINUTES); } @Test - public void testInvalidationOnUpdate() throws InterruptedException { + public void testInvalidationOnUpdate() throws InterruptedException, TimeoutException { User user = TestUtil.createUser(3); mUserDao.insert(user); - LatchObserver observer = new LatchObserver(1, "User"); + LoggingObserver observer = new LoggingObserver("User"); mDb.getInvalidationTracker().addObserver(observer); + drain(); mUserDao.updateById(3, "foo2"); - waitUntilIOThreadIsIdle(); - assertThat(observer.await(), is(true)); + drain(); assertThat(observer.getInvalidatedTables(), hasSize(1)); assertThat(observer.getInvalidatedTables(), hasItem("User")); } @Test - public void testInvalidationOnDelete() throws InterruptedException { + public void testInvalidationOnDelete() throws InterruptedException, TimeoutException { User user = TestUtil.createUser(3); mUserDao.insert(user); - LatchObserver observer = new LatchObserver(1, "User"); + LoggingObserver observer = new LoggingObserver("User"); mDb.getInvalidationTracker().addObserver(observer); + drain(); mUserDao.delete(user); - waitUntilIOThreadIsIdle(); - assertThat(observer.await(), is(true)); + drain(); assertThat(observer.getInvalidatedTables(), hasSize(1)); assertThat(observer.getInvalidatedTables(), hasItem("User")); } @Test - public void testInvalidationOnInsert() throws InterruptedException { - LatchObserver observer = new LatchObserver(1, "User"); + public void testInvalidationOnInsert() throws InterruptedException, TimeoutException { + LoggingObserver observer = new LoggingObserver("User"); mDb.getInvalidationTracker().addObserver(observer); + drain(); mUserDao.insert(TestUtil.createUser(3)); - waitUntilIOThreadIsIdle(); - assertThat(observer.await(), is(true)); + drain(); assertThat(observer.getInvalidatedTables(), hasSize(1)); assertThat(observer.getInvalidatedTables(), hasItem("User")); } @Test - public void testDontInvalidateOnLateInsert() throws InterruptedException { - LatchObserver observer = new LatchObserver(1, "User"); + public void testDontInvalidateOnLateInsert() throws InterruptedException, TimeoutException { + LoggingObserver observer = new LoggingObserver("User"); mUserDao.insert(TestUtil.createUser(3)); - waitUntilIOThreadIsIdle(); + drain(); mDb.getInvalidationTracker().addObserver(observer); - waitUntilIOThreadIsIdle(); - assertThat(observer.await(), is(false)); + drain(); + assertThat(observer.getInvalidatedTables(), nullValue()); } @Test - public void testMultipleTables() throws InterruptedException { - LatchObserver observer = new LatchObserver(1, "User", "Pet"); + public void testMultipleTables() throws InterruptedException, TimeoutException { + LoggingObserver observer = new LoggingObserver("User", "Pet"); mDb.getInvalidationTracker().addObserver(observer); + drain(); mUserDao.insert(TestUtil.createUser(3)); - waitUntilIOThreadIsIdle(); - assertThat(observer.await(), is(true)); + drain(); assertThat(observer.getInvalidatedTables(), hasSize(1)); assertThat(observer.getInvalidatedTables(), hasItem("User")); } - private static class LatchObserver extends InvalidationTracker.Observer { - CountDownLatch mLatch; - + private static class LoggingObserver extends InvalidationTracker.Observer { private Set<String> mInvalidatedTables; - LatchObserver(int permits, String... tables) { + LoggingObserver(String... tables) { super(tables); - mLatch = new CountDownLatch(permits); - } - - boolean await() throws InterruptedException { - return mLatch.await(5, TimeUnit.SECONDS); } @Override public void onInvalidated(@NonNull Set<String> tables) { mInvalidatedTables = tables; - mLatch.countDown(); } Set<String> getInvalidatedTables() { diff --git a/android/arch/persistence/room/integration/testapp/test/QueryDataSourceTest.java b/android/arch/persistence/room/integration/testapp/test/QueryDataSourceTest.java index e11117e4..2735c05a 100644 --- a/android/arch/persistence/room/integration/testapp/test/QueryDataSourceTest.java +++ b/android/arch/persistence/room/integration/testapp/test/QueryDataSourceTest.java @@ -166,17 +166,13 @@ public class QueryDataSourceTest extends TestDatabaseTest { p = dataSource.loadBefore(15, list.get(0), 10); assertNotNull(p); - for (User u : p) { - list.add(0, u); - } + list.addAll(0, p); assertArrayEquals(Arrays.copyOfRange(expected, 5, 35), list.toArray()); p = dataSource.loadBefore(5, list.get(0), 10); assertNotNull(p); - for (User u : p) { - list.add(0, u); - } + list.addAll(0, p); assertArrayEquals(Arrays.copyOfRange(expected, 0, 35), list.toArray()); } diff --git a/android/arch/persistence/room/integration/testapp/test/QueryTransactionTest.java b/android/arch/persistence/room/integration/testapp/test/QueryTransactionTest.java new file mode 100644 index 00000000..854c8627 --- /dev/null +++ b/android/arch/persistence/room/integration/testapp/test/QueryTransactionTest.java @@ -0,0 +1,471 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.arch.persistence.room.integration.testapp.test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +import android.arch.core.executor.ArchTaskExecutor; +import android.arch.core.executor.testing.CountingTaskExecutorRule; +import android.arch.lifecycle.Lifecycle; +import android.arch.lifecycle.LiveData; +import android.arch.lifecycle.Observer; +import android.arch.paging.LivePagedListProvider; +import android.arch.paging.PagedList; +import android.arch.paging.TiledDataSource; +import android.arch.persistence.room.Dao; +import android.arch.persistence.room.Database; +import android.arch.persistence.room.Entity; +import android.arch.persistence.room.Ignore; +import android.arch.persistence.room.Insert; +import android.arch.persistence.room.PrimaryKey; +import android.arch.persistence.room.Query; +import android.arch.persistence.room.Relation; +import android.arch.persistence.room.Room; +import android.arch.persistence.room.RoomDatabase; +import android.arch.persistence.room.RoomWarnings; +import android.arch.persistence.room.Transaction; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.test.InstrumentationRegistry; +import android.support.test.filters.SmallTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +import io.reactivex.Flowable; +import io.reactivex.Maybe; +import io.reactivex.Single; +import io.reactivex.observers.TestObserver; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.subscribers.TestSubscriber; + +@SmallTest +@RunWith(Parameterized.class) +public class QueryTransactionTest { + @Rule + public CountingTaskExecutorRule countingTaskExecutorRule = new CountingTaskExecutorRule(); + private static final AtomicInteger sStartedTransactionCount = new AtomicInteger(0); + private TransactionDb mDb; + private final boolean mUseTransactionDao; + private Entity1Dao mDao; + private final LiveDataQueryTest.TestLifecycleOwner mLifecycleOwner = new LiveDataQueryTest + .TestLifecycleOwner(); + + @NonNull + @Parameterized.Parameters(name = "useTransaction_{0}") + public static Boolean[] getParams() { + return new Boolean[]{false, true}; + } + + public QueryTransactionTest(boolean useTransactionDao) { + mUseTransactionDao = useTransactionDao; + } + + @Before + public void initDb() { + InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + mLifecycleOwner.handleEvent(Lifecycle.Event.ON_START); + } + }); + + resetTransactionCount(); + mDb = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getTargetContext(), + TransactionDb.class).build(); + mDao = mUseTransactionDao ? mDb.transactionDao() : mDb.dao(); + drain(); + } + + @After + public void closeDb() { + InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + mLifecycleOwner.handleEvent(Lifecycle.Event.ON_DESTROY); + } + }); + drain(); + mDb.close(); + } + + @Test + public void readList() { + mDao.insert(new Entity1(1, "foo")); + resetTransactionCount(); + + int expectedTransactionCount = mUseTransactionDao ? 1 : 0; + List<Entity1> allEntities = mDao.allEntities(); + assertTransactionCount(allEntities, expectedTransactionCount); + } + + @Test + public void liveData() { + LiveData<List<Entity1>> listLiveData = mDao.liveData(); + observeForever(listLiveData); + drain(); + assertThat(listLiveData.getValue(), is(Collections.<Entity1>emptyList())); + + resetTransactionCount(); + mDao.insert(new Entity1(1, "foo")); + drain(); + + //noinspection ConstantConditions + assertThat(listLiveData.getValue().size(), is(1)); + int expectedTransactionCount = mUseTransactionDao ? 2 : 1; + assertTransactionCount(listLiveData.getValue(), expectedTransactionCount); + } + + @Test + public void flowable() { + Flowable<List<Entity1>> flowable = mDao.flowable(); + TestSubscriber<List<Entity1>> subscriber = observe(flowable); + drain(); + assertThat(subscriber.values().size(), is(1)); + + resetTransactionCount(); + mDao.insert(new Entity1(1, "foo")); + drain(); + + List<Entity1> allEntities = subscriber.values().get(1); + assertThat(allEntities.size(), is(1)); + int expectedTransactionCount = mUseTransactionDao ? 2 : 1; + assertTransactionCount(allEntities, expectedTransactionCount); + } + + @Test + public void maybe() { + mDao.insert(new Entity1(1, "foo")); + resetTransactionCount(); + + int expectedTransactionCount = mUseTransactionDao ? 1 : 0; + Maybe<List<Entity1>> listMaybe = mDao.maybe(); + TestObserver<List<Entity1>> observer = observe(listMaybe); + drain(); + List<Entity1> allEntities = observer.values().get(0); + assertTransactionCount(allEntities, expectedTransactionCount); + } + + @Test + public void single() { + mDao.insert(new Entity1(1, "foo")); + resetTransactionCount(); + + int expectedTransactionCount = mUseTransactionDao ? 1 : 0; + Single<List<Entity1>> listMaybe = mDao.single(); + TestObserver<List<Entity1>> observer = observe(listMaybe); + drain(); + List<Entity1> allEntities = observer.values().get(0); + assertTransactionCount(allEntities, expectedTransactionCount); + } + + @Test + public void relation() { + mDao.insert(new Entity1(1, "foo")); + mDao.insert(new Child(1, 1)); + mDao.insert(new Child(2, 1)); + resetTransactionCount(); + + List<Entity1WithChildren> result = mDao.withRelation(); + int expectedTransactionCount = mUseTransactionDao ? 1 : 0; + assertTransactionCountWithChildren(result, expectedTransactionCount); + } + + @Test + public void pagedList() { + LiveData<PagedList<Entity1>> pagedList = mDao.pagedList().create(null, 10); + observeForever(pagedList); + drain(); + assertThat(sStartedTransactionCount.get(), is(mUseTransactionDao ? 0 : 0)); + + mDao.insert(new Entity1(1, "foo")); + drain(); + //noinspection ConstantConditions + assertThat(pagedList.getValue().size(), is(1)); + assertTransactionCount(pagedList.getValue(), mUseTransactionDao ? 2 : 1); + + mDao.insert(new Entity1(2, "bar")); + drain(); + assertThat(pagedList.getValue().size(), is(2)); + assertTransactionCount(pagedList.getValue(), mUseTransactionDao ? 4 : 2); + } + + @Test + public void dataSource() { + mDao.insert(new Entity1(2, "bar")); + drain(); + resetTransactionCount(); + TiledDataSource<Entity1> dataSource = mDao.dataSource(); + dataSource.loadRange(0, 10); + assertThat(sStartedTransactionCount.get(), is(mUseTransactionDao ? 1 : 0)); + } + + private void assertTransactionCount(List<Entity1> allEntities, int expectedTransactionCount) { + assertThat(sStartedTransactionCount.get(), is(expectedTransactionCount)); + assertThat(allEntities.isEmpty(), is(false)); + for (Entity1 entity1 : allEntities) { + assertThat(entity1.transactionId, is(expectedTransactionCount)); + } + } + + private void assertTransactionCountWithChildren(List<Entity1WithChildren> allEntities, + int expectedTransactionCount) { + assertThat(sStartedTransactionCount.get(), is(expectedTransactionCount)); + assertThat(allEntities.isEmpty(), is(false)); + for (Entity1WithChildren entity1 : allEntities) { + assertThat(entity1.transactionId, is(expectedTransactionCount)); + assertThat(entity1.children, notNullValue()); + assertThat(entity1.children.isEmpty(), is(false)); + for (Child child : entity1.children) { + assertThat(child.transactionId, is(expectedTransactionCount)); + } + } + } + + private void resetTransactionCount() { + sStartedTransactionCount.set(0); + } + + private void drain() { + try { + countingTaskExecutorRule.drainTasks(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw new AssertionError("interrupted", e); + } catch (TimeoutException e) { + throw new AssertionError("drain timed out", e); + } + } + + private <T> TestSubscriber<T> observe(final Flowable<T> flowable) { + TestSubscriber<T> subscriber = new TestSubscriber<>(); + flowable.observeOn(Schedulers.from(ArchTaskExecutor.getMainThreadExecutor())) + .subscribeWith(subscriber); + return subscriber; + } + + private <T> TestObserver<T> observe(final Maybe<T> maybe) { + TestObserver<T> observer = new TestObserver<>(); + maybe.observeOn(Schedulers.from(ArchTaskExecutor.getMainThreadExecutor())) + .subscribeWith(observer); + return observer; + } + + private <T> TestObserver<T> observe(final Single<T> single) { + TestObserver<T> observer = new TestObserver<>(); + single.observeOn(Schedulers.from(ArchTaskExecutor.getMainThreadExecutor())) + .subscribeWith(observer); + return observer; + } + + private <T> void observeForever(final LiveData<T> liveData) { + FutureTask<Void> futureTask = new FutureTask<>(new Callable<Void>() { + @Override + public Void call() throws Exception { + liveData.observe(mLifecycleOwner, new Observer<T>() { + @Override + public void onChanged(@Nullable T t) { + + } + }); + return null; + } + }); + ArchTaskExecutor.getMainThreadExecutor().execute(futureTask); + try { + futureTask.get(); + } catch (InterruptedException e) { + throw new AssertionError("interrupted", e); + } catch (ExecutionException e) { + throw new AssertionError("execution error", e); + } + } + + @SuppressWarnings("WeakerAccess") + static class Entity1WithChildren extends Entity1 { + @Relation(entity = Child.class, parentColumn = "id", + entityColumn = "entity1Id") + public List<Child> children; + + Entity1WithChildren(int id, String value) { + super(id, value); + } + } + + @SuppressWarnings("WeakerAccess") + @Entity + static class Child { + @PrimaryKey(autoGenerate = true) + public int id; + public int entity1Id; + @Ignore + public final int transactionId = sStartedTransactionCount.get(); + + Child(int id, int entity1Id) { + this.id = id; + this.entity1Id = entity1Id; + } + } + + @SuppressWarnings("WeakerAccess") + @Entity + static class Entity1 { + @PrimaryKey(autoGenerate = true) + public int id; + public String value; + @Ignore + public final int transactionId = sStartedTransactionCount.get(); + + Entity1(int id, String value) { + this.id = id; + this.value = value; + } + } + + // we don't support dao inheritance for queries so for now, go with this + interface Entity1Dao { + String SELECT_ALL = "select * from Entity1"; + + List<Entity1> allEntities(); + + Flowable<List<Entity1>> flowable(); + + Maybe<List<Entity1>> maybe(); + + Single<List<Entity1>> single(); + + LiveData<List<Entity1>> liveData(); + + List<Entity1WithChildren> withRelation(); + + LivePagedListProvider<Integer, Entity1> pagedList(); + + TiledDataSource<Entity1> dataSource(); + + @Insert + void insert(Entity1 entity1); + + @Insert + void insert(Child entity1); + } + + @Dao + interface EntityDao extends Entity1Dao { + @Override + @Query(SELECT_ALL) + List<Entity1> allEntities(); + + @Override + @Query(SELECT_ALL) + Flowable<List<Entity1>> flowable(); + + @Override + @Query(SELECT_ALL) + LiveData<List<Entity1>> liveData(); + + @Override + @Query(SELECT_ALL) + Maybe<List<Entity1>> maybe(); + + @Override + @Query(SELECT_ALL) + Single<List<Entity1>> single(); + + @Override + @Query(SELECT_ALL) + @SuppressWarnings(RoomWarnings.RELATION_QUERY_WITHOUT_TRANSACTION) + List<Entity1WithChildren> withRelation(); + + @Override + @Query(SELECT_ALL) + LivePagedListProvider<Integer, Entity1> pagedList(); + + @Override + @Query(SELECT_ALL) + TiledDataSource<Entity1> dataSource(); + } + + @Dao + interface TransactionDao extends Entity1Dao { + @Override + @Transaction + @Query(SELECT_ALL) + List<Entity1> allEntities(); + + @Override + @Transaction + @Query(SELECT_ALL) + Flowable<List<Entity1>> flowable(); + + @Override + @Transaction + @Query(SELECT_ALL) + LiveData<List<Entity1>> liveData(); + + @Override + @Transaction + @Query(SELECT_ALL) + Maybe<List<Entity1>> maybe(); + + @Override + @Transaction + @Query(SELECT_ALL) + Single<List<Entity1>> single(); + + @Override + @Transaction + @Query(SELECT_ALL) + List<Entity1WithChildren> withRelation(); + + @Override + @Transaction + @Query(SELECT_ALL) + LivePagedListProvider<Integer, Entity1> pagedList(); + + @Override + @Transaction + @Query(SELECT_ALL) + TiledDataSource<Entity1> dataSource(); + } + + @Database(version = 1, entities = {Entity1.class, Child.class}, exportSchema = false) + abstract static class TransactionDb extends RoomDatabase { + abstract EntityDao dao(); + + abstract TransactionDao transactionDao(); + + @Override + public void beginTransaction() { + super.beginTransaction(); + sStartedTransactionCount.incrementAndGet(); + } + } +} diff --git a/android/arch/persistence/room/migration/Migration.java b/android/arch/persistence/room/migration/Migration.java index 907e624b..d69ea0dc 100644 --- a/android/arch/persistence/room/migration/Migration.java +++ b/android/arch/persistence/room/migration/Migration.java @@ -17,6 +17,7 @@ package android.arch.persistence.room.migration; import android.arch.persistence.db.SupportSQLiteDatabase; +import android.support.annotation.NonNull; /** * Base class for a database migration. @@ -58,5 +59,5 @@ public abstract class Migration { * * @param database The database instance */ - public abstract void migrate(SupportSQLiteDatabase database); + public abstract void migrate(@NonNull SupportSQLiteDatabase database); } diff --git a/android/arch/persistence/room/migration/bundle/ForeignKeyBundle.java b/android/arch/persistence/room/migration/bundle/ForeignKeyBundle.java index 1467a4f0..d72cf8cb 100644 --- a/android/arch/persistence/room/migration/bundle/ForeignKeyBundle.java +++ b/android/arch/persistence/room/migration/bundle/ForeignKeyBundle.java @@ -16,13 +16,18 @@ package android.arch.persistence.room.migration.bundle; +import android.support.annotation.RestrictTo; + import com.google.gson.annotations.SerializedName; import java.util.List; /** * Holds the information about a foreign key reference. + * + * @hide */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public class ForeignKeyBundle { @SerializedName("table") private String mTable; diff --git a/android/arch/persistence/room/paging/LimitOffsetDataSource.java b/android/arch/persistence/room/paging/LimitOffsetDataSource.java index 800514cc..2f9a8882 100644 --- a/android/arch/persistence/room/paging/LimitOffsetDataSource.java +++ b/android/arch/persistence/room/paging/LimitOffsetDataSource.java @@ -49,10 +49,13 @@ public abstract class LimitOffsetDataSource<T> extends TiledDataSource<T> { private final RoomDatabase mDb; @SuppressWarnings("FieldCanBeLocal") private final InvalidationTracker.Observer mObserver; + private final boolean mInTransaction; - protected LimitOffsetDataSource(RoomDatabase db, RoomSQLiteQuery query, String... tables) { + protected LimitOffsetDataSource(RoomDatabase db, RoomSQLiteQuery query, + boolean inTransaction, String... tables) { mDb = db; mSourceQuery = query; + mInTransaction = inTransaction; mCountQuery = "SELECT COUNT(*) FROM ( " + mSourceQuery.getSql() + " )"; mLimitOffsetQuery = "SELECT * FROM ( " + mSourceQuery.getSql() + " ) LIMIT ? OFFSET ?"; mObserver = new InvalidationTracker.Observer(tables) { @@ -98,13 +101,30 @@ public abstract class LimitOffsetDataSource<T> extends TiledDataSource<T> { sqLiteQuery.copyArgumentsFrom(mSourceQuery); sqLiteQuery.bindLong(sqLiteQuery.getArgCount() - 1, loadCount); sqLiteQuery.bindLong(sqLiteQuery.getArgCount(), startPosition); - Cursor cursor = mDb.query(sqLiteQuery); - - try { - return convertRows(cursor); - } finally { - cursor.close(); - sqLiteQuery.release(); + if (mInTransaction) { + mDb.beginTransaction(); + Cursor cursor = null; + try { + cursor = mDb.query(sqLiteQuery); + List<T> rows = convertRows(cursor); + mDb.setTransactionSuccessful(); + return rows; + } finally { + if (cursor != null) { + cursor.close(); + } + mDb.endTransaction(); + sqLiteQuery.release(); + } + } else { + Cursor cursor = mDb.query(sqLiteQuery); + //noinspection TryFinallyCanBeTryWithResources + try { + return convertRows(cursor); + } finally { + cursor.close(); + sqLiteQuery.release(); + } } } } diff --git a/android/arch/persistence/room/util/StringUtil.java b/android/arch/persistence/room/util/StringUtil.java index bee05ddd..d01e3c53 100644 --- a/android/arch/persistence/room/util/StringUtil.java +++ b/android/arch/persistence/room/util/StringUtil.java @@ -17,6 +17,7 @@ package android.arch.persistence.room.util; import android.support.annotation.Nullable; +import android.support.annotation.RestrictTo; import android.util.Log; import java.util.ArrayList; @@ -24,10 +25,14 @@ import java.util.List; import java.util.StringTokenizer; /** + * @hide + * * String utilities for Room */ -@SuppressWarnings("WeakerAccess") +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public class StringUtil { + + @SuppressWarnings("unused") public static final String[] EMPTY_STRING_ARRAY = new String[0]; /** * Returns a new StringBuilder to be used while producing SQL queries. |