diff --git a/database/src/androidTest/java/com/firebase/ui/database/paging/DatabasePagingSourceTest.java b/database/src/androidTest/java/com/firebase/ui/database/paging/DatabasePagingSourceTest.java new file mode 100644 index 000000000..9bcaa08ff --- /dev/null +++ b/database/src/androidTest/java/com/firebase/ui/database/paging/DatabasePagingSourceTest.java @@ -0,0 +1,104 @@ +package com.firebase.ui.database.paging; + +import androidx.paging.PagingSource; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.firebase.ui.database.Bean; +import com.firebase.ui.database.TestUtils; +import com.google.firebase.FirebaseApp; +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.FirebaseDatabase; +import com.google.firebase.database.ValueEventListener; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import androidx.annotation.NonNull; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +public class DatabasePagingSourceTest { + private static final int PAGE_SIZE = 3; + private static final int TOTAL_ITEMS = 6; + + private DatabaseReference mRef; + + @Before + public void setUp() throws InterruptedException { + FirebaseApp app = TestUtils.getAppInstance(ApplicationProvider.getApplicationContext()); + mRef = FirebaseDatabase.getInstance(app).getReference().child("paging_test"); + + mRef.removeValue(); + for (int i = 1; i <= TOTAL_ITEMS; i++) { + mRef.push().setValue(new Bean(i)); + } + + CountDownLatch latch = new CountDownLatch(1); + mRef.addValueEventListener(new ValueEventListener() { + @Override + public void onDataChange(@NonNull DataSnapshot snapshot) { + if (snapshot.getChildrenCount() >= TOTAL_ITEMS) { + mRef.removeEventListener(this); + latch.countDown(); + } + } + + @Override + public void onCancelled(@NonNull DatabaseError error) {} + }); + assertTrue("Timed out seeding test data", latch.await(30, TimeUnit.SECONDS)); + } + + @After + public void tearDown() { + mRef.removeValue(); + } + + @Test + public void testOrderByChild_noDuplicatesAcrossPages() { + DatabasePagingSource source = new DatabasePagingSource(mRef.orderByChild("number")); + + PagingSource.LoadResult result1 = + source.loadSingle(new PagingSource.LoadParams.Refresh<>(null, PAGE_SIZE, false)) + .timeout(30, TimeUnit.SECONDS) + .blockingGet(); + + assertTrue(result1 instanceof PagingSource.LoadResult.Page); + PagingSource.LoadResult.Page page1 = + (PagingSource.LoadResult.Page) result1; + assertEquals(PAGE_SIZE, page1.getData().size()); + + DatabasePagingKey nextKey = page1.getNextKey(); + + PagingSource.LoadResult result2 = + source.loadSingle(new PagingSource.LoadParams.Append<>(nextKey, PAGE_SIZE, false)) + .timeout(30, TimeUnit.SECONDS) + .blockingGet(); + + assertTrue(result2 instanceof PagingSource.LoadResult.Page); + PagingSource.LoadResult.Page page2 = + (PagingSource.LoadResult.Page) result2; + + Set allKeys = new HashSet<>(); + for (DataSnapshot snapshot : page1.getData()) { + allKeys.add(snapshot.getKey()); + } + for (DataSnapshot snapshot : page2.getData()) { + assertTrue("Duplicate key across pages: " + snapshot.getKey(), + allKeys.add(snapshot.getKey())); + } + assertEquals(TOTAL_ITEMS, allKeys.size()); + } +} diff --git a/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingKey.java b/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingKey.java new file mode 100644 index 000000000..b04e57723 --- /dev/null +++ b/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingKey.java @@ -0,0 +1,35 @@ +package com.firebase.ui.database.paging; + +import java.util.Objects; + +public class DatabasePagingKey { + private final Object mChildValue; + private final String mNodeKey; + + public DatabasePagingKey(Object childValue, String nodeKey) { + mChildValue = childValue; + mNodeKey = nodeKey; + } + + public Object getChildValue() { + return mChildValue; + } + + public String getNodeKey() { + return mNodeKey; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DatabasePagingKey that = (DatabasePagingKey) o; + return Objects.equals(mChildValue, that.mChildValue) && + Objects.equals(mNodeKey, that.mNodeKey); + } + + @Override + public int hashCode() { + return Objects.hash(mChildValue, mNodeKey); + } +} diff --git a/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingOptions.java b/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingOptions.java index 012de7bcb..9cc5dfcba 100644 --- a/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingOptions.java +++ b/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingOptions.java @@ -94,7 +94,7 @@ public Builder setQuery(@NonNull Query query, public Builder setQuery(@NonNull Query query, @NonNull PagingConfig config, @NotNull SnapshotParser parser) { - final Pager pager = new Pager<>(config, + final Pager pager = new Pager<>(config, () -> new DatabasePagingSource(query)); mData = PagingLiveData.cachedIn(PagingLiveData.getLiveData(pager), mOwner.getLifecycle()); diff --git a/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingSource.java b/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingSource.java index 0544a7cfe..0ab53cd6e 100644 --- a/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingSource.java +++ b/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingSource.java @@ -1,12 +1,16 @@ package com.firebase.ui.database.paging; import android.annotation.SuppressLint; +import android.util.Log; import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.Tasks; import com.google.firebase.database.DataSnapshot; import com.google.firebase.database.DatabaseError; import com.google.firebase.database.Query; +import com.google.firebase.database.snapshot.Index; +import com.google.firebase.database.snapshot.PathIndex; +import com.google.firebase.database.snapshot.ValueIndex; import org.jetbrains.annotations.Nullable; @@ -21,7 +25,7 @@ import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; -public class DatabasePagingSource extends RxPagingSource { +public class DatabasePagingSource extends RxPagingSource { private final Query mQuery; private static final String STATUS_DATABASE_NOT_FOUND = "DATA_NOT_FOUND"; @@ -32,18 +36,33 @@ public DatabasePagingSource(Query query) { this.mQuery = query; } + private static final String TAG = "DatabasePagingSource"; + /** - * DatabaseError.fromStatus() is not meant to be public. + * DatabaseError.fromStatus() and PathIndex are not meant to be public. */ @SuppressLint("RestrictedApi") @NonNull @Override - public Single> loadSingle(@NonNull LoadParams params) { + public Single> loadSingle( + @NonNull LoadParams params) { + final Index index = mQuery.getSpec().getIndex(); + final boolean needsIndexedCursor = + index instanceof PathIndex || index instanceof ValueIndex; Task task; + if (params.getKey() == null) { task = mQuery.limitToFirst(params.getLoadSize()).get(); } else { - task = mQuery.startAt(null, params.getKey()).limitToFirst(params.getLoadSize() + 1).get(); + DatabasePagingKey key = params.getKey(); + if (needsIndexedCursor) { + task = startAtChildValue(key.getChildValue(), key.getNodeKey()) + .limitToFirst(params.getLoadSize() + 1).get(); + } else { + // orderByKey() — the node key alone is a sufficient cursor + task = mQuery.startAt(null, key.getNodeKey()) + .limitToFirst(params.getLoadSize() + 1).get(); + } } return Single.fromCallable(() -> { @@ -51,10 +70,8 @@ public Single> loadSingle(@NonNull LoadParams data = new ArrayList<>(); - String lastKey = null; + DatabasePagingKey lastKey = null; if (params.getKey() == null) { for (DataSnapshot snapshot : dataSnapshot.getChildren()) { @@ -69,15 +86,16 @@ public Single> loadSingle(@NonNull LoadParams> loadSingle(@NonNull LoadParams(e); } - }).subscribeOn(Schedulers.io()).onErrorReturn(LoadResult.Error::new); + }).subscribeOn(Schedulers.io()).onErrorReturn(e -> { + Log.e(TAG, "DatabasePagingSource load failed", e); + return new LoadResult.Error<>(e); + }); + } + + @SuppressLint("RestrictedApi") + private Object getIndexedValue(DataSnapshot snapshot, Index index) { + if (index instanceof PathIndex) { + return snapshot.child(((PathIndex) index).getQueryDefinition()).getValue(); + } else if (index instanceof ValueIndex) { + return snapshot.getValue(); + } + return null; } - private LoadResult toLoadResult( + @SuppressLint("RestrictedApi") + private Query startAtChildValue(Object childValue, String nodeKey) { + if (childValue instanceof String) { + return mQuery.startAt((String) childValue, nodeKey); + } else if (childValue instanceof Boolean) { + return mQuery.startAt((Boolean) childValue, nodeKey); + } else if (childValue instanceof Number) { + return mQuery.startAt(((Number) childValue).doubleValue(), nodeKey); + } + // childValue is null when a node lacks the ordered child field; key alone keeps the cursor correct + return mQuery.startAt(null, nodeKey); + } + + private LoadResult toLoadResult( @NonNull List snapshots, - String nextPage + DatabasePagingKey nextPage ) { return new LoadResult.Page<>( snapshots, @@ -111,18 +155,10 @@ private LoadResult toLoadResult( LoadResult.Page.COUNT_UNDEFINED); } - @Nullable - private String getLastPageKey(@NonNull List data) { - if (data.isEmpty()) { - return null; - } else { - return data.get(data.size() - 1).getKey(); - } - } - @Nullable @Override - public String getRefreshKey(@NonNull PagingState state) { + public DatabasePagingKey getRefreshKey( + @NonNull PagingState state) { return null; } } diff --git a/database/src/main/java/com/firebase/ui/database/paging/FirebaseRecyclerPagingAdapter.java b/database/src/main/java/com/firebase/ui/database/paging/FirebaseRecyclerPagingAdapter.java index 7b20bad48..e1cd3f9f5 100644 --- a/database/src/main/java/com/firebase/ui/database/paging/FirebaseRecyclerPagingAdapter.java +++ b/database/src/main/java/com/firebase/ui/database/paging/FirebaseRecyclerPagingAdapter.java @@ -7,10 +7,10 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.Lifecycle; -import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.LifecycleEventObserver; +import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; -import androidx.lifecycle.OnLifecycleEvent; import androidx.paging.PagingData; import androidx.paging.PagingDataAdapter; import androidx.recyclerview.widget.RecyclerView; @@ -22,7 +22,7 @@ */ public abstract class FirebaseRecyclerPagingAdapter extends PagingDataAdapter - implements LifecycleObserver { + implements LifecycleEventObserver { private DatabasePagingOptions mOptions; private SnapshotParser mParser; @@ -88,7 +88,6 @@ public void updateOptions(@NonNull DatabasePagingOptions options) { /** * Start listening to paging / scrolling events and populating adapter data. */ - @OnLifecycleEvent(Lifecycle.Event.ON_START) public void startListening() { mPagingData.observeForever(mDataObserver); } @@ -97,11 +96,19 @@ public void startListening() { * 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() { mPagingData.removeObserver(mDataObserver); } + @Override + public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) { + if (event == Lifecycle.Event.ON_START) { + startListening(); + } else if (event == Lifecycle.Event.ON_STOP) { + stopListening(); + } + } + @Override public void onBindViewHolder(@NonNull VH viewHolder, int position) { DataSnapshot snapshot = getItem(position);