diff --git a/app/src/main/java/com/firebase/uidemo/database/firestore/FirestorePagingActivity.java b/app/src/main/java/com/firebase/uidemo/database/firestore/FirestorePagingActivity.java index ee8c41707..eb5bdb288 100644 --- a/app/src/main/java/com/firebase/uidemo/database/firestore/FirestorePagingActivity.java +++ b/app/src/main/java/com/firebase/uidemo/database/firestore/FirestorePagingActivity.java @@ -12,7 +12,6 @@ import com.firebase.ui.firestore.paging.FirestorePagingAdapter; import com.firebase.ui.firestore.paging.FirestorePagingOptions; -import com.firebase.ui.firestore.paging.LoadingState; import com.firebase.uidemo.R; import com.firebase.uidemo.databinding.ActivityFirestorePagingBinding; import com.google.android.gms.tasks.OnCompleteListener; @@ -27,10 +26,14 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; -import androidx.paging.PagedList; +import androidx.paging.CombinedLoadStates; +import androidx.paging.LoadState; +import androidx.paging.PagingConfig; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import kotlin.Unit; +import kotlin.jvm.functions.Function1; public class FirestorePagingActivity extends AppCompatActivity { @@ -56,11 +59,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { private void setUpAdapter() { Query baseQuery = mItemsCollection.orderBy("value", Query.Direction.ASCENDING); - PagedList.Config config = new PagedList.Config.Builder() - .setEnablePlaceholders(false) - .setPrefetchDistance(10) - .setPageSize(20) - .build(); + PagingConfig config = new PagingConfig(20, 10, false); FirestorePagingOptions options = new FirestorePagingOptions.Builder() .setLifecycleOwner(this) @@ -84,34 +83,42 @@ protected void onBindViewHolder(@NonNull ItemViewHolder holder, @NonNull Item model) { holder.bind(model); } + }; + adapter.addLoadStateListener(new Function1() { + @Override + public Unit invoke(CombinedLoadStates states) { + LoadState refresh = states.getRefresh(); + LoadState append = states.getAppend(); - @Override - protected void onLoadingStateChanged(@NonNull LoadingState state) { - switch (state) { - case LOADING_INITIAL: - case LOADING_MORE: - mBinding.swipeRefreshLayout.setRefreshing(true); - break; - case LOADED: - mBinding.swipeRefreshLayout.setRefreshing(false); - break; - case FINISHED: - mBinding.swipeRefreshLayout.setRefreshing(false); - showToast("Reached end of data set."); - break; - case ERROR: - showToast("An error occurred."); - retry(); - break; - } + if (refresh instanceof LoadState.Error || append instanceof LoadState.Error) { + showToast("An error occurred."); + adapter.retry(); + } + + if (append instanceof LoadState.Loading) { + mBinding.swipeRefreshLayout.setRefreshing(true); + } + + if (append instanceof LoadState.NotLoading) { + LoadState.NotLoading notLoading = (LoadState.NotLoading) append; + if (notLoading.getEndOfPaginationReached()) { + // This indicates that the user has scrolled + // until the end of the data set. + mBinding.swipeRefreshLayout.setRefreshing(false); + showToast("Reached end of data set."); + return null; } - @Override - protected void onError(@NonNull Exception e) { + if (refresh instanceof LoadState.NotLoading) { + // This indicates the most recent load + // has finished. mBinding.swipeRefreshLayout.setRefreshing(false); - Log.e(TAG, e.getMessage(), e); + return null; } - }; + } + return null; + } + }); mBinding.pagingRecycler.setLayoutManager(new LinearLayoutManager(this)); mBinding.pagingRecycler.setAdapter(adapter); diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index e97ff149f..6607cf14b 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -37,7 +37,8 @@ object Config { const val lifecycleViewModel = "androidx.lifecycle:lifecycle-viewmodel:2.2.0" const val legacySupportv4 = "androidx.legacy:legacy-support-v4:1.0.0" const val multidex = "androidx.multidex:multidex:2.0.1" - const val paging = "androidx.paging:paging-runtime:2.1.2" + const val paging = "androidx.paging:paging-runtime:3.0.0" + const val pagingRxJava = "androidx.paging:paging-rxjava3:3.0.0" const val recyclerView = "androidx.recyclerview:recyclerview:1.1.0" const val design = "com.google.android.material:material:1.2.1" diff --git a/firestore/README.md b/firestore/README.md index aab379e7f..4454cdfa3 100644 --- a/firestore/README.md +++ b/firestore/README.md @@ -249,11 +249,11 @@ The `FirestorePagingAdapter` binds a `Query` to a `RecyclerView` by loading docu This results in a time and memory efficient binding, however it gives up the real-time events afforded by the `FirestoreRecyclerAdapter`. -The `FirestorePagingAdapter` is built on top of the [Android Paging Support Library][paging-support]. -Before using the adapter in your application, you must add a dependency on the support library: +The `FirestorePagingAdapter` is built on top of the [Android Paging 3 Library][paging-support]. +Before using the adapter in your application, you must add a dependency on that library: ```groovy -implementation 'androidx.paging:paging-runtime:2.x.x' +implementation 'androidx.paging:paging-runtime:3.x.x' ``` First, configure the adapter by building `FirestorePagingOptions`. Since the paging adapter @@ -262,16 +262,13 @@ an adapter that loads a generic `Item`: ```java // The "base query" is a query with no startAt/endAt/limit clauses that the adapter can use -// to form smaller queries for each page. It should only include where() and orderBy() clauses +// to form smaller queries for each page. It should only include where() and orderBy() clauses Query baseQuery = mItemsCollection.orderBy("value", Query.Direction.ASCENDING); -// This configuration comes from the Paging Support Library -// https://developer.android.com/reference/androidx/paging/PagedList.Config -PagedList.Config config = new PagedList.Config.Builder() - .setEnablePlaceholders(false) - .setPrefetchDistance(10) - .setPageSize(20) - .build(); +// This configuration comes from the Paging 3 Library +// https://developer.android.com/reference/kotlin/androidx/paging/PagingConfig +PagingConfig config = new PagingConfig(/* page size */ 20, /* prefetchDistance */ 10, + /* enablePlaceHolders */ false); // The options for the adapter combine the paging configuration with query information // and application-specific options for lifecycle, etc. @@ -362,38 +359,103 @@ 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: +changes or when there is an error. To do this: -```java -FirestorePagingAdapter adapter = - new FirestorePagingAdapter(options) { +##### In Java - // ... +Use the `addLoadStateListener` method from the adapter: +```java +adapter.addLoadStateListener(new Function1() { @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 + public Unit invoke(CombinedLoadStates states) { + LoadState refresh = states.getRefresh(); + LoadState append = states.getAppend(); + + if (refresh instanceof LoadState.Error || append instanceof LoadState.Error) { + // The previous load (either initial or additional) failed. Call + // the retry() method in order to retry the load operation. + // ... + } + + if (refresh instanceof LoadState.Loading) { + // The initial Load has begun + // ... + } + + if (append instanceof LoadState.Loading) { + // The adapter has started to load an additional page + // ... + } + + if (append instanceof LoadState.NotLoading) { + LoadState.NotLoading notLoading = (LoadState.NotLoading) append; + if (notLoading.getEndOfPaginationReached()) { + // The adapter has finished loading all of the data set // ... - case LOADED: + return null; + } + + if (refresh instanceof LoadState.NotLoading) { // 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. - // ... + return null; + } } + return null; } - }; + }); +``` + +#### In Kotlin + +Use the `loadStateFlow` exposed by the adapter, in a Coroutine Scope: + +```kotlin +// Activities can use lifecycleScope directly, but Fragments should instead use +// viewLifecycleOwner.lifecycleScope. +lifecycleScope.launch { + pagingAdapter.loadStateFlow.collectLatest { loadStates -> + when (loadStates.refresh) { + is LoadState.Error -> { + // The initial load failed. Call the retry() method + // in order to retry the load operation. + // ... + } + is LoadState.Loading -> { + // The initial Load has begun + // ... + } + } + + when (loadStates.append) { + is LoadState.Error -> { + // The additional load failed. Call the retry() method + // in order to retry the load operation. + // ... + } + is LoadState.Loading -> { + // The adapter has started to load an additional page + // ... + } + is LoadState.NotLoading -> { + if (loadStates.append.endOfPaginationReached) { + // The adapter has finished loading all of the data set + // ... + } + if (loadStates.refresh is LoadState.NotLoading) { + // The previous load (either initial or additional) completed + // ... + } + } + } + } +} + ``` [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/androidx/recyclerview/widget/RecyclerView [arch-components]: https://developer.android.com/topic/libraries/architecture/index.html -[paging-support]: https://developer.android.com/topic/libraries/architecture/paging.html +[paging-support]: https://developer.android.com/topic/libraries/architecture/paging/v3-overview diff --git a/firestore/build.gradle.kts b/firestore/build.gradle.kts index 8cfe2dbcb..ac1fb1bab 100644 --- a/firestore/build.gradle.kts +++ b/firestore/build.gradle.kts @@ -42,6 +42,11 @@ android { consumerProguardFiles("proguard-rules.pro") } } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } } dependencies { @@ -53,6 +58,7 @@ dependencies { api(Config.Libs.Androidx.recyclerView) compileOnly(Config.Libs.Androidx.paging) + api(Config.Libs.Androidx.pagingRxJava) annotationProcessor(Config.Libs.Androidx.lifecycleCompiler) lintChecks(project(":lint")) diff --git a/firestore/src/androidTest/java/com/firebase/ui/firestore/FirestoreDataSourceTest.java b/firestore/src/androidTest/java/com/firebase/ui/firestore/FirestoreDataSourceTest.java deleted file mode 100644 index c861dff11..000000000 --- a/firestore/src/androidTest/java/com/firebase/ui/firestore/FirestoreDataSourceTest.java +++ /dev/null @@ -1,213 +0,0 @@ -package com.firebase.ui.firestore; - -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 com.google.firebase.firestore.Source; - -import org.junit.Before; -import org.junit.Rule; -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 androidx.annotation.Nullable; -import androidx.arch.core.executor.testing.InstantTaskExecutorRule; -import androidx.lifecycle.Observer; -import androidx.paging.PageKeyedDataSource; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -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; - - /** - * Needed to run tasks on the main thread so observeForever() doesn't throw. - */ - @Rule - public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule(); - - @Mock Query mMockQuery; - @Mock PageKeyedDataSource.LoadInitialCallback mInitialCallback; - @Mock PageKeyedDataSource.LoadCallback mAfterCallback; - - @Before - public void setUp() { - MockitoAnnotations.initMocks(this); - initMockQuery(); - - // Create a testing data source - mDataSource = new FirestoreDataSource(mMockQuery, Source.DEFAULT); - } - - @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(Source.DEFAULT)).thenReturn(Tasks.forResult(mockSnapshot)); - } - - private void mockQueryFailure(String message) { - when(mMockQuery.get(Source.DEFAULT)) - .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/ChangeEventListener.java b/firestore/src/main/java/com/firebase/ui/firestore/ChangeEventListener.java index 980167b4e..6ac4073bd 100644 --- a/firestore/src/main/java/com/firebase/ui/firestore/ChangeEventListener.java +++ b/firestore/src/main/java/com/firebase/ui/firestore/ChangeEventListener.java @@ -7,4 +7,5 @@ /** * Listener for changes to a {@link FirestoreArray}. */ -public interface ChangeEventListener extends BaseChangeEventListener {} +public interface ChangeEventListener extends + BaseChangeEventListener {} 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 deleted file mode 100644 index cecfbfd9e..000000000 --- a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreDataSource.java +++ /dev/null @@ -1,233 +0,0 @@ -package com.firebase.ui.firestore.paging; - -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 com.google.firebase.firestore.Source; - -import java.util.List; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RestrictTo; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.paging.DataSource; -import androidx.paging.PageKeyedDataSource; - -/** - * 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; - private final Source mSource; - - public Factory(@NonNull Query query, @NonNull Source source) { - mQuery = query; - mSource = source; - } - - @Override - @NonNull - public DataSource create() { - return new FirestoreDataSource(mQuery, mSource); - } - } - - private final MutableLiveData mLoadingState = new MutableLiveData<>(); - private final MutableLiveData mException = new MutableLiveData<>(); - - private final Query mBaseQuery; - private final Source mSource; - - private Runnable mRetryRunnable; - - public FirestoreDataSource(@NonNull Query baseQuery, @NonNull Source source) { - mBaseQuery = baseQuery; - mSource = source; - } - - @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(mSource) - .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(mSource) - .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); - } - }); - - } - - @NonNull - private PageKey getNextPageKey(@NonNull QuerySnapshot snapshot) { - List data = snapshot.getDocuments(); - DocumentSnapshot last = getLast(data); - - return new PageKey(last, null); - } - - @NonNull - public LiveData getLoadingState() { - return mLoadingState; - } - - @NonNull - public LiveData getLastError() { - return mException; - } - - 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(); - - //Set to the MutableLiveData to determine Latest Error - mException.postValue(e); - } - - 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 index 6eba09699..d11c1fb73 100644 --- a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingAdapter.java +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingAdapter.java @@ -1,21 +1,17 @@ package com.firebase.ui.firestore.paging; -import android.util.Log; - import com.firebase.ui.firestore.SnapshotParser; import com.google.firebase.firestore.DocumentSnapshot; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.arch.core.util.Function; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleObserver; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; import androidx.lifecycle.OnLifecycleEvent; -import androidx.lifecycle.Transformations; -import androidx.paging.PagedList; -import androidx.paging.PagedListAdapter; +import androidx.paging.PagingData; +import androidx.paging.PagingDataAdapter; import androidx.recyclerview.widget.RecyclerView; /** @@ -24,55 +20,23 @@ * Configured with {@link FirestorePagingOptions}. */ public abstract class FirestorePagingAdapter - extends PagedListAdapter + extends PagingDataAdapter implements LifecycleObserver { - private static final String TAG = "FirestorePagingAdapter"; - /* - LiveData created via Transformation do not have a value until an Observer is attached. - We attach this empty observer so that our getValue() calls return non-null later. - */ - private final Observer mDataSourceObserver = new Observer() { - @Override - public void onChanged(@Nullable FirestoreDataSource source) { - - } - }; - //Error observer to determine last occurred Error - private final Observer mErrorObserver = new Observer() { - @Override - public void onChanged(@Nullable Exception e) { - onError(e); - } - }; - private final Observer mStateObserver = - new Observer() { + private final Observer> mDataObserver = + 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) { + public void onChanged(@Nullable PagingData snapshots) { if (snapshots == null) { return; } - submitList(snapshots); + submitData(mOptions.getOwner().getLifecycle(), snapshots); } }; private FirestorePagingOptions mOptions; private SnapshotParser mParser; - private LiveData> mSnapshots; - private LiveData mLoadingState; - private LiveData mException; - private LiveData mDataSource; + private LiveData> mSnapshots; /** * Construct a new FirestorePagingAdapter from the given {@link FirestorePagingOptions}. @@ -86,36 +50,10 @@ public FirestorePagingAdapter(@NonNull FirestorePagingOptions options) { } /** - * Initializes Snapshots and LiveData + * Initializes Snapshots */ private void init() { - mSnapshots = mOptions.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(); - } - }); - - mException = Transformations.switchMap(mSnapshots, - new Function, LiveData>() { - @Override - public LiveData apply(PagedList input) { - FirestoreDataSource dataSource = (FirestoreDataSource) input.getDataSource(); - return dataSource.getLastError(); - } - }); + mSnapshots = mOptions.getPagingData(); mParser = mOptions.getParser(); @@ -124,32 +62,6 @@ public LiveData apply(PagedList input) { } } - /** - * 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(); - } - - /** - * To attempt to refresh the list. It will reload the list from beginning. - */ - public void refresh() { - FirestoreDataSource mFirebaseDataSource = mDataSource.getValue(); - if (mFirebaseDataSource == null) { - Log.w(TAG, "Called refresh() when FirestoreDataSource is null!"); - return; - } - mFirebaseDataSource.invalidate(); - } - /** * Re-initialize the Adapter with a new set of options. Can be used to change the query without * re-constructing the entire adapter. @@ -178,9 +90,6 @@ public void updateOptions(@NonNull FirestorePagingOptions options) { @OnLifecycleEvent(Lifecycle.Event.ON_START) public void startListening() { mSnapshots.observeForever(mDataObserver); - mLoadingState.observeForever(mStateObserver); - mDataSource.observeForever(mDataSourceObserver); - mException.observeForever(mErrorObserver); } /** @@ -190,9 +99,6 @@ public void startListening() { @OnLifecycleEvent(Lifecycle.Event.ON_STOP) public void stopListening() { mSnapshots.removeObserver(mDataObserver); - mLoadingState.removeObserver(mStateObserver); - mDataSource.removeObserver(mDataSourceObserver); - mException.removeObserver(mErrorObserver); } @Override @@ -206,23 +112,4 @@ public void onBindViewHolder(@NonNull VH holder, int position) { * @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 - } - - /** - * Called whenever the {@link Exception} is caught. - *

- * When {@link Exception} is caught the adapter will stop loading any data - */ - protected void onError(@NonNull Exception e) { - Log.w(TAG, "onError", e); - } } 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 index 3b7997e88..272f17d14 100644 --- a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingOptions.java +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingOptions.java @@ -10,40 +10,44 @@ import androidx.annotation.Nullable; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; +import androidx.paging.Pager; +import androidx.paging.PagingConfig; +import androidx.paging.PagingData; +import androidx.paging.PagingLiveData; +import androidx.paging.PagingSource; import androidx.recyclerview.widget.DiffUtil; +import kotlin.jvm.functions.Function0; import static com.firebase.ui.common.Preconditions.assertNull; /** * Options to configure an {@link FirestorePagingAdapter}. - * + *

* Use {@link Builder} to create a new instance. */ public final class FirestorePagingOptions { - private static final String ERR_DATA_SET = "Data already set. " + - "Call only one of setData() or setQuery()"; + private static final String ERR_DATA_SET = "Data already set. " + + "Call only one of setPagingData() or setQuery()"; - private final LiveData> mData; + private final LiveData> mPagingData; private final SnapshotParser mParser; private final DiffUtil.ItemCallback mDiffCallback; private final LifecycleOwner mOwner; - private FirestorePagingOptions(@NonNull LiveData> data, + private FirestorePagingOptions(@NonNull LiveData> pagingData, @NonNull SnapshotParser parser, @NonNull DiffUtil.ItemCallback diffCallback, @Nullable LifecycleOwner owner) { - mData = data; + mPagingData = pagingData; mParser = parser; mDiffCallback = diffCallback; mOwner = owner; } @NonNull - public LiveData> getData() { - return mData; + public LiveData> getPagingData() { + return mPagingData; } @NonNull @@ -66,22 +70,21 @@ public LifecycleOwner getOwner() { */ public static final class Builder { - private LiveData> mData; + private LiveData> mPagingData; private SnapshotParser mParser; private LifecycleOwner mOwner; private DiffUtil.ItemCallback mDiffCallback; /** - * Directly set data using and parse with a {@link ClassSnapshotParser} based on - * the given class. + * Directly set data using and parse with a {@link ClassSnapshotParser} based on the given + * class. *

* Do not call this method after calling {@code setQuery}. */ @NonNull - public Builder setData(@NonNull LiveData> data, - @NonNull Class modelClass) { - - return setData(data, new ClassSnapshotParser<>(modelClass)); + public Builder setPagingData(@NonNull LiveData> data, + @NonNull Class modelClass) { + return setPagingData(data, new ClassSnapshotParser<>(modelClass)); } /** @@ -90,50 +93,50 @@ public Builder setData(@NonNull LiveData> data, * Do not call this method after calling {@code setQuery}. */ @NonNull - public Builder setData(@NonNull LiveData> data, - @NonNull SnapshotParser parser) { - assertNull(mData, ERR_DATA_SET); + public Builder setPagingData(@NonNull LiveData> pagingData, + @NonNull SnapshotParser parser) { + assertNull(mPagingData, ERR_DATA_SET); - mData = data; + mPagingData = pagingData; mParser = parser; return this; } /** - * Sets the query using {@link Source#DEFAULT} and a {@link ClassSnapshotParser} based - * on the given Class. - * - * See {@link #setQuery(Query, Source, PagedList.Config, SnapshotParser)}. + * Sets the query using {@link Source#DEFAULT} and a {@link ClassSnapshotParser} based on + * the given Class. + *

+ * See {@link #setQuery(Query, Source, PagingConfig, SnapshotParser)}. */ @NonNull public Builder setQuery(@NonNull Query query, - @NonNull PagedList.Config config, + @NonNull PagingConfig config, @NonNull Class modelClass) { return setQuery(query, Source.DEFAULT, config, modelClass); } /** * Sets the query using {@link Source#DEFAULT} and a custom {@link SnapshotParser}. - * - * See {@link #setQuery(Query, Source, PagedList.Config, SnapshotParser)}. + *

+ * See {@link #setQuery(Query, Source, PagingConfig, SnapshotParser)}. */ @NonNull public Builder setQuery(@NonNull Query query, - @NonNull PagedList.Config config, + @NonNull PagingConfig config, @NonNull SnapshotParser parser) { return setQuery(query, Source.DEFAULT, config, parser); } /** - * Sets the query using a custom {@link Source} and a {@link ClassSnapshotParser} based - * on the given class. - * - * See {@link #setQuery(Query, Source, PagedList.Config, SnapshotParser)}. + * Sets the query using a custom {@link Source} and a {@link ClassSnapshotParser} based on + * the given class. + *

+ * See {@link #setQuery(Query, Source, PagingConfig, SnapshotParser)}. */ @NonNull public Builder setQuery(@NonNull Query query, @NonNull Source source, - @NonNull PagedList.Config config, + @NonNull PagingConfig config, @NonNull Class modelClass) { return setQuery(query, source, config, new ClassSnapshotParser<>(modelClass)); } @@ -141,8 +144,8 @@ public Builder setQuery(@NonNull Query query, /** * Sets the Firestore query to paginate. * - * @param query the Firestore query. This query should only contain where() and - * orderBy() clauses. Any limit() or pagination clauses will cause errors. + * @param query the Firestore query. This query should only contain where() and orderBy() + * clauses. Any limit() or pagination clauses will cause errors. * @param source the data source to use for query data. * @param config paging configuration, passed directly to the support paging library. * @param parser the {@link SnapshotParser} to parse {@link DocumentSnapshot} into model @@ -150,26 +153,34 @@ public Builder setQuery(@NonNull Query query, * @return this, for chaining. */ @NonNull - public Builder setQuery(@NonNull Query query, - @NonNull Source source, - @NonNull PagedList.Config config, + public Builder setQuery(@NonNull final Query query, + @NonNull final Source source, + @NonNull PagingConfig config, @NonNull SnapshotParser parser) { - assertNull(mData, ERR_DATA_SET); - - // Build paged list - FirestoreDataSource.Factory factory = new FirestoreDataSource.Factory(query, source); - mData = new LivePagedListBuilder<>(factory, config).build(); + assertNull(mPagingData, ERR_DATA_SET); mParser = parser; + + final Pager pager = new Pager<>(config, + new Function0>() { + @Override + public PagingSource invoke() { + return new FirestorePagingSource(query, source); + } + }); + + mPagingData = PagingLiveData.cachedIn(PagingLiveData.getLiveData(pager), + mOwner.getLifecycle()); return this; } + /** - * Sets an optional custom {@link DiffUtil.ItemCallback} to compare - * {@link DocumentSnapshot} objects. - * + * Sets an optional custom {@link DiffUtil.ItemCallback} to compare {@link DocumentSnapshot} + * objects. + *

* The default implementation is {@link DefaultSnapshotDiffCallback}. - * + * * @return this, for chaining. */ @NonNull @@ -179,9 +190,9 @@ public Builder setDiffCallback(@NonNull DiffUtil.ItemCallback setLifecycleOwner(@NonNull LifecycleOwner owner) { */ @NonNull public FirestorePagingOptions build() { - if (mData == null || mParser == null) { - throw new IllegalStateException("Must call setQuery() or setDocumentSnapshot()" + + if (mPagingData == null || mParser == null) { + throw new IllegalStateException("Must call setQuery() or setPagingData()" + " before calling build()."); } if (mDiffCallback == null) { - mDiffCallback = new DefaultSnapshotDiffCallback(mParser); + mDiffCallback = new DefaultSnapshotDiffCallback<>(mParser); } - return new FirestorePagingOptions<>(mData, mParser, mDiffCallback, mOwner); + return new FirestorePagingOptions<>(mPagingData, mParser, mDiffCallback, mOwner); } - } } diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java new file mode 100644 index 000000000..8b453d98e --- /dev/null +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java @@ -0,0 +1,97 @@ +package com.firebase.ui.firestore.paging; + +import com.google.android.gms.tasks.Task; +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 com.google.firebase.firestore.Source; + +import java.util.List; +import java.util.concurrent.Callable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.paging.PagingState; +import androidx.paging.rxjava3.RxPagingSource; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.functions.Function; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public class FirestorePagingSource extends RxPagingSource { + + private final Query mQuery; + private final Source mSource; + + public FirestorePagingSource(@NonNull Query query, @NonNull Source source) { + mQuery = query; + mSource = source; + } + + @NonNull + @Override + public Single> loadSingle(@NonNull LoadParams params) { + final Task task; + if (params.getKey() == null) { + task = mQuery.limit(params.getLoadSize()).get(mSource); + } else { + task = params.getKey().getPageQuery(mQuery, params.getLoadSize()).get(mSource); + } + + return Single.fromCallable(new Callable>() { + @Override + public LoadResult call() throws Exception { + Tasks.await(task); + if (task.isSuccessful()) { + QuerySnapshot snapshot = task.getResult(); + PageKey nextPage = getNextPageKey(snapshot); + if (snapshot.getDocuments().isEmpty()) { + return toLoadResult(snapshot.getDocuments(), null); + } + return toLoadResult(snapshot.getDocuments(), nextPage); + } + throw task.getException(); + } + }).subscribeOn(Schedulers.io()) + .onErrorReturn(new Function>() { + @Override + public LoadResult apply(Throwable throwable) { + return new LoadResult.Error<>(throwable); + } + }); + } + + private LoadResult toLoadResult( + @NonNull List snapshots, + @Nullable PageKey nextPage + ) { + return new LoadResult.Page<>( + snapshots, + null, // Only paging forward. + nextPage, + LoadResult.Page.COUNT_UNDEFINED, + LoadResult.Page.COUNT_UNDEFINED); + } + + @Nullable + @Override + public PageKey getRefreshKey(@NonNull PagingState state) { + return null; + } + + @NonNull + private PageKey getNextPageKey(@NonNull QuerySnapshot snapshot) { + List data = snapshot.getDocuments(); + DocumentSnapshot last = getLast(data); + return new PageKey(last, null); + } + + @Nullable + private DocumentSnapshot getLast(@NonNull List data) { + if (data.isEmpty()) { + return null; + } else { + return data.get(data.size() - 1); + } + } +} 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 deleted file mode 100644 index efe677a63..000000000 --- a/firestore/src/main/java/com/firebase/ui/firestore/paging/LoadingState.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.firebase.ui.firestore.paging; - -/** - * Loading state exposed by {@link FirestorePagingAdapter}. - */ -public enum LoadingState { - /** - * Loading initial data. - */ - 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 -}