Scout — Full Product Context → feature documentation

Camera-roll import / photo retrace

'Import from Camera Roll' (in-app UI: 'Retrace my steps') lets a user backfill their Scout collection from photos they already have, instead of only unlocking patches by…

Screen recordings

Short spans cut from real sessions on a real device. Silent, no narration, no editing beyond the trim.

1 found 7.8s · recorded 2026-09-09
23 -> 68 found 30s · recorded 2026-09-09
87 -> 118 found 31.03s · recorded 2026-09-09
Retrace scan 23.6s · recorded 2026-09-11
Import from Camera Roll 1.6s · recorded 2026-09-11
You've already been there 2.7s · recorded 2026-09-11
0 -> 116 found 45.2s · recorded 2026-09-11
117 new 4.3s · recorded 2026-09-11
After the import 3.6s · recorded 2026-09-11

Full sessions

The complete, unedited recordings the clips above were cut from — every tap, including the dead ends. These are the raw captures, reframed for the web and otherwise untouched.

Full recording · 09-09 12:36 110.7s · recorded 2026-09-09 · unedited
Full recording · 09-11 11:15 59.4s · recorded 2026-09-11 · unedited

Summary

"Import from Camera Roll" (in-app UI: "Retrace my steps") lets a user backfill their Scout collection from photos they already have, instead of only unlocking patches by physically carrying the phone there. The app reads GPS and timestamp metadata off the user's photo library (or one album, or a hand-picked selection), sends the coordinates to the backend in batches, gets back which patches' boundaries contain each point, and lets the user pick which of the found patches to add to their collection. The patch's "collected" date is set to the photo's own creation date, not the moment of import. Patches earned this way are explicitly tagged source: 'import' and are excluded from the achievement system — only in-person GPS unlocks count there. No image ever leaves the device; only latitude/longitude and a timestamp are sent to the backend.

Status (shipped / beta-badged / flagged off)

Shipped, fully live, no feature flag. There is no photo_import-style entry in the feature-flag registries (mobile/src/config/feature-flags.ts, backend/src/app-config/app-config-schema.ts) and openSource/usePhotoImport run unconditionally. Unlike sibling drawer items ("The Album", "My Patches" grouping, "Day Trip", "Road Trip"), the drawer's "Import from Camera Roll" row carries no trailing: betaBadge — it is not marked beta (mobile/src/components/navigation/drawerSections.ts:246-251). The only gate is isVisible: (ctx) => ctx.hasSession, and guests get a session, so it is visible to essentially every user, signed-in or not.

User-facing surfaces (screens, routes, deep links, entry points)

How it works (end-to-end mechanism)

  1. Source selection. usePhotoImport.runImport(source) (mobile/src/hooks/usePhotoImport.ts:216) drives everything from here on; scanAllPhotos/scanAlbum/selectPhotos are thin wrappers that pick a ImportSource ({kind:'library'}, {kind:'album', albumId, title}, or {kind:'picked'}).
  2. Acquisition (mobile/src/hooks/photoImportSources.ts, openSource):
    • library/album: requests expo-media-library permission on demand (tied to the user's tap, not screen mount), then pages through MediaLibrary.getAssetsAsync in batches of 100 (BATCH_SIZE), reading up to 15 assets concurrently per batch (READ_CONCURRENCY).
    • picked: opens expo-image-picker's native multi-select (launchImageLibraryAsync with exif: true), which needs no library permission grant at all.
  3. Per-photo GPS read. For library/album, each asset's location comes from expo-media-library/build/next's Asset(nextAssetRef(id)).getLocation() — deliberately not MediaLibrary.getAssetInfoAsync. A code comment (photoImportSources.ts:128-137) documents the measured trap: on a 131-photo simulator library, getAssetInfoAsync took 174ms/photo (its iOS path resolves a PHContentEditingInput and decodes the file off disk to build a full EXIF dictionary), ran on the main thread, and dragged the UI to ~4fps for the whole scan (27.1s total). getLocation() reads only PHAsset.location (Android: the EXIF header via ExifInterface), measured at 1.3ms/photo with identical results (0.30s total for the same 131 photos). mediaAssetRef.nextAssetRef() (mobile/src/utils/mediaAssetRef.ts) translates the legacy asset id into the next API's id shape (ph:// on iOS, a rebuilt content://media/external/images/media/<id> URI on Android).
    • For picked photos, GPS instead comes from the EXIF dict ImagePicker already returns (extractGpsCoordinates, mobile/src/lib/exif.ts), applying the N/S/E/W sign to the raw magnitudes.
    • A read failure is tallied separately from "no GPS" (LocationRead's ok: false branch) so a systematic failure surfaces in logs rather than silently reading as "none of your photos have location."
  4. Matching, page by page. As each page of GPS-bearing photos comes back, usePhotoImport chunks it to 100 (MATCH_CHUNK, since the batch endpoint caps at 100 locations — see below) and calls matchPhotosToPatches (mobile/src/utils/unified-photo-matching.ts:336), which calls POST /api/location/check-batch (mobile/src/utils/unified-photo-matching.ts:140, backend at backend/src/location/location.controller.ts:39 / backend/src/location/location.service.ts:89). If the backend call throws, matching falls back to a client-side Haversine centroid-plus-radius check (matchViaClientFallback) — which cannot see polygon boundaries at all, only patches with a plain lat/lng, so an offline/degraded import silently misses every polygon-backed (city, state, NPS, OSM) patch until a later backend-connected pass runs.
    • A previous page's match is awaited before the next page's match starts (keeps memory/accumulator updates serial), while the acquisition generator is already fetching/reading the next page in parallel — so disk reads and the network round-trip overlap.
  5. Server-side containment (LocationService.checkBatch, backend/src/location/location.service.ts:89-203) runs two SQL queries per batch:
    • Point match: for patches with neither a protectedAreaId nor a geofenceId (a bare lat/lng pin), ST_DWithin within photoMatchRadiusMeters (AppConfig key, default 150m — deliberately larger than live-tracking tolerance because photo GPS is less accurate).
    • Polygon match: ST_Contains against protected_areas.geom UNION geofences.geomplain containment, no buffer. A comment (location.service.ts:144-159) documents that this replaced an earlier blanket photoPolygonBufferMeters (10m) applied to every polygon, which let a whole stadium be "earned" from the pavement outside it; any tolerance a subject needs (a statue, a monument) is now baked into the geometry itself (geofences.source = 'osm_buffered'), not into the import path. This makes camera-roll import agree with the live-tracking unlock rule (checkPolygons) for every polygon-backed patch — the one documented exception is a PatchPhoto near-miss buffer used elsewhere for photo check-in verification, not this batch path; see location.geometry.db.spec.ts's own comments for that history.
    • Because containment is queried directly (not per-photo iteration), a single photo inside nested boundaries (e.g. a zoo inside a city park inside a city inside a state) returns every containing patch as an independent direct match — there is no separate "cascade" step; that logic was deleted (unified-photo-matching.ts:257-266) when city/state patches got real boundary polygons.
  6. Accumulation. createMatchAccumulator() (mobile/src/hooks/photoImportMatching.ts) dedupes matches by patch id as pages stream in, keeping the earliest photo date per patch (so if the same place appears in five photos, the earliest one's date is what gets recorded), while preserving first-seen order for the "recently found" strip shown during the scan.
  7. Visit photos (device-local, not the community gallery). Every matched still photo (not video) that has a durable MediaLibrary asset id is kept as a (patchId, assetId, takenAt) pair (visitPhotoPairsFrom, mobile/src/hooks/visitPhotoCapture.ts:40) and committed to the Zustand store's visitPhotos map (addVisitPhotosmobile/src/domain/visitPhotos.ts:37, deduped by (patchId, assetId)) as soon as the scan finishes — independent of which patches the user later chooses to collect. This is what populates a patch's "My Visit" photo carousel. These are private on-device references (never uploaded as image bytes here); they are distinct from the public PatchPhoto gallery rows (see "What this feature does NOT do").
  8. Confirm. The user reviews the claim grid (already-collected patches shown disabled but not re-collectable) and taps "Add N to my guide" → confirmImport()store.collectPatchBatch() (mobile/src/domain/store.ts:1639), which enqueues a PendingPatch per newly-selected patch with collectedAt: <the photo's creationTime> and source: 'import', then relies on the ordinary sync push (POST /api/sync/push, JWT-guarded) to write it to UserPatch on the server. Anything already in userPatches is silently skipped (no duplicate row, no re-collect).
  9. Analytics-only reporting of misses. Photos that had GPS but matched no patch (capped at 500, MAX_POTENTIAL_MATCHES) are POSTed to POST /api/potential-matches (no auth guard; carries only lat/lng, a null place name, and the photo's date) so Scout can see where users go that aren't covered by a patch yet. This is the only place is genuinely "leaves the device" beyond the check-batch coordinates themselves — still never an image.

Data model (Prisma models and key fields)

API surface (endpoints, auth requirements)

Endpoint Method Guard Used for
/api/location/check-batch POST none (backend/src/location/location.controller.ts:39) Batch GPS→patch matching during the scan. Body: { locations: [{id, lat, lng}] }, max 100 per CheckBatchDto (@ArrayMaxSize(100)). Response: per-location pointMatches/polygonMatches patch id arrays.
/api/location/check-polygons POST none The live-tracking unlock path (checkPolygons), not import — cited here only because the two share a service and a plain-containment rule; camera-roll import calls check-batch, never this one.
/api/potential-matches POST none Uploads GPS-only "no match" rows for analytics after a completed import.
/api/potential-matches/admin GET AdminGuard Admin dashboard read of aggregated potential matches (out of this feature's mobile flow).
/api/sync/push POST JwtAuthGuard The generic sync push that actually writes UserPatch rows (including source: 'import' ones) to the server — not a dedicated import endpoint.

There is no dedicated "import" backend endpoint; the mechanism reuses the generic location-matching and sync infrastructure.

Key files (annotated)

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Open questions