diff --git a/app/build.gradle b/app/build.gradle index 8ec70a284..3c643aaa7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -54,6 +54,9 @@ dependencies { implementation 'com.github.bumptech.glide:glide:4.6.1' annotationProcessor 'com.github.bumptech.glide:compiler:4.6.1' + // Used for FirestorePagingActivity + implementation "android.arch.paging:runtime:$pagingVersion" + // The following dependencies are not required to use the Firebase UI library. // They are used to make some aspects of the demo app implementation simpler for // demonstrative purposes, and you may find them useful in your own apps; YMMV. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 78baf88a5..0a381a18c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,6 +43,11 @@ android:name=".database.firestore.FirestoreChatActivity" android:label="@string/title_firestore_activity" /> + + + options = new FirestorePagingOptions.Builder() + .setLifecycleOwner(this) + .setQuery(baseQuery, config, Item.class) + .build(); + + FirestorePagingAdapter adapter = + new FirestorePagingAdapter(options) { + @NonNull + @Override + public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, + int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_item, parent, false); + return new ItemViewHolder(view); + } + + @Override + protected void onBindViewHolder(@NonNull ItemViewHolder holder, + int position, + Item model) { + holder.bind(model); + } + + @Override + protected void onLoadingStateChanged(@NonNull LoadingState state) { + switch (state) { + case LOADING_INITIAL: + case LOADING_MORE: + mProgressBar.setVisibility(View.VISIBLE); + break; + case LOADED: + mProgressBar.setVisibility(View.GONE); + break; + case FINISHED: + mProgressBar.setVisibility(View.GONE); + showToast("Reached end of data set."); + break; + case ERROR: + showToast("An error occurred."); + retry(); + break; + } + } + }; + + mRecycler.setLayoutManager(new LinearLayoutManager(this)); + mRecycler.setAdapter(adapter); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_firestore_paging, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.item_add_data) { + showToast("Adding data..."); + createItems().addOnCompleteListener(this, new OnCompleteListener() { + @Override + public void onComplete(@NonNull Task task) { + if (task.isSuccessful()) { + showToast("Data added."); + } else { + Log.w(TAG, "addData", task.getException()); + showToast("Error adding data."); + } + } + }); + + return true; + } + return super.onOptionsItemSelected(item); + } + + private Task createItems() { + WriteBatch writeBatch = mFirestore.batch(); + + for (int i = 0; i < 250; i++) { + String title = "Item " + i; + + String id = String.format(Locale.getDefault(), "item_%03d", i); + Item item = new Item(title, i); + + writeBatch.set(mItemsCollection.document(id), item); + } + + return writeBatch.commit(); + } + + private void showToast(String message) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + } + + public static class Item { + + public String text; + public int value; + + public Item() {} + + public Item(String text, int value) { + this.text = text; + this.value = value; + } + + } + + public static class ItemViewHolder extends RecyclerView.ViewHolder { + + @BindView(R.id.item_text) + TextView mTextView; + + @BindView(R.id.item_value) + TextView mValueView; + + ItemViewHolder(View itemView) { + super(itemView); + ButterKnife.bind(this, itemView); + } + + void bind(Item item) { + mTextView.setText(item.text); + mValueView.setText(String.valueOf(item.value)); + } + } + +} diff --git a/app/src/main/res/layout/activity_firestore_paging.xml b/app/src/main/res/layout/activity_firestore_paging.xml new file mode 100644 index 000000000..0febad887 --- /dev/null +++ b/app/src/main/res/layout/activity_firestore_paging.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_item.xml b/app/src/main/res/layout/item_item.xml new file mode 100644 index 000000000..199abe810 --- /dev/null +++ b/app/src/main/res/layout/item_item.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/app/src/main/res/menu/menu_firestore_paging.xml b/app/src/main/res/menu/menu_firestore_paging.xml new file mode 100644 index 000000000..b2f2da4cb --- /dev/null +++ b/app/src/main/res/menu/menu_firestore_paging.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 40e3bdce4..b8ce3cd0b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,11 +4,13 @@ Auth UI demo Cloud Firestore Demo + Cloud Firestore Paging Demo Real-time database demo Storage Image Demo Demonstrates the Firebase Auth UI flow, with customization options. Demonstrates using a FirestoreRecyclerAdapter to load data from Cloud Firestore into a RecyclerView for a basic chat app. + Demonstrates using a FirestorePagingAdapter to load/infinite scroll paged data from Cloud Firestore. Demonstrates using a FirebaseRecyclerAdapter to load data from Firebase Database into a RecyclerView for a basic chat app. Demonstrates displaying an image from Cloud Storage using Glide. @@ -97,5 +99,7 @@ Make sure your device is online and that Anonymous Auth is configured in your Firebase project (https://console.firebase.google.com/project/_/authentication/providers) + + Add Data Say something… diff --git a/constants.gradle b/constants.gradle index 137eb22af..dd22e143a 100644 --- a/constants.gradle +++ b/constants.gradle @@ -10,6 +10,7 @@ project.ext { firebaseVersion = '15.0.0' supportLibraryVersion = '27.1.0' - architectureVersion = '1.1.0' + architectureVersion = '1.1.1' kotlinVersion = '1.2.30' + pagingVersion = '1.0.0-beta1' } diff --git a/firestore/README.md b/firestore/README.md index cb52d0b62..7ee7b17da 100644 --- a/firestore/README.md +++ b/firestore/README.md @@ -12,9 +12,13 @@ Before using this library, you should be familiar with the following topics: 1. [Data model](#data-model) 1. [Querying](#querying) 1. [Populating a RecyclerView](#using-firebaseui-to-populate-a-recyclerview) - 1. [Using the adapter](#using-the-firestorerecycleradapter) - 1. [Adapter lifecyle](#firestorerecycleradapter-lifecycle) - 1. [Events](#data-and-error-events) + 1. [Choosing an adapter](#choosing-an-adapter) + 1. [Using the FirestoreRecyclerAdapter](#using-the-firestorerecycleradapter) + 1. [Adapter lifecyle](#firestorerecycleradapter-lifecycle) + 1. [Events](#data-and-error-events) + 1. [Using the FirestorePagingAdapter](#using-the-firestorepagingadapter) + 1. [Adapter lifecyle](#firestorepagingadapter-lifecycle) + 1. [Events](#paging-events) ## Data model @@ -109,6 +113,18 @@ updates with the `EventListener` on the `Query`. Fear not, FirebaseUI does all of this for you automatically! + +### Choosing an adapter + +FirebaseUI offers two types of RecyclerView adapters for Cloud Firestore: + + * `FirestoreRecyclerAdapter` — binds a `Query` to a `RecyclerView` and responds to all real-time + events included items being added, removed, moved, or changed. Best used with small result sets + since all results are loaded at once. + * `FirestorePagingAdapter` — binds a `Query` to a `RecyclerView` by loading data in pages. Best + used with large, static data sets. Real-time events are not respected by this adapter, so it + will not detect new/removed items or changes to items already loaded. + ### Using the `FirestoreRecyclerAdapter` The `FirestoreRecyclerAdapter` binds a `Query` to a `RecyclerView`. When documents are added, @@ -161,12 +177,12 @@ FirestoreRecyclerAdapter adapter = new FirestoreRecyclerAdapter options = new FirestorePagingOptions.Builder() + .setLifecycleOwner(this) + .setQuery(baseQuery, config, Item.class) + .build(); +``` + +If you need to customize how your model class is parsed, you can use a custom `SnapshotParser`: + +```java +...setQuery(..., new SnapshotParser() { + @NonNull + @Override + public Item parseSnapshot(@NonNull DocumentSnapshot snapshot) { + return ...; + } +}); +``` + +Next, create the `FirestorePagingAdapter` object. You should already have a `ViewHolder` subclass +for displaying each item. In this case we will use a custom `ItemViewHolder` class: + +```java +FirestorePagingAdapter adapter = + new FirestorePagingAdapter(options) { + @NonNull + @Override + public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + // Create the ItemViewHolder + // ... + } + + @Override + protected void onBindViewHolder(@NonNull ItemViewHolder holder, + int position, + @NonNull Item model) { + // Bind the item to the view holder + // ... + } + }; +``` + +Finally attach the adapter to your `RecyclerView` with the `RecyclerView#setAdapter()` method. +Don't forget to also set a `LayoutManager`! + +#### `FirestorePagingAdapter` lifecycle + +##### Start/stop listening + +The `FirestorePagingAdapter` listens for scrolling events and loads additional pages from the +database only when needed. + +To begin populating data, call the `startListening()` method. You may want to call this +in your `onStart()` method. Make sure you have finished any authentication necessary to read the +data before calling `startListening()` or your query will fail. + +```java +@Override +protected void onStart() { + super.onStart(); + adapter.startListening(); +} +``` + +Similarly, the `stopListening()` call freezes the data in the `RecyclerView` and prevents any future +loading of data pages. + +Call this method when the containing Activity or Fragment stops: + +```java +@Override +protected void onStop() { + super.onStop(); + adapter.stopListening(); +} +``` + +##### Automatic listening + +If you don't want to manually start/stop listening you can use +[Android Architecture Components][arch-components] to automatically manage the lifecycle of the +`FirestorePagingAdapter`. Pass a `LifecycleOwner` to +`FirestorePagingOptions.Builder#setLifecycleOwner(...)` and FirebaseUI will automatically +start and stop listening in `onStart()` and `onStop()`. + +#### Paging events + +When using the `FirestorePagingAdapter`, you may want to perform some action every time data +changes or when there is an error. To do this, override the `onLoadingStateChanged()` +method of the adapter: + +```java +FirestorePagingAdapter adapter = + new FirestorePagingAdapter(options) { + + // ... + + @Override + protected void onLoadingStateChanged(@NonNull LoadingState state) { + switch (state) { + case LOADING_INITIAL: + // The initial load has begun + // ... + case LOADING_MORE: + // The adapter has started to load an additional page + // ... + case LOADED: + // The previous load (either initial or additional) completed + // ... + case ERROR: + // The previous load (either initial or additional) failed. Call + // the retry() method in order to retry the load operation. + // ... + } + } + }; +``` + [firestore-docs]: https://firebase.google.com/docs/firestore/ [firestore-custom-objects]: https://firebase.google.com/docs/firestore/manage-data/add-data#custom_objects [recyclerview]: https://developer.android.com/reference/android/support/v7/widget/RecyclerView.html [arch-components]: https://developer.android.com/topic/libraries/architecture/index.html +[paging-support]: https://developer.android.com/topic/libraries/architecture/paging.html diff --git a/firestore/build.gradle b/firestore/build.gradle index 5c6dfe2bf..fef6074fc 100644 --- a/firestore/build.gradle +++ b/firestore/build.gradle @@ -22,7 +22,12 @@ dependencies { api "com.android.support:recyclerview-v7:$supportLibraryVersion" annotationProcessor "android.arch.lifecycle:compiler:$architectureVersion" + compileOnly "android.arch.paging:runtime:$pagingVersion" + androidTestImplementation 'junit:junit:4.12' androidTestImplementation 'com.android.support.test:runner:1.0.1' androidTestImplementation 'com.android.support.test:rules:1.0.1' + //noinspection GradleDynamicVersion + androidTestImplementation 'org.mockito:mockito-android:2.15.+' + androidTestImplementation 'android.arch.paging:runtime:1.0.0-beta1' } diff --git a/firestore/src/androidTest/java/com/firebase/ui/firestore/FirestoreDataSourceTest.java b/firestore/src/androidTest/java/com/firebase/ui/firestore/FirestoreDataSourceTest.java new file mode 100644 index 000000000..a919d6c9b --- /dev/null +++ b/firestore/src/androidTest/java/com/firebase/ui/firestore/FirestoreDataSourceTest.java @@ -0,0 +1,204 @@ +package com.firebase.ui.firestore; + +import android.arch.lifecycle.Observer; +import android.arch.paging.PageKeyedDataSource; +import android.support.annotation.Nullable; +import android.support.test.runner.AndroidJUnit4; + +import com.firebase.ui.firestore.paging.FirestoreDataSource; +import com.firebase.ui.firestore.paging.LoadingState; +import com.firebase.ui.firestore.paging.PageKey; +import com.google.android.gms.tasks.Tasks; +import com.google.firebase.firestore.DocumentSnapshot; +import com.google.firebase.firestore.Query; +import com.google.firebase.firestore.QuerySnapshot; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(AndroidJUnit4.class) +public class FirestoreDataSourceTest { + + private FirestoreDataSource mDataSource; + + @Mock Query mMockQuery; + @Mock PageKeyedDataSource.LoadInitialCallback mInitialCallback; + @Mock PageKeyedDataSource.LoadCallback mAfterCallback; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + initMockQuery(); + + // Create a testing data source + mDataSource = new FirestoreDataSource(mMockQuery); + } + + @Test + public void testLoadInitial_success() throws Exception { + mockQuerySuccess(new ArrayList()); + + TestObserver observer = new TestObserver<>(2); + mDataSource.getLoadingState().observeForever(observer); + + // Kick off an initial load of 20 items + PageKeyedDataSource.LoadInitialParams params = + new PageKeyedDataSource.LoadInitialParams<>(20, false); + mDataSource.loadInitial(params, mInitialCallback); + + // Should go from LOADING_INITIAL --> LOADED + observer.await(); + observer.assertResults(Arrays.asList(LoadingState.LOADING_INITIAL, LoadingState.LOADED)); + } + + @Test + public void testLoadInitial_failure() throws Exception { + mockQueryFailure("Could not get initial documents."); + + TestObserver observer = new TestObserver<>(2); + mDataSource.getLoadingState().observeForever(observer); + + // Kick off an initial load of 20 items + PageKeyedDataSource.LoadInitialParams params = + new PageKeyedDataSource.LoadInitialParams<>(20, false); + mDataSource.loadInitial(params, mInitialCallback); + + // Should go from LOADING_INITIAL --> ERROR + observer.await(); + observer.assertResults(Arrays.asList(LoadingState.LOADING_INITIAL, LoadingState.ERROR)); + } + + @Test + public void testLoadAfter_success() throws Exception { + mockQuerySuccess(new ArrayList()); + + TestObserver observer = new TestObserver<>(2); + mDataSource.getLoadingState().observeForever(observer); + + // Kick off an initial load of 20 items + PageKey pageKey = new PageKey(null, null); + PageKeyedDataSource.LoadParams params = + new PageKeyedDataSource.LoadParams<>(pageKey, 20); + mDataSource.loadAfter(params, mAfterCallback); + + // Should go from LOADING_MORE --> LOADED + observer.await(); + observer.assertResults(Arrays.asList(LoadingState.LOADING_MORE, LoadingState.LOADED)); + } + + @Test + public void testLoadAfter_failure() throws Exception { + mockQueryFailure("Could not load more documents."); + + TestObserver observer = new TestObserver<>(2); + mDataSource.getLoadingState().observeForever(observer); + + // Kick off an initial load of 20 items + PageKey pageKey = new PageKey(null, null); + PageKeyedDataSource.LoadParams params = + new PageKeyedDataSource.LoadParams<>(pageKey, 20); + mDataSource.loadAfter(params, mAfterCallback); + + // Should go from LOADING_MORE --> ERROR + observer.await(); + observer.assertResults(Arrays.asList(LoadingState.LOADING_MORE, LoadingState.ERROR)); + } + + @Test + public void testLoadAfter_retry() throws Exception { + mockQueryFailure("Could not load more documents."); + + TestObserver observer1 = new TestObserver<>(2); + mDataSource.getLoadingState().observeForever(observer1); + + // Kick off an initial load of 20 items + PageKey pageKey = new PageKey(null, null); + PageKeyedDataSource.LoadParams params = + new PageKeyedDataSource.LoadParams<>(pageKey, 20); + mDataSource.loadAfter(params, mAfterCallback); + + // Should go from LOADING_MORE --> ERROR + observer1.await(); + observer1.assertResults(Arrays.asList(LoadingState.LOADING_MORE, LoadingState.ERROR)); + + // Create a new observer + TestObserver observer2 = new TestObserver<>(3); + mDataSource.getLoadingState().observeForever(observer2); + + // Retry the load + mockQuerySuccess(new ArrayList()); + mDataSource.retry(); + + // Should go from ERROR --> LOADING_MORE --> SUCCESS + observer2.await(); + observer2.assertResults( + Arrays.asList(LoadingState.ERROR, LoadingState.LOADING_MORE, LoadingState.LOADED)); + } + + private void initMockQuery() { + when(mMockQuery.startAfter(any())).thenReturn(mMockQuery); + when(mMockQuery.endBefore(any())).thenReturn(mMockQuery); + when(mMockQuery.limit(anyLong())).thenReturn(mMockQuery); + } + + private void mockQuerySuccess(List snapshots) { + QuerySnapshot mockSnapshot = mock(QuerySnapshot.class); + when(mockSnapshot.getDocuments()).thenReturn(snapshots); + + when(mMockQuery.get()).thenReturn(Tasks.forResult(mockSnapshot)); + } + + private void mockQueryFailure(String message) { + when(mMockQuery.get()) + .thenReturn(Tasks.forException(new Exception(message))); + } + + private static class TestObserver implements Observer { + + private final List mResults = new ArrayList<>(); + private final CountDownLatch mLatch; + + public TestObserver(int expectedCount) { + mLatch = new CountDownLatch(expectedCount); + } + + @Override + public void onChanged(@Nullable T t) { + if (t != null) { + mResults.add(t); + mLatch.countDown(); + } + } + + public List getResults() { + return mResults; + } + + public void await() throws InterruptedException { + mLatch.await(); + } + + public void assertResults(List expected) { + assertEquals(expected.size(), mResults.size()); + + for (int i = 0; i < mResults.size(); i++) { + assertEquals(mResults.get(i), expected.get(i)); + } + } + + } +} diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/DefaultSnapshotDiffCallback.java b/firestore/src/main/java/com/firebase/ui/firestore/paging/DefaultSnapshotDiffCallback.java new file mode 100644 index 000000000..09965ffca --- /dev/null +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/DefaultSnapshotDiffCallback.java @@ -0,0 +1,33 @@ +package com.firebase.ui.firestore.paging; + +import android.support.annotation.RestrictTo; +import android.support.v7.util.DiffUtil; + +import com.firebase.ui.firestore.SnapshotParser; +import com.google.firebase.firestore.DocumentSnapshot; + +/** + * Default diff callback implementation for Firestore snapshots. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +public class DefaultSnapshotDiffCallback extends DiffUtil.ItemCallback { + + private final SnapshotParser mParser; + + public DefaultSnapshotDiffCallback(SnapshotParser parser) { + mParser = parser; + } + + @Override + public boolean areItemsTheSame(DocumentSnapshot oldItem, DocumentSnapshot newItem) { + return oldItem.getId().equals(newItem.getId()); + } + + @Override + public boolean areContentsTheSame(DocumentSnapshot oldItem, DocumentSnapshot newItem) { + T oldModel = mParser.parseSnapshot(oldItem); + T newModel = mParser.parseSnapshot(newItem); + + return oldModel.equals(newModel); + } +} diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreDataSource.java b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreDataSource.java new file mode 100644 index 000000000..1aaaaa6a5 --- /dev/null +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreDataSource.java @@ -0,0 +1,215 @@ +package com.firebase.ui.firestore.paging; + +import android.arch.lifecycle.LiveData; +import android.arch.lifecycle.MutableLiveData; +import android.arch.paging.DataSource; +import android.arch.paging.PageKeyedDataSource; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.RestrictTo; +import android.util.Log; + +import com.google.android.gms.tasks.OnFailureListener; +import com.google.android.gms.tasks.OnSuccessListener; +import com.google.firebase.firestore.DocumentSnapshot; +import com.google.firebase.firestore.Query; +import com.google.firebase.firestore.QuerySnapshot; + +import java.util.List; + +/** + * Data source to power a {@link FirestorePagingAdapter}. + * + * Note: although loadInitial, loadBefore, and loadAfter are not called on the main thread by the + * paging library, we treat them as if they were so that we can facilitate retry without + * managing our own thread pool or requiring the user to pass us an executor. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +public class FirestoreDataSource extends PageKeyedDataSource { + + private static final String TAG = "FirestoreDataSource"; + + public static class Factory extends DataSource.Factory { + + private final Query mQuery; + + public Factory(Query query) { + mQuery = query; + } + + @Override + public DataSource create() { + return new FirestoreDataSource(mQuery); + } + } + + private final MutableLiveData mLoadingState = new MutableLiveData<>(); + + private final Query mBaseQuery; + + private Runnable mRetryRunnable; + + public FirestoreDataSource(Query baseQuery) { + mBaseQuery = baseQuery; + } + + @Override + public void loadInitial(@NonNull final LoadInitialParams params, + @NonNull final LoadInitialCallback callback) { + + // Set initial loading state + mLoadingState.postValue(LoadingState.LOADING_INITIAL); + + mBaseQuery.limit(params.requestedLoadSize) + .get() + .addOnSuccessListener(new OnLoadSuccessListener() { + @Override + protected void setResult(@NonNull QuerySnapshot snapshot) { + PageKey nextPage = getNextPageKey(snapshot); + callback.onResult(snapshot.getDocuments(), null, nextPage); + } + }) + .addOnFailureListener(new OnLoadFailureListener() { + @Override + protected Runnable getRetryRunnable() { + return getRetryLoadInitial(params, callback); + } + }); + + } + + @Override + public void loadBefore(@NonNull LoadParams params, + @NonNull LoadCallback callback) { + // Ignored for now, since we only ever append to the initial load. + // Future work: + // * Could we dynamically unload past pages? + // * Could we ask the developer for both a forward and reverse base query + // so that we can load backwards easily? + } + + @Override + public void loadAfter(@NonNull final LoadParams params, + @NonNull final LoadCallback callback) { + final PageKey key = params.key; + + // Set loading state + mLoadingState.postValue(LoadingState.LOADING_MORE); + + key.getPageQuery(mBaseQuery, params.requestedLoadSize) + .get() + .addOnSuccessListener(new OnLoadSuccessListener() { + @Override + protected void setResult(@NonNull QuerySnapshot snapshot) { + PageKey nextPage = getNextPageKey(snapshot); + callback.onResult(snapshot.getDocuments(), nextPage); + } + }) + .addOnFailureListener(new OnLoadFailureListener() { + @Override + protected Runnable getRetryRunnable() { + return getRetryLoadAfter(params, callback); + } + }); + + } + + private PageKey getNextPageKey(@NonNull QuerySnapshot snapshot) { + List data = snapshot.getDocuments(); + DocumentSnapshot last = getLast(data); + + return new PageKey(last, null); + } + + public LiveData getLoadingState() { + return mLoadingState; + } + + public void retry() { + LoadingState currentState = mLoadingState.getValue(); + if (currentState != LoadingState.ERROR) { + Log.w(TAG, "retry() not valid when in state: " + currentState); + return; + } + + if (mRetryRunnable == null) { + Log.w(TAG, "retry() called with no eligible retry runnable."); + return; + } + + mRetryRunnable.run(); + } + + @Nullable + private DocumentSnapshot getLast(@NonNull List data) { + if (data.isEmpty()) { + return null; + } else { + return data.get(data.size() - 1); + } + } + + @NonNull + private Runnable getRetryLoadAfter(@NonNull final LoadParams params, + @NonNull final LoadCallback callback) { + return new Runnable() { + @Override + public void run() { + loadAfter(params, callback); + } + }; + } + + @NonNull + private Runnable getRetryLoadInitial(@NonNull final LoadInitialParams params, + @NonNull final LoadInitialCallback callback) { + return new Runnable() { + @Override + public void run() { + loadInitial(params, callback); + } + }; + } + + /** + * Success listener that sets success state and nullifies the retry runnable. + */ + private abstract class OnLoadSuccessListener implements OnSuccessListener { + + @Override + public void onSuccess(QuerySnapshot snapshot) { + setResult(snapshot); + mLoadingState.postValue(LoadingState.LOADED); + + // Post the 'FINISHED' state when no more pages will be loaded. The data source + // callbacks interpret an empty result list as a signal to cancel any future loads. + if (snapshot.getDocuments().isEmpty()) { + mLoadingState.postValue(LoadingState.FINISHED); + } + + mRetryRunnable = null; + } + + protected abstract void setResult(@NonNull QuerySnapshot snapshot); + } + + /** + * Error listener that logs, sets the error state, and sets up retry. + */ + private abstract class OnLoadFailureListener implements OnFailureListener { + + @Override + public void onFailure(@NonNull Exception e) { + Log.w(TAG, "load:onFailure", e); + + // On error we do NOT post any value to the PagedList, we just tell + // the developer that we are now in the error state. + mLoadingState.postValue(LoadingState.ERROR); + + // Set the retry action + mRetryRunnable = getRetryRunnable(); + } + + protected abstract Runnable getRetryRunnable(); + } +} diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingAdapter.java b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingAdapter.java new file mode 100644 index 000000000..04170a3a5 --- /dev/null +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingAdapter.java @@ -0,0 +1,147 @@ +package com.firebase.ui.firestore.paging; + +import android.arch.core.util.Function; +import android.arch.lifecycle.Lifecycle; +import android.arch.lifecycle.LifecycleObserver; +import android.arch.lifecycle.LiveData; +import android.arch.lifecycle.Observer; +import android.arch.lifecycle.OnLifecycleEvent; +import android.arch.lifecycle.Transformations; +import android.arch.paging.PagedList; +import android.arch.paging.PagedListAdapter; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.widget.RecyclerView; +import android.util.Log; + +import com.firebase.ui.firestore.SnapshotParser; +import com.google.firebase.firestore.DocumentSnapshot; + +/** + * Paginated RecyclerView Adapter for a Cloud Firestore query. + * + * Configured with {@link FirestorePagingOptions}. + */ +public abstract class FirestorePagingAdapter + extends PagedListAdapter + implements LifecycleObserver { + + private static final String TAG = "FirestorePagingAdapter"; + + private final SnapshotParser mParser; + + private final LiveData> mSnapshots; + private final LiveData mLoadingState; + private final LiveData mDataSource; + + private final Observer mStateObserver = + new Observer() { + @Override + public void onChanged(@Nullable LoadingState state) { + if (state == null) { + return; + } + + onLoadingStateChanged(state); + } + }; + + private final Observer> mDataObserver = + new Observer>() { + @Override + public void onChanged(@Nullable PagedList snapshots) { + if (snapshots == null) { + return; + } + + submitList(snapshots); + } + }; + + /** + * Construct a new FirestorePagingAdapter from the given {@link FirestorePagingOptions}. + */ + public FirestorePagingAdapter(@NonNull FirestorePagingOptions options) { + super(options.getDiffCallback()); + + mSnapshots = options.getData(); + + mLoadingState = Transformations.switchMap(mSnapshots, + new Function, LiveData>() { + @Override + public LiveData apply(PagedList input) { + FirestoreDataSource dataSource = (FirestoreDataSource) input.getDataSource(); + return dataSource.getLoadingState(); + } + }); + + mDataSource = Transformations.map(mSnapshots, + new Function, FirestoreDataSource>() { + @Override + public FirestoreDataSource apply(PagedList input) { + return (FirestoreDataSource) input.getDataSource(); + } + }); + + mParser = options.getParser(); + + if (options.getOwner() != null) { + options.getOwner().getLifecycle().addObserver(this); + } + } + + /** + * If {@link #onLoadingStateChanged(LoadingState)} indicates error state, call this method + * to attempt to retry the most recent failure. + */ + public void retry() { + FirestoreDataSource source = mDataSource.getValue(); + if (source == null) { + Log.w(TAG, "Called retry() when FirestoreDataSource is null!"); + return; + } + + source.retry(); + } + + /** + * Start listening to paging / scrolling events and populating adapter data. + */ + @OnLifecycleEvent(Lifecycle.Event.ON_START) + public void startListening() { + mSnapshots.observeForever(mDataObserver); + mLoadingState.observeForever(mStateObserver); + } + + /** + * Unsubscribe from paging / scrolling events, no more data will be populated, but the existing + * data will remain. + */ + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + public void stopListening() { + mSnapshots.removeObserver(mDataObserver); + mLoadingState.removeObserver(mStateObserver); + } + + @Override + public void onBindViewHolder(@NonNull VH holder, int position) { + DocumentSnapshot snapshot = getItem(position); + onBindViewHolder(holder, position, mParser.parseSnapshot(snapshot)); + } + + /** + * @param model the model object containing the data that should be used to populate the view. + * @see #onBindViewHolder(RecyclerView.ViewHolder, int) + */ + protected abstract void onBindViewHolder(@NonNull VH holder, int position, @NonNull T model); + + /** + * Called whenever the loading state of the adapter changes. + * + * When the state is {@link LoadingState#ERROR} the adapter will stop loading any data unless + * {@link #retry()} is called. + */ + protected void onLoadingStateChanged(@NonNull LoadingState state) { + // For overriding + } +} diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingOptions.java b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingOptions.java new file mode 100644 index 000000000..40c4b9fd3 --- /dev/null +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingOptions.java @@ -0,0 +1,109 @@ +package com.firebase.ui.firestore.paging; + +import android.arch.lifecycle.LifecycleOwner; +import android.arch.lifecycle.LiveData; +import android.arch.paging.LivePagedListBuilder; +import android.arch.paging.PagedList; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.util.DiffUtil; + +import com.firebase.ui.firestore.ClassSnapshotParser; +import com.firebase.ui.firestore.SnapshotParser; +import com.google.firebase.firestore.DocumentSnapshot; +import com.google.firebase.firestore.Query; + +/** + * Options to conifigure an {@link FirestorePagingAdapter}. + */ +public class FirestorePagingOptions { + + private final LiveData> mData; + private final SnapshotParser mParser; + private final DiffUtil.ItemCallback mDiffCallback; + private final LifecycleOwner mOwner; + + private FirestorePagingOptions(@NonNull LiveData> data, + @NonNull SnapshotParser parser, + @NonNull DiffUtil.ItemCallback diffCallback, + @Nullable LifecycleOwner owner) { + mData = data; + mParser = parser; + mDiffCallback = diffCallback; + mOwner = owner; + } + + @NonNull + public LiveData> getData() { + return mData; + } + + @NonNull + public SnapshotParser getParser() { + return mParser; + } + + @NonNull + public DiffUtil.ItemCallback getDiffCallback() { + return mDiffCallback; + } + + @Nullable + public LifecycleOwner getOwner() { + return mOwner; + } + + public static class Builder { + + private LiveData> mData; + private SnapshotParser mParser; + private LifecycleOwner mOwner; + private DiffUtil.ItemCallback mDiffCallback; + + @NonNull + public Builder setQuery(@NonNull Query query, + @NonNull PagedList.Config config, + @NonNull Class modelClass) { + return setQuery(query, config, new ClassSnapshotParser(modelClass)); + } + + @NonNull + public Builder setQuery(@NonNull Query query, + @NonNull PagedList.Config config, + @NonNull SnapshotParser parser) { + // Build paged list + FirestoreDataSource.Factory factory = new FirestoreDataSource.Factory(query); + mData = new LivePagedListBuilder<>(factory, config).build(); + + mParser = parser; + return this; + } + + @NonNull + public Builder setDiffCallback(@NonNull DiffUtil.ItemCallback diffCallback) { + mDiffCallback = diffCallback; + return this; + } + + @NonNull + public Builder setLifecycleOwner(@NonNull LifecycleOwner owner) { + mOwner = owner; + return this; + } + + @NonNull + public FirestorePagingOptions build() { + if (mData == null || mParser == null) { + throw new IllegalStateException("Must call setQuery() before calling build()."); + } + + if (mDiffCallback == null) { + mDiffCallback = new DefaultSnapshotDiffCallback(mParser); + } + + return new FirestorePagingOptions<>(mData, mParser, mDiffCallback, mOwner); + } + + } + +} diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/LoadingState.java b/firestore/src/main/java/com/firebase/ui/firestore/paging/LoadingState.java new file mode 100644 index 000000000..d527599fd --- /dev/null +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/LoadingState.java @@ -0,0 +1,31 @@ +package com.firebase.ui.firestore.paging; + +/** + * Loading state exposed by {@link FirestorePagingAdapter}. + */ +public enum LoadingState { + /** + * Loading initial data.Pag + */ + LOADING_INITIAL, + + /** + * Loading a page other than the first page. + */ + LOADING_MORE, + + /** + * Not currently loading any pages, at least one page loaded. + */ + LOADED, + + /** + * The last page loaded had zero documents, and therefore no further pages will be loaded. + */ + FINISHED, + + /** + * The most recent load encountered an error. + */ + ERROR +} diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/PageKey.java b/firestore/src/main/java/com/firebase/ui/firestore/paging/PageKey.java new file mode 100644 index 000000000..fdadd199b --- /dev/null +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/PageKey.java @@ -0,0 +1,48 @@ +package com.firebase.ui.firestore.paging; + +import android.support.annotation.Nullable; +import android.support.annotation.RestrictTo; + +import com.google.firebase.firestore.DocumentSnapshot; +import com.google.firebase.firestore.Query; + +/** + * Key for Firestore pagination. Holds the DocumentSnapshot(s) that bound the page. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +public class PageKey { + + private final DocumentSnapshot mStartAfter; + private final DocumentSnapshot mEndBefore; + + public PageKey(@Nullable DocumentSnapshot startAfter, @Nullable DocumentSnapshot endBefore) { + mStartAfter = startAfter; + mEndBefore = endBefore; + } + + public Query getPageQuery(Query baseQuery, int size) { + Query pageQuery = baseQuery; + + if (mStartAfter != null) { + pageQuery = pageQuery.startAfter(mStartAfter); + } + + if (mEndBefore != null) { + pageQuery = pageQuery.endBefore(mEndBefore); + } else { + pageQuery = pageQuery.limit(size); + } + + return pageQuery; + } + + @Override + public String toString() { + String startAfter = mStartAfter == null ? null : mStartAfter.getId(); + String endBefore = mEndBefore == null ? null : mEndBefore.getId(); + return "PageKey{" + + "StartAfter=" + startAfter + + ", EndBefore=" + endBefore + + '}'; + } +} diff --git a/proguard-tests/build.gradle b/proguard-tests/build.gradle index 544800b6c..0b482340e 100644 --- a/proguard-tests/build.gradle +++ b/proguard-tests/build.gradle @@ -29,6 +29,7 @@ android { dependencies { implementation "com.google.firebase:firebase-core:$firebaseVersion" + implementation "android.arch.paging:runtime:$pagingVersion" implementation project(path: ':auth') implementation project(path: ':firestore') diff --git a/settings.gradle b/settings.gradle index 6745ced57..6e5f96a1d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,2 @@ include ':app', ':library', ':database', ':auth', ':storage', ':firestore', ':common', - 'proguard-tests', ':internal:lint', ':internal:lintchecks' + ':proguard-tests', ':internal:lint', ':internal:lintchecks'