Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

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.Firestore Shiny.DocumentDb.Firestore.Mobile
SDK Google.Cloud.Firestore (admin/gRPC) Native Firebase SDK (iOS/Android)
Runs where Server / backend host On the client device
Auth Service account (ADC) Firebase Auth, per end-user
Security rules Bypassed (admin credentials) Enforced
Offline None Native persistent cache — offline by default
Registration AddFirestoreDocumentStore(…) 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
Target Behaviour
net10.0-android Real adapter over the native SDK
net10.0-ios Real adapter over the native SDK
Anything else Throws 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.ConfigureDocument<Play>(cfg => cfg.ToCollection("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 (cfg.MapIdProperty(...) 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.

The value being compared against can be a literal, a captured local, a field or property read, a method call, or an inline array — it is evaluated without runtime code generation, so predicates behave the same on a full-AOT build as they do in the debugger.

var cutoff = DateTime.UtcNow.AddDays(-7);
var wanted = new[] { "Alpha", "Beta" };
await store.Query<Play>().Where(p => p.Created > cutoff).ToList(); // captured local
await store.Query<Play>().Where(p => wanted.Contains(p.Name)).ToList();
await store.Query<Play>().Where(p => new[] { 1, 2 }.Contains(p.Version)).ToList();
  • 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 */ };
Property Type Default Description
ProjectId string? null Firebase project id — optional when a config file is bundled
AppId string? null Firebase application id, paired with ProjectId for explicit init
ApiKey string? null Firebase Web API key
PersistenceEnabled bool true The native offline cache
EmulatorHost string? null host:port of the Firestore emulator
TypeNameResolution TypeNameResolution ShortName How collection names are derived
JsonSerializerOptions JsonSerializerOptions? null Drives field names and serialization
UseReflectionFallback bool true Set false for iOS full-AOT
Logging Action<string>? null Diagnostic callback

Per-type mappings, inside a ConfigureDocument<T> block: cfg.ToCollection(...) (or the provider-agnostic cfg.Table = ...), cfg.MapIdProperty(...), cfg.AddQueryFilter(...), cfg.MapVersionProperty(...), cfg.OnBeforeWrite(...) / cfg.OnAfterWrite(...). Store-level: options.MapIdType<TId>(...), options.AddInterceptor(...), options.AddBulkInterceptor(...).

The package is built with IsAotCompatible, so the trim and AOT analyzers run over it and it produces no IL warnings of its own. Two pieces are worth knowing about.

Document (de)serialization is the one place that still reaches for reflection, and only when you let it. Pass a JsonTypeInfo<T> — from a JsonSerializerContext — and the typed path is used end to end:

[JsonSerializable(typeof(Play))]
public partial class AppJsonContext : JsonSerializerContext;
await store.Insert(play, AppJsonContext.Default.Play);
var loaded = await store.Get("p1", AppJsonContext.Default.Play);

Set UseReflectionFallback = false to make that mandatory: any call that would have fallen back to reflection throws InvalidOperationException naming the type instead of silently working in the debugger and failing on a trimmed device build.

Firebase Auth (AddFirebaseIdentity) is fully source-generated and needs nothing from you — it has no JsonTypeInfo<T> parameter to thread through, so it carries its own context internally.

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, cfg.OnBeforeWrite(...) and cfg.OnAfterWrite(...) are accepted by the options but are never invoked. Keep that logic in your calling code.
  • cfg.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 full-text, spatial, vector, temporal, blob or computed-property support. The shared ConfigureDocument<T> builder offers all of them, so mapping one here is a DocumentConfigurationException when the store is built — naming every problem at once, rather than failing on first use.
  • Count and the aggregates materialize documents rather than using native aggregation.
  • IgnoreQueryFilters() restarts the query. It rebuilds from the collection, so any Where applied before it is dropped. Call it first: store.Query<T>().IgnoreQueryFilters().Where(…).

This package ships from the shinyorg/firebase repo and versions independently of DocumentDB itself — its releases are listed here rather than on the DocumentDB releases page.

BREAKING

Requires Shiny.DocumentDb 13.0.0, and per-type configuration is one ConfigureDocument<T> block. The flat per-type methods on MobileFirestoreOptions are removed, matching every other DocumentDB provider — the type is named once and its whole configuration reads top to bottom:

o.ConfigureDocument<Play>(cfg =>
{
cfg.ToCollection("plays");
cfg.MapIdProperty(x => x.Id);
cfg.AddQueryFilter(p => p.Version >= 1);
});

MapTypeToCollection<T>cfg.ToCollection(...), MapIdProperty<T>cfg.MapIdProperty(...), MapVersionProperty<T>cfg.MapVersionProperty(...), AddQueryFilter<T>cfg.AddQueryFilter(...), OnBeforeWrite<T> / OnAfterWrite<T>cfg.OnBeforeWrite(...) / cfg.OnAfterWrite(...). Store-level members are untouched: MapIdType<TId>, AddInterceptor, AddBulkInterceptor, and every plain property. See Migrating v12 → v13.

Feature

A mapping the native SDK cannot honor is now a startup error. MobileFirestoreOptions implements IDocumentStoreOptions and declares its capabilities, so the shared validation pass runs when the store is constructed. The provider-agnostic builder accepts MapTemporal, MapBlob, MapSpatialProperty, MapVectorProperty, MapFullTextProperty and MapComputedProperty on any provider — on device Firestore there is no engine or sidecar behind any of them, and each one is now reported by name in a single DocumentConfigurationException rather than doing nothing.

Fix

The collection name ignored TypeNameResolution. The store resolved a type’s collection from typeof(T).Name instead of the store’s resolved document type name, so a store configured with TypeNameResolution.FullName still wrote to the short-name collection — and disagreed with the name every other part of the provider used. It now goes through the same resolver as the rest of the store.

BREAKING

Requires Shiny.DocumentDb 12.0.0. DocumentDB 12 moved the single-document write pipeline onto DocumentProviderBase and added four provider hooks (Mappings, IdCache, ResolveTypeInfo, ResolveDocumentTypeName); this provider implements them and its options now delegate id, query-filter and version mapping to the shared DocumentMappingRegistry. The public surface of MobileFirestoreOptions is unchanged — existing configuration code compiles as-is — but the package will not restore against DocumentDB 11.

Feature

Trim and AOT clean. The package is built with IsAotCompatible and produces no IL warnings. Firebase Auth now uses a source-generated JSON context instead of reflection-based PostAsJsonAsync/ReadFromJsonAsync, so sign-in, sign-up and token refresh survive trimming — previously they could fail only on a published device build. Query comparison values are evaluated and serialized without runtime code generation. See Trimming and AOT.

BREAKING

The query builder returns a copy instead of mutating. Where, OrderBy, OrderByDescending and Paginate used to add to the query and return the same instance; they now return a new query, matching every other DocumentDB 12 provider. Two consequences. A builder call used as a bare statement is now discarded — q.Where(x => !x.IsDeleted); on its own line silently loses the clause, so assign the result. Conversely, branching now works: var recent = all.Where(…) no longer disturbs all.

var all = store.Query<Play>();
var recent = all.Where(p => p.Version >= 2); // `all` is unchanged — previously both were the same query
FixiOS

Any() permanently capped the query it was called on. The Swift ShinyFirestoreQuery wrapper mutated itself and returned self, so the limitTo(1) that Any() issues stuck to the underlying query — a subsequent ToList() on the same query returned at most one document. The wrapper now returns a new instance from whereField, orderBy and limitTo, preserving Firestore’s own immutable Query semantics. Android was unaffected: its native Query was already immutable.

Fix

new[] { … }.Contains(x.Prop) in a Where threw instead of querying. On .NET 10 an inline-array Contains binds to MemoryExtensions.Contains, so the collection reaches the translator wrapped in an array-to-ReadOnlySpan conversion — which the old expression-compiling evaluator could not box, on either platform. Inline arrays now translate to a native in filter. Contains over a captured collection was unaffected.

Enhancement

A predicate that cannot be evaluated without code generation now says so. Where the translator previously relied on Expression.Compile() — which Mono quietly services with its interpreter and NativeAOT cannot — it walks the expression directly. An exotic shape on a build without code generation throws NotSupportedException naming the node type and suggesting you hoist the value into a local, rather than failing at runtime in the native SDK.

Feature

Initial release, announced alongside DocumentDB 11.2 — built on the provider extension points opened in 11.1.1. See the DocumentDB releases page for the introduction.