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'