Architecture
FamSync is a local-first Flutter application. Each device keeps a full replica of its family's data in a local encrypted SQLite database; Cloud Firestore is used only to sync shared state across co-parents. The vault uses AES-256 on-device with biometric-derived keys. Authentication is anonymous — no email, no profile, no PII.
1. Overview
FamSync is built on a four-layer stack designed around three principles: data locality (your device is the source of truth), end-to-end encryption for sensitive items (notes, vault documents), and realtime sync only for shared state (calendar, expenses, messages, photos).
- Storage layer: Drift (SQLite) on device + Cloud Firestore for shared family state. EU region.
- Crypto layer: AES-256 for vault content, EncryptedSharedPreferences (AES-256-GCM via Keystore) for tokens, SHA-256 to derive vault keys from the family identity.
- Identity layer: Firebase Authentication with
signInAnonymously()— zero PII, retry-with-backoff to handle offline boot. - Sync layer: Riverpod providers that merge Drift local streams with Firestore snapshots; last-write-wins by server timestamp.
- Family units: a family can hold up to 8 roles (parent, grandparent, babysitter, lawyer…). A single role can span multiple devices; a single device can hold multiple roles.
Architecture data-flow diagram. Inside your device: Drift SQLite (FamSyncDatabase) for local storage, AES-256 vault with SHA-256 familyId key derivation, and Riverpod providers for reactive UI. Two-way sync to Firebase Authentication (anonymous sign-in with retry backoff) and Cloud Firestore (families collection, EU region). The family circle on the right shows up to 8 roles co-sharing state.
2. Local-first storage — Drift (SQLite)
Each device owns a full replica of its family's data in a local SQLite database through drift_flutter. The schema lives in lib/core/data/database.dart as FamSyncDatabase extends _$FamSyncDatabase, declared with @DriftDatabase(tables: […]).
Tables (non-exhaustive)
- Events — custody blocks, handoffs, rich metadata per role color.
- Expenses — receipt OCR text, amount, category, payer, approver.
- Messages — text, tone indicator (on-device), read receipts.
- Photos — vault pointer, E2E ciphertext blob, parent/child.
- SafeNotes — vault records, AES-256 ciphertext, familyId-keyed.
- Family / Roles — members, role codes, permissions, last-seen.
Observers
Drift stream listeners (.watch()) propagate local writes to Riverpod providers in lib/di/repository_providers.dart. The UI subscribes to providers, not to Firestore directly.
3. AES-256 vault (on-device encryption)
Sensitive content — private notes, locked documents — is encrypted on the device with AES-256 using the encrypt Dart package. The implementation lives in lib/core/util/crypto_helper.dart.
Key derivation
- SHA-256(familyId) → 32-byte AES-256 key. Each family's vault has an isolated keyspace.
- AES-256-CBC with random 16-byte IV per record (current implementation).
- Tech-debt (planned): migrate to AES-256-GCM for authenticated encryption. Tracked in the source as a known-good upgrade path.
flutter_secure_storage's encryptedSharedPreferences: true option, which uses Android Keystore + AES-256-GCM under the hood. lib/core/data/secure_storage.dart.4. Firebase Authentication (anonymous, zero-PII)
Identity is established via FirebaseAuth.instance.signInAnonymously() — the user never provides an email, phone number, display name, or password. The returned uid is the only persistent identifier used across devices and used by Firestore security rules.
Why anonymous
- No PII stored on Google's servers. Account deletion = unauthenticated forever.
- Family-bound identity. The anonymized UID is bound to a
familyIdvia Firestore membership rule. - Boot resilience. Retries handle cold-start offline scenarios.
The single social exception
lib/core/auth/social_auth_service.dart implements Google Sign-In as an optional fallback. If a user's anonymised UID is lost (factory reset on a long-disused device), a Google-linked UID can recover membership of the family the user has joined in the last 90 days. The Google profile is never displayed in-app, never shared, and never required.
5. Cloud Firestore — shared family state
Cloud Firestore is the only networked state layer; the rest is local. Collections are partitioned per family:
FCM push tokens are stored alongside each member record and rotated on every cold start (lib/core/notifications/fcm_service.dart). No separate fcm/ subcollection.
Region
All Firestore data lives in a Google Cloud region inside the European Union, configured by lib/core/data/firebase_config.dart. No data leaves the EU.
Real-time sync
Repository classes subscribe via .snapshots() and merge into Drift via familyInfoStreamProvider and similar. Conflict resolution is last-write-wins by server_timestamp() — straightforward, predictably monotonic, and visible to the user through a small "edited" badge.
Security rules
Firestore security rules (see firestore.rules) gate reads/writes to members/{uid}.role. Auxiliary roles (grandparents, babysitters, lawyer — up to 8 per family) have granular per-area permissions enforced server-side, not just client-side.
6. Offline & sync
The device is offline-tolerant by design:
- All UI reads are served by Drift (the local SQLite database) — never blocked by network.
- Writes are applied to Drift optimistically; a background worker mirrors them to Firestore and FCM.
_deferredBootstrap()(inlib/main.dart) intentionally delays the first read offamilyId,FirestoreConfig.configure(), andAuthService.signIn()until after the first frame. This keeps cold start under 800ms on iPhone 8.- On reconnect, Drift
familyInfoStreamProviderre-syncs against Firestore snapshots — the UI converges within a few hundred ms.
7. Push notifications
Firebase Cloud Messaging handles push. Tokens are aligned server-side via lib/core/notifications/fcm_service.dart after onboarding, when familyId and deviceRole are known. Notification targeting (lib/core/notifications/notification_targeter.dart) distributes pushes by role and event category — only the parent's phone chimes for a calendar handoff; only the accountant-role phone chimes for a new expense.
8. Data retention and DSR
- An active family retains its Firestore data indefinitely while at least one device is in active use.
- Inactive families (no live signed-in device for 365 days) are automatically purged by a scheduled Cloud Function. Drift on those orphaned devices re-prompts the user to either re-join or create a new family.
- The user may at any time request a Data Subject Request (DSR) from the DSR / GDPR page: export (JSON + ZIP), correct, or fully delete. Requests are answered within 30 days.
9. Stack — packages and versions
Flutter core
Riverpod 2.6 · go_router 14.8 · Drift 2.x via drift_flutter · encrypt 5.0 · flutter_secure_storage 9.2 · Firebase Auth 5.7 · Cloud Firestore 5.6 · Firebase Cloud Messaging.
Why this stack
Drift gives reactive typed SQL with row-level Diff — ideal for live UI sync without server round-trips. The encrypt package is small, audited, and pure Dart — no platform channels to audit. Riverpod's generator-based providers give compile-time DI check. Firebase Auth anonymous gives a zero-PII account layer that's still revocable server-side.
10. Reliability tests
The integrity of the vault, the Drift/Firestore merge order, and the AES key derivation are covered by automated tests under test/:
- Unit tests for
CryptoHelper.encrypt/.decryptround-trip. - Drift migrations regression tests.
- Repository stream tests with fake Cloud Firestore.
- Integration tests on real devices for the cold-boot path.