Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<DatabasePagingKey, DataSnapshot> 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<DatabasePagingKey, DataSnapshot> page1 =
(PagingSource.LoadResult.Page<DatabasePagingKey, DataSnapshot>) result1;
assertEquals(PAGE_SIZE, page1.getData().size());

DatabasePagingKey nextKey = page1.getNextKey();

PagingSource.LoadResult<DatabasePagingKey, DataSnapshot> 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<DatabasePagingKey, DataSnapshot> page2 =
(PagingSource.LoadResult.Page<DatabasePagingKey, DataSnapshot>) result2;

Set<String> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public Builder<T> setQuery(@NonNull Query query,
public Builder<T> setQuery(@NonNull Query query,
@NonNull PagingConfig config,
@NotNull SnapshotParser<T> parser) {
final Pager<String, DataSnapshot> pager = new Pager<>(config,
final Pager<DatabasePagingKey, DataSnapshot> pager = new Pager<>(config,
() -> new DatabasePagingSource(query));
mData = PagingLiveData.cachedIn(PagingLiveData.getLiveData(pager),
mOwner.getLifecycle());
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -21,7 +25,7 @@
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;

public class DatabasePagingSource extends RxPagingSource<String, DataSnapshot> {
public class DatabasePagingSource extends RxPagingSource<DatabasePagingKey, DataSnapshot> {
private final Query mQuery;

private static final String STATUS_DATABASE_NOT_FOUND = "DATA_NOT_FOUND";
Expand All @@ -32,29 +36,42 @@ 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<LoadResult<String, DataSnapshot>> loadSingle(@NonNull LoadParams<String> params) {
public Single<LoadResult<DatabasePagingKey, DataSnapshot>> loadSingle(
@NonNull LoadParams<DatabasePagingKey> params) {
final Index index = mQuery.getSpec().getIndex();
final boolean needsIndexedCursor =
index instanceof PathIndex || index instanceof ValueIndex;
Task<DataSnapshot> 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 {
Comment thread
demolaf marked this conversation as resolved.
// orderByKey() — the node key alone is a sufficient cursor
task = mQuery.startAt(null, key.getNodeKey())
.limitToFirst(params.getLoadSize() + 1).get();
}
}

return Single.fromCallable(() -> {
try {
Tasks.await(task);
DataSnapshot dataSnapshot = task.getResult();
if (dataSnapshot.exists()) {

//Make List of DataSnapshot
List<DataSnapshot> data = new ArrayList<>();
String lastKey = null;
DatabasePagingKey lastKey = null;

if (params.getKey() == null) {
for (DataSnapshot snapshot : dataSnapshot.getChildren()) {
Expand All @@ -69,15 +86,16 @@ public Single<LoadResult<String, DataSnapshot>> loadSingle(@NonNull LoadParams<S
}

while (iterator.hasNext()) {
DataSnapshot snapshot = iterator.next();
data.add(snapshot);
data.add(iterator.next());
}
}

//Detect End of Data
if (!data.isEmpty()) {
//Get Last Key
lastKey = getLastPageKey(data);
DataSnapshot last = data.get(data.size() - 1);
Object cursorValue = needsIndexedCursor
? getIndexedValue(last, index) : null;
lastKey = new DatabasePagingKey(cursorValue, last.getKey());
}
return toLoadResult(data, lastKey);
} else {
Expand All @@ -89,19 +107,45 @@ public Single<LoadResult<String, DataSnapshot>> loadSingle(@NonNull LoadParams<S
}
} catch (ExecutionException e) {
if (e.getCause() instanceof Exception) {
// throw the original Exception
throw (Exception) e.getCause();
}
// Only throw a new Exception when the original
// Throwable cannot be cast to Exception
throw new Exception(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new LoadResult.Error<DatabasePagingKey, DataSnapshot>(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<String, DataSnapshot> 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);
Comment thread
demolaf marked this conversation as resolved.
}
Comment thread
demolaf marked this conversation as resolved.

private LoadResult<DatabasePagingKey, DataSnapshot> toLoadResult(
@NonNull List<DataSnapshot> snapshots,
String nextPage
DatabasePagingKey nextPage
) {
return new LoadResult.Page<>(
snapshots,
Expand All @@ -111,18 +155,10 @@ private LoadResult<String, DataSnapshot> toLoadResult(
LoadResult.Page.COUNT_UNDEFINED);
}

@Nullable
private String getLastPageKey(@NonNull List<DataSnapshot> data) {
if (data.isEmpty()) {
return null;
} else {
return data.get(data.size() - 1).getKey();
}
}

@Nullable
@Override
public String getRefreshKey(@NonNull PagingState<String, DataSnapshot> state) {
public DatabasePagingKey getRefreshKey(
@NonNull PagingState<DatabasePagingKey, DataSnapshot> state) {
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,7 +22,7 @@
*/
public abstract class FirebaseRecyclerPagingAdapter<T, VH extends RecyclerView.ViewHolder>
extends PagingDataAdapter<DataSnapshot, VH>
implements LifecycleObserver {
implements LifecycleEventObserver {

private DatabasePagingOptions<T> mOptions;
private SnapshotParser<T> mParser;
Expand Down Expand Up @@ -88,7 +88,6 @@ public void updateOptions(@NonNull DatabasePagingOptions<T> options) {
/**
* Start listening to paging / scrolling events and populating adapter data.
*/
@OnLifecycleEvent(Lifecycle.Event.ON_START)
public void startListening() {
mPagingData.observeForever(mDataObserver);
}
Expand All @@ -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);
Expand Down
Loading