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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/android_unit_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ jobs:
# Tasks are listed per package rather than using the root testDebugUnitTest
# task, which would also build the Dart sources of the aggregate test app.
# Add a task here when a package gains an android/src/test directory.
run: cd tests/android && ./gradlew :firebase_crashlytics:testDebugUnitTest
run: |
cd tests/android
./gradlew \
:cloud_firestore:testDebugUnitTest \
:firebase_crashlytics:testDebugUnitTest
- name: 'Upload test reports'
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
Expand Down
2 changes: 2 additions & 0 deletions packages/cloud_firestore/cloud_firestore/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ android {
api firebaseCoreProject
implementation platform("com.google.firebase:firebase-bom:${getRootProjectExtOrCoreProperty("FirebaseSDKVersion", firebaseCoreProject)}")
implementation 'com.google.firebase:firebase-firestore'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.14.2'
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2026, the Chromium project authors. Please see the AUTHORS file
* for details. All rights reserved. Use of this source code is governed by a
* BSD-style license that can be found in the LICENSE file.
*/

package io.flutter.plugins.firebase.firestore.streamhandler;

import io.flutter.plugin.common.StandardMethodCodec;
import io.flutter.plugins.firebase.firestore.GeneratedAndroidFirebaseFirestore;
import java.nio.ByteBuffer;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/** Wire messages for bounded Android query-snapshot transport. */
final class QuerySnapshotChunkProtocol {
// Keep these values in sync with query_snapshot_chunk_assembler.dart.
private static final String CHUNK_MARKER_KEY = "firestoreQuerySnapshotChunk";
private static final String SNAPSHOT_ID_KEY = "snapshotId";
private static final String KIND_KEY = "kind";
private static final String PAYLOAD_KEY = "payload";
private static final String DOCUMENT_COUNT_KEY = "documentCount";
private static final String DOCUMENT_CHANGE_COUNT_KEY = "documentChangeCount";

static final int START_KIND = 0;
static final int DOCUMENTS_KIND = 1;
static final int DOCUMENT_CHANGES_KIND = 2;
static final int END_KIND = 3;

/**
* Keeps the transient platform-channel envelope far below the 18–31 MiB allocations observed in
* the crash report. A single Firestore document may exceed this target, but Firestore itself
* bounds that exceptional message to one 1 MiB document plus codec overhead.
*/
static final int MAX_ENCODED_ENVELOPE_BYTES = 512 * 1024;

private static final StandardMethodCodec METHOD_CODEC =
new StandardMethodCodec(GeneratedAndroidFirebaseFirestore.PigeonCodec.INSTANCE);

private QuerySnapshotChunkProtocol() {}

static Map<String, Object> start(
long snapshotId,
int documentCount,
int documentChangeCount,
GeneratedAndroidFirebaseFirestore.InternalSnapshotMetadata metadata) {
Map<String, Object> message = base(snapshotId, START_KIND);
message.put(PAYLOAD_KEY, metadata);
message.put(DOCUMENT_COUNT_KEY, documentCount);
message.put(DOCUMENT_CHANGE_COUNT_KEY, documentChangeCount);
return message;
}

static Map<String, Object> itemChunk(long snapshotId, int kind, List<?> items) {
Map<String, Object> message = base(snapshotId, kind);
message.put(PAYLOAD_KEY, items);
return message;
}

static Map<String, Object> end(long snapshotId) {
return base(snapshotId, END_KIND);
}

static int encodedMessageSize(Object message) {
ByteBuffer encoded =
GeneratedAndroidFirebaseFirestore.PigeonCodec.INSTANCE.encodeMessage(message);
return encoded == null ? 0 : encoded.position();
}

static int encodedEnvelopeSize(Object message) {
ByteBuffer encoded = METHOD_CODEC.encodeSuccessEnvelope(message);
return encoded.position();
}

private static Map<String, Object> base(long snapshotId, int kind) {
Map<String, Object> message = new LinkedHashMap<>();
message.put(CHUNK_MARKER_KEY, true);
message.put(SNAPSHOT_ID_KEY, snapshotId);
message.put(KIND_KEY, kind);
return message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright 2026, the Chromium project authors. Please see the AUTHORS file
* for details. All rights reserved. Use of this source code is governed by a
* BSD-style license that can be found in the LICENSE file.
*/

package io.flutter.plugins.firebase.firestore.streamhandler;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.function.Function;
import java.util.function.ToIntFunction;

/**
* Incrementally converts and partitions snapshot items without retaining a converted copy of the
* complete query snapshot.
*
* <p>The inexpensive per-item size estimate chooses an initial boundary. The exact envelope sizer
* then removes tail items until the actual platform message fits. A single item is always allowed
* through even when it exceeds the target, because Firestore already bounds an individual document
* to 1 MiB and the stream must continue making progress.
*/
final class QuerySnapshotChunker<S, T> {
private final Iterator<S> source;
private final Function<S, T> converter;
private final ToIntFunction<T> itemSize;
private final int targetBytes;
private final ArrayDeque<T> pending = new ArrayDeque<>();

QuerySnapshotChunker(
Iterator<S> source, Function<S, T> converter, ToIntFunction<T> itemSize, int targetBytes) {
if (targetBytes <= 0) {
throw new IllegalArgumentException("targetBytes must be positive");
}
this.source = source;
this.converter = converter;
this.itemSize = itemSize;
this.targetBytes = targetBytes;
}

boolean hasNext() {
return !pending.isEmpty() || source.hasNext();
}

List<T> nextChunk(ToIntFunction<List<T>> exactEnvelopeSize) {
if (!hasNext()) {
throw new NoSuchElementException("No snapshot items remain");
}

ArrayList<T> chunk = new ArrayList<>();
int estimatedBytes = 0;

while (hasNext()) {
T item = pending.isEmpty() ? converter.apply(source.next()) : pending.removeFirst();
int encodedItemBytes = Math.max(0, itemSize.applyAsInt(item));

if (!chunk.isEmpty() && estimatedBytes + encodedItemBytes > targetBytes) {
pending.addFirst(item);
break;
}

chunk.add(item);
estimatedBytes += encodedItemBytes;
if (estimatedBytes >= targetBytes) {
break;
}
}

while (chunk.size() > 1 && exactEnvelopeSize.applyAsInt(chunk) > targetBytes) {
pending.addFirst(chunk.remove(chunk.size() - 1));
}

return chunk;
}
}
Loading
Loading