Skip to content

feat: mobile v2 — Expo rewrite with offline-first sync - #1952

Open
derneuere wants to merge 77 commits into
devfrom
feat/mobile-v2
Open

feat: mobile v2 — Expo rewrite with offline-first sync#1952
derneuere wants to merge 77 commits into
devfrom
feat/mobile-v2

Conversation

@derneuere

Copy link
Copy Markdown
Member

Summary

Rebuilds the official LibrePhotos mobile app from scratch as apps/mobile-v2:
an Expo (SDK 57, New Architecture) app with an offline-first,
device-mirrored
data model. Synced content renders from a local SQLite mirror
of the server; the network is used only to converge that mirror and to fetch
image bytes. Adds a shared packages/api-client (zod schemas + injectable
transport + TanStack Query hooks) and the backend delta-sync API the mirror
converges against.

The legacy apps/mobile (bare RN 0.72 + NativeBase) is untouched here and is
removed in a follow-up once v2 ships on all channels.

Full design docs: plans/mobile-v2/.

Architecture

  • Mirror-first reads — photos, albums, people, and sharing live in on-device
    SQLite (expo-sqlite + Drizzle, useLiveQuery). Synced entities render from
    SQLite; everything else through TanStack Query; a screen never mixes both for
    one entity. (01-architecture.md,
    02-local-database.md)
  • Sync engine — keyset-paginated updated_after pull endpoints + a
    server-side DeletionLog tombstone table. Seed → delta → integrity snapshot
    (per-table counts vs server); drift schedules a reseed. The mirror is
    disposable, so reseed is always a safe recovery.
    (03-sync-engine.md)
  • Outbox — offline mutations apply optimistically to the mirror inside a
    transaction that also queues an outbox row; a FIFO replay drains it when
    online. Delta pulls hold off while an entity has pending rows —
    replay-first, last-write-wins with server authority, no merge logic.
  • Backup — camera-roll assets (expo-media-library) hashed with native MD5
    to match LibrePhotos' md5(bytes)+user.id scheme, joined to the mirror by
    content hash; merged timeline is a SQL UNION of remote + local-only assets.
    A restart-surviving, wifi/charging-aware upload queue checks
    /api/exists/{hash} then chunk-uploads with device timestamps.
  • Shared API clientpackages/api-client is RN- and React-DOM-free (zod

Features by phase

  • Phase 0 — Foundations: Expo Router + NativeWind + Drizzle scaffold;
    packages/api-client extracted; server-URL + JWT login with secure-store and
    token refresh; online query-backed timeline.
  • Phase 1 — Offline reads: Drizzle schema + migrations; seeder + thumb
    prefetch cache; timeline, viewer, favorites/hidden/trash/videos/recent, user
    • auto albums, people — all from SQLite. Airplane-mode browse.
  • Phase 2 — Delta sync: backend last_modified audit, DeletionLog,
    /api/sync/* feeds + counts; client cursors, delta applier, integrity check,
    sync-status screen.
  • Phase 3 — Local assets & backup: device media sync + MD5 pipeline; merged
    timeline with upload badges; upload queue + backup settings (album selection,
    wifi/charging); device-timestamp upload fallback.
  • Phase 4 — Offline mutations & background: outbox + replay with optimistic
    mirror writes (favorite, hide, trash/restore, rating, album add/remove,
    caption, person rename); expo-background-task registration.
  • Phase 5 — Parity & release scaffolding: sharing hub (by-me / with-me /
    public links + revoke), face list/tagging, places/things/tags album grids,
    search (server + offline fallback), memories card + local-notification
    reminder, share target, profile/settings/admin-lite, stats, i18n string base
    • legacy-translation import script.

Backend API additions

  • Models / tombstones: new DeletionLog tombstone model; last_modified
    added to AlbumAuto, AlbumPlace, AlbumThing, AlbumUser, Person,
    Tag, Photo, User. Signals bump last_modified on mutation and record
    tombstones; a cleaning-service prune step ages tombstones out. Migration
    0138_deletionlog_albumauto_last_modified_and_more.
  • Delta-sync feeds (api/views/sync.py, api/serializers/sync.py):
    GET /api/sync/photos, /api/sync/persons, /api/sync/albums/{user,auto,thing,place,tag},
    /api/sync/sharing, and /api/sync/counts — all keyset-paginated by
    updated_after, returning tombstones alongside upserts.
  • Upload: device filesystem/media timestamps in the upload completion
    payload so EXIF-less photos are dated correctly instead of by upload time
    (issue Get date from filesystem (client side) #614).
  • Tests: api/tests/test_sync_api.py (convergence + visibility +
    tombstone coverage).

Test totals

  • mobile-v2 jest: 188/188 passing, 40 suites, 2 projects (rn +
    node-real-SQLite).
  • api-client vitest: 25/25 passing (3 files).
  • backend: 1938 passing (full api.tests suite; 21 skipped, 2 expected
    failures).

Verification matrix

Component Gate Result
packages/api-client tsc --noEmit pass
packages/api-client eslint pass
packages/api-client vitest 25/25
apps/mobile-v2 tsc --noEmit pass
apps/mobile-v2 eslint pass
apps/mobile-v2 jest (rn + node) 188/188
apps/mobile-v2 db:generate idempotent pass (no diff)
apps/backend full api.tests suite 1938 OK
apps/docs yarn build (onBrokenLinks: throw) pass

CI

New .github/workflows/mobile-v2.yml (path-filtered to apps/mobile-v2/**,
packages/**, and root manifests): Node 22, single npm ci at the workspace
root, then typecheck + lint + jest for apps/mobile-v2 and typecheck + lint +
vitest for packages/api-client. No existing jobs modified.

Issues

Closes #784, Closes #761, Closes #614, Closes #783, Closes #760

Deferred

  • iOS share-extension — share target lands; the native iOS share
    extension is wired at prebuild and not yet configured.
  • Multi-chunk resume — uploads are chunked and the queue survives restarts,
    but resume restarts the in-flight chunk rather than resuming mid-chunk.
  • Per-thing/place/tag membership mirroring — those albums are mirrored, but
    full per-photo membership for these auto-album types is not yet offline.

See 06-roadmap.md for the full phasing,
risks, and issue-closure rationale.

🤖 Generated with Claude Code

derneuere and others added 30 commits July 24, 2026 13:10
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Platform-agnostic @librephotos/api-client package extracted from the
apps/frontend and apps/mobile api_client trees:

- zod schemas per entity (auth, user, photos, albums, persons, search,
  sharing, jobs, settings, upload/exists, tags) as the single source of
  API types
- React-free injectable fetch transport with single-flight refresh
  interceptor (proactive expiry refresh + reactive 401 retry), baseUrl /
  token supplier / fetch all injected
- thin endpoint wrappers and TanStack Query v5 hooks behind an
  ApiClientProvider context
- media URL builders + Authorization-header helper for RN image loads
- vitest: schema-vs-fixture contract tests + transport tests (mocked fetch)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expo SDK 54 app (New Architecture, typed routes, TS strict) consuming the
shared @librephotos/api-client:

- expo-router file tree matching the doc-01 routing map: (auth)/login,
  (tabs) Photos/Albums/Search/Backup/Profile, photo/[id] modal, sharing;
  stub screens for routes beyond auth + timeline
- providers: TanStack Query v5 + ApiClientProvider + i18next scaffold;
  zustand stores for settings (server URL, theme) and auth session
- auth flow: server-URL-first login, JWT via /auth/token/obtain,
  expo-secure-store token storage, refresh handled by the api-client
  transport interceptor, logout blacklists the refresh token
- online timeline proof: Photos tab fetches via the api-client hook and
  renders a FlashList grid of expo-image thumbnails with an auth header
- NativeWind v4, eslint flat config, jest-expo + RNTL
- tests: login render, timeline render, api-client integration (mocked
  fetch); shared package resolves via metro watchFolders + tsconfig/jest
  path mapping (build-free source import)
- root package.json declares workspaces (packages/*, apps/mobile-v2);
  apps/frontend/mobile/docs deliberately excluded

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add last_modified (auto_now, indexed) to Person, AlbumUser, AlbumAuto,
AlbumThing, AlbumPlace, Tag and User so each synced model can answer
"what changed since X?" for the mobile-v2 delta sync (doc 04 §1). Add a
composite (last_modified, id) index on Photo for keyset pagination, and a
DeletionLog tombstone model scoped to the viewer whose mirror must drop the
row (doc 04 §2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uning

Register m2m_changed / post_delete receivers (api/sync_signals.py) that bump
the parent row's last_modified on album membership and sharing changes and
write DeletionLog tombstones on hard delete and on un-share (visibility loss
= deletion, doc 04 §2). Re-sharing a row cancels its stale tombstone so a
resurrected row is never shadowed on the recipient's next pull.

SetPhotosShared writes the shared_to through table directly (no m2m signal),
so it does the bump/tombstone/cancel by hand. The bulk SetPhotos{Deleted,
Favorite,Hidden,Public} endpoints use queryset .update() (which bypasses
auto_now), so they now set last_modified explicitly. Add prune_deletion_log()
(90-day horizon) and schedule it daily.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keyset-paginated pull feeds under /api/sync/ (photos, persons,
albums/{user,auto,thing,place,tag}, sharing, counts) with a uniform envelope
{v, items, tombstones, next_cursor, total, server_time}, base64 keyset cursor
on (last_modified, id), 410 cursor_expired past the 90-day horizon, page_size
cap 1000, and flat .values()-based serializers (no N+1). Scoped to
owner=user OR shared_to=user; is_favorite resolved server-side; user/auto
albums embed photo_ids (doc 04 §3-§5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/api/upload/complete/ accepts optional device_created_at / device_modified_at
(epoch ms or ISO-8601). apply_device_timestamp_fallback runs after extraction
and adopts the device time only when the file carried no EXIF date, feeding it
through the USER_DEFINED datetime rule so the date album is built too
(doc 04 §5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cursor completeness, tie-break on equal timestamps, tombstones on
delete/bulk-delete/un-share, 410 on pruned cursor, sharing visibility both
directions, album membership embedding + bump, counts, the convergence
property (scripted mutations -> simulated client pull loop == server visible
set), and the upload timestamp fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-device SQLite mirror (doc 02): remote_photo + mirror tables, local media
tables (empty until Phase 3), and app-state tables (sync_state/outbox/
upload_queue/thumb_cache/sync_log). drizzle-kit SQL migrations committed +
inlined for the Node/expo-sqlite driver split. Query modules: merged timeline
page + bucket (raw UNION), flag filters, user/auto albums + membership, people,
detail cache, sync-state. Batched idempotent writers with is_favorite/bucket
materialization. Seeder groundwork over existing endpoints (injectable source).
Node test harness runs the real SQL under better-sqlite3.

api-client: DateAlbumFilter export + tag-list / notimestamp / album-detail
endpoint wrappers and hooks for the seeder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seeder groundwork (injectable source, resumable/idempotent, materialized
is_favorite + buckets) with a real api-client-backed source and a post-login
coordinator. Thumb-cache LRU (pure policy + expo-file-system prefetch, 2 GB
configurable cap). App runtime: expo-sqlite client, migration runner with
wipe-and-reseed fallback (preserving outbox/upload_queue), DbProvider +
useReactiveQuery live-query hook. Node DB tests against better-sqlite3: schema,
merged-timeline correctness (remote + local-arm union), buckets, filters,
albums/people, seeder idempotency/resume, LRU eviction, and EXPLAIN QUERY PLAN
budget on a 100k-row fixture (index usage, no full scan, no temp sort).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Photos timeline (day headers + grid via merged/bucket query), favorites/hidden/
deleted/videos/recent/notimestamp filters, My Albums + detail, Events (auto) +
detail, People list — all render from the mirror via useReactiveQuery. Photo
viewer pages over the timeline context from the mirror with a cache-then-network
detail sheet (remote_photo_detail). Thumbnails resolve local-first (camera-roll
uri / thumb_cache file / network expo-image). DatabaseGate opens + migrates the
DB and provides it app-wide; SeedOnLogin runs the initial seed; pull-to-refresh
re-seeds. Component tests render the timeline + viewer from a seeded better-
sqlite3 DB under the DbProvider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add zod schemas (v-field validated, unknown fields tolerated via passthrough)
and thin endpoint wrappers for the mobile-v2 /api/sync/* feeds: photos,
persons, user/auto/thing/place/tag albums, sharing, and counts. Align the
package's React dev types to 19 (its runtime consumer, mobile-v2) so the npm
workspace dedupes to a single @types/react.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend the SQLite mirror for the delta engine: app_meta k/v table and richer
sync_log columns (op/applied/deleted/duration/cursor) via migration 0001; add
the 'sharing' sync entity + SYNC_ENTITIES order; expose tx-level writers so a
whole page applies in one transaction; add shared_user writers, per-entity
tombstone deletes, per-entity clearEntity() for targeted reseed, and local
count/app-meta/sync-log query modules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the Phase 1 legacy seeder with sync-endpoint seeding (seed = delta from
cursor zero). Per-entity keyset pull loop applies each page in one transaction
(upsert + tombstone delete + cursor advance), idempotent, cancellable, resumable
at page granularity; the durable resume token is stored verbatim to avoid the
ms-truncation infinite-loop. 410 cursor_expired triggers a per-entity reseed.

Orchestrator syncAll() runs the doc 03 §1 sequence with a single-flight mutex:
outbox-replay stub (Phase 4), remote pull, device/hash/upload stubs (Phase 3/5),
wired thumb-prefetch, integrity check (counts vs local -> schedule reseed). Adds
favorite_min_rating change detection, manual repairSync, and app foreground
(throttled)/pull-to-refresh/connectivity(expo-network) triggers. Deprecates the
legacy seed source; deletes coordinator.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Settings -> Sync status: per-entity cursor/last-run/counts table, live progress
bar, structured sync-log list, export logs (RN Share), Sync now + Repair sync
actions. Register the route under the profile tab stack and link from the
profile hub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Node tests: keyset pagination, durable-cursor persistence, mid-entity resume,
tombstone application, 410 reseed, idempotent re-apply, dependency ordering,
integrity-drift detection, single-flight, foreground throttle, membership
replace, AbortSignal cancellation. RN test: status screen renders from fixture
state + live progress. Make the node jest zod mapper resolve via require.resolve
(zod hoists to the workspace root).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Camera roll → local_asset/local_album tables (doc 03 §4) over an injectable
MediaProvider: permission flow with iOS-limited degrade, per-album createdAfter
fast path, metadata-only full id-diff fallback on count mismatch (Immich
checkAddition→fullDiff), deletion via orphan sweep. Hash pipeline computes
md5(bytes)+userId (matches backend File.hash), backup-selected first, ~50-batch
yielding + cancellable, lazy video hashing, modified_at hash invalidation.
Real expo-media-library/expo-file-system impls kept app-only; pure logic
Node-tested with fakes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Serial, wifi/charging-gated upload worker (doc 03 §5) over an injectable
UploadTransport + DeviceProbe: enqueue rule (hashed, backup-selected, not
excluded, no remote hash match), exists-skip, chunked upload, /upload/complete/
WITH device_created_at/device_modified_at (EXIF-less date fallback, #614),
exponential backoff + capped attempts (new upload_queue.next_attempt_at column,
migration 0002). Backup config (enabled/wifi-only/charging-only) persists in
app_meta; per-album selection on local_album.backup_selection. Real fetch +
expo-file-system(BACKGROUND session) transport and expo-network/expo-battery
probe kept app-only; state machine + gate Node-tested with fakes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire the device/hash/upload steps into the orchestrator (run.ts appHooks:
syncDeviceMedia, hashAssets, topUpUploadQueue), a backup-only runBackupNow, and
a MediaLibrary change listener that nudges a foreground sync (doc 03 §4).
Backup tab: overall queue status, wifi/charging + enable toggles, per-album
selection list with counts, live queue with per-item state/progress/error,
retry, iOS limited-access note. Merged-timeline local-only cells show a
pending-upload badge that flips once the server row arrives on delta sync.
Adds expo-media-library + expo-battery deps. Full gate green: tsc, eslint,
93/93 jest (63 prior + 30 new).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Offline mutation pipeline (doc 03 §6): each offline-capable action applies an
optimistic write to the mirror and inserts a zod-validated outbox row in one
transaction, then triggers replay-first sync. Adds the api-client mutation
endpoints (favorite/hide/trash/rating/caption + album add/remove/create/rename +
person rename), the outbox state machine with backoff + crash recovery, and the
FIFO replay engine (success deletes, 4xx drops + toast, network keeps + backs
off). Album create uses a negative client-temp id reconciled to the server id on
replay (membership + chained pending payloads remapped). Adds next_attempt_at/
inflight_at columns (migration 0003).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Grid multi-select on all mirror grids (long-press to enter, tap to toggle,
day-header select) with an action bar: favorite/hide/trash/add-to-album go
through the outbox; download/share-link/delete-permanent render disabled while
offline (expo-network). Viewer action bar adds favorite, hide, trash/restore,
0-5 rating, caption edit and add-to-album. Album detail gains rename +
remove-photos; My Albums gains create; People gains person rename. Adds the
offline banner + dropped-mutation toast host to the app chrome and a pending-
outbox badge on the sync status screen + profile row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Registers one expo-background-task/expo-task-manager job at login that runs a
chunked top-up within the OS window: outbox replay -> photos delta ->
runBackupNow (small upload budget) -> thumb cache top-up, each step useful as a
prefix. Last-run result is written to sync_log (visible on the status screen).
Opens the DB headlessly and bails when signed out. app.json adds the
expo-background-task config plugin (iOS BGTaskScheduler / Android WorkManager;
config only, no prebuild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dpoints

Adds the query/mutation surface Phase 5 mobile screens need:
- thing/place/tag album detail grids (grouped_photos)
- shared-by-me / with-me photos + albums
- share/unshare photos + albums, photo public-link toggle
- face review: list faces, incomplete clusters, label/delete/train
- admin: paged jobs, worker availability, scan/full-scan triggers
- user prefs PATCH (favorite_min_rating et al.)

New schemas/faces.ts; reuses existing TagAlbumResponse. 13 new vitest
cases (endpoints.test.ts); api-client now 25 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Mirrored list screens for thing/place/tag albums (cover + count),
  reusing AlbumsListScreen; `all` route shows the list, a numeric id
  opens the online detail grid.
- OnlineAlbumDetailScreen fetches per-album membership via api-client
  (not mirrored) with loading/error/offline/empty states through a new
  reusable OnlinePhotoGrid; title stays offline-safe from the mirror.
- pigPhoto helpers (PigPhoto/grouped_photos → GridItem).
- Expand en.ts with the full Phase 5 string surface (search, memories,
  sharing, faces, settings, profile, stats, admin, shareIntent).
- Tests: named-albums query unit tests (node) + OnlineAlbumDetailScreen
  render/offline (rn).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Search tab: debounced input; server semantic/text search when online via
useSearchPhotosQuery; offline fallback runs local SQL over search_location,
person names, album titles, and date terms (labeled "offline results");
recent searches persist in app_meta (MRU, capped, dedup).

Memories: pure-mirror "On this day" query (favorites weighted, prior years);
horizontally scrollable MemoriesCard on the timeline header + full
/memories grid screen for the See-all / notification deep-link target.

Tests: offline-search + recent-search query units (node), on-this-day query
(node), SearchScreen server/offline/recent (rn), MemoriesCard (rn).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Sharing hub (Profile → Sharing): by-me / with-me toggle, shared albums
  (list) + shared photos (grid) fetched online with offline state.
- ShareSheet wired into the grid selection bar (online-only share action):
  share selected photos to a chosen user (UserPickerSheet + setPhotosShared)
  or make them public and copy the gallery link (expo-clipboard).
- api-client calls go through useApiClient() so tests inject a mock client.
- jest FlashList mock now renders ListHeaderComponent (fixes header-backed
  screens under test).
- Adds expo-clipboard (~57.0.1).

Tests: SharingScreen (rn), ShareSheet share + public-link (rn).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…der, share target

Face tagging (Profile → Faces, online-only): Suggested tab accepts/rejects
inferred faces inline; Unknown tab multi-selects and assigns a name with
mirror-backed autocomplete; "Rebuild people" queues re-clustering.

Profile hub: identity + storage, library StatsCard (mirror counts), and rows
to Settings / Sharing / Faces / Server (admin only) / Sync.

Settings: theme, locale switcher (33-locale registry + RTL, changeAppLanguage),
thumb-cache usage/cap/clear, wifi/charging backup toggles, favorite_min_rating
(online PATCH → reseed via runSync), library scan, password change.

Admin-lite (Server): polled job list + worker indicator, scan / full-rescan.

Memories reminder: local (no-push) daily expo-notifications schedule deep-linking
to /memories, enable+time in Settings, NotificationRouter for taps; app.json
notifications plugin.

Share target: Android SEND/SEND_MULTIPLE intent filters + /share receiving
screen enqueuing one-off uploads via the upload worker path (synthetic album
survives orphan sweep); iOS documented as prebuild-time.

Adds expo-notifications. Tests: faces, profile, settings, admin, stats,
libraryStats, memories-notif prefs, shared-upload enqueue, share-intent, format.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- scripts/import-legacy-translations.mjs: best-effort importer mapping the
  legacy RN app's resource keys onto mobile-v2 i18next keys. The legacy app
  only ever shipped English, so it reports what maps and writes nothing for
  other locales — which correctly fall back to English (fallbackLng) until
  Weblate fills them in. Committed as the reusable mechanism + mapping table.
- ShareSheet: add "Remove public link" (setPhotosPublic false) so public-link
  management covers create / copy / revoke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Track the root package-lock.json for reproducible npm-workspaces installs,
ignore the root node_modules/, and add a 'check' script (typecheck + lint +
test) to apps/mobile-v2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the Expo template README with an architecture + dev-setup guide
(mirror-first reads, sync engine, outbox, backup; Windows/mac setup; test and
db:generate commands; dependency constraints; F-Droid notes; deferred items).

Add a user-facing 'LibrePhotos Mobile v2' page to apps/docs (install, first
login, offline mode, backup, sync status/repair, memories, share target) and
link it from the Mobile Apps index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
derneuere and others added 30 commits July 26, 2026 22:48
The Albums tab was a bare ScrollView of seven text links with hardcoded
English labels — no icons, no counts, no previews, and it bypassed i18n
entirely, which is a real bug: every user-facing string must go through
`t()`.

It is now an Explore hub with the same information architecture as the web
frontend's `/album` route. One section per category — My Albums, People,
Things, Tags, Places, Auto Created Albums, Folders — each with a category
icon, the frontend's own wording, a live count, a "View all" affordance and
a horizontal strip of preview cards (cover, title, photo count). The
existing category routes are unchanged and keep working.

Everything except Folders is read from the SQLite mirror in a single
reactive query, so the screen is fully usable offline and updates as sync
converges. Folders have no mirror table (they are a server-side directory
walk), so that row degrades to an explanatory notice instead of a broken
strip, and the folders route says the same thing.

First run leaves the mirror empty for several minutes, so a section shows
skeletons while its entity has no completed full sync in `sync_state`, and
a per-section empty state ("No albums yet") once seeding has finished —
the two are distinguishable, which a blank gap never was.

Wording is lifted from the frontend's `explore.*`, `sidemenu.*` and
`personalbum.*` keys so mobile and web say the same thing for the same
concept. People get their own `explore.peopleCount` rather than the
frontend's "N albums", which reads wrong under a row of faces.

The category list/detail screens are localized along the way: the album and
people grids, their empty states, the "Unknown" person fallback and the
per-card photo counts all go through `t()` now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Search was a plain text box over a single photo grid. It now presents
results the way the web frontend groups them — people, albums, places,
things, tags, then photos — with each group labelled, counted, horizontally
scrollable and tappable through to the matching screen.

The non-photo groups always come from the SQLite mirror, so they answer with
no latency and keep working offline; only the photo grid depends on the
server's semantic search. That also fixes a layout hazard: the groups live
in a height-capped scroller above the grid, so a virtualized list is never
nested inside a ScrollView.

The offline fallback is unchanged in behaviour and still explicitly labelled
"Offline results" with its explanatory note — plus an icon, so it reads as a
deliberate state rather than a degraded one.

Every state is now real and localized: a prompt with recent-search chips
(44pt targets, clear-all) when nothing is typed, a searching spinner, a
search-specific error with a Retry button, and a no-results state carrying
the query back to the user. The 300ms debounce is untouched; the field gains
a leading magnifier and a visible clear button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop an unused plural key and reword the search error so it does not promise
a pull-to-refresh the screen does not have — the recovery affordance is the
Retry button next to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pipeline

Viewer (#7). Tapping a camera-roll photo did nothing, which read as "lightbox
not implemented". Every caller guarded with `if (item.imageHash)`, and a local
asset has no image hash until it is hashed. The viewer now takes any identity
the grid can produce (remote id, image hash, or local asset id), resolves it
against the merged timeline (falling back to a single-slide lookup for deep
links), and renders local-only photos straight from their ph:// uri, hiding the
server-only action bar and explaining why. Added pinch- and double-tap zoom
(ZoomableImage): pan is enabled only while zoomed so the horizontal pager keeps
its swipe, and the single tap stays a Pressable so it remains accessible.

Dates (#8). Nothing formatted dates at all: timeline section headers printed the
raw bucket_day ("2026-07-24") and the viewer printed the raw exif_timestamp.
Added locale-aware helpers to lib/format.ts - formatDayHeading (Today /
Yesterday / weekday+date / +year), formatMonthHeading, formatFullDate - parsing
day keys as LOCAL dates (a UTC parse shifts the day west of Greenwich). Wired
into the timeline headers and the viewer info sheet, whose labels are now
translated too.

Pipeline starvation (#9). runSequence was one sequential chain, so hashing could
not start until the entire camera-roll enumeration finished - and enumeration
restarts on every reload. A user watched 161/2867 photos hash, reloaded, and the
counter never moved again. The scan and the hash pass now run concurrently
(orchestrator and the Backup screen's "back up now" path alike); runHashPass
gains `keepGoing` so it keeps picking up assets the scan commits mid-run instead
of exiting on the first empty batch. New node tests assert hashing progresses
during the scan, resumes from local_asset.hash after an interrupted run rather
than restarting at zero, that an aborted run latches nothing, and that the
second run takes the createdAfter fast path instead of re-enumerating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sync pipeline was a strict `await` chain — outbox, remote delta, device
scan, hashing, uploads, thumbs. On a real 2867-photo library that shape failed
three ways at once: hashing stalled behind a full camera-roll enumeration,
uploads never began because they sat last behind the slowest steps, and the
only record of progress was the JS call stack, so a reload restarted
everything. A user watched 161 photos hash, reloaded, and the counter never
moved again.

Replace the driver with a `job_queue` table and a worker, shaped like the
`outbox` and `upload_queue` tables that already work this way (attempts,
next_attempt_at, last_error, boot reclaim of rows a killed process left
behind).

- Eight job kinds, each sized to finish well under a second: outbox_replay,
  reseed_check, remote_delta (one page), device_scan (one chunk), hash_batch
  (~50 assets), upload_asset (one photo), thumb_prefetch, integrity_check.
  `delta.pullEntityStep` and `worker.runUploadItem` are the new single-unit
  entry points; the old loops stay for the background task.
- Claims are transactional, so two workers can never share a job. A partial
  unique index on dedupe_key over (pending|running) makes re-enqueuing the
  same work a no-op instead of a pile-up.
- device_scan / hash_batch / upload_asset deliberately share one priority.
  Distinct priorities would let the best-ranked one monopolise the worker,
  which is the strict chain re-implemented in data; sharing one makes the
  tie-break insertion order, so the three stages round-robin and uploads
  begin while hashing is still running.
- Cancellation releases the in-flight job with its attempt refunded, so
  backgrounding the app costs nothing.

`syncAll` now enqueues and drains rather than sequencing; runSync,
runBackupNow, repairSync, cancelSync and the triggers keep their signatures.
Integrity drift is now repaired inside the same drain instead of being
deferred to some later run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…reens

The device run turned up screens that could not answer the three questions
that matter when a backup looks stuck: which stage is running, how far along
it is, and what is blocking it. The worst symptom was a progress fraction
whose denominator grew as the stage discovered work — "0/161" on a
2867-photo library, which the maintainer had to ask about.

- jobs/status.ts: queue depth per kind, the in-flight job by name, and every
  failure with its own error text and retry window. Plus per-stage progress
  (remote / scan / hash / upload / thumbs), each against a total that is a
  count of work already known to exist: the device's own reported library
  size, mirror row counts, the upload queue's own total. Never a running
  tally of what has been found so far.
- upload/status.ts gains the queue rather than duplicating it: `backupState`
  takes the snapshot, adds a `scanning` stage with the real device total, and
  a `job_failed` blocker that quotes the actual error — the only blocker that
  can say in its own words why a whole stage stopped.
- Sync status screen grows a work-queue section and a pipeline section; the
  Backup screen's headline now names the scan instead of reporting a hash
  fraction over a library it has not finished enumerating. Retry revives
  failed jobs alongside failed uploads.

Hash progress counts images only and reports unreadable files separately, so
a permanently unreadable photo cannot leave the bar stuck at "2864 of 2867"
with no explanation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The OS background pass was a hand-written copy of the foreground sequence —
replay, photos delta, backup top-up, thumbs — so there were two orderings to
keep in step, and a window the OS cut short left no record of how far it got.

It now enqueues the same jobs and drains with a job budget. The queue's
priorities already put the outbox and the photo delta first, so "any prefix
is useful" is the default rather than something the step order has to encode,
and every completed job is durable when the OS suspends us mid-window.

Also documents the queue in the README: the table, the job kinds and their
sub-second sizing rule, the claim/reclaim mechanics, and why the three
background stages deliberately share one priority.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two `failed` rows can share a dedupe key when the same work dies terminally
in two different sessions. Reviving both violates the partial unique index
that guarantees one live row per key, so the retry threw instead of retrying.

Keep only the most recent failure per key, alongside the existing rule that
drops a failure whose work has since been re-requested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compares the web lightbox (29 files, ~4.5k lines) capability by capability
against the mobile viewer and decides ship/adapt/defer for all 57 of them:
27 ship, 12 adapt, 18 defer.

The load-bearing decisions:

  - A phone has no room for the 400px sidebar, so the info surface is a
    draggable three-detent bottom sheet built on the gesture-handler and
    reanimated we already depend on.
  - Three data tiers with three explicit offline states. The cached
    `remote_photo_detail` payload turns out to carry EXIF, people (with
    face boxes), similar photos and the AI caption, so those are offline
    once seen; album membership is mirrored and offline always. Nothing
    renders blank.
  - Mutations that the mirror can represent go through the outbox;
    timestamp edit and make-public are online-only direct calls, disabled
    with a reason rather than silently inert.
  - Local-only camera-roll photos degrade with an explanation instead of
    vanishing — the bug that made the lightbox look unimplemented.

Doc 05's viewer row is updated to match, and `expo-video` is installed at
the SDK 54 version for the video slides that follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tokens were persisted in secure-store but the server URL lived in an
in-memory zustand store. After any reload the app restored its tokens,
reported itself authenticated, and then issued every request with an
empty base URL. In Expo Go a relative request resolves against the Metro
dev server, which answers unknown paths with its own HTML error page --
so sync failed with a flood of "API error: 500" that never reached
LibrePhotos at all (the Django access log recorded none of them).

- Persist the URL next to the tokens: the two only mean anything
  together, since a token identifies you to a particular server.
- Hydrate it during auth bootstrap, before the router reports
  authenticated and the first sync fires.
- Treat a missing URL as signed out rather than authenticated-with-
  nowhere-to-go.
- Throw from the transport when the base URL is empty, so this can never
  again masquerade as a server-side 500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the five-row detail box with the bottom sheet doc 07 specifies:
three detents, dragged or tapped, mounted over the pager so the photo
still swipes underneath.

Sections, each with an explicit empty state and — where the data is the
cached detail payload — an explicit "open this once online" state:
caption (edited through the outbox, with the model's own suggestion
offered from the cache), people with tap-to-highlight face boxes,
location with an OpenStreetMap tile preview, albums straight from the
mirror, scene labels, file info, camera info, similar photos.

The map is raster tiles fetched as plain images rather than a native map
module: every one of those is absent from Expo Go or needs a dev build,
and an iOS dev build needs a paid Apple account this project declined.
Tapping hands off to the platform maps app.

Also here:

  - Chrome (top bar, filmstrip, action bar) toggles on a single tap;
    the sheet has its own button, because a tap means "get out of the
    way" on a phone, not "show me EXIF".
  - The action bar is icons rather than unicode stars, and gains
    make-public — the one control with no mirror representation, so it
    renders disabled with a reason instead of looking tappable.
  - Timestamp edit lands as an online-only PATCH. It reorders the
    timeline and regroups its day buckets, so an optimistic offline
    write would reshuffle the mirror against a change the server may
    reject; `isViewerActionAvailable` now says so instead of claiming
    every viewer action is offline-capable.
  - Video slides play through expo-video, and only the active slide
    mounts a player — a 500-slide window of native decoders is not a
    thing a phone survives.
  - Neighbouring slides (±2) are prefetched into expo-image's disk
    cache, which doubles as offline warming.

Local-only camera-roll photos keep working throughout: they render from
their uri, skip the server-backed sections entirely, and say why.

apps/mobile-v2: 348 tests / 50 suites green (was 303 / 48).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A GIF's big thumbnail is a single frame, so an animated photo sat there
motionless. The active slide now loads from the originals endpoint when
the payload's path says .gif — active only, because originals are
full-size files and the pager window holds hundreds of slides.

Tests for the video slide (one player, mounted only for the slide being
watched), the filmstrip, and the GIF path. Four `viewer.*` strings that
the old five-row detail box owned are gone with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
expo-sqlite's addDatabaseChangeListener is sqlite3_update_hook, so it fires
once per changed ROW, not once per commit — the event payload carries a rowId.
A 2867-photo camera-roll scan therefore emitted well over 10,000 notifications
(one per asset upsert, one per album link, one per hash UPDATE, plus upload and
job queue churn), and every one of them synchronously re-ran every mounted
reactive query. The most expensive of those is the merged timeline: a UNION ALL
with no inner LIMIT that materialises the whole library and sorts it in a temp
B-tree. That is the jank.

A coalescing hub now sits between the raw event source and the queries:

  - trailing debounce (90ms) turns a burst into one flush;
  - a max-wait ceiling (450ms) forces a flush under a continuous stream, so the
    scan stays visible instead of being debounced into invisibility;
  - optional per-query table scoping, so a local_asset write need not re-run a
    query that only reads remote_photo. Undeclared queries wake on everything
    and an unnamed table wakes everyone — both defaults fail safe;
  - optional deferral while the user is mid-gesture, capped so progress never
    stalls outright (wired to a real interaction signal in a later commit).

Coalescing cannot drop the final update: a flush carries no data, it is only a
signal to re-read, and the re-read hits the live database at the moment it
runs. Every change re-arms the timer, so a flush is guaranteed within quietMs
of the last change and observes final state.

The hub is pure TypeScript, so it is Node-tested with fake timers against a
real better-sqlite3 database and the real timeline query: 2867 row events
collapse to fewer than 29 query runs and the last one still sees every row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marks all fourteen steps of doc 07's order as landed, with the file each
one lives in, and gives the viewer its own section in the app README —
the three things (bottom sheet not sidebar, three offline tiers with no
blanks, raster tiles not a map module) worth knowing before touching it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The queue's stated contract is that one job run finishes well under a second —
that is what makes background work interruptible for the user and an
interrupted run cheap. Two budgets did not hold on an iPhone 11:

  - SCAN_CHUNK was 400 assets: metadata enumeration plus ~800 SQLite row
    writes. Now 150. The budget was also only checked *after* a whole provider
    page had been read, so it was advisory rather than a cap; streamAssets now
    clamps each fetch to the remaining budget, and the page size drops 200 -> 100
    so the scan yields to the UI thread twice as often.
  - HASH_BATCH_SIZE was 50: fifty md5 passes over whole photos, each of which
    may first have to materialise a ph:// asset out of iCloud — seconds, not
    milliseconds. Now 8. hasher.ts's own default tracks the same constant so
    the two cannot drift apart again.

Both are starting values, not new guesses. sizing.ts measures what a unit
actually costs on this device and nudges the budget toward a 400ms target,
damped to at most 2x per sample so one iCloud-stalled hash cannot collapse the
batch to the floor, and clamped to a hand-checked range. An explicit `budgets`
override bypasses the controller entirely, which is what keeps the pipeline and
chaining tests deterministic.

So this is diagnosable next time rather than guessed at again, the worker now
times every settled job, records the slowest one in the run summary, and logs
any job over 1s as a warning naming its duration — the scan chunk and hash
batch stayed invisible until a device report precisely because a duration that
nobody flags is a duration nobody reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The queue already yielded between jobs, but a macrotask yield only shares the
thread — it does not prioritise anyone. A camera-roll scan therefore ran at
exactly the same rate whether the user was staring at a static screen or
dragging the timeline, which is the difference between "a scan is running" and
"the app is janky".

sync/activity carries a cheap interaction stamp, fed by the photo grids'
onScroll (the only signal that survives a native ScrollView's momentum) and by
a capture-phase touch sniffer at the router root for taps and tab switches.
AppState clears it on background, so a backgrounded app spends its short OS
window working rather than waiting out a stale touch, and stamps it on resume so
the first moments after coming back belong to the user.

Two consumers:

  - the worker's between-jobs yield waits in 100ms slices while the user is
    interacting, so the pipeline runs at a low duty cycle during a gesture and
    resumes within one slice of the finger lifting;
  - the live-query hub defers its flush for the same reason — re-running the
    merged timeline mid-drag reflows the list under the user's finger.

Both are capped (1.5s per yield, 1.5s per flush), so back-off delays work but
can never drop or stall it: a user who scrolls without pause still gets a steady
drip of progress and a visibly moving counter. A worker test drives a 12-job
chain with interaction pinned on for its entire duration and asserts the drain
still completes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ouch

Deferring a flush for any interaction had a nasty edge: a tap that favourites a
photo is itself a database write, so the touch that caused the change would also
postpone the query that shows it — up to 1.5s of the user's own action not
appearing.

Reflow-under-the-finger is specifically a scroll problem, so the two signals are
now tracked separately. The photo grids report scrolls, the root sniffer reports
taps; the worker backs off on either, but the live-query hub only defers while
content is actually moving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-job timing rows are useful but short-lived: sync_log is a 500-row ring, and
a first backup pushes thousands of hash and upload rows through it in seconds,
so by the time the maintainer exports the log the interesting rows are gone.

The worker now folds each job's duration into a per-kind worst case and the run
summary prints it worst-first ("slowest hash_batch 620ms, device_scan 380ms").
One surviving row answers the question the per-job rows were meant to: which
unit of work is too big on this phone.

Also wires thumb_prefetch into the adaptive sizer, which had a spec but was
still reading the fixed constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/api/upload/complete/ read the JWT from a `jwt` cookie only. That is a
browser-ism: React Native's fetch cannot reliably set a Cookie header (on iOS
NSURLSession owns the cookie store and drops it), so mobile-v2's completion call
arrived unauthenticated and answered 403 x20 while the chunk POST — sent by a
native uploader whose headers do survive — answered 200 x32. Nothing ever
reached the server.

Both UploadPhotosChunked and UploadPhotosChunkedComplete now resolve the user
through one helper that prefers `Authorization: Bearer` and keeps the cookie as
a fallback, so check_permissions and on_completion cannot drift apart again and
the web frontend is unaffected.

Second defect, worth a separate line: a completion that failed inside
on_completion left the row marked COMPLETE, so every retry of that upload_id
answered a permanent 400 "Upload has already been marked as complete" and the
staged bytes were orphaned. The status is now rolled back when on_completion
raises. Also closes the staged file handle before deleting it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three defects, all in the same three-line hop.

1. `complete()` forged a `Cookie: jwt=<token>` header that React Native's fetch
   never delivered. It now authenticates with `Authorization: Bearer`, which the
   backend accepts on both upload views; the cookie is still sent alongside so
   this client keeps working against an un-updated server.

2. The completion checksum was `local_asset.hash`, recorded during the hash
   pass. An asset whose bytes changed in between makes the server answer a
   permanent 400 "md5 checksum does not match" that no retry can clear, because
   every retry re-sends the same stale hash. The transport now checksums the
   bytes it is about to upload, in the same stat call that sizes them.

3. Worst of the three: `runUploadItem`'s `failed` was dropped on the way through
   the seam, so an upload that neither uploaded nor skipped settled as a
   *successful* job with applied=0. That is why a server rejecting every single
   completion produced a sync log full of `done` and no blocker anywhere. The
   flag now survives, and the handler throws — a rejected upload fails the job,
   which is what buys a retry, a backoff and a visible blocker.

The server's own explanation ("md5 checksum does not match", "No scan directory
configured") now survives into the thrown message instead of a bare HTTP code,
so the next occurrence is a one-line diagnosis rather than access-log
archaeology.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The merged timeline (remote photos UNION camera-roll assets) carried no
limit inside either UNION arm, so SQLite could not push the outer LIMIT
through the compound select: it materialised every remote photo and every
eligible camera-roll asset, sorted the lot in a temp B-tree, and threw all
but N rows away. That ran synchronously on the JS thread and grew with the
library — 17ms at 3k+2.8k rows, 43ms at 12k+2.8k — and it sat on the
critical path of opening the viewer.

Each arm now carries its own ORDER BY … LIMIT, so SQLite walks
idx_remote_photo_visible / the new idx_local_asset_timeline in key order and
stops. The only sort left is the merge of two already-sorted arms, bounded
by 2×limit whatever the library size. A 41-row viewer window now costs
~2-3ms and no longer scales with the library.

Also here, because windowing needs them:

- keyset pages take a direction ("older"/"newer") and an inclusive flag, so
  a caller can read a slice *around* a photo rather than from the top;
- timelineCursorFor() resolves a remote id, an image hash or a camera-roll
  asset id to its place in the sort order;
- the remote arm reads its camera-roll counterpart through correlated scalar
  subqueries instead of a LEFT JOIN, so a phone holding the same image twice
  no longer duplicates that photo in the timeline (and, now that the arm is
  limited, duplicates cannot eat the page).

EXPLAIN assertions extended to cover both arms and to pin the fact that
exactly one bounded sort survives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device report: "clicking on a photo to open up a lightbox takes a while.
this should be instant."

The viewer's first act was to ask the database for context it had just been
handed: `timelinePage(db, { limit: 500 })`, in a live query, on mount. That
cost a whole-library sort (see the previous commit), 500 rows marshalled
into JS, and 500 filmstrip tiles mounted — all before one pixel of the
tapped photo, and re-run on every database commit while a camera-roll scan
was writing.

Now:

- the grid hands the viewer the slide it was already drawing (hash, uri,
  type) as route params, so frame one is that photo and owes the database
  nothing. A deep link without params falls back to one indexed row lookup,
  not the timeline;
- the pager is a ~41-slide window anchored on the tapped photo (keyset
  cursor, both directions), extended by a page as a swipe approaches either
  end and still able to reach both ends of the library. It is deliberately
  not reactive: a viewer is opened at one photo and paged from there, so a
  background sync no longer reflows the pager under a finger. Photo-level
  state (favourite, rating, caption, faces) stays live;
- the detail payload (disk read + zod parse + network), the album rows and
  the neighbour prefetch are deferred behind `useAfterFirstPaint`, so the
  sheet fills in a beat later while the photo is already up;
- remote slides show the grid's square thumbnail as an expo-image
  placeholder, which is already decoded, so there is no beat of black while
  the big thumbnail loads.

FlashList only maintains visible content position for vertical lists, so the
screen re-anchors the pager itself whenever the window grows at the start;
slides are exactly one screen wide, so the target index is unambiguous.

Pinch/double-tap zoom, the filmstrip, face overlays, video slides, the info
sheet's offline tiers and opening a local-only camera-roll asset all keep
working — covered by the existing suite plus new tests for windowing (both
ends), route seeding and first paint against an empty mirror.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…two arms

Device report: "backed up images disappear from the timeline."

The merged timeline's two arms disagreed about what "already on the server"
meant. The camera-roll arm dropped an asset the moment *any* remote_photo
row shared its hash; the remote arm only surfaced rows that were visible
(hidden = 0 AND in_trashcan = 0 AND removed = 0 AND timestamp IS NOT NULL).
Anything in between fell through both arms and vanished — on the device, on
the server, invisible in the app.

The common way in is a fresh upload: the server's row syncs back before EXIF
extraction has given the photo a timestamp, so the user backs a photo up and
watches it disappear. Server-side hide/trash/delete had the same effect.

The local arm now suppresses an asset exactly when the remote arm is showing
it — the same predicate, not bare existence. The trade-off is deliberate: a
photo hidden or trashed on the server returns to the timeline as a plain
camera-roll photo for as long as the file is on the phone. It is on the
device; the app must not pretend otherwise.

Hypothesis B (backup selection) was checked and does not reproduce: dropping
iOS smart-album enumeration cannot orphan an asset, because nothing prunes
album rows or their memberships and every asset is relinked to the synthetic
whole-library album on every run. Pinned with tests in media-sync, which also
record the one real consequence — a stale, no-longer-reported album stops
accumulating new assets — so it cannot regress into a disappearance.

Regression cover: a backed-up photo still on the device appears exactly once
(and is bucketed once, and can still anchor the viewer) when its server row
has no timestamp, is hidden, is trashed, is removed, or all of those; and a
photo whose server row IS visible is still emitted by one arm only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…loud

"Preparing photos" was measured at ~1.8 s per photo on a real iPhone
(hash_batch: hashed 8 in 14726 ms). md5 over a 3 MB file is tens of
milliseconds, so hashing was never the cost — fetching the bytes was.

Every `ph://` asset was resolved with
`getAssetInfoAsync(id, { shouldDownloadFromNetwork: true })`. With iCloud
Photos and "Optimise iPhone Storage" — the default once a library outgrows
the device — most originals are not on the phone, so each hash silently
pulled a multi-megabyte original down the wire before a single byte was
hashed. The pipeline had no idea it was doing it: the only visible symptom
was a progress bar moving at one photo per two seconds.

The hash pass now asks with `shouldDownloadFromNetwork: false`. iOS answers
immediately with either a localUri (hash at full speed) or
`isNetworkAsset: true` and no uri — populated *only* on the no-network call,
which is precisely the one we make now. That third outcome gets a name:
AssetHasher returns hashed | remote | unavailable instead of `string | null`,
so "the bytes are elsewhere" is a state rather than a failure.

Remote assets are parked on the row as `local_asset.hash_state = 'icloud'`
(new column, migration 0006) with `hashed_at` left NULL — they have not been
attempted, they are waiting. selectUnhashed excludes them, which is
load-bearing: without it every batch would re-select them, re-learn they are
remote, and report `more` forever.

The Backup screen gets the sentence it needs: "1,204 photos are stored in
iCloud, not on this phone", plus a dedicated stage for when that is all
that is left. A stalled bar is not a state; this is. "Retry failed" also
un-parks them, so turning "Optimise iPhone Storage" off is recoverable
without a reinstall.

The hash log now carries ms/photo, the number that exposed the bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A photo that has to come down from iCloud to be hashed used to come down
again to be uploaded. Now the upload path does both from one fetch.

iCloud-parked assets (hash_state = 'icloud', no hash) are queued for upload
unhashed — refusing them would silently drop most of the library on any
iPhone using "Optimise iPhone Storage" — and sort behind everything already
on disk, so a network fetch never holds up photos that are ready to go.

When the worker reaches one it calls the new `materialize` seam: the single
call in the app allowed to pull an original down. It returns the local uri
*and* the md5 of the bytes it fetched, so that one download feeds the hash,
the /api/exists dedupe check and the upload itself. The hash is persisted
before the network round trips, so an interrupted run does not throw the
download away, and the transport takes the pre-measured md5 rather than
re-reading a file it was just handed the checksum for.

This also puts every iCloud download behind the backup toggle and the
Wi-Fi/charging gate, which is where it belonged: hashing is a background
housekeeping pass and had no business spending the user's cellular data,
while an upload the user asked for plainly does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fetch

The whole regression lived in one options object handed to a native module,
where no pure test could see it. So expo-asset-hasher now has a suite that
mocks expo-media-library and asserts on the arguments: hashing must call
getAssetInfoAsync with shouldDownloadFromNetwork FALSE, and an iCloud-only
answer must come back as `remote` without a single byte being read.

Also covered:
- classification of local / iCloud-only / unreadable, including the choice to
  defer rather than write off an asset when the platform says nothing;
- the parked state — hash_state set, hashed_at left NULL, dropped by
  selectUnhashed so the hash_batch chain cannot spin on it forever;
- deferrals counting against the job budget, and clearDeferredHashes reviving
  them when the phone's storage setting changes;
- the shared fetch: one materialize call feeds hash, dedupe and upload, the
  hash is persisted, and a second run does not download again;
- queue ordering, so a network fetch never overtakes a photo already on disk;
- the Backup screen saying "stored in iCloud, not on this phone" instead of
  showing a bar that stops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The default that caused the bug (shouldDownloadFromNetwork: true) is the
module's default, so the next person to touch this code will reintroduce it
unless the README says why not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…field

The mobile delta feed read `Photo.timestamp` and emitted it as the photo's
capture time. That field is the *input* override: a user correcting a date in
the UI writes it (PhotoSerializer.update), and the upload endpoint writes a
client-supplied `device_created_at` into it for files that carry no EXIF date
(apply_device_timestamp_fallback). A scan never touches it. The date the scan
extracts goes to `Photo.exif_timestamp`, which is what every other serializer in
the codebase reads and what the datetime rules fold the override back into.

So an ordinary scanned photo - the overwhelming majority - reported
`timestamp: null`, and a client that orders its timeline by capture time had
nothing to order by and showed nothing at all. Read `exif_timestamp` and keep
the override as the fallback, for the window in which the fallback has written
it but re-extraction has not yet run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant