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.
This is not the Firestore provider
Section titled “This is not the Firestore provider”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(…) |
When to Use
Section titled “When to Use”- 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
Platform support
Section titled “Platform support”| 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.
Installation
Section titled “Installation”dotnet add package Shiny.DocumentDb.Firestore.Mobile-
Initialise Firebase
Bundle the platform config file —
google-services.json(Android) orGoogleService-Info.plist(iOS) — for auto-init. The store throwsInvalidOperationExceptionif Firebase is not initialised by the time it is resolved. -
Register the store
using Shiny.DocumentDb;builder.Services.AddMobileFirestoreDocumentStore(o =>{o.ProjectId = "my-project"; // optional when the config file is bundledo.PersistenceEnabled = true; // default — the offline cacheo.MapTypeToCollection<Play>("plays"); // default collection = the type name});This registers one singleton exposed as four contracts —
IDocumentStore,IDocumentMaintenance,IObservableDocumentStoreandIChangeFeedDocumentStoreall resolve to the same instance.On iOS, setting
ProjectId+AppIdlets the provider configure Firebase itself; a config file the app has already loaded always wins. -
Register identity (optional)
builder.Services.AddFirebaseIdentity(o => o.ApiKey = "your-web-api-key");
Documents
Section titled “Documents”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 absentawait store.Upsert(new Play { Id = "p1", Name = "Slant Right", Version = 2 });await store.Remove<Play>("p1"); // always true — Firestore deletes are idempotentvar cleared = await store.Clear<Play>(); // deletes doc-by-doc, returns the countQuerying
Section titled “Querying”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 nativeLimit(offset + take)and skips client-side, because Firestore has no offset. Deep pagination reads everything up to the offset.Max/Min/Sum/Averagematerialize and compute in managed code.Selectprojection throws — read withToListand project client-side.
Offline
Section titled “Offline”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.
Change observation
Section titled “Change observation”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 queryawait 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.
Identity
Section titled “Identity”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 expiryvar uid = identity.CurrentUserId;identity.AuthStateChanged += (_, u) => { /* u is null on sign-out */ };Options Reference
Section titled “Options Reference”| 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 |
Mapping methods: MapTypeToCollection, MapIdProperty, MapIdType, AddQueryFilter.
Emulator
Section titled “Emulator”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.
Limitations
Section titled “Limitations”This provider ships in milestones. Today:
- These throw
NotSupportedException: the stringQuery/QueryStream/Count(whereClause)overloads (use LINQ),BatchInsert,SetProperty,RemoveProperty,GetDiff,ClearAll, andSelectprojection. - Write interceptors do not run.
AddInterceptor,AddBulkInterceptor,OnBeforeWrite<T>andOnAfterWrite<T>are accepted by the options but are never invoked. Keep that logic in your calling code. MapVersionPropertydoes not enforce concurrency. The mapping is recorded, but writes are a plainset()— no version check, andConcurrencyExceptionis never thrown. A stale write silently wins.- No
request.auth.uidin rules yet — see Identity. - No native full-text, spatial, vector or temporal support.
Countand the aggregates materialize documents rather than using native aggregation.