A private Android app for cataloging family heirlooms and keepsakes — real-time cloud sync across all family devices, multi-photo support, Google Sign-In with an email allowlist, and a full Firebase backend
Our Family Archive is a native Android app built to solve a real family problem: cataloging inherited heirlooms, keepsakes, and sentimental items before memories and context are lost. Family members can add items with photos, detailed descriptions, origin stories, storage locations, and tags — and all of it syncs in real-time across every authorized device via Firebase.
Access is strictly controlled. New users must be invited by an admin and added to an allowlist stored in Firestore before Google Sign-In is permitted. An in-app admin panel manages the allowlist, tracks invite status (invited vs. signed up), and sends invite emails with download and install instructions. The app is distributed privately via a hosted APK rather than the Play Store, keeping it within the family.
The architecture follows MVVM with a Repository layer, Hilt for dependency injection, Room for local-first storage, and Firestore snapshot listeners for real-time two-way sync. Photos upload to Firebase Cloud Storage and are accessible by URL on any device — with a local file fallback for offline use and a backfill upload that catches any pre-existing local photos on sign-in.
Modern Android architecture with a Firebase backend — built for reliability, offline capability, and real-time multi-device sync.
A complete family cataloging system — add, search, organize, and share memories across all family devices in real time.
The non-obvious engineering decisions that make a multi-device family app reliable.
When an item is saved, two async operations run concurrently: the item document is written to Firestore, and photos are uploaded to Cloud Storage. A Firestore snapshot listener on another device can receive and apply the item document before the photo upload finishes — at which point remoteUrl is still null. If the listener then overwrites the local Room record, the photo URL gets erased. The fix: the sync listener checks whether Firestore already has a remoteUrl for each photo before applying the incoming document, and preserves the existing URL if one is present. The push path mirrors this — it never writes a null over a URL that Firestore already holds.
Firestore addSnapshotListener calls accumulate if called more than once on the same collection — each registration fires independently, causing duplicate Room writes, duplicate UI updates, and subtle ordering bugs. The fix is a session-scoped flag per collection: the sync layer checks a boolean before registering a listener, sets it immediately after the first registration, and never registers again for the lifetime of the session. Listeners are also explicitly removed on sign-out so they don't fire on the next session's sign-in before auth is fully initialized.
When a user signs in for the first time after Cloud Storage was added (v0.3.0), they may have dozens of photos stored only locally — taken before sync existed. On sign-in the app scans every item in Room, identifies photos that have a local path but no remoteUrl, and uploads them to Cloud Storage in sequence. Each successful upload writes the returned download URL back to both Room and Firestore. This backfill is idempotent — if it's interrupted partway through, it picks up cleanly on the next sign-in by re-checking which photos still lack a remote URL.
The admin invite system needed to distinguish between three states: a user who hasn't been invited yet, a user who has been invited but hasn't signed up, and a user who has signed up. A simple boolean allowlist couldn't represent the middle state. The solution uses two Firestore fields per email: allowed: true (added when invited) and inviteSentAt: timestamp (written before the email composer opens, so the record persists even if the composer is dismissed). The admin UI shows the state as a badge — "Not Invited", "Invite Sent", or "Signed Up" — based on the presence of these fields.
Firebase Cloud Storage's putFile() method is documented for local URIs but proved unreliable with file:// paths from Android's scoped storage on some devices. The fix uses putStream() instead: open the file via ContentResolver.openInputStream() (which handles both file:// and content:// URIs uniformly), pass the stream directly to Cloud Storage, and close it in a finally block. This approach works consistently across Android versions and avoids URI scheme edge cases entirely.
The login screen features a desert landscape illustration that shifts color throughout the year — 12 distinct palettes, one per calendar month. Each palette defines sky, sun, mesa, ground, and shadow colors that together evoke a specific season: deep winter blues, spring greens, summer amber, autumn rust. The current palette is selected by month index at runtime. This was a deliberate design decision to give the app a sense of time passing and make the login screen feel alive rather than static — a small detail that matters for an app meant to be used across years and generations.
From architecture to Firebase backend — we build Android apps that are fast, reliable, and built to last.