Skip to content
Client v5: BLE, BLE Hosting, HTTP, Jobs - Linux, MacOS, & Blazor Support! Full AOT, RX on BLE only & MANY other features! Power up!

Firestore Mobile (on-device)

The Shiny.DocumentDb.Firestore.Mobile package provides a document store over the native Firebase Firestore SDK, running on the device. The native SDK owns the hard parts — the local cache, the offline write queue, snapshot listeners, backoff and conflict handling — and this package is a thin typed adapter mapping IDocumentStore onto it.

NuGet package Shiny.DocumentDb.Firestore.Mobile

There are two Firestore providers and they are not interchangeable. Pick by where your code runs:

Shiny.DocumentDb.FirestoreShiny.DocumentDb.Firestore.Mobile
SDKGoogle.Cloud.Firestore (admin/gRPC)Native Firebase SDK (iOS/Android)
Runs whereServer / backend hostOn the client device
AuthService account (ADC)Firebase Auth, per end-user
Security rulesBypassed (admin credentials)Enforced
OfflineNoneNative persistent cache — offline by default
RegistrationAddFirestoreDocumentStore(…)AddMobileFirestoreDocumentStore(…)
  • A mobile app that must work offline and sync when connectivity returns
  • Per-user data enforced by Firestore security rules rather than a trusted backend
  • Live updates pushed from any writer, straight to the device
TargetBehaviour
net10.0-androidReal adapter over the native SDK
net10.0-iosReal adapter over the native SDK
Anything elseThrows PlatformNotSupportedException

Both mobile heads are at feature parity — the same operations work, the same ones throw — and both are verified end-to-end against the Firestore emulator. The net10.0 target is a stub that exists so the surface stays unit-testable without a device; guard multi-targeted code with #if ANDROID || IOS.

Terminal window
dotnet add package Shiny.DocumentDb.Firestore.Mobile
  1. Initialise Firebase

    Bundle the platform config file — google-services.json (Android) or GoogleService-Info.plist (iOS) — for auto-init. The store throws InvalidOperationException if Firebase is not initialised by the time it is resolved.

  2. Register the store

    using Shiny.DocumentDb;
    builder.Services.AddMobileFirestoreDocumentStore(o =>
    {
    o.ProjectId = "my-project"; // optional when the config file is bundled
    o.PersistenceEnabled = true; // default — the offline cache
    o.MapTypeToCollection<Play>("plays"); // default collection = the type name
    });

    This registers one singleton exposed as four contracts — IDocumentStore, IDocumentMaintenance, IObservableDocumentStore and IChangeFeedDocumentStore all resolve to the same instance.

    On iOS, setting ProjectId + AppId lets the provider configure Firebase itself; a config file the app has already loaded always wins.

  3. Register identity (optional)

    builder.Services.AddFirebaseIdentity(o => o.ApiKey = "your-web-api-key");

Each document type maps to its own collection (the type name by default). The document id is the Firestore document id, read from an Id property (MapIdProperty<T> to override) and matched case-insensitively against the serialized JSON.

class Play
{
public string Id { get; set; } = null!;
public string Name { get; set; } = null!;
public int Version { get; set; }
}

The id must be set and non-empty on every write — this provider does not generate ids, and an empty one throws. Field names are the JSON property names, so a PropertyNamingPolicy applies to queries automatically.

var store = sp.GetRequiredService<IDocumentStore>();
await store.Insert(new Play { Id = "p1", Name = "Slant Left", Version = 1 });
var play = await store.Get<Play>("p1"); // null when absent
await store.Upsert(new Play { Id = "p1", Name = "Slant Right", Version = 2 });
await store.Remove<Play>("p1"); // always true — Firestore deletes are idempotent
var cleared = await store.Clear<Play>(); // deletes doc-by-doc, returns the count

Filters, ordering and limit push down to the native query. Aggregates and the pagination offset are applied client-side over materialized results.

var plays = await store.Query<Play>()
.Where(p => p.Version >= 2)
.OrderBy(p => p.Version)
.ToList();
var count = await store.Query<Play>().Count();
var n = await store.Query<Play>().Where(p => p.Version >= 2).ExecuteDelete();
await store.Query<Play>().ExecuteUpdate(p => p.Name, "Renamed");

Supported: Where, OrderBy, OrderByDescending, Paginate, ToList, ToAsyncEnumerable, Count, Any, ExecuteDelete, ExecuteUpdate, Max, Min, Sum, Average, NotifyOnChange, IgnoreQueryFilters.

Translated operators: ==, !=, <, <=, >, >=, &&, and ICollection.Contains. Anything else throws.

  • Count() materializes every matching document — it is not a native aggregate count. Avoid on large collections.
  • Paginate(offset, take) issues a native Limit(offset + take) and skips client-side, because Firestore has no offset. Deep pagination reads everything up to the offset.
  • Max/Min/Sum/Average materialize and compute in managed code.
  • Select projection throws — read with ToList and project client-side.

PersistenceEnabled (default true) turns on the native persistent cache: reads are cache-first, and writes queue locally and drain automatically on reconnect. This is the entire point of the provider — leave it on outside of tests.

await using var sub = await changeFeedStore.SubscribeChanges<Play>((change, ct) =>
{
Console.WriteLine($"{change.ChangeType}: {change.Id}");
return Task.CompletedTask;
});
await foreach (var change in observableStore.NotifyOnChange<Play>(ct)) { … }
// scoped to a filtered query
await foreach (var change in store.Query<Play>().Where(p => p.Version > 1).NotifyOnChange(ct)) { … }

Both are backed by native snapshot listeners, so changes arrive from any writer. ChangeType is Inserted, Updated or Removed; a Removed change carries the id but no document.

IFirebaseIdentity signs users in through the Firebase Auth REST API — anonymous and email/password, with automatic token refresh.

var identity = sp.GetRequiredService<IFirebaseIdentity>();
var user = await identity.SignInAnonymouslyAsync();
var token = await identity.GetIdTokenAsync(); // refreshes within a minute of expiry
var uid = identity.CurrentUserId;
identity.AuthStateChanged += (_, u) => { /* u is null on sign-out */ };
PropertyTypeDefaultDescription
ProjectIdstring?nullFirebase project id — optional when a config file is bundled
AppIdstring?nullFirebase application id, paired with ProjectId for explicit init
ApiKeystring?nullFirebase Web API key
PersistenceEnabledbooltrueThe native offline cache
EmulatorHoststring?nullhost:port of the Firestore emulator
TypeNameResolutionTypeNameResolutionShortNameHow collection names are derived
JsonSerializerOptionsJsonSerializerOptions?nullDrives field names and serialization
UseReflectionFallbackbooltrueSet false for iOS full-AOT
LoggingAction<string>?nullDiagnostic callback

Mapping methods: MapTypeToCollection, MapIdProperty, MapIdType, AddQueryFilter.

var opts = new MobileFirestoreOptions
{
ProjectId = "demo-shiny",
EmulatorHost = "10.0.2.2:8080", // Android emulator; use "localhost:8080" on the iOS simulator
PersistenceEnabled = false // clean slate per run
};

10.0.2.2 is the Android emulator’s alias for the host machine — localhost will not reach it from there. The iOS simulator shares the host’s network stack, so it uses localhost. The emulator accepts any demo project id and a fake API key. The Auth emulator equivalent is FirebaseIdentityOptions.AuthEmulatorHost.

This provider ships in milestones. Today:

  • These throw NotSupportedException: the string Query/QueryStream/Count(whereClause) overloads (use LINQ), BatchInsert, SetProperty, RemoveProperty, GetDiff, ClearAll, and Select projection.
  • Write interceptors do not run. AddInterceptor, AddBulkInterceptor, OnBeforeWrite<T> and OnAfterWrite<T> are accepted by the options but are never invoked. Keep that logic in your calling code.
  • MapVersionProperty does not enforce concurrency. The mapping is recorded, but writes are a plain set() — no version check, and ConcurrencyException is never thrown. A stale write silently wins.
  • No request.auth.uid in rules yet — see Identity.
  • No native full-text, spatial, vector or temporal support.
  • Count and the aggregates materialize documents rather than using native aggregation.