Scout — Full Product Context → feature documentation

Camera-roll import / photo retrace

'Import from Camera Roll' (in-app UI: 'Light up your map') 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: "Light up your map") 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. For an account WITHOUT cloud album access, no image leaves the device: only latitude/longitude and a timestamp are sent to the backend. An account WITH access also backs up the matched photos to its cloud album (album-and-cloud-backup.md), and the import screen's two ledger rows say so — both come from privacyCopyFor(hasAccess) (mobile/src/domain/cloudAlbumCopy.ts). Until 2026-09-30 the first row said "never the image itself" to every account.

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 row carries no trailing: betaBadge — it is not marked beta (mobile/src/components/navigation/drawerSections.ts). The only gate is a session, and guests get one, so it is visible to essentially every user, signed-in or not.

The import itself is unchanged by Post from My Visit (built 2026-10-11, shipped in v1.1.106): that build only changes what a user can do afterwards with a matched visit photo — post it to the patch's board instead of publishing it to the retired patch gallery. See "What this feature does NOT do".

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:138-150) 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.geom — plain 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 (addVisitPhotos → mobile/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). Making one public is a separate, per-photo act outside this feature: since v1.1.106 (built 2026-10-11) the user posts it to the patch's community board from the My Visit lightbox; store builds before 1.1.106 offer "Add to gallery" (the retired public PatchPhoto gallery) instead (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.

Mission step events. The import records two feature events that exist only to light up steps of the "Scan your camera roll" mission (missions.md): photo_access_granted when the photo source opens (mobile/src/hooks/usePhotoImport.ts:306, and when the album picker is chosen at :501) and photo_import_scanned when a scan finishes, whether or not it found anything (:448). No achievement consumes them, and a scan that matches nothing still cannot earn Automator.

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