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)
- Drawer entry (
mobile/src/components/navigation/drawerSections.ts,href: '/import-modal'). On develop since 2026-10-19, not released yet: the row is named "Import patches from camera roll" (was "Import photos"), and until the ACCOUNT has a patch from an import (the Automator achievement, earned on the first imported patch) it is lifted above EXPLORE as a lit "Start here" prompt (DrawerImportPrompt, testIDdrawer-import-prompt) instead of sitting in COLLECTION. After that it is a plain COLLECTION row. Unlike Home's import section, it does not read the device'simportScanCompletedAt, which survives sign-out. See navigation-drawer. In the live 1.1.107 it is Drawer → COLLECTION → "Import photos". - Onboarding's camera-roll beat (
mobile/app/onboarding-reveal.tsx, bodymobile/src/components/onboarding/starter-pack/RetraceStep.tsx, testIDonboarding-retrace-screen). On develop since 2026-10-19, not released; the live 1.1.107 has no such step. The last beat of first-run, after the first-unlock reveal, shown to every account (it was gated on a never-scanned device until 2026-10-19, and went missing on exactly the devices that had scanned once). Copy: "One more thing / Fill in where you've already been. / Scan your camera roll and Scout adds the patches for the places in your photos, dated to the day you were there.", over up to three catalogue photos of real places tagged "Example photos" (not the user's photos — the roll cannot be read before this screen's permission ask), and a ledger whose first row isprivacyCopyFor(hasAccess)'sreadLead+retraceLead, the same source as this screen's own ledger. "Scan my camera roll" (onboarding-retrace-scan) lands home and pushes/import-modalon top; "Maybe later" (onboarding-retrace-later) lands home. The beat never asks for photo permission itself — this screen does, as from any other entry point. Full rules: onboarding. - Home import section (
mobile/src/components/home-v3/ImportBand.tsx, testIDfg-import): an unboxed section on the bare canvas, in the same grammar as place mode's "Your photos" — no card, fill or border (redesigned 2026-09-28; before that it was a filled, bordered card with a looping scan-beam animation over a two-photo stack). Top to bottom: an optional eyebrow, a Fraunces title, a row of three square photographs of nearby patches each wearing a brass tick, and the dashed "Scan your camera roll" row (ScanRollRow.tsx, shared with "Your photos"). The whole section is one tap target. The photo row appears only with three or more photos (FieldGuideHomepasses the hero patch plus the nearest ones, up to three); with fewer it is left out and the scan row carries the section alone. The photos are catalog photos of nearby places, not the user's own — the camera roll cannot be read before the permission this section is asking for. It has two faces, and which one you get is decided by how many patches you have collected:standard— no eyebrow. Copy: "Been here before? / Scan your camera roll / Your old trips can fill in patches too."lead— fewer than 5 collected patches: a "Start here" eyebrow and a brass chevron on the scan row. Copy: "Retrace your steps / Scan your camera roll / Photos you already took can fill in their patches" — the title is the same words as the CTA inside the screen it opens.- Both faces sit directly below the hero, above everything else; only the face changes. It is never rendered twice (pinned by a test, since home already carries six sections and a seventh is the failure mode).
- The gate is
selectImportPriority(mobile/src/components/home-v3/selectors.ts). It reads the collected count, not import history, because import history is not knowable on device — see "Edge cases and known limits". - Suppressed back to
standardonceimportScanCompletedAtis set — a device-local marker written when a run reachessuccessorno_matches, so someone who scanned and genuinely came up empty is not asked again. Like the rest of the album cache (visitPhotos,scannedPhotoIds) it is kept through sign-out and every other auth transition (pickAlbumCache,mobile/src/domain/store.ts, since 2026-10-05).
- 50 States and National Parks maps: a "Looking a little empty?" callout
directly under the map plate (testIDs
states-map-import/national-parks-map-import), shown only when the user has one state or fewer and no scan has finished on this device (importScanCompletedAt). The parks screen gates on states too. It is home'sImportBandin itsleadface with map-specific copy — "Looking a little empty? / Scan your camera roll / Fill in the states you've already been to" (parks: "Find the parks you've already visited") — and the user's own collected places as its photos (MapImportCallout,mobile/src/components/field-record/MapImportCallout.tsx). Full rules:field-records-and-web-surfaces.md. - Album screen: "Import from Camera Roll" row
(
mobile/app/(drawer)/album.tsx:332). - Place/park home: the YOUR PHOTOS section, when you are standing in a City
Challenge or a national park (
mobile/src/components/home-v3/placeSections.tsx→PlacePhotos, testIDsfg-place-photos/fg-place-photos-scan). Like the home section it has two faces, decided by whether you have any photos in this place:- Position never changes: it is always the first section under the
hero, above
THE STOPS, in every mode (PlaceBody.tsx). Only the copy adapts. (This doc used to say it dropped below the stops once you had photos; the code pins it first, and the tests below assert that.) - No photos here → a
START HEREkicker instead ofYOUR PHOTOSand the copy "Been here before? / Scan your camera roll / Photos you already took in <place> can fill in its patches." - Any photos here → the
YOUR PHOTOSeyebrow, theN in <place>count and the grid, then the scan row.
- Position never changes: it is always the first section under the
hero, above
- Deep link:
scout://import-modal, registered inmobile/src/dev/deepLinkRoutes.ts:105(category "Patches"). - Screen:
mobile/app/import-modal.tsx— a single pushed full screen (the filename is historical; a comment at the top notes it used to be a page-sheet before the flow grew multiple "beats"). It renders different content perusePhotoImportstate: "Light up the map", built 2026-10-28 (4ac33715) and shipped in v1.1.114 (design:mobile/research/import-gamified-lab.htmllane A, picked by Alan 2026-10-28; components inmobile/src/components/import/lightup/). Through 1.1.113 the screen was the Retrace flow (an invite card, a decorative route drawing, a claim grid and a gold stamp that closed itself after 1.1 s).idle→LightUpInvite— the Your America map (the album map's Skia drawing) with only the states you already have lit, decorative "?" rings, the "N of 50 states" meter, the two privacy rows (privacyCopyFor(hasAccess)), and "Light up my map" (import-scan-cta), "Choose what to scan" (import-choose-source) and "Maybe later" (import-maybe-later). Nothing on it is from the camera roll: it cannot be read before this screen's permission ask.choosing_source→ImportSourceSheet— pick "Entire library" / "An album" / "Specific photos".choosing_album→AlbumPicker— a two-up grid of device albums, richest first.requesting_permission/selecting→ a brass spinner (ScanDial).permission_denied/limited_access/error→ dedicated cards, each with a "Pick photos instead" escape hatch.processing→LightUpScan: the map fills in while the library is read (Alan, 2026-10-29). "Retracing your steps…", "N of M photos read" (the count updates every 50 photos and at each page's end,REPORT_EVERYinphotoImportSources.ts); after a page that found a new placeusePhotoImportpublishes the finds so far (progress.found) — a page that only found an older photo of a place already found publishes nothing (the finished result still dates every place to its earliest photo) — and each new find joins a queue that lands one every 260 ms (useLivePlayback,PACEinmobile/src/components/import/lightup/useLivePlayback.ts): its photo flies from a film strip to its pin, its state fills with that photo, the tallies (new patches, states /50, rare) tick up, and every find pops its patch art above the map, with its chips: "Yours" on a place you already have, the rarity on a new one that is not Common, "1 photo · 3 patches" when one photo brought a city/state/park with it; a new state also gets a "New state · X" banner and a medium haptic. It works the same whether a place is yours or not (Alan, 2026-11-02, lab lane A′,stateOf/useLivePlayback): the scan starts on a dark map, so a state you already have lights when the scan reaches it, not before (until then your states were lit from the start and only new or rare finds popped, so a rescan, which is every import after the first, showed only grey pins appearing: Alan's 1.1.115 rescan found 0 new and 133 of his). One pop at a time (a pop due within 0.7 s of the last one closing waits for it; later, it is skipped); a burst of more than 12 queued finds lands its older ones at once without a flight, so the map keeps up with the scan. Skip here stops the sweep and keeps what it found (stopScan), then goes straight to the score.confirming→ the sameLightUpScanelement lands whatever is still queued and, 1.2 s after the last one, hands over to the score. Skip jumps to the score now; with Reduce Motion finds land at once with no flights. ThenLightUpReveal(the score: "+N", "patches found in M photos", states /50, new cities, rare, the best find) → "See all N" →LightUpClaim, a sheet over the dimmed map, grouped by state in the order you went, every new find ticked, already-yours tiles dimmed with a "Yours" chip and not toggleable, "Select none"/"Select all" (import-select-all-button), "Add N to my guide" (import-confirm-button). A scan where every match is already yours goes to "All caught up" instead (no claim).saving/success→LightUpAdded: every state you have flashes in, a "+N Added" seal, "N patches in your guide, each dated to the day you were there.", the 50 States meter. It stays until you leave — from 3 new patches "Share my map" (the photo map card, below), then "See your 50 States map" (/states-map), then Done. N counts only what you added (an unticked find is not in it).no_matches, and anerrorwhoseemptyReasonisno_gps→LightUpEmpty: "None of these photos were taken at a place Scout knows yet" / "None of these photos say where they were taken" (with why), Done and "Choose an album or specific photos". These replace the old "No patches matched" alert and the no-GPS error card.- Share my map (board ticket 1aa46b17):
ImportShareCard(the import's words on the sharedPhotoMapCardframe inmobile/src/components/field-record/, which the 50 States and National Parks cards also use), 1080×1350, rendered off-screen and captured byuseShareCard({ surface: 'import_result' })like the 50 States card: the horizontal Scout wordmark, "Found in my camera roll", "Where I've been", "S of 50 states · +N patches", the US map with each state you have filled with your photo from it (react-native-svg, gold tint when a photo can't be read), the four best finds (rarest first), States / Parks (of 64) / Patches, and the invite code footer (InviteCodeLines). Share text: "Scout found N places I'd already been:" +/i/<code>?src=import(/get?src=importfor a guest).
- No other screen embeds this flow — it is reached only via the entry points above, not from a patch detail page or the main Map tab (the 50 States / National Parks callout above is the only map-screen entry).
How it works (end-to-end mechanism)
- Source selection.
usePhotoImport.runImport(source)(mobile/src/hooks/usePhotoImport.ts:216) drives everything from here on;scanAllPhotos/scanAlbum/selectPhotosare thin wrappers that pick aImportSource({kind:'library'},{kind:'album', albumId, title}, or{kind:'picked'}). - Acquisition (
mobile/src/hooks/photoImportSources.ts,openSource):library/album: requestsexpo-media-librarypermission on demand (tied to the user's tap, not screen mount), then pages throughMediaLibrary.getAssetsAsyncin batches of 100 (BATCH_SIZE), reading up to 15 assets concurrently per batch (READ_CONCURRENCY).picked: opensexpo-image-picker's native multi-select (launchImageLibraryAsyncwithexif: true), which needs no library permission grant at all.
- Per-photo GPS read. For library/album, each asset's location comes from
expo-media-library/build/next'sAsset(nextAssetRef(id)).getLocation()— deliberately notMediaLibrary.getAssetInfoAsync. A code comment (photoImportSources.ts:138-150) documents the measured trap: on a 131-photo simulator library,getAssetInfoAsynctook 174ms/photo (its iOS path resolves aPHContentEditingInputand 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 onlyPHAsset.location(Android: the EXIF header viaExifInterface), 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 thenextAPI's id shape (ph://on iOS, a rebuiltcontent://media/external/images/media/<id>URI on Android).- For
pickedphotos, 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'sok: falsebranch) so a systematic failure surfaces in logs rather than silently reading as "none of your photos have location."
- For
- Matching, page by page. As each page of GPS-bearing photos comes back,
usePhotoImportchunks it to 100 (MATCH_CHUNK, since the batch endpoint caps at 100 locations — see below) and callsmatchPhotosToPatches(mobile/src/utils/unified-photo-matching.ts:336), which callsPOST /api/location/check-batch(mobile/src/utils/unified-photo-matching.ts:140, backend atbackend/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.
- 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
protectedAreaIdnor ageofenceId(a bare lat/lng pin),ST_DWithinwithinphotoMatchRadiusMeters(AppConfig key, default 150m — deliberately larger than live-tracking tolerance because photo GPS is less accurate). - Polygon match:
ST_Containsagainstprotected_areas.geomUNIONgeofences.geom— plain containment, no buffer. A comment (location.service.ts:144-159) documents that this replaced an earlier blanketphotoPolygonBufferMeters(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 aPatchPhotonear-miss buffer used elsewhere for photo check-in verification, not this batch path; seelocation.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.
- Point match: for patches with neither a
- 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. - 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'svisitPhotosmap (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 publicPatchPhotogallery) instead (see "What this feature does NOT do"). - 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 aPendingPatchper newly-selected patch withcollectedAt: <the photo's creationTime>andsource: 'import', then relies on the ordinary sync push (POST /api/sync/push, JWT-guarded) to write it toUserPatchon the server. Anything already inuserPatchesis silently skipped (no duplicate row, no re-collect). - Analytics-only reporting of misses. Photos that had GPS but matched no
patch (capped at 500,
MAX_POTENTIAL_MATCHES) are POSTed toPOST /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)
UserPatch(backend/prisma/schema.prisma:785-808) — the collected badge.source: String @default("unknown")records how it was earned;'gps'is the only value that counts for achievements,'import'(this feature) and'unknown'(pre-column rows) do not (backend/src/achievements/feature-context.ts:12—IN_PERSON_SOURCE = 'gps').collectedAt: DateTimeis set from the photo's own creation time, notnow()— see step 8 above.PotentialMatch(schema.prisma:1055-1070) — the "no patch matched this location" analytics row:userId?,deviceId?,latitude,longitude,placeName?(always null from this path — seeusePhotoImport.ts:322-327),photoDate?.Patch.protectedAreaId/Patch.geofenceId— which polygon table (if any) a patch's boundary lives in;checkBatchunions both, and a patch with neither falls back to the point-radius path.PatchPhoto(schema.prisma:517) is the retired public community gallery row and is not written by this feature (see "does NOT do"); it is a separate, unrelated upload path. Neither isPost, which is where a visit photo goes when the user posts it.- Visit photos (
VisitPhotoRef/VisitPhotosMap,mobile/src/domain/visitPhotos.ts) are not a backend table — they live only in the Zustand store on-device (persisted via MMKV), keyed by patch id.
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)
mobile/app/import-modal.tsx— screen orchestrator, state→UI mapping, back navigation, post-success sync trigger. Split into the house view-model pattern:ImportModalScreenViewModelImplowns every hook (theusePhotoImportmachine, the post-success sync, the Light up the map model (buildLightUpModel: your album, your states/parks before this import viabeforeImport, the result, andadded= the result less unticked finds), the share card,Linking.openSettings), andImportModalScreenLayoutis a pure layout that draws the view model. Two pure helpers are exported so the screen-mock gallery derives the same chrome the screen does rather than asserting it by hand:importModalSurface(state, emptyReason)maps anImportStateonto theImportModalSurfacediscriminant (invite/choosing-source/choosing-album/waiting+ its dial label /permission-denied/limited-access/retracing+ itsRetracePhase/empty+ its kind /error), andimportModalShowBack(state)is the back-chevron rule (hidden onsavingandsuccess). The view model carries the scan as DATA —progress(scanned/total/matchesFound/recentMatches),sourceLabel, the per-matchmatcheswith theiralreadyCollectedflag,selectedPatchIds, and the reward counts — never a rendered node.mobile/src/hooks/usePhotoImport.ts— the state machine: acquire → page → match → accumulate → confirm → save.MATCH_CHUNK = 100(line ~140) is deliberately decoupled from the media page size (100) but coincidentally equal to the batch endpoint's cap.mobile/src/hooks/photoImportSources.ts— where photos come from (openSource); thegetAssetInfoAsyncperformance trap is documented at lines 138-150;BATCH_SIZE/READ_CONCURRENCYtuning at lines 87-88. The coordinate validatortoValidCoords(rejects null island and non-numbers) moved tomobile/src/lib/assetLocation.ts:10-17on 2026-10-11 so the visit-post sheet reads location exactly like this sweep;readAssetLocationhere keeps its ok/error distinction.mobile/src/hooks/photoImportMatching.ts—MatchResultflattening and the dedupe accumulator (earliest-date-wins).mobile/src/hooks/visitPhotoCapture.ts— turns a match result into device-local visit-photo pairs; the video/no-asset-id exclusion rule lives here (lines 21-39).mobile/src/utils/unified-photo-matching.ts— the shared matcher used by both camera-roll import and single-photo check-in; backend-first with a polygon-blind client fallback (lines 105-256); per-photo bucketing into new/already-collected (lines 297-314).mobile/src/utils/mediaAssetRef.ts— thenext-API id translation (iOSph://…, Android reconstructedcontent://…).mobile/src/lib/exif.ts— shared EXIF GPS parser (used by both the picked path here and the separate upload picker).mobile/src/domain/visitPhotos.ts— pure reducer for the on-device patch→photo map (add/remove/dedupe).mobile/src/domain/store.ts:1639(collectPatchBatch) — writes thesource: 'import'pending patches; comment at line ~1671 states plainly these "earn no achievements."mobile/src/domain/importResult.ts— pure:buildImportResultregroups the matches by photo into hits (place + the containers it brought), with the new states/cities, rare finds, best finds,shareable(≥SHARE_MIN3) and anAlbumof the finds for the map;beforeImport(your states and parks before this import, by subtraction).mobile/src/components/import/lightup/*— the presentation layer:LightUpMap(the album map'sWorldat the country framing, lit state by state, with pins),LightUpInvite,LightUpScan(+useLivePlayback, the live clock),LightUpReveal,LightUpClaim,LightUpAdded,LightUpEmpty,ImportShareCard,parts.tsx(chips, tiles).World/StateFilllive inmobile/src/components/album/map/World.tsx, shared with the album map.mobile/src/components/import/retrace/*— what is left of the old Retrace flow:ImportSourceSheet,AlbumPicker, andRetraceStage(+MapBackdrop,retracePath.ts), which only the Album's empty state still draws.backend/src/location/location.service.ts—checkPolygons(live tracking, hard containment) vscheckBatch(import: point-radius for pin-only patches, hard containment for polygon patches).backend/src/location/dto/location.dto.ts—CheckBatchDto(100-location cap),LocationInput.backend/src/potential-matches/potential-matches.service.ts— stores unmatched-photo analytics rows.backend/src/app-config/app-config-schema.ts:51-67—photoMatchRadiusMeters(used, default 150m) andphotoPolygonBufferMeters(still present in the admin-editable schema but not read anywhere in currentlocation.service.ts— see Open Questions).
Configuration and flags
- No feature flag gates this feature (see Status).
photoMatchRadiusMeters(AppConfig, admin-editable, default 150m, range 50–500m) — the only tunable that affects matching: it sets the point- match radius for patches that have neither a geofence nor a protected-area polygon. Mobile has its own local fallback default of 150m (mobile/src/domain/app-config/types.ts:30,DEFAULT_APP_CONFIG) used if the app-config fetch hasn't landed yet, and also as the client-fallback Haversine radius when the backend call fails outright.MAX_POTENTIAL_MATCHES = 500(mobile, hardcoded) — caps how many unmatched-photo rows one scan will upload.BATCH_SIZE = 100/READ_CONCURRENCY = 15(mobile, hardcoded, tuning constants for the native photo sweep, not user- or server-configurable).
Edge cases and known limits
-
A big roll's live map is kept cheap (2026-11-02). 1.1.115, the first build with the map filling in during the read, took 94 s to scan Alan's 19,447 photos against 39-48 s on every build before (
import_finished.duration_ms). The library is read newest first and a place keeps its earliest photo, so most pages of a big roll only re-dated places already found: each republished the finds, rebuilt the result, and swapped every lit state's map photo, and each new photo was another shrink on the main thread (~170 ms,useCoverFile); the progress line also redrew the whole map every 10 photos. Now the finds are published only for a new place, a lit state keeps the photo it lit up with (LightUpMap), the map is memoized so a progress tick does not redraw it, and the count updates every 50 photos. Pinned byscreen-tests/components/import-lightup-scan-perf.test.tsx,usePhotoImport.liveFinds.test.tsxandphotoImportSources.test.ts. Not yet re-timed on a device. -
An import never asks for a rating (since 2026-10-27; it asked from 2026-09-30). 54 of 55 store-review asks fired here, before the person had done anything, and earned about zero ratings. Imported patches do not count toward the review ladder, which counts live unlocks only (unlock-and-location.md). Pinned by
import-modal.test.tsx→no rating ask after an import. -
The import doesn't check who took a photo. It reads the location and date on the photo itself (
PHAsset.locationon iOS, the EXIF header on Android; see How it works, step 1), so a photo someone else took, such as one AirDropped with its location left on, matches the place it was taken and collects that patch. Known and accepted for now (owner decision, 2026-09-23): revisit if it's abused at scale. Imported patches still earn no achievements (see "What this feature does NOT do"). -
Imported patches can be ordered as physical patches. The in-app store lists every collected patch whatever its
source(backend/src/store-my-patches/store-my-patches.service.ts,listForUserfilters onuserIdonly), so a patch from a 2019 photo is orderable the same as one collected on site. -
No GPS on any scanned photo: if the whole sweep finds zero GPS-bearing photos and the source was not limited access, the user sees "Scanned N photos but none had location data. Please enable GPS/Location when taking photos." (
usePhotoImport.ts:380-382) and lands back in an error state. -
iOS limited-library selection:
MediaLibrary.requestPermissionsAsync()reportsaccessPrivileges === 'limited'. If the totalCount visible is 0, the flow shows a distinct "limited_access" card rather than implying the library has no GPS photos (photoImportSources.ts:346-350); if photos are visible but zero of them have GPS, the same "limited_access" branch fires instead of the generic no-GPS error, because "the odds a hand-picked subset happens to include GPS photos are low" (usePhotoImport.ts:372-378).expandPhotoAccess()callsMediaLibrary.presentPermissionsPickerAsync(['photo'])to widen access (re-opens the system picker on Android 14+/limited-library manager on iOS); if that call itself throws, it falls back toLinking.openSettings(). -
Android ≤12 (API ≤32) breakage (regression-guarded, not currently live): a Regression test (
mobile/src/config/__tests__/androidMediaPermissions.test.ts) pins thatWRITE_EXTERNAL_STORAGEand every permissionexpo-media-library's config plugin requests must not be listed inandroid.blockedPermissions. If it were blocked,MediaLibraryModule.hasReadPermissions()'s pre-Tiramisu branch would demandREAD_EXTERNAL_STORAGE+WRITE_EXTERNAL_STORAGEtogether with no manifest check, so the read gate would fail forever; the failure would be silent up front (requestPermissionsAsync()still resolvesgranted, since it adapts to what the manifest actually declares) and only surface later asgetAssetsAsyncthrowingERR_PERMISSIONS. API 33+ (READ_MEDIA_IMAGES) is unaffected either way. -
Video handling: on iOS, videos are swept alongside photos (
sweepMediaTypes()includesMediaLibrary.MediaType.video) becausePHAsset.locationis populated for videos the same cheap way as stills, so a video's GPS can unlock a patch. On Android, videos are excluded from the sweep entirely —ExifInterface(the fast path used there) has no MP4/MOV parser, a video's coordinates live in the QuickTimeudtaatom reachable only viaMediaMetadataRetriever(whichexpo-media-librarydoesn't expose), andnextAssetRef()only builds an imagescontent://URI. On both platforms, a matched video is used to unlock the patch but is never kept as a "My Visit" photo reference —visitPhotoPairsFromdrops anykind === 'video'photo, because that list feeds the cloud-album upload path, which is built for still images only. -
Photos with no location: silently skipped during the sweep (not reported as errors; only a genuine read failure is tallied and logged separately — see
LocationRead). -
Backend call failure: falls back to client-side Haversine matching, which is polygon-blind (centroid + radius only) — city/state/NPS/OSM polygon-backed patches will not be found until a backend-connected re-scan.
-
Cancel mid-scan:
reset()/unmount flips aScanToken.abortedflag checked at every page boundary and everyREAD_CONCURRENCYchunk inside a page; partial results are discarded, not surfaced. -
Stop mid-scan and KEEP what was found (
stopScan, testIDimport-stop-scan): a whole-library sweep runs for minutes on a large roll, and Cancel throws every match away — so someone with twelve patches on the counter and no time to wait had to either sit through the rest or lose all twelve. The scan footer offers "Stop · keep N" beside Cancel, where N is the liveprogress.matchesFoundthe HUD is already showing.- It is hidden, not disabled, until the first match: "Stop · keep 0" is Cancel with extra steps.
- Mechanically
stopScansetsScanToken.stoppedandaborted, so all the existing guards halt reading immediately; the post-loop check isif (token.aborted && !token.stopped) return, so a stop falls through to the normal finish path (dedupe → commit visit photos →confirming) on whatever the accumulator holds. Cancel and unmount still discard — that distinction is what stops someone who walked away from the screen being yanked into a claim grid. - A chunk whose backend round-trip has already returned is kept on a stop
(
if (token.aborted && !token.stopped) returninsidematchPagetoo), so the number on the button is the number that lands on the grid. Discarding it made the button under-deliver against its own label.
-
Repeat scans / dedup: matching a patch that's already in the user's collection buckets it into
alreadyCollectedMatches(shown disabled in the claim grid, not re-collectable, not re-synced) but the photo is still attached to visit photos. Visit-photo refs dedupe by(patchId, assetId)(addVisitPhotoRefs), so rescanning the same library twice does not duplicate "My Visit" entries. -
The animated map is decorative, not geographic:
RetraceMap/retracePath.tsdraws one fixed, hand-authored SVG route (ROUTE_D, 320×190 design space) with a reticle and 7 fixed "pin" positions that "bloom" as a subset — none of this reflects the user's actual photo locations. It is driven purely by the real scan'sscanned/totalfraction (via a shared value animated withwithTiming), so its pacing is real but its geography is not. -
The map fills at the scan's pace, not the camera roll's. Finds arrive a page (100 photos) at a time, so on a library with few GPS photos the map can sit still for a while and then light several states in a burst; the queue spreads a burst out (one every 260 ms, older ones beyond 12 landing at once). Until 2026-10-29 nothing landed on the map until the scan had finished, then the results were replayed.
-
Not every find gets its pop on screen. Every find pops (since 2026-11-02), but one at a time, about one a second, and a pop that would land more than 0.7 s late is skipped so they never stack; a burst of more than 12 queued finds lands without flights or pops. On a big roll most finds show only as a pin and in the row under the map (in the lab, 8 of 30 finds pop).
-
Your states are dark until the scan reaches them. A state you have with no located photo in this scan stays dark through the scan; the score, claim and Added screens light every state you have, as before.
-
The user is never told how many photos matched nothing. Photos that had GPS but hit no patch are counted only for
POST /api/potential-matches; the claim header reports found patches and the scan bar reports photos read, so the miss count only ever exists as the arithmetic gap between them (e.g. "1,864 of 4,182" read, "12 patches found"). There is no "N photos matched nothing" line anywhere in the flow. -
The scan radius silently falls back when app-config has not landed.
usePhotoImportreadsphotoMatchRadiusMetersviauseQuery(appConfigQuery())and drops bothisLoadinganderror(usePhotoImport.ts:149-151), defaulting toDEFAULT_APP_CONFIG .photoMatchRadiusMeters(150m). A scan begun before the config resolves, or while the config request is failing, matches point-only patches at the client's local default rather than the admin-configured value, with nothing on screen to say so. It does not produce a false terminal state — the screen has no loading or error surface of its own to get wrong — but the results are quietly computed at a different radius. -
A failed post-import sync still reads as success. The
successeffect firessyncNow(true)andsyncCloudAlbum()and onlyconsole.errors a rejection; Added still says the patches are in your guide (since 2026-10-28 it no longer auto-dismisses; before that the stamp closed itself after 1100 ms). The claim is safe (it is already in the local pending queue and a later sync pushes it), but the user is told the import landed on the server when it may not have. -
Two number formats on one screen. The scan readout uses
toLocaleString()("1,864 of 4,182") while the no-GPS error prints the rawprocessedCount("Scanned 4182 photos but none had location data"). -
Confirm skips already-in-progress-elsewhere duplicates:
collectPatchBatchchecksexistingPatchIdsat call time and silently drops anything already collected —confirmImportcan legitimately reportcollectedCount: 0for a non-empty selection if another sync landed those same patches first. -
The app cannot tell whether a user has ever imported.
UserPatch.sourceis written on the way out (source: 'import'on the pending-sync row) but the sync pull does not send it back:SyncService.pullUserData(backend/src/sync/sync.service.ts:354-360) maps onlyid,patchId,collectedAt,latitude,longitude.src/domain/types.ts:1091-1093documents the consequence —sourceis present on rows still pending sync and absent on everything read back — so once an import syncs, an imported patch is indistinguishable on device from a GPS one. This is why the home band's gate is the collected count, and whyimportScanCompletedAtis only a local "we already asked" marker rather than a record of having imported. Returningsourcein the pull would fix it and is a one-line backend change, but it has not been made. -
scannedPhotoIdsis dead. The field, its four actions (getScannedPhotoIds/addScannedPhotoIds/clearScannedPhotos/getScannedPhotoCount) and its persistence all exist inmobile/src/domain/store.ts, and nothing outside that file references any of them — so the per-asset "already scanned" cache its comments describe does not actually skip anything, and every import re-reads the whole library.
What this feature does NOT do
-
Does not show badges, XP or achievement progress anywhere in the flow. Imported patches earn none (below), so Light up the map's score is patches, states (of 50), cities and rare finds, never an achievement.
-
Is not "Rewind". The camera-roll rewind (your roll played back month by month, a print and a dated stamp per place) was take C of the same lab and is a separate, unbuilt feature: board ticket 19dd4a98.
-
Does not share automatically, and shares only what you added. Share my map appears from 3 new patches and only when tapped; the card counts what you ticked, and it carries your own photos (in the states) only because you chose to share it.
-
Does not earn achievements.
UserPatch.source = 'import'is explicitly excluded fromIN_PERSON_SOURCE("gps") inbackend/src/achievements/achievements.service.ts:41-47andfeature-context.ts:7-12— importing your whole camera roll and unlocking 200 patches at once earns zero achievement progress. This is a deliberate, explicit product rule ("Achievements are for being somewhere in person"), not an oversight. -
Does not upload or publish any photo. No image bytes are ever sent to the backend by this feature. Only GPS coordinates and a timestamp travel to
/api/location/check-batchand/api/potential-matches. Visit photos stay as on-device MediaLibrary references (VisitPhotoRef.assetId); they are not automatically part of the user's cloud album backup, a board post or the old public gallery — see the next point. -
Does not post or publish a matched photo anywhere. A photo matched during import only becomes a device-local "My Visit" reference; this feature never calls the community API or
backend/src/patch-photos/. Making a visit photo visible to other users is a distinct, per-photo action elsewhere in the app: since v1.1.106 (built 2026-10-11) the user posts it to the patch's board from the My Visit or Album lightbox (one tap on + Post since the quick photo post of 2026-10-25, no title; v1.1.106 up to that release asks for a title first), and that post carries the photo's own GPS publicly (Post.imageCoords) — see community-and-moderation. Nothing publishes to a patch gallery any more in that build: thePatchPhotogallery is retired from it (store builds before 1.1.106 still offer "Add to gallery"). The post reads a photo's location with the same reader this sweep uses (readAssetCoords/toValidCoords,mobile/src/lib/assetLocation.ts). -
Does not perform live GPS tracking or drive the compass. That is a separate always-on background feature (
checkPolygons/mobile/src/services/location/tracker.ts); camera-roll import is a one-shot retrospective sweep the user explicitly triggers. -
Does not apply any tolerance/buffer to polygon-backed patches beyond what live tracking gets. The old blanket 10m
photoPolygonBufferMetersbuffer is gone from the code path; a photo just outside a small building's footprint will not match it, by the same rule that applies to walking up to it in person. -
Does not show the user's real travel route. The "map" animation during scanning is a fixed decorative graphic, not a rendering of the photos' true locations (see Edge cases).
-
Does not gate on any feature flag or beta badge — it is treated as a fully mainstream, always-on feature, not an experiment.
Tests that cover it
-
The live map on a big roll (2026-11-02) —
mobile/screen-tests/components/import-lightup-scan-perf.test.tsx: twelve pages of older photos of the same two places shrink no new state photo (red before: 22 extra shrinks), and twenty progress ticks redraw the map zero times while a new find does (red before: 20 redraws).mobile/src/hooks/__tests__/usePhotoImport.liveFinds.test.tsx: an older photo of a place already found publishes nothing (same array, first photo), a new place publishes, and the finished result still keeps the earliest photo (red before: the re-find republished with the older photo).photoImportSources.test.ts: a 100-photo page reports at most 3 times (red before: 11). -
Map-screen callout —
mobile/screen-tests/states-map.test.tsxandnational-parks-map.test.tsx(describe('… import callout')), plusshouldPitchMapImportinmobile/src/hooks/__tests__/statesProgress.test.tsandmobile/research/tests/map-import-callout.test.mjs. Detail infield-records-and-web-surfaces.md→ Tests. -
Home band placement — the band's job is where it sits, so these assert render order, never presence (it renders in both branches, so a presence test would pass whichever ordering shipped):
mobile/screen-tests/index.test.tsx→describe('Home import band placement')(4 cases) — leads above the day deck and journeys at 2 collected patches, sits below both at 12, is rendered exactly once in each ordering, and demotes whenimportScanCompletedAtis set. The 2-patch and 12-patch cases are each other's falsification: same seed, same screen, the count is the only difference and each expects the opposite order.mobile/src/components/home-v3/__tests__/selectors.test.ts→selectImportPriority(4 cases) — including the exclusive boundary (4 leads, 5 does not) and the scan-completed suppression.mobile/src/components/home-v3/__tests__/ImportBand.test.tsx(13 cases) — per-priority copy and kicker, thestandarddefault, a stablefg-importtestID across both, one tap target, the photo row at three photos (capped at three) and its absence at zero, one and two, no card treatment, the shared section frame, the lead/standard chevron colour pair, and the map callout's copy/testID/layout overrides.mobile/screen-tests/index.test.tsx→Home import band placementalso asserts the photo row fills from nearby stops and is left out (section kept) when none has a photo — the pair falsified by slicing home's collage back to two.mobile/research/tests/home-import-band.test.mjs(7 cases) — the same invariants against the HTML design mock, which is the source of truth the RN screen conforms to.mobile/src/components/home-v3/__tests__/PlaceBody.test.tsx→PlaceBody photo-section placement(7 cases) — first under the hero with and without photos, on arrival and on a completed city, renders exactly once either way, and theSTART HERE/YOUR PHOTOSkicker swap.
-
Stop and keep:
mobile/src/hooks/__tests__/usePhotoImport.stopScan.test.tsx(4 cases) — the engine half: a stop lands on the claim grid with only what was found before it, an unstopped sweep keeps both pages (the paired control that proves the first is measuring the stop), a cancel still discards, and the count kept equals the count the button offered.- The UI half moved with the screen:
mobile/screen-tests/retrace-stop-scan.test.tsx(6 cases on the Retrace stop button) was deleted in4ac33715with the Retrace stage. Its successor ismobile/screen-tests/components/import-lightup-scan.test.tsx— "Skip while still reading stops the scan and keeps the finds" (:165), "after a stop the counter shows the photos actually read" (:217), "leaving mid-scan cancels everything still to come" (:184), and "Skip once the scan has finished goes straight to the score" (:156). The same file pins the film strip's gold lens drawn whole (:203; 2026-11-04): the strip clips its sliding photos, and the lens once stood 8pt above it and lost its top edge. mobile/maestro/tests/home.yamlstep 3 — the same ordering asserted on device inside the Key West Challenge, relative tofg-place-trail.mobile/maestro/tests/home.yamlleg 5 — the on-device E2E. Both sides are seeded (reset-user-progress.js/seed-collected-patches.js) rather than inherited from where the flow happens to be standing, so the leg cannot silently flip as the catalogue grows.
-
Unit / hook tests (mobile, Jest):
mobile/src/hooks/__tests__/photoImportSources.test.ts(23 cases) — acquisition, permission branches, page paging, location-read success/failure handling.mobile/src/hooks/__tests__/photoImportMatching.test.ts(9 cases) — accumulator dedupe/earliest-date behavior.mobile/src/hooks/__tests__/visitPhotoCapture.test.ts— visit-photo pair derivation, video/no-asset-id exclusion.mobile/src/hooks/__tests__/usePhotoImport.featureEvents.test.tsx(3 cases) — analytics event firing.mobile/src/utils/__tests__/unified-photo-matching.test.ts(7 cases) — backend-first/client-fallback matching, bucketing.mobile/src/utils/__tests__/mediaAssetRef.test.ts(2 cases) — iOS/Android id translation.mobile/src/components/import/retrace/__tests__/retracePath.test.ts(8 cases) — pure route-geometry sampling.mobile/src/config/__tests__/androidMediaPermissions.test.ts— the Android ≤12 regression guard described above.mobile/src/dev/mocks/import-modal.tsx+ the rot guard inmobile/screen-tests/screen-mocks.test.tsx— fifteen gallery states (scout://dev-screen-mock/import-modal?state=<slug>) covering the beats a device cannot reach without a seeded camera roll:invite,choosing-source,choosing-album,requesting-permission,opening-picker,permission-denied,limited-access,tracing,claiming,claiming-none-selected,claiming-all-collected,claiming-large,finishing,no-gpsanderror. Each asserts a string or testID only that state renders; the patches are 39 real Massachusetts catalog rows (none seeded from anadmin_onlycollection) and only the photographs and the scan counts are synthetic.mobile/screen-tests/import-modal.test.tsx— the screen registry's integration test; explicitly documents thatstateis local to the hook and can only be driven by real interaction, so it stubsusePhotoImportfor the later states and drives the defaultidle→LightUpInviterender for real (only your states lit); covers confirming → score → claim, "All caught up", the no-place and no-GPS faces (no alert), Added staying until Done and counting only what you ticked, and the post-success effect (kickssyncCloudAlbum, does not sync mid-scan).mobile/src/domain/__tests__/importResult.test.ts— hits, nests, best finds, the share threshold (2 vs 3), states/parks after, andbeforeImport.src/hooks/__tests__/usePhotoImport.stopScan.test.tsxpins that the finds so far are published after every page, while stillprocessing.mobile/screen-tests/components/import-lightup-*.test.tsx— the map (lit states, delayed lights, tint fallback, pins), the scan (finds landing while the scan runs and the score waiting for it to finish, a burst keeping up, a find's pop and "New state", a rescan of your own place lighting its state only when reached and popping with "Yours" (no banner, falsified by popping only new finds), Skip, Reduce Motion, unmount cancels, Skip-while-reading stops and keeps), the score and empty faces, the claim, Added (Share at 3 and not at 2) and the share card;album-map-world.test.tsxpinsWorld's fade-in.
-
Backend tests (NestJS/Jest):
backend/src/location/location.service.spec.ts—checkBatchunit-level behavior includingphotoMatchRadiusMeterssourcing from AppConfig.backend/src/location/location.geometry.db.spec.ts— real-PostGIS fixture tests assertingcheckBatchmatches via plain polygon containment (no buffer) and via the point radius; the file's own comments narrate the removal of the old buffer.backend/src/location/location.catalog.live.spec.ts— sweeps the real catalogue throughcheckBatch(interior/exterior points per geometry kind).
-
End-to-end (Maestro):
mobile/maestro/tests/photo-import.yamlandmobile/maestro/tests/photo-import-android.yaml— drives a real scan against curated GPS-tagged fixture photos (mobile/maestro/fixtures/geo-photos/geo_*.jpg, seeded onto the simulator viaxcrun simctl addmedia), asserts nested-boundary matching (a photo at the Saint Louis Zoo unlocking both the zoo and the containing Forest Park patch), and — per this file's own header comment — also absorbed the formeralbum-photo-removal.yamlflow, since removing an imported "My Visit" photo needs an import to have already happened. Since 2026-10-28 both flows wait for the score (import-see-all, "See all N"), open the claim, confirm, and press Done on Added (it no longer closes itself); they do not tap Skip, which mid-scan stops the sweep. Not yet run against the new screens.
Open questions
photoPolygonBufferMetersremains a live, admin-editable AppConfig key (backend/src/app-config/app-config-schema.ts:63-70) but is not read anywhere in currentlocation.service.ts— it appears to be dead configuration left over from the buffer-removal described in that file's own comments. Unconfirmed whether it's still wired into some other code path (e.g. the single-photo check-in flow) not read for this document, or is simply stale and safe to remove.- Whether
photoMatchRadiusMetersis the only config surfaced in the admin UI for tuning import matching, or whether an admin-facing screen exposes more, was not verified — this document only traces what the mobile client andlocation.service.tsactually consume. - The exact wall-clock/UX behavior for a very large library (tens of thousands of photos) beyond the documented 131-photo benchmark was not measured directly for this document; only the code's own stated measurements are cited.