From 66db2bd159b7f0b57fb0ba5dd6c80febe8222774 Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 17 Jun 2026 16:43:10 +0100 Subject: [PATCH 01/19] fix(database): fix DatabasePagingSource pagination when using orderByChild --- .../paging/DatabasePagingSourceTest.java | 117 ++++++++++++++++++ .../ui/database/paging/DatabasePagingKey.java | 19 +++ .../paging/DatabasePagingOptions.java | 2 +- .../database/paging/DatabasePagingSource.java | 77 ++++++++---- 4 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 database/src/androidTest/java/com/firebase/ui/database/paging/DatabasePagingSourceTest.java create mode 100644 database/src/main/java/com/firebase/ui/database/paging/DatabasePagingKey.java 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..d6848fdae --- /dev/null +++ b/database/src/androidTest/java/com/firebase/ui/database/paging/DatabasePagingSourceTest.java @@ -0,0 +1,117 @@ +package com.firebase.ui.database.paging; + +import android.content.Context; + +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.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +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 String APP_NAME = "firebaseui-paging-tests"; + 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; + try { + app = FirebaseApp.getInstance(APP_NAME); + } catch (IllegalStateException e) { + Context context = ApplicationProvider.getApplicationContext(); + app = FirebaseApp.initializeApp(context, new FirebaseOptions.Builder() + .setApplicationId("fir-ui-tests") + .setDatabaseUrl("https://fir-ui-tests.firebaseio.com/") + .build(), APP_NAME); + } + + 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..d57d0e398 --- /dev/null +++ b/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingKey.java @@ -0,0 +1,19 @@ +package com.firebase.ui.database.paging; + +class DatabasePagingKey { + private final Object mChildValue; + private final String mNodeKey; + + DatabasePagingKey(Object childValue, String nodeKey) { + mChildValue = childValue; + mNodeKey = nodeKey; + } + + Object getChildValue() { + return mChildValue; + } + + String getNodeKey() { + return 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..6724f324c 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 @@ -7,6 +7,8 @@ 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 org.jetbrains.annotations.Nullable; @@ -21,7 +23,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"; @@ -33,17 +35,27 @@ public DatabasePagingSource(Query query) { } /** - * 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) { + PathIndex pathIndex = getPathIndex(); 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 (pathIndex != null) { + task = startAtChildValue(key.getChildValue(), key.getNodeKey()) + .limitToFirst(params.getLoadSize() + 1).get(); + } else { + task = mQuery.startAt(null, key.getNodeKey()) + .limitToFirst(params.getLoadSize() + 1).get(); + } } return Single.fromCallable(() -> { @@ -51,10 +63,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 +79,16 @@ public Single> loadSingle(@NonNull LoadParams> loadSingle(@NonNull LoadParams toLoadResult( + @SuppressLint("RestrictedApi") + private PathIndex getPathIndex() { + Index index = mQuery.getSpec().getIndex(); + return index instanceof PathIndex ? (PathIndex) index : null; + } + + @SuppressLint("RestrictedApi") + private Object getChildValue(DataSnapshot snapshot, PathIndex pathIndex) { + return snapshot.child(pathIndex.getQueryDefinition()).getValue(); + } + + @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 Double) { + return mQuery.startAt((Double) childValue, nodeKey); + } else if (childValue instanceof Long) { + return mQuery.startAt(((Long) childValue).doubleValue(), nodeKey); + } + return mQuery.startAt(null, nodeKey); + } + + private LoadResult toLoadResult( @NonNull List snapshots, - String nextPage + DatabasePagingKey nextPage ) { return new LoadResult.Page<>( snapshots, @@ -111,18 +144,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; } } From 5df651d70b89c18813c25e268256237a074a2bcd Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 17 Jun 2026 17:48:02 +0100 Subject: [PATCH 02/19] fix(database): make DatabasePagingKey public and add equals/hashCode --- .../ui/database/paging/DatabasePagingKey.java | 24 +++++++++++++++---- .../database/paging/DatabasePagingSource.java | 6 ++--- 2 files changed, 22 insertions(+), 8 deletions(-) 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 index d57d0e398..b04e57723 100644 --- a/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingKey.java +++ b/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingKey.java @@ -1,19 +1,35 @@ package com.firebase.ui.database.paging; -class DatabasePagingKey { +import java.util.Objects; + +public class DatabasePagingKey { private final Object mChildValue; private final String mNodeKey; - DatabasePagingKey(Object childValue, String nodeKey) { + public DatabasePagingKey(Object childValue, String nodeKey) { mChildValue = childValue; mNodeKey = nodeKey; } - Object getChildValue() { + public Object getChildValue() { return mChildValue; } - String getNodeKey() { + 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/DatabasePagingSource.java b/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingSource.java index 6724f324c..26e643a86 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 @@ -124,10 +124,8 @@ private Query startAtChildValue(Object childValue, String nodeKey) { return mQuery.startAt((String) childValue, nodeKey); } else if (childValue instanceof Boolean) { return mQuery.startAt((Boolean) childValue, nodeKey); - } else if (childValue instanceof Double) { - return mQuery.startAt((Double) childValue, nodeKey); - } else if (childValue instanceof Long) { - return mQuery.startAt(((Long) childValue).doubleValue(), nodeKey); + } else if (childValue instanceof Number) { + return mQuery.startAt(((Number) childValue).doubleValue(), nodeKey); } return mQuery.startAt(null, nodeKey); } From a9e554b7b62c5e542b3319101c2f1c9e7c573a55 Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 17 Jun 2026 20:15:12 +0100 Subject: [PATCH 03/19] fix(database): surface load errors to logcat and fix lifecycle observer for API 29+ --- .../database/paging/DatabasePagingSource.java | 11 ++++++++++- .../paging/FirebaseRecyclerPagingAdapter.java | 17 ++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) 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 26e643a86..0064f67ce 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,6 +1,7 @@ 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; @@ -34,6 +35,8 @@ public DatabasePagingSource(Query query) { this.mQuery = query; } + private static final String TAG = "DatabasePagingSource"; + /** * DatabaseError.fromStatus() and PathIndex are not meant to be public. */ @@ -103,8 +106,14 @@ public Single> loadSingle( throw (Exception) e.getCause(); } throw new Exception(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new LoadResult.Error(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") 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); From bbd468769daab1fbf9583741a638470cb989ed29 Mon Sep 17 00:00:00 2001 From: demolaf Date: Fri, 19 Jun 2026 10:54:35 +0100 Subject: [PATCH 04/19] fix(database): extend orderByValue() cursor support and use shared TestUtils in paging test --- .../paging/DatabasePagingSourceTest.java | 17 ++--------- .../database/paging/DatabasePagingSource.java | 30 +++++++++++-------- 2 files changed, 19 insertions(+), 28 deletions(-) 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 index d6848fdae..9bcaa08ff 100644 --- a/database/src/androidTest/java/com/firebase/ui/database/paging/DatabasePagingSourceTest.java +++ b/database/src/androidTest/java/com/firebase/ui/database/paging/DatabasePagingSourceTest.java @@ -1,14 +1,12 @@ package com.firebase.ui.database.paging; -import android.content.Context; - 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.FirebaseOptions; import com.google.firebase.database.DataSnapshot; import com.google.firebase.database.DatabaseError; import com.google.firebase.database.DatabaseReference; @@ -32,7 +30,6 @@ @RunWith(AndroidJUnit4.class) public class DatabasePagingSourceTest { - private static final String APP_NAME = "firebaseui-paging-tests"; private static final int PAGE_SIZE = 3; private static final int TOTAL_ITEMS = 6; @@ -40,17 +37,7 @@ public class DatabasePagingSourceTest { @Before public void setUp() throws InterruptedException { - FirebaseApp app; - try { - app = FirebaseApp.getInstance(APP_NAME); - } catch (IllegalStateException e) { - Context context = ApplicationProvider.getApplicationContext(); - app = FirebaseApp.initializeApp(context, new FirebaseOptions.Builder() - .setApplicationId("fir-ui-tests") - .setDatabaseUrl("https://fir-ui-tests.firebaseio.com/") - .build(), APP_NAME); - } - + FirebaseApp app = TestUtils.getAppInstance(ApplicationProvider.getApplicationContext()); mRef = FirebaseDatabase.getInstance(app).getReference().child("paging_test"); mRef.removeValue(); 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 0064f67ce..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 @@ -10,6 +10,7 @@ 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; @@ -45,17 +46,20 @@ public DatabasePagingSource(Query query) { @Override public Single> loadSingle( @NonNull LoadParams params) { - PathIndex pathIndex = getPathIndex(); + 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 { DatabasePagingKey key = params.getKey(); - if (pathIndex != null) { + 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(); } @@ -89,9 +93,9 @@ public Single> loadSingle( //Detect End of Data if (!data.isEmpty()) { DataSnapshot last = data.get(data.size() - 1); - Object childValue = pathIndex != null - ? getChildValue(last, pathIndex) : null; - lastKey = new DatabasePagingKey(childValue, last.getKey()); + Object cursorValue = needsIndexedCursor + ? getIndexedValue(last, index) : null; + lastKey = new DatabasePagingKey(cursorValue, last.getKey()); } return toLoadResult(data, lastKey); } else { @@ -117,14 +121,13 @@ public Single> loadSingle( } @SuppressLint("RestrictedApi") - private PathIndex getPathIndex() { - Index index = mQuery.getSpec().getIndex(); - return index instanceof PathIndex ? (PathIndex) index : null; - } - - @SuppressLint("RestrictedApi") - private Object getChildValue(DataSnapshot snapshot, PathIndex pathIndex) { - return snapshot.child(pathIndex.getQueryDefinition()).getValue(); + 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; } @SuppressLint("RestrictedApi") @@ -136,6 +139,7 @@ private Query startAtChildValue(Object childValue, String 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); } From cb299247fc869b81d8e74ab68db5a2aa9b747085 Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 17 Jun 2026 12:31:21 +0100 Subject: [PATCH 05/19] feat(storage): add StringLoader to support loading gs:// URL strings via Glide --- storage/README.md | 33 +++++++++++++++ .../storage/images/FirebaseImageLoader.java | 40 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/storage/README.md b/storage/README.md index 0e4346e02..8c76b6905 100644 --- a/storage/README.md +++ b/storage/README.md @@ -72,6 +72,39 @@ GlideApp.with(this /* context */) .into(imageView); ``` +#### Loading from a gs:// URL string + +If your image paths are stored as `gs://` URL strings (e.g. retrieved from Firestore) rather +than as `StorageReference` objects, register `FirebaseImageLoader.StringLoader.Factory` in your +`AppGlideModule` alongside the default loader: + +```java +@GlideModule +public class MyAppGlideModule extends AppGlideModule { + + @Override + public void registerComponents(Context context, Glide glide, Registry registry) { + registry.append(StorageReference.class, InputStream.class, + new FirebaseImageLoader.Factory()); + registry.append(String.class, InputStream.class, + new FirebaseImageLoader.StringLoader.Factory()); + } +} +``` + +You can then pass a `gs://` string directly to Glide: + +```java +String gsUrl = "gs://my-bucket.appspot.com/images/photo.png"; + +GlideApp.with(this /* context */) + .load(gsUrl) + .into(imageView); +``` + +`StringLoader` only intercepts strings that start with `gs://`, so Glide's built-in loaders +for `http://`, `https://`, and other schemes continue to work as normal. + ### Troubleshooting If GlideApp is not an importable class, build your application first before trying to use. diff --git a/storage/src/main/java/com/firebase/ui/storage/images/FirebaseImageLoader.java b/storage/src/main/java/com/firebase/ui/storage/images/FirebaseImageLoader.java index 2ed9150fe..b286833db 100644 --- a/storage/src/main/java/com/firebase/ui/storage/images/FirebaseImageLoader.java +++ b/storage/src/main/java/com/firebase/ui/storage/images/FirebaseImageLoader.java @@ -12,6 +12,7 @@ import com.bumptech.glide.load.model.MultiModelLoaderFactory; import com.google.android.gms.tasks.OnFailureListener; import com.google.android.gms.tasks.OnSuccessListener; +import com.google.firebase.storage.FirebaseStorage; import com.google.firebase.storage.StorageReference; import com.google.firebase.storage.StreamDownloadTask; @@ -86,6 +87,45 @@ public boolean handles(@NonNull StorageReference reference) { return true; } + /** + * ModelLoader that accepts a {@code gs://} URL string and delegates to + * {@link FirebaseStorageFetcher}, so callers can pass a plain gs:// string instead of a + * {@link StorageReference}. + */ + public static class StringLoader implements ModelLoader { + + @Nullable + @Override + public LoadData buildLoadData(@NonNull String gsUrl, + int width, + int height, + @NonNull Options options) { + try { + StorageReference ref = FirebaseStorage.getInstance().getReferenceFromUrl(gsUrl); + return new LoadData<>(new FirebaseStorageKey(ref), new FirebaseStorageFetcher(ref)); + } catch (IllegalArgumentException e) { + return null; + } + } + + @Override + public boolean handles(@NonNull String gsUrl) { + return gsUrl.startsWith("gs://"); + } + + public static class Factory implements ModelLoaderFactory { + + @NonNull + @Override + public ModelLoader build(@NonNull MultiModelLoaderFactory factory) { + return new StringLoader(); + } + + @Override + public void teardown() {} + } + } + private static class FirebaseStorageKey implements Key { private StorageReference mRef; From 663a5c940f1cb2f5c1a3a664269cc2181281ce3a Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 17 Jun 2026 12:51:39 +0100 Subject: [PATCH 06/19] feat(storage): allow StringLoader to accept a custom FirebaseStorage instance and add unit tests --- storage/build.gradle.kts | 3 + .../storage/images/FirebaseImageLoader.java | 29 +++++- .../ui/storage/images/StringLoaderTest.java | 98 +++++++++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 storage/src/test/java/com/firebase/ui/storage/images/StringLoaderTest.java diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts index 892d917bb..93e223f95 100644 --- a/storage/build.gradle.kts +++ b/storage/build.gradle.kts @@ -51,4 +51,7 @@ dependencies { api(Config.Libs.Firebase.storage) // Override Play Services implementation(Config.Libs.Androidx.legacySupportv4) + + testImplementation(Config.Libs.Test.junit) + testImplementation(Config.Libs.Test.mockitoCore) } diff --git a/storage/src/main/java/com/firebase/ui/storage/images/FirebaseImageLoader.java b/storage/src/main/java/com/firebase/ui/storage/images/FirebaseImageLoader.java index b286833db..7e3c55087 100644 --- a/storage/src/main/java/com/firebase/ui/storage/images/FirebaseImageLoader.java +++ b/storage/src/main/java/com/firebase/ui/storage/images/FirebaseImageLoader.java @@ -94,6 +94,17 @@ public boolean handles(@NonNull StorageReference reference) { */ public static class StringLoader implements ModelLoader { + @Nullable + private final FirebaseStorage mStorage; + + public StringLoader() { + mStorage = null; + } + + public StringLoader(@NonNull FirebaseStorage storage) { + mStorage = storage; + } + @Nullable @Override public LoadData buildLoadData(@NonNull String gsUrl, @@ -101,9 +112,10 @@ public LoadData buildLoadData(@NonNull String gsUrl, int height, @NonNull Options options) { try { - StorageReference ref = FirebaseStorage.getInstance().getReferenceFromUrl(gsUrl); + FirebaseStorage storage = mStorage != null ? mStorage : FirebaseStorage.getInstance(); + StorageReference ref = storage.getReferenceFromUrl(gsUrl); return new LoadData<>(new FirebaseStorageKey(ref), new FirebaseStorageFetcher(ref)); - } catch (IllegalArgumentException e) { + } catch (IllegalArgumentException | IllegalStateException e) { return null; } } @@ -115,10 +127,21 @@ public boolean handles(@NonNull String gsUrl) { public static class Factory implements ModelLoaderFactory { + @Nullable + private final FirebaseStorage mStorage; + + public Factory() { + mStorage = null; + } + + public Factory(@NonNull FirebaseStorage storage) { + mStorage = storage; + } + @NonNull @Override public ModelLoader build(@NonNull MultiModelLoaderFactory factory) { - return new StringLoader(); + return mStorage != null ? new StringLoader(mStorage) : new StringLoader(); } @Override diff --git a/storage/src/test/java/com/firebase/ui/storage/images/StringLoaderTest.java b/storage/src/test/java/com/firebase/ui/storage/images/StringLoaderTest.java new file mode 100644 index 000000000..c6f2f384a --- /dev/null +++ b/storage/src/test/java/com/firebase/ui/storage/images/StringLoaderTest.java @@ -0,0 +1,98 @@ +package com.firebase.ui.storage.images; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.ModelLoader.LoadData; +import com.google.firebase.storage.FirebaseStorage; +import com.google.firebase.storage.StorageReference; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnitRunner; + +import java.io.InputStream; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class StringLoaderTest { + + private static final String VALID_GS_URL = "gs://my-bucket.appspot.com/images/photo.png"; + private static final String INVALID_GS_URL = "gs://bad-url"; + + @Mock FirebaseStorage mStorage; + @Mock StorageReference mRef; + + @Before + public void setUp() { + when(mStorage.getReferenceFromUrl(VALID_GS_URL)).thenReturn(mRef); + } + + // handles() + + @Test + public void handles_gsUrl_returnsTrue() { + assertTrue(new FirebaseImageLoader.StringLoader(mStorage).handles(VALID_GS_URL)); + } + + @Test + public void handles_httpsUrl_returnsFalse() { + assertFalse(new FirebaseImageLoader.StringLoader(mStorage).handles("https://example.com/image.png")); + } + + @Test + public void handles_emptyString_returnsFalse() { + assertFalse(new FirebaseImageLoader.StringLoader(mStorage).handles("")); + } + + // buildLoadData() + + @Test + public void buildLoadData_validUrl_returnsLoadData() { + LoadData result = loaderWithMockStorage().buildLoadData(VALID_GS_URL, 0, 0, new Options()); + assertNotNull(result); + } + + @Test + public void buildLoadData_illegalArgumentException_returnsNull() { + when(mStorage.getReferenceFromUrl(INVALID_GS_URL)).thenThrow(new IllegalArgumentException()); + LoadData result = loaderWithMockStorage().buildLoadData(INVALID_GS_URL, 0, 0, new Options()); + assertNull(result); + } + + @Test + public void buildLoadData_illegalStateException_returnsNull() { + when(mStorage.getReferenceFromUrl(VALID_GS_URL)).thenThrow(new IllegalStateException()); + LoadData result = loaderWithMockStorage().buildLoadData(VALID_GS_URL, 0, 0, new Options()); + assertNull(result); + } + + // Factory + + @Test + public void factory_noArg_buildsStringLoader() { + // Verify the no-arg Factory produces a non-null loader without throwing + FirebaseImageLoader.StringLoader loader = + (FirebaseImageLoader.StringLoader) new FirebaseImageLoader.StringLoader.Factory().build(null); + assertNotNull(loader); + } + + @Test + public void factory_withStorage_buildsStringLoaderUsingProvidedInstance() { + FirebaseImageLoader.StringLoader loader = + (FirebaseImageLoader.StringLoader) new FirebaseImageLoader.StringLoader.Factory(mStorage).build(null); + // The provided storage instance should be used — valid URL resolves without exception + LoadData result = loader.buildLoadData(VALID_GS_URL, 0, 0, new Options()); + assertNotNull(result); + } + + private FirebaseImageLoader.StringLoader loaderWithMockStorage() { + return new FirebaseImageLoader.StringLoader(mStorage); + } +} From c59837de81b3a175f7c23ef30b1b8c9dfb4c1e1d Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 17 Jun 2026 12:31:29 +0100 Subject: [PATCH 07/19] sample(storage): add storage image loading demo and modularise app demo structure --- app/build.gradle.kts | 3 + app/src/main/AndroidManifest.xml | 25 +- .../firebaseui/android/demo/MainActivity.kt | 196 ++------------ .../android/demo/auth/AuthChooserActivity.kt | 253 ++++++++++++++++++ .../AuthFlowControllerDemoActivity.kt | 2 +- .../CustomSlotsThemingDemoActivity.kt | 2 +- .../{ => auth}/HighLevelApiDemoActivity.kt | 3 +- .../demo/storage/StorageDemoActivity.kt | 200 ++++++++++++++ .../demo/storage/StorageGlideModule.kt | 26 ++ 9 files changed, 527 insertions(+), 183 deletions(-) create mode 100644 app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/AuthFlowControllerDemoActivity.kt (99%) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/CustomSlotsThemingDemoActivity.kt (99%) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/HighLevelApiDemoActivity.kt (99%) create mode 100644 app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt create mode 100644 app/src/main/java/com/firebaseui/android/demo/storage/StorageGlideModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 542cb8899..74ae1285a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") id("com.google.gms.google-services") + id("kotlin-kapt") } android { @@ -47,6 +48,8 @@ android { dependencies { implementation(project(":auth")) + implementation(project(":storage")) + kapt(Config.Libs.Misc.glideCompiler) implementation(Config.Libs.Kotlin.jvm) implementation(Config.Libs.Androidx.lifecycleRuntime) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7403aaf50..cce54df2a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,14 +19,9 @@ android:theme="@style/Theme.FirebaseUIAndroid"> - - - - - @@ -38,23 +33,37 @@ + + + + + + diff --git a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt index 300014174..1bb6be4e7 100644 --- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt @@ -17,23 +17,19 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.util.EmailLinkConstants +import com.firebaseui.android.demo.auth.AuthChooserActivity +import com.firebaseui.android.demo.auth.HighLevelApiDemoActivity +import com.firebaseui.android.demo.storage.StorageDemoActivity import com.google.firebase.FirebaseApp -/** - * Main launcher activity that allows users to choose between different - * authentication API demonstrations. - */ class MainActivity : ComponentActivity() { companion object { private const val USE_AUTH_EMULATOR = false @@ -45,7 +41,6 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() - // Initialize Firebase and configure emulator if needed FirebaseApp.initializeApp(applicationContext) val authUI = FirebaseAuthUI.getInstance() @@ -54,7 +49,6 @@ class MainActivity : ComponentActivity() { } var pendingEmailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK) - if (pendingEmailLink.isNullOrEmpty() && authUI.canHandleIntent(intent)) { pendingEmailLink = intent.data?.toString() } @@ -62,10 +56,7 @@ class MainActivity : ComponentActivity() { Log.d("MainActivity", "Pending email link: $pendingEmailLink") fun launchHighLevelDemo() { - val demoIntent = Intent( - this, - HighLevelApiDemoActivity::class.java - ).apply { + val demoIntent = Intent(this, HighLevelApiDemoActivity::class.java).apply { pendingEmailLink?.let { link -> putExtra(EmailLinkConstants.EXTRA_EMAIL_LINK, link) pendingEmailLink = null @@ -87,14 +78,12 @@ class MainActivity : ComponentActivity() { color = MaterialTheme.colorScheme.background ) { ChooserScreen( - onHighLevelApiClick = ::launchHighLevelDemo, - onLowLevelApiClick = { - startActivity(Intent(this, AuthFlowControllerDemoActivity::class.java)) - }, - onCustomSlotsClick = { - startActivity(Intent(this, CustomSlotsThemingDemoActivity::class.java)) + onAuthClick = { + startActivity(Intent(this, AuthChooserActivity::class.java)) }, - isEmulatorMode = USE_AUTH_EMULATOR + onStorageClick = { + startActivity(Intent(this, StorageDemoActivity::class.java)) + } ) } } @@ -104,199 +93,62 @@ class MainActivity : ComponentActivity() { @Composable fun ChooserScreen( - onHighLevelApiClick: () -> Unit, - onLowLevelApiClick: () -> Unit, - onCustomSlotsClick: () -> Unit, - isEmulatorMode: Boolean = false + onAuthClick: () -> Unit, + onStorageClick: () -> Unit, ) { - val scrollState = rememberScrollState() - Column( modifier = Modifier .fillMaxSize() - .verticalScroll(scrollState) + .verticalScroll(rememberScrollState()) .systemBarsPadding() .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) - // Header - Text( - text = "Firebase Auth UI Compose", - style = MaterialTheme.typography.headlineLarge, - textAlign = TextAlign.Center - ) + Spacer(modifier = Modifier.height(8.dp)) + Text("FirebaseUI Android", style = MaterialTheme.typography.headlineLarge) Text( - text = "Choose a demo to explore different authentication APIs", + "Choose a module to explore its demos", style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant ) - // Emulator Mode Warning - if (isEmulatorMode) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ) - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "⚠️ Emulator Mode", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onErrorContainer - ) - Text( - text = "Running with Firebase Auth Emulator. Some features like third-party" + - " OAuth providers (Facebook, Twitter, LINE etc.) may not work correctly." + - " Disable Firebase Auth Emulator using" + - " MainActivity.USE_AUTH_EMULATOR = false", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } - } - } - - // High-Level API Card - Card( - modifier = Modifier.fillMaxWidth(), - onClick = onHighLevelApiClick - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = "🎨 High-Level API", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = "FirebaseAuthScreen Composable", - style = MaterialTheme.typography.titleMedium - ) - Text( - text = "Best for: Pure Compose applications that want a complete, ready-to-use authentication UI with minimal setup.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Features:", - style = MaterialTheme.typography.labelLarge - ) - Text( - text = "• Drop-in Composable\n• Automatic navigation\n• State management included\n• Customizable content", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - // Low-Level API Card - Card( - modifier = Modifier.fillMaxWidth(), - onClick = onLowLevelApiClick - ) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onAuthClick) { Column( modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "⚙️ Low-Level API", + text = "Auth", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary ) Text( - text = "AuthFlowController", - style = MaterialTheme.typography.titleMedium - ) - Text( - text = "Best for: Applications that need fine-grained control over the authentication flow with ActivityResultLauncher integration.", + text = "High-Level API, Low-Level API, Custom Slots & Theming", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Features:", - style = MaterialTheme.typography.labelLarge - ) - Text( - text = "• Lifecycle-safe controller\n• ActivityResultLauncher\n• Observable state with Flow\n• Manual flow control", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } } - // Custom Slots & Theming Card - Card( - modifier = Modifier.fillMaxWidth(), - onClick = onCustomSlotsClick - ) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onStorageClick) { Column( modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "🎨 Custom Slots & Theming", + text = "Storage", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary ) Text( - text = "Slot APIs & Theme Customization", - style = MaterialTheme.typography.titleMedium - ) - Text( - text = "Best for: Applications that need fully custom UI while leveraging the authentication logic and state management.", + text = "Loading images from Firebase Storage with Glide", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Features:", - style = MaterialTheme.typography.labelLarge - ) - Text( - text = "• Custom email auth UI via slots\n• Custom phone auth UI via slots\n• AuthUITheme.fromMaterialTheme()\n• Custom ProviderStyle examples", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // Info card - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer - ) - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "💡 Tip", - style = MaterialTheme.typography.labelLarge - ) - Text( - text = "Both APIs provide the same authentication capabilities. Choose based on your app's architecture and control requirements.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) } } diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt new file mode 100644 index 000000000..488233e9d --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt @@ -0,0 +1,253 @@ +package com.firebaseui.android.demo.auth + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +class AuthChooserActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + AuthChooserScreen( + onHighLevelApiClick = { + startActivity(Intent(this, HighLevelApiDemoActivity::class.java)) + }, + onLowLevelApiClick = { + startActivity(Intent(this, AuthFlowControllerDemoActivity::class.java)) + }, + onCustomSlotsClick = { + startActivity(Intent(this, CustomSlotsThemingDemoActivity::class.java)) + } + ) + } + } + } + } +} + +@Composable +fun AuthChooserScreen( + onHighLevelApiClick: () -> Unit, + onLowLevelApiClick: () -> Unit, + onCustomSlotsClick: () -> Unit, + isEmulatorMode: Boolean = false +) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .systemBarsPadding() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + // Header + Text( + text = "Firebase Auth UI Compose", + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center + ) + + Text( + text = "Choose a demo to explore different authentication APIs", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Emulator Mode Warning + if (isEmulatorMode) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "⚠️ Emulator Mode", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = "Running with Firebase Auth Emulator. Some features like third-party" + + " OAuth providers (Facebook, Twitter, LINE etc.) may not work correctly." + + " Disable Firebase Auth Emulator using" + + " MainActivity.USE_AUTH_EMULATOR = false", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + + // High-Level API Card + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onHighLevelApiClick + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "🎨 High-Level API", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "FirebaseAuthScreen Composable", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Best for: Pure Compose applications that want a complete, ready-to-use authentication UI with minimal setup.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Features:", + style = MaterialTheme.typography.labelLarge + ) + Text( + text = "• Drop-in Composable\n• Automatic navigation\n• State management included\n• Customizable content", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Low-Level API Card + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onLowLevelApiClick + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "⚙️ Low-Level API", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "AuthFlowController", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Best for: Applications that need fine-grained control over the authentication flow with ActivityResultLauncher integration.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Features:", + style = MaterialTheme.typography.labelLarge + ) + Text( + text = "• Lifecycle-safe controller\n• ActivityResultLauncher\n• Observable state with Flow\n• Manual flow control", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Custom Slots & Theming Card + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onCustomSlotsClick + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "🎨 Custom Slots & Theming", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Slot APIs & Theme Customization", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Best for: Applications that need fully custom UI while leveraging the authentication logic and state management.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Features:", + style = MaterialTheme.typography.labelLarge + ) + Text( + text = "• Custom email auth UI via slots\n• Custom phone auth UI via slots\n• AuthUITheme.fromMaterialTheme()\n• Custom ProviderStyle examples", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Info card + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "💡 Tip", + style = MaterialTheme.typography.labelLarge + ) + Text( + text = "Both APIs provide the same authentication capabilities. Choose based on your app's architecture and control requirements.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/AuthFlowControllerDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt similarity index 99% rename from app/src/main/java/com/firebaseui/android/demo/AuthFlowControllerDemoActivity.kt rename to app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt index ed6de15a6..7d82c1a18 100644 --- a/app/src/main/java/com/firebaseui/android/demo/AuthFlowControllerDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt @@ -1,4 +1,4 @@ -package com.firebaseui.android.demo +package com.firebaseui.android.demo.auth import android.app.Activity import android.content.Context diff --git a/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt similarity index 99% rename from app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt rename to app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt index 00b0054f0..e03e545a6 100644 --- a/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt @@ -1,4 +1,4 @@ -package com.firebaseui.android.demo +package com.firebaseui.android.demo.auth import android.os.Bundle import android.util.Log diff --git a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt similarity index 99% rename from app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt rename to app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt index fcae3ea9c..f0a05fe63 100644 --- a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt @@ -1,4 +1,4 @@ -package com.firebaseui.android.demo +package com.firebaseui.android.demo.auth import android.os.Bundle import android.util.Log @@ -42,6 +42,7 @@ import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen import com.firebase.ui.auth.util.EmailLinkConstants +import com.firebaseui.android.demo.R import com.google.firebase.auth.actionCodeSettings class HighLevelApiDemoActivity : ComponentActivity() { diff --git a/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt new file mode 100644 index 000000000..8faa4c35e --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt @@ -0,0 +1,200 @@ +package com.firebaseui.android.demo.storage + +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.widget.ImageView +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.google.firebase.storage.FirebaseStorage + +class StorageDemoActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + StorageDemoScreen() + } + } + } + } +} + +@Composable +fun StorageDemoScreen() { + var gsUrl by remember { mutableStateOf("gs://your-project.appspot.com/path/to/image.png") } + var stringStatus by remember { mutableStateOf("Not loaded") } + var stringLoadKey by remember { mutableIntStateOf(0) } + var refStatus by remember { mutableStateOf("Not loaded") } + var refLoadKey by remember { mutableIntStateOf(0) } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .systemBarsPadding() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Firebase Storage + Glide", style = MaterialTheme.typography.headlineSmall) + Text( + "Enter a gs:// URL and load it using either approach below.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + OutlinedTextField( + value = gsUrl, + onValueChange = { gsUrl = it }, + label = { Text("gs:// URL") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Approach 1: gs:// string via StringLoader + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Via gs:// String", style = MaterialTheme.typography.titleMedium) + Text( + "Uses FirebaseImageLoader.StringLoader, registered in StorageGlideModule. " + + "Pass the gs:// URL string directly to Glide.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button(onClick = { + stringStatus = "Loading..." + stringLoadKey++ + }) { Text("Load") } + StatusText(stringStatus) + AndroidView( + factory = { ImageView(it) }, + update = { view -> + if (stringLoadKey > 0) { + GlideApp.with(view) + .load(gsUrl) + .listener(glideListener { success, error -> + stringStatus = if (success) "Loaded" else "Error: $error" + }) + .into(view) + } + }, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + ) + } + } + + // Approach 2: StorageReference + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Via StorageReference", style = MaterialTheme.typography.titleMedium) + Text( + "Converts the gs:// URL to a StorageReference first, then passes it to Glide. " + + "Handled by FirebaseImageLoader.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button(onClick = { + refStatus = "Loading..." + refLoadKey++ + }) { Text("Load") } + StatusText(refStatus) + AndroidView( + factory = { ImageView(it) }, + update = { view -> + if (refLoadKey > 0) { + runCatching { + FirebaseStorage.getInstance().getReferenceFromUrl(gsUrl) + }.onSuccess { ref -> + GlideApp.with(view) + .load(ref) + .listener(glideListener { success, error -> + refStatus = if (success) "Loaded" else "Error: $error" + }) + .into(view) + }.onFailure { e -> + refStatus = "Invalid URL: ${e.message}" + } + } + }, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + ) + } + } + } +} + +@Composable +private fun StatusText(status: String) { + Text( + text = "Status: $status", + style = MaterialTheme.typography.bodySmall, + color = if (status.startsWith("Error") || status.startsWith("Invalid")) + MaterialTheme.colorScheme.error + else + MaterialTheme.colorScheme.onSurface + ) +} + +private fun glideListener(onResult: (success: Boolean, error: String?) -> Unit) = + object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + onResult(false, e?.message ?: "Unknown error") + return false + } + + override fun onResourceReady( + resource: Drawable, + model: Any, + target: Target, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + onResult(true, null) + return false + } + } diff --git a/app/src/main/java/com/firebaseui/android/demo/storage/StorageGlideModule.kt b/app/src/main/java/com/firebaseui/android/demo/storage/StorageGlideModule.kt new file mode 100644 index 000000000..499dfe253 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/storage/StorageGlideModule.kt @@ -0,0 +1,26 @@ +package com.firebaseui.android.demo.storage + +import android.content.Context +import com.bumptech.glide.Glide +import com.bumptech.glide.Registry +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.module.AppGlideModule +import com.firebase.ui.storage.images.FirebaseImageLoader +import com.google.firebase.storage.StorageReference +import java.io.InputStream + +@GlideModule +class StorageGlideModule : AppGlideModule() { + override fun registerComponents(context: Context, glide: Glide, registry: Registry) { + registry.append( + StorageReference::class.java, + InputStream::class.java, + FirebaseImageLoader.Factory() + ) + registry.append( + String::class.java, + InputStream::class.java, + FirebaseImageLoader.StringLoader.Factory() + ) + } +} From e810c1b6092fc8544948bc0106c484bdb63909ea Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 17 Jun 2026 12:46:21 +0100 Subject: [PATCH 08/19] feat(storage): update StorageDemoScreen to include placeholder for gs:// URL input --- .../com/firebaseui/android/demo/storage/StorageDemoActivity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt index 8faa4c35e..74b034654 100644 --- a/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt @@ -52,7 +52,7 @@ class StorageDemoActivity : ComponentActivity() { @Composable fun StorageDemoScreen() { - var gsUrl by remember { mutableStateOf("gs://your-project.appspot.com/path/to/image.png") } + var gsUrl by remember { mutableStateOf("") } var stringStatus by remember { mutableStateOf("Not loaded") } var stringLoadKey by remember { mutableIntStateOf(0) } var refStatus by remember { mutableStateOf("Not loaded") } @@ -77,6 +77,7 @@ fun StorageDemoScreen() { value = gsUrl, onValueChange = { gsUrl = it }, label = { Text("gs:// URL") }, + placeholder = { Text("gs://your-project.appspot.com/path/to/image.png") }, modifier = Modifier.fillMaxWidth(), singleLine = true ) From 24dc53f87dbad729177253fdbf8a7488ff45361a Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 17 Jun 2026 18:05:57 +0100 Subject: [PATCH 09/19] sample(database): add database paging demo to app module --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 7 + .../firebaseui/android/demo/MainActivity.kt | 23 +++ .../demo/database/DatabaseDemoActivity.kt | 131 ++++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 74ae1285a..c8a428794 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,7 +48,9 @@ android { dependencies { implementation(project(":auth")) + implementation(project(":database")) implementation(project(":storage")) + implementation(Config.Libs.Androidx.paging) kapt(Config.Libs.Misc.glideCompiler) implementation(Config.Libs.Kotlin.jvm) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cce54df2a..96e3af4d6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,6 +58,13 @@ android:exported="false" android:theme="@style/Theme.FirebaseUIAndroid" /> + + + Unit, + onDatabaseClick: () -> Unit, onStorageClick: () -> Unit, ) { Column( @@ -131,6 +136,24 @@ fun ChooserScreen( } } + Card(modifier = Modifier.fillMaxWidth(), onClick = onDatabaseClick) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Database", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Paginated list with FirebaseRecyclerPagingAdapter and orderByChild", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Card(modifier = Modifier.fillMaxWidth(), onClick = onStorageClick) { Column( modifier = Modifier.padding(20.dp), diff --git a/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt new file mode 100644 index 000000000..7dc122b33 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt @@ -0,0 +1,131 @@ +package com.firebaseui.android.demo.database + +import android.os.Bundle +import android.view.ViewGroup +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.paging.PagingConfig +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.firebase.ui.database.paging.DatabasePagingOptions +import com.firebase.ui.database.paging.FirebaseRecyclerPagingAdapter +import com.google.firebase.database.DatabaseReference +import com.google.firebase.database.FirebaseDatabase + +class DatabaseDemoActivity : ComponentActivity() { + + private lateinit var adapter: ScoreAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val ref = FirebaseDatabase.getInstance().reference.child("database_demo") + + val options = DatabasePagingOptions.Builder() + .setLifecycleOwner(this) + .setQuery(ref.orderByChild("score"), PagingConfig(pageSize = 10), ScoreItem::class.java) + .build() + + adapter = ScoreAdapter(options) + + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + DatabaseDemoScreen( + adapter = adapter, + onSeedData = { seedData(ref) }, + onRefresh = { adapter.refresh() } + ) + } + } + } + } + + private fun seedData(ref: DatabaseReference) { + repeat(20) { i -> + ref.push().setValue(ScoreItem("Item ${i + 1}", (1..100).random())) + } + } +} + +data class ScoreItem(var name: String = "", var score: Int = 0) + +class ScoreViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder( + TextView(parent.context).apply { + layoutParams = RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.WRAP_CONTENT + ) + setPadding(48, 24, 48, 24) + textSize = 16f + } +) + +class ScoreAdapter(options: DatabasePagingOptions) : + FirebaseRecyclerPagingAdapter(options) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ScoreViewHolder(parent) + + override fun onBindViewHolder(holder: ScoreViewHolder, position: Int, model: ScoreItem) { + (holder.itemView as TextView).text = "${model.name} — score: ${model.score}" + } +} + +@Composable +fun DatabaseDemoScreen( + adapter: ScoreAdapter, + onSeedData: () -> Unit, + onRefresh: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Firebase Database Paging", style = MaterialTheme.typography.headlineSmall) + Text( + "Paginated list using FirebaseRecyclerPagingAdapter with orderByChild(\"score\").", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onSeedData) { Text("Seed data") } + OutlinedButton(onClick = onRefresh) { Text("Refresh") } + } + + AndroidView( + factory = { context -> + RecyclerView(context).apply { + layoutManager = LinearLayoutManager(context) + addItemDecoration( + DividerItemDecoration(context, DividerItemDecoration.VERTICAL) + ) + setAdapter(adapter) + } + }, + modifier = Modifier.fillMaxSize() + ) + } +} From c5b109b660d35c1f6b7bae57fe0d1ccbd858a2c2 Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 17 Jun 2026 20:11:15 +0100 Subject: [PATCH 10/19] sample(database): add page counter and seed 50 items for visible pagination --- .../demo/database/DatabaseDemoActivity.kt | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt index 7dc122b33..46ea1923e 100644 --- a/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.Button @@ -18,21 +19,29 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.paging.LoadState import androidx.paging.PagingConfig import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.firebase.ui.database.paging.DatabasePagingOptions import com.firebase.ui.database.paging.FirebaseRecyclerPagingAdapter +import com.google.firebase.auth.FirebaseAuth import com.google.firebase.database.DatabaseReference import com.google.firebase.database.FirebaseDatabase class DatabaseDemoActivity : ComponentActivity() { private lateinit var adapter: ScoreAdapter + private var currentPage by mutableIntStateOf(1) + private var prevAppendWasLoading = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -47,12 +56,26 @@ class DatabaseDemoActivity : ComponentActivity() { adapter = ScoreAdapter(options) + adapter.addLoadStateListener { states -> + if (states.refresh is LoadState.Loading) { + currentPage = 1 + prevAppendWasLoading = false + return@addLoadStateListener + } + val appendLoading = states.append is LoadState.Loading + if (prevAppendWasLoading && !appendLoading) { + currentPage++ + } + prevAppendWasLoading = appendLoading + } + setContent { MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { DatabaseDemoScreen( adapter = adapter, - onSeedData = { seedData(ref) }, + currentPage = currentPage, + onSeedData = { signInThenSeed(ref) }, onRefresh = { adapter.refresh() } ) } @@ -60,8 +83,18 @@ class DatabaseDemoActivity : ComponentActivity() { } } + private fun signInThenSeed(ref: DatabaseReference) { + val auth = FirebaseAuth.getInstance() + val signIn = if (auth.currentUser != null) { + com.google.android.gms.tasks.Tasks.forResult(null) + } else { + auth.signInAnonymously() + } + signIn.addOnSuccessListener { seedData(ref) } + } + private fun seedData(ref: DatabaseReference) { - repeat(20) { i -> + repeat(50) { i -> ref.push().setValue(ScoreItem("Item ${i + 1}", (1..100).random())) } } @@ -93,6 +126,7 @@ class ScoreAdapter(options: DatabasePagingOptions) : @Composable fun DatabaseDemoScreen( adapter: ScoreAdapter, + currentPage: Int, onSeedData: () -> Unit, onRefresh: () -> Unit, ) { @@ -111,10 +145,22 @@ fun DatabaseDemoScreen( ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onSeedData) { Text("Seed data") } + Button(onClick = onSeedData) { Text("Authenticate & Seed Data") } OutlinedButton(onClick = onRefresh) { Text("Refresh") } } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Page: $currentPage", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + AndroidView( factory = { context -> RecyclerView(context).apply { @@ -125,7 +171,9 @@ fun DatabaseDemoScreen( setAdapter(adapter) } }, - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxWidth() + .weight(1f) ) } } From d382bd5b94b9a783ed4dd394fea659dc759c9cba Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:53:30 +0100 Subject: [PATCH 11/19] fix(auth): validate display name only when required in sign-up form (#2383) * fix(auth): validate display name only when required in sign-up form * fix(auth): use locale-safe matcher for sign-up button in test --- .../ui/auth/ui/screens/email/SignUpUI.kt | 2 +- .../ui/auth/ui/screens/email/SignUpUITest.kt | 115 ++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt index dfc413bc6..6ff0d19f4 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt @@ -95,7 +95,7 @@ fun SignUpUI( val isFormValid = remember(displayName, email, password, confirmPassword) { derivedStateOf { listOf( - displayNameValidator.validate(displayName), + !provider.isDisplayNameRequired || displayNameValidator.validate(displayName), emailValidator.validate(email), passwordValidator.validate(password), confirmPasswordValidator.validate(confirmPassword) diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt new file mode 100644 index 000000000..ac642adb7 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.email + +import android.content.Context +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [SignUpUI], covering form validity logic. + * + * @suppress Internal test class + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class SignUpUITest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var applicationContext: Context + private lateinit var stringProvider: AuthUIStringProvider + + @Before + fun setUp() { + applicationContext = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(applicationContext) + } + + @Test + fun `sign up button becomes enabled when display name is not required and hidden`() { + val provider = AuthProvider.Email( + isDisplayNameRequired = false, + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + } + + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + + SignUpUI( + configuration = configuration, + isLoading = false, + displayName = "", + email = email, + password = password, + confirmPassword = confirmPassword, + onDisplayNameChange = { }, + onEmailChange = { email = it }, + onPasswordChange = { password = it }, + onConfirmPasswordChange = { confirmPassword = it }, + onGoToSignIn = { }, + onSignUpClick = { } + ) + } + } + + // Name field should not be rendered since it isn't required. + composeTestRule.onNodeWithText(stringProvider.nameHint).assertDoesNotExist() + + composeTestRule.onNodeWithText(stringProvider.emailHint) + .performTextInput("test@example.com") + composeTestRule.onNodeWithText(stringProvider.passwordHint) + .performTextInput("Password123") + composeTestRule.onNodeWithText(stringProvider.confirmPasswordHint) + .performTextInput("Password123") + + composeTestRule.waitForIdle() + + // With email/password/confirmPassword all valid and no display name required, + // the sign up button should be enabled even though displayName is still "". + composeTestRule.onNode(hasText(stringProvider.signupPageTitle.uppercase()) and hasClickAction()) + .assertIsEnabled() + } +} From 7280905ee3f7ac4ef4a8bad2be94ceeea5a8dc83 Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 20 Jul 2026 14:20:17 +0100 Subject: [PATCH 12/19] fix(firestore): catch InterruptedException in FirestorePagingSource --- .../paging/FirestorePagingSourceTest.java | 31 +++++++++++++++++++ .../paging/FirestorePagingSource.java | 12 ++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java b/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java index 3cec97075..3f28ae451 100644 --- a/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java +++ b/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java @@ -1,5 +1,6 @@ package com.firebase.ui.firestore.paging; +import com.google.android.gms.tasks.TaskCompletionSource; import com.google.android.gms.tasks.Tasks; import com.google.firebase.firestore.DocumentSnapshot; import com.google.firebase.firestore.Query; @@ -14,12 +15,15 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import androidx.paging.PagingSource; import androidx.paging.PagingSource.LoadParams.Append; import androidx.paging.PagingSource.LoadParams.Refresh; import androidx.paging.PagingSource.LoadResult.Page; import androidx.test.ext.junit.runners.AndroidJUnit4; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.plugins.RxJavaPlugins; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -105,6 +109,33 @@ public void testLoadAfter_failure() { assertEquals(expected, actual); } + @Test + public void testLoadSingle_interruptedWhileDisposed_doesNotThrowUndeliverableException() + throws InterruptedException { + FirestorePagingSource pagingSource = new FirestorePagingSource(mMockQuery, Source.DEFAULT); + TaskCompletionSource taskCompletionSource = new TaskCompletionSource<>(); + when(mMockQuery.get(Source.DEFAULT)).thenReturn(taskCompletionSource.getTask()); + + List undeliverableErrors = new CopyOnWriteArrayList<>(); + RxJavaPlugins.setErrorHandler(undeliverableErrors::add); + try { + Refresh refreshRequest = new Refresh<>(null, 2, false); + Disposable disposable = pagingSource.loadSingle(refreshRequest) + .subscribe(result -> { }, error -> { }); + + Thread.sleep(300); + disposable.dispose(); + Thread.sleep(300); + + assertTrue( + "Interruption during an in-flight load must not surface as an " + + "undeliverable RxJava exception: " + undeliverableErrors, + undeliverableErrors.isEmpty()); + } finally { + RxJavaPlugins.setErrorHandler(null); + } + } + private void initMockQuery() { when(mMockQuery.startAfter(any(DocumentSnapshot.class))).thenReturn(mMockQuery); when(mMockQuery.endBefore(any(DocumentSnapshot.class))).thenReturn(mMockQuery); 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 index d10dd2b1b..f23301669 100644 --- a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java @@ -1,5 +1,7 @@ package com.firebase.ui.firestore.paging; +import android.util.Log; + import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.Tasks; import com.google.firebase.firestore.DocumentSnapshot; @@ -19,6 +21,8 @@ public class FirestorePagingSource extends RxPagingSource { + private static final String TAG = "FirestorePagingSource"; + private final Query mQuery; private final Source mSource; @@ -54,8 +58,14 @@ public Single> loadSingle(@NonNull LoadPar // 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(e); } - }).subscribeOn(Schedulers.io()).onErrorReturn(LoadResult.Error::new); + }).subscribeOn(Schedulers.io()).onErrorReturn(e -> { + Log.e(TAG, "FirestorePagingSource load failed", e); + return new LoadResult.Error<>(e); + }); } private LoadResult toLoadResult( From 3b117f85aa8103fca05594a9254feda6a66d03e5 Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 20 Jul 2026 19:01:17 +0100 Subject: [PATCH 13/19] chore(app): pin glide to 5.0.7 for compileSdk 36 compatibility --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d9f030cdb..83a6e27b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,7 +33,7 @@ material = "1.14.0" paging = "3.5.0" recyclerview = "1.4.0" -glide = "5.0.9" +glide = "5.0.7" lint = "30.0.0" junit = "4.13.2" From 3d01b8a32567910f61436c94d26a87978e990556 Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 20 Jul 2026 19:16:32 +0100 Subject: [PATCH 14/19] sample(firestore): add firestore paging demo to app module --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 7 + .../firebaseui/android/demo/MainActivity.kt | 23 ++ .../demo/firestore/FirestoreDemoActivity.kt | 199 ++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 45d87922b..bb74b0a75 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,6 +55,7 @@ kotlin { dependencies { implementation(project(":auth")) implementation(project(":database")) + implementation(project(":firestore")) implementation(project(":storage")) implementation(libs.androidx.paging) kapt(libs.glide.compiler) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9631e7ec0..c155a8d5a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -95,6 +95,13 @@ android:exported="false" android:theme="@style/Theme.FirebaseUIAndroid" /> + + + Unit, onDatabaseClick: () -> Unit, + onFirestoreClick: () -> Unit, onStorageClick: () -> Unit, ) { Column( @@ -154,6 +159,24 @@ fun ChooserScreen( } } + Card(modifier = Modifier.fillMaxWidth(), onClick = onFirestoreClick) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Firestore", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Paginated list with FirestorePagingAdapter and orderBy", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Card(modifier = Modifier.fillMaxWidth(), onClick = onStorageClick) { Column( modifier = Modifier.padding(20.dp), diff --git a/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt new file mode 100644 index 000000000..1743b019f --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt @@ -0,0 +1,199 @@ +package com.firebaseui.android.demo.firestore + +import android.os.Bundle +import android.view.ViewGroup +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.paging.LoadState +import androidx.paging.PagingConfig +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.firebase.ui.firestore.paging.FirestorePagingAdapter +import com.firebase.ui.firestore.paging.FirestorePagingOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.Query + +class FirestoreDemoActivity : ComponentActivity() { + + companion object { + // Point the app at a local Firestore emulator (firebase emulators:start). + // 10.0.2.2 is the Android emulator's alias for the host machine's localhost. + private const val USE_FIRESTORE_EMULATOR = true + private const val FIRESTORE_EMULATOR_HOST = "10.0.2.2" + private const val FIRESTORE_EMULATOR_PORT = 8080 + + private var emulatorConfigured = false + } + + private lateinit var adapter: ScoreAdapter + private var currentPage by mutableIntStateOf(1) + private var prevAppendWasLoading = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val firestore = FirebaseFirestore.getInstance() + if (USE_FIRESTORE_EMULATOR && !emulatorConfigured) { + firestore.useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT) + emulatorConfigured = true + } + + val collection = firestore.collection("firestore_demo") + + // Query must only contain where()/orderBy() — the paging library adds limit(). + val query: Query = collection.orderBy("score") + + val options = FirestorePagingOptions.Builder() + .setLifecycleOwner(this) + .setQuery(query, PagingConfig(pageSize = 10), ScoreItem::class.java) + .build() + + adapter = ScoreAdapter(options) + + adapter.addLoadStateListener { states -> + if (states.refresh is LoadState.Loading) { + currentPage = 1 + prevAppendWasLoading = false + return@addLoadStateListener + } + val appendLoading = states.append is LoadState.Loading + if (prevAppendWasLoading && !appendLoading) { + currentPage++ + } + prevAppendWasLoading = appendLoading + } + + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + FirestoreDemoScreen( + adapter = adapter, + currentPage = currentPage, + onSeedData = { signInThenSeed(collection) }, + onRefresh = { adapter.refresh() } + ) + } + } + } + } + + private fun signInThenSeed(collection: CollectionReference) { + val auth = FirebaseAuth.getInstance() + val signIn = if (auth.currentUser != null) { + com.google.android.gms.tasks.Tasks.forResult(null) + } else { + auth.signInAnonymously() + } + signIn.addOnSuccessListener { seedData(collection) } + } + + private fun seedData(collection: CollectionReference) { + for (i in 1..50) { + collection.add(ScoreItem("Item $i", (1..100).random())) + } + } +} + +data class ScoreItem(var name: String = "", var score: Int = 0) + +class ScoreViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder( + TextView(parent.context).apply { + layoutParams = RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.WRAP_CONTENT + ) + setPadding(48, 24, 48, 24) + textSize = 16f + } +) + +class ScoreAdapter(options: FirestorePagingOptions) : + FirestorePagingAdapter(options) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ScoreViewHolder(parent) + + override fun onBindViewHolder(holder: ScoreViewHolder, position: Int, model: ScoreItem) { + (holder.itemView as TextView).text = "${model.name} — score: ${model.score}" + } +} + +@Composable +fun FirestoreDemoScreen( + adapter: ScoreAdapter, + currentPage: Int, + onSeedData: () -> Unit, + onRefresh: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Firebase Firestore Paging", style = MaterialTheme.typography.headlineSmall) + Text( + "Paginated list using FirestorePagingAdapter with orderBy(\"score\").", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onSeedData) { Text("Authenticate & Seed Data") } + OutlinedButton(onClick = onRefresh) { Text("Refresh") } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Page: $currentPage", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + AndroidView( + factory = { context -> + RecyclerView(context).apply { + layoutManager = LinearLayoutManager(context) + addItemDecoration( + DividerItemDecoration(context, DividerItemDecoration.VERTICAL) + ) + setAdapter(adapter) + } + }, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + } +} From 55f08a5c6cbbfc9de5b86a8ca1ddde30508427cf Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 20 Jul 2026 19:42:39 +0100 Subject: [PATCH 15/19] sample(app): move auth demos into auth subpackage and add firestore emulator toggle --- app/src/main/AndroidManifest.xml | 10 +++++----- .../firebaseui/android/demo/MainActivity.kt | 13 ++++++++++++- .../android/demo/auth/AuthChooserActivity.kt | 1 - .../CredentialLinkingDemoActivity.kt | 2 +- .../CustomMethodPickerDemoActivity.kt | 3 ++- .../auth/CustomSlotsThemingDemoActivity.kt | 4 ---- .../{ => auth}/EmailAuthSlotDemoActivity.kt | 2 +- .../{ => auth}/PhoneAuthSlotDemoActivity.kt | 2 +- .../ShapeCustomizationDemoActivity.kt | 2 +- .../demo/firestore/FirestoreDemoActivity.kt | 18 +----------------- e2eTest/firebase.json | 6 ++++++ 11 files changed, 30 insertions(+), 33 deletions(-) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/CredentialLinkingDemoActivity.kt (99%) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/CustomMethodPickerDemoActivity.kt (99%) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/EmailAuthSlotDemoActivity.kt (99%) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/PhoneAuthSlotDemoActivity.kt (99%) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/ShapeCustomizationDemoActivity.kt (99%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c155a8d5a..4bc5abcfe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -59,31 +59,31 @@ android:theme="@style/Theme.FirebaseUIAndroid" /> diff --git a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt index 3a3348a66..e8e179b05 100644 --- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt @@ -31,12 +31,18 @@ import com.firebaseui.android.demo.database.DatabaseDemoActivity import com.firebaseui.android.demo.firestore.FirestoreDemoActivity import com.firebaseui.android.demo.storage.StorageDemoActivity import com.google.firebase.FirebaseApp +import com.google.firebase.firestore.FirebaseFirestore class MainActivity : ComponentActivity() { companion object { - private const val USE_AUTH_EMULATOR = false + private const val USE_AUTH_EMULATOR = true private const val AUTH_EMULATOR_HOST = "10.0.2.2" private const val AUTH_EMULATOR_PORT = 9099 + + // 10.0.2.2 is the Android emulator's alias for the host machine's localhost. + private const val USE_FIRESTORE_EMULATOR = true + private const val FIRESTORE_EMULATOR_HOST = "10.0.2.2" + private const val FIRESTORE_EMULATOR_PORT = 8080 } override fun onCreate(savedInstanceState: Bundle?) { @@ -50,6 +56,11 @@ class MainActivity : ComponentActivity() { authUI.auth.useEmulator(AUTH_EMULATOR_HOST, AUTH_EMULATOR_PORT) } + if (USE_FIRESTORE_EMULATOR) { + FirebaseFirestore.getInstance() + .useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT) + } + var pendingEmailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK) if (pendingEmailLink.isNullOrEmpty() && authUI.canHandleIntent(intent)) { pendingEmailLink = intent.data?.toString() diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt index 1818c8b3d..ae7f7913b 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.firebaseui.android.demo.CredentialLinkingDemoActivity class AuthChooserActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/com/firebaseui/android/demo/CredentialLinkingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/CredentialLinkingDemoActivity.kt similarity index 99% rename from app/src/main/java/com/firebaseui/android/demo/CredentialLinkingDemoActivity.kt rename to app/src/main/java/com/firebaseui/android/demo/auth/CredentialLinkingDemoActivity.kt index c901084cc..afced8665 100644 --- a/app/src/main/java/com/firebaseui/android/demo/CredentialLinkingDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/CredentialLinkingDemoActivity.kt @@ -1,4 +1,4 @@ -package com.firebaseui.android.demo +package com.firebaseui.android.demo.auth import android.os.Bundle import androidx.activity.ComponentActivity diff --git a/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/CustomMethodPickerDemoActivity.kt similarity index 99% rename from app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt rename to app/src/main/java/com/firebaseui/android/demo/auth/CustomMethodPickerDemoActivity.kt index 54eadccc3..fd2e60aa6 100644 --- a/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/CustomMethodPickerDemoActivity.kt @@ -1,7 +1,8 @@ -package com.firebaseui.android.demo +package com.firebaseui.android.demo.auth import android.os.Bundle import android.util.Log +import com.firebaseui.android.demo.R import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt index 1d752308d..df069091f 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt @@ -22,10 +22,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.firebaseui.android.demo.CustomMethodPickerDemoActivity -import com.firebaseui.android.demo.EmailAuthSlotDemoActivity -import com.firebaseui.android.demo.PhoneAuthSlotDemoActivity -import com.firebaseui.android.demo.ShapeCustomizationDemoActivity class CustomSlotsThemingDemoActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/com/firebaseui/android/demo/EmailAuthSlotDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/EmailAuthSlotDemoActivity.kt similarity index 99% rename from app/src/main/java/com/firebaseui/android/demo/EmailAuthSlotDemoActivity.kt rename to app/src/main/java/com/firebaseui/android/demo/auth/EmailAuthSlotDemoActivity.kt index cb9605621..5c5e6e13c 100644 --- a/app/src/main/java/com/firebaseui/android/demo/EmailAuthSlotDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/EmailAuthSlotDemoActivity.kt @@ -1,4 +1,4 @@ -package com.firebaseui.android.demo +package com.firebaseui.android.demo.auth import android.os.Bundle import android.util.Log diff --git a/app/src/main/java/com/firebaseui/android/demo/PhoneAuthSlotDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/PhoneAuthSlotDemoActivity.kt similarity index 99% rename from app/src/main/java/com/firebaseui/android/demo/PhoneAuthSlotDemoActivity.kt rename to app/src/main/java/com/firebaseui/android/demo/auth/PhoneAuthSlotDemoActivity.kt index 9639beefb..a36c567b5 100644 --- a/app/src/main/java/com/firebaseui/android/demo/PhoneAuthSlotDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/PhoneAuthSlotDemoActivity.kt @@ -1,4 +1,4 @@ -package com.firebaseui.android.demo +package com.firebaseui.android.demo.auth import android.os.Bundle import android.util.Log diff --git a/app/src/main/java/com/firebaseui/android/demo/ShapeCustomizationDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/ShapeCustomizationDemoActivity.kt similarity index 99% rename from app/src/main/java/com/firebaseui/android/demo/ShapeCustomizationDemoActivity.kt rename to app/src/main/java/com/firebaseui/android/demo/auth/ShapeCustomizationDemoActivity.kt index 5faba7336..77419b210 100644 --- a/app/src/main/java/com/firebaseui/android/demo/ShapeCustomizationDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/ShapeCustomizationDemoActivity.kt @@ -1,4 +1,4 @@ -package com.firebaseui.android.demo +package com.firebaseui.android.demo.auth import android.os.Bundle import androidx.activity.ComponentActivity diff --git a/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt index 1743b019f..5c379ed0b 100644 --- a/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt @@ -40,16 +40,6 @@ import com.google.firebase.firestore.Query class FirestoreDemoActivity : ComponentActivity() { - companion object { - // Point the app at a local Firestore emulator (firebase emulators:start). - // 10.0.2.2 is the Android emulator's alias for the host machine's localhost. - private const val USE_FIRESTORE_EMULATOR = true - private const val FIRESTORE_EMULATOR_HOST = "10.0.2.2" - private const val FIRESTORE_EMULATOR_PORT = 8080 - - private var emulatorConfigured = false - } - private lateinit var adapter: ScoreAdapter private var currentPage by mutableIntStateOf(1) private var prevAppendWasLoading = false @@ -58,13 +48,7 @@ class FirestoreDemoActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() - val firestore = FirebaseFirestore.getInstance() - if (USE_FIRESTORE_EMULATOR && !emulatorConfigured) { - firestore.useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT) - emulatorConfigured = true - } - - val collection = firestore.collection("firestore_demo") + val collection = FirebaseFirestore.getInstance().collection("firestore_demo") // Query must only contain where()/orderBy() — the paging library adds limit(). val query: Query = collection.orderBy("score") diff --git a/e2eTest/firebase.json b/e2eTest/firebase.json index 2fb2a16b0..23ea2b1f1 100644 --- a/e2eTest/firebase.json +++ b/e2eTest/firebase.json @@ -3,6 +3,12 @@ "auth": { "port": 9099 }, + "firestore": { + "port": 8080 + }, + "database": { + "port": 8199 + }, "ui": { "enabled": true }, From d53e1e9585341893aee36e962cd1f28d5ccf4c7f Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 20 Jul 2026 21:09:11 +0100 Subject: [PATCH 16/19] sample(firestore): log interrupted paging load at warn level --- .../com/firebase/ui/firestore/paging/FirestorePagingSource.java | 1 + 1 file changed, 1 insertion(+) 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 index f23301669..11d858a8a 100644 --- a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java @@ -60,6 +60,7 @@ public Single> loadSingle(@NonNull LoadPar throw new Exception(e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); + Log.w(TAG, "FirestorePagingSource load interrupted", e); return new LoadResult.Error(e); } }).subscribeOn(Schedulers.io()).onErrorReturn(e -> { From 25f585a1da8dde5f693eb0254b565a776c382d0d Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 21 Jul 2026 13:55:19 +0100 Subject: [PATCH 17/19] sample(app): address gemini review feedback on demo app --- .../firebaseui/android/demo/MainActivity.kt | 12 ++++++++- .../android/demo/auth/AuthChooserActivity.kt | 4 ++- .../demo/database/DatabaseDemoActivity.kt | 5 +++- .../demo/firestore/FirestoreDemoActivity.kt | 10 ++++++-- .../demo/storage/StorageDemoActivity.kt | 25 +++++++++++-------- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt index e8e179b05..8faa13f7e 100644 --- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt @@ -31,11 +31,12 @@ import com.firebaseui.android.demo.database.DatabaseDemoActivity import com.firebaseui.android.demo.firestore.FirestoreDemoActivity import com.firebaseui.android.demo.storage.StorageDemoActivity import com.google.firebase.FirebaseApp +import com.google.firebase.database.FirebaseDatabase import com.google.firebase.firestore.FirebaseFirestore class MainActivity : ComponentActivity() { companion object { - private const val USE_AUTH_EMULATOR = true + internal const val USE_AUTH_EMULATOR = true private const val AUTH_EMULATOR_HOST = "10.0.2.2" private const val AUTH_EMULATOR_PORT = 9099 @@ -43,6 +44,10 @@ class MainActivity : ComponentActivity() { private const val USE_FIRESTORE_EMULATOR = true private const val FIRESTORE_EMULATOR_HOST = "10.0.2.2" private const val FIRESTORE_EMULATOR_PORT = 8080 + + private const val USE_DATABASE_EMULATOR = true + private const val DATABASE_EMULATOR_HOST = "10.0.2.2" + private const val DATABASE_EMULATOR_PORT = 8199 } override fun onCreate(savedInstanceState: Bundle?) { @@ -61,6 +66,11 @@ class MainActivity : ComponentActivity() { .useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT) } + if (USE_DATABASE_EMULATOR) { + FirebaseDatabase.getInstance() + .useEmulator(DATABASE_EMULATOR_HOST, DATABASE_EMULATOR_PORT) + } + var pendingEmailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK) if (pendingEmailLink.isNullOrEmpty() && authUI.canHandleIntent(intent)) { pendingEmailLink = intent.data?.toString() diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt index ae7f7913b..a418427da 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.firebaseui.android.demo.MainActivity class AuthChooserActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -48,7 +49,8 @@ class AuthChooserActivity : ComponentActivity() { }, onCredentialLinkingClick = { startActivity(Intent(this, CredentialLinkingDemoActivity::class.java)) - } + }, + isEmulatorMode = MainActivity.USE_AUTH_EMULATOR ) } } diff --git a/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt index 46ea1923e..4e99cbfd3 100644 --- a/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt @@ -108,7 +108,10 @@ class ScoreViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder( RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT ) - setPadding(48, 24, 48, 24) + val density = context.resources.displayMetrics.density + val hPadding = (16 * density).toInt() + val vPadding = (8 * density).toInt() + setPadding(hPadding, vPadding, hPadding, vPadding) textSize = 16f } ) diff --git a/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt index 5c379ed0b..91d07abb9 100644 --- a/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt @@ -98,9 +98,12 @@ class FirestoreDemoActivity : ComponentActivity() { } private fun seedData(collection: CollectionReference) { + val batch = collection.firestore.batch() for (i in 1..50) { - collection.add(ScoreItem("Item $i", (1..100).random())) + val docRef = collection.document() + batch.set(docRef, ScoreItem("Item $i", (1..100).random())) } + batch.commit() } } @@ -112,7 +115,10 @@ class ScoreViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder( RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT ) - setPadding(48, 24, 48, 24) + val density = context.resources.displayMetrics.density + val hPadding = (16 * density).toInt() + val vPadding = (8 * density).toInt() + setPadding(hPadding, vPadding, hPadding, vPadding) textSize = 16f } ) diff --git a/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt index 74b034654..5959a5aa4 100644 --- a/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt @@ -23,7 +23,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -54,9 +53,9 @@ class StorageDemoActivity : ComponentActivity() { fun StorageDemoScreen() { var gsUrl by remember { mutableStateOf("") } var stringStatus by remember { mutableStateOf("Not loaded") } - var stringLoadKey by remember { mutableIntStateOf(0) } + var stringUrlToLoad by remember { mutableStateOf("") } var refStatus by remember { mutableStateOf("Not loaded") } - var refLoadKey by remember { mutableIntStateOf(0) } + var refUrlToLoad by remember { mutableStateOf("") } Column( modifier = Modifier @@ -96,16 +95,18 @@ fun StorageDemoScreen() { color = MaterialTheme.colorScheme.onSurfaceVariant ) Button(onClick = { - stringStatus = "Loading..." - stringLoadKey++ + if (gsUrl.isNotEmpty()) { + stringStatus = "Loading..." + stringUrlToLoad = gsUrl + } }) { Text("Load") } StatusText(stringStatus) AndroidView( factory = { ImageView(it) }, update = { view -> - if (stringLoadKey > 0) { + if (stringUrlToLoad.isNotEmpty()) { GlideApp.with(view) - .load(gsUrl) + .load(stringUrlToLoad) .listener(glideListener { success, error -> stringStatus = if (success) "Loaded" else "Error: $error" }) @@ -133,16 +134,18 @@ fun StorageDemoScreen() { color = MaterialTheme.colorScheme.onSurfaceVariant ) Button(onClick = { - refStatus = "Loading..." - refLoadKey++ + if (gsUrl.isNotEmpty()) { + refStatus = "Loading..." + refUrlToLoad = gsUrl + } }) { Text("Load") } StatusText(refStatus) AndroidView( factory = { ImageView(it) }, update = { view -> - if (refLoadKey > 0) { + if (refUrlToLoad.isNotEmpty()) { runCatching { - FirebaseStorage.getInstance().getReferenceFromUrl(gsUrl) + FirebaseStorage.getInstance().getReferenceFromUrl(refUrlToLoad) }.onSuccess { ref -> GlideApp.with(view) .load(ref) From 2cb187a326e7c5f8b2e28f74e5ad7206af830e0f Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 21 Jul 2026 17:21:10 +0100 Subject: [PATCH 18/19] sample(database): fix RTDB paging demo silently failing to render seeded data --- e2eTest/.firebaserc | 2 +- e2eTest/database.rules.json | 9 +++++++++ e2eTest/firebase.json | 3 +++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 e2eTest/database.rules.json diff --git a/e2eTest/.firebaserc b/e2eTest/.firebaserc index c4ee98c1e..6aa25141c 100644 --- a/e2eTest/.firebaserc +++ b/e2eTest/.firebaserc @@ -1,5 +1,5 @@ { "projects": { - "default": "fake-project-id" + "default": "flutterfire-e2e-tests" } } diff --git a/e2eTest/database.rules.json b/e2eTest/database.rules.json new file mode 100644 index 000000000..4d031995c --- /dev/null +++ b/e2eTest/database.rules.json @@ -0,0 +1,9 @@ +{ + "rules": { + ".read": true, + ".write": true, + "database_demo": { + ".indexOn": "score" + } + } +} diff --git a/e2eTest/firebase.json b/e2eTest/firebase.json index 23ea2b1f1..f7a4d6ed5 100644 --- a/e2eTest/firebase.json +++ b/e2eTest/firebase.json @@ -1,4 +1,7 @@ { + "database": { + "rules": "database.rules.json" + }, "emulators": { "auth": { "port": 9099 From c1e9d7a2181067a6d9837f4c4d95a8f791792018 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 21 Jul 2026 19:26:11 +0100 Subject: [PATCH 19/19] sample(database): fix e2e CI auth project mismatch from RTDB namespace fix --- e2eTest/.firebaserc | 2 +- e2eTest/firebase.json | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/e2eTest/.firebaserc b/e2eTest/.firebaserc index 6aa25141c..c4ee98c1e 100644 --- a/e2eTest/.firebaserc +++ b/e2eTest/.firebaserc @@ -1,5 +1,5 @@ { "projects": { - "default": "flutterfire-e2e-tests" + "default": "fake-project-id" } } diff --git a/e2eTest/firebase.json b/e2eTest/firebase.json index f7a4d6ed5..5d004205b 100644 --- a/e2eTest/firebase.json +++ b/e2eTest/firebase.json @@ -1,7 +1,10 @@ { - "database": { - "rules": "database.rules.json" - }, + "database": [ + { + "instance": "flutterfire-e2e-tests-default-rtdb", + "rules": "database.rules.json" + } + ], "emulators": { "auth": { "port": 9099