Cleanup: Improve Photo Handling and Uploads - #223
Merged
Conversation
Sentry: POST /api/v3/tags failed 422 ~494x/11 users. Root cause: when an upload was rejected as a duplicate, the backend returned no photo_id, so the retry path posted a local placeholder id (onboarding string / camera-roll counter). A non-integer id 422s and, because imagesArray is persisted, retries on every HomeScreen focus forever. Backend review also surfaced a second latent bug: POST /api/v3/tags *appends* (double-counts XP for ordinary users on a repeat), so a lost-response retry inflates the photo. Changes (see readme/upload-spec.md): - isServerPhotoId guard on the tag write — a non-integer id never hits the network or Sentry again (permanent safety net). - Tag write switched POST -> PUT (replace, idempotent) and renamed postTagsToPhoto -> addTagsToPhoto. Q1 confirmed PUT-first-time == POST. - Plumb already_uploaded/tagged from the upload response; on tagged:true skip tagging and clear the photo from the inbox (removeTaggedPhoto). - Transitional: a "duplicate" 422 (pre-idempotent backend) drops the stranded photo instead of stranding it — no infinite retry. - jest.config: transform RTK/immer so reducer/thunk tests can import slices. - Tests: isServerPhotoId; addTagsToPhoto PUT + guard; inbox cleanup + drop. Deferred until the idempotent backend ships and old app versions age out: remove the photo-already-uploaded branch, the alreadyUploaded counter, and the dead classifyError matches. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BOOP for the idempotent-tagging fix (commit 5742c7e): - Bump version 7.7.6 -> 7.7.7 - Changelog: readme/changelog/2026-06-07.md - Docs: MobileUpload, BackendMobileApi, Onboarding, CLAUDE.md, AGENTS.md updated for the postTagsToPhoto -> addTagsToPhoto rename, POST -> PUT (replace/idempotent) tag write, and the isServerPhotoId guard. Backend/web POST /api/v3/tags docs left intact (that endpoint still exists for the web SPA). - Removed readme/upload-spec.md (review artifact; facts folded into the docs above) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e photos Integer sibling of the "must be an integer" bug. A photo_id that is a valid integer but has no live server row (a stale local-counter id persisted from a pre-fix version, or a photo deleted server-side) failed the backend exists rule. classifyError mapped it to retryable+reportable `validation`, and nothing removed the photo, so it re-PUT on every HomeScreen focus → recurring 422 + Sentry spam. - classifyError: a 422 with `errors.photo_id` (or "...photo id..." message) is now `invalid-photo-id`, reportable:false — permanent, unrecoverable, no Sentry noise. - photos_reducer: new `addTagsToPhoto.rejected` handler drops the photo on `invalid-photo-id` (covers both the client guard and the backend rejection); transient errors (timeout/network/server) keep it for retry. - Tests: classifyError photo_id 422 cases + reducer drop/keep (54 passing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BOOP for the invalid-photo-id fix: - Bump version 7.7.7 -> 7.7.8 - Changelog entry (readme/changelog/2026-06-07.md) - MobileUpload.md: invalid-photo-id (both raise paths) + permanent-drop retry note - i18n: "This photo could not be tagged and was removed." added to all 8 languages Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Native App-Hang/crash events arrived unsymbolicated (<unknown> frames, "debug information file was missing") because the build never uploaded debug symbols. - project.pbxproj: guarded "Upload Debug Symbols To Sentry" build phase (sentry-xcode-debug-files.sh). Skips Debug builds and any env without SENTRY_AUTH_TOKEN, so it can't break local/unconfigured builds. plutil -lint ok. - ios/sentry.properties: org/project/url config, no token (token via env). - package.json: ios:upload-dsyms script for CI / one-off backfill. - CLAUDE.md: Build Notes + verified coverage caveat. Scope: covers the app-binary dSYM (the actionable "In App" frame). Verified against 15 local archives - dSYMs/ folders contain only openlittermap.app.dSYM; React.framework.dSYM absent and Hermes ships prebuilt+stripped (no dSYM). So framework frames (React/Hermes) stay <unknown>; closing that is a separate follow-up. No archive matched the live issue's Debug IDs, so the existing 51-event hang is fix-forward (not backfillable from this machine). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Separate from the app-binary dSYM commit. RN 0.84.1 ships React-Core-prebuilt, ReactNativeDependencies and hermes-engine as prebuilt binaries, so their dSYMs aren't produced locally and framework frames stay <unknown>. Documents the options (fetch matching prebuilt dSYMs vs build from source) + verification. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The embedded React/Hermes frameworks are stripped in the archive, so their
native frames were <unknown>. The prebuilt RELEASE artifact tarballs (cached at
ios/Pods/{ReactNativeCore,hermes-engine}-artifacts/*-release.tar.gz) hold the
unstripped, symtab-bearing binaries with the SAME Debug IDs, so uploading them
symbolicates those frames.
- ios/sentry-upload-framework-symbols.sh: extract the release tarballs, gate on
"Usable: yes" (no hardcoded Debug IDs — version-pinned, IDs match the build by
construction and would break on the next RN/Hermes bump), then upload. Skips
Debug builds and when SENTRY_AUTH_TOKEN is unset.
- project.pbxproj: existing "Upload Debug Symbols To Sentry" phase now also runs
this script (one line). plutil -lint ok.
- package.json: ios:upload-framework-symbols for CI/manual parity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the native (.so/Hermes) and JS (Hermes bytecode) symbolication gap. Minify is off, so Java traces are already readable and no ProGuard mapping upload is needed (flags set off explicitly; flip autoUploadProguardMapping if minify is ever enabled). - build.gradle: io.sentry:sentry-android-gradle-plugin:6.1.0 (the version pinned by @sentry/react-native@8.4.0). - app/build.gradle: apply io.sentry.android.gradle (uploadNativeSymbols) + RN sentry.gradle for JS sourcemaps. The sentry.gradle apply uses the relative ../../node_modules path (matching the existing vector-icons apply-from) so it needs no `node` on the Gradle PATH in CI. autoInstallation disabled (RN already ships the sentry-android SDK). - sentry.properties: org/project/url, no token (token via SENTRY_AUTH_TOKEN env). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ation work CLAUDE.md: the React/Hermes gap is from prebuilt vendored frameworks (not a dwarf setting — refuted), and their release-artifact symbols are now uploaded, so those frames symbolicate. One changelog line covering iOS + Android symbolication. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Without a token, skip the RN sourcemap apply and native-symbol auto-upload so an unconfigured/CI release build can't fail on the upload step — mirroring the iOS build-phase skip-when-unset guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…8dfbe9) Kept the doc (not deleted) so the investigation trail stays intact; the prebuilt release artifacts carry matching symbols, so the original "fetch dSYM" framing was superseded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 7-day window dead-ended users whose recent roll had no geotagged photos in the last week. Replaced it with: - Geotagged-only inbox, newest-first, no date window; a location pin top-right on each photo. New selectGeotaggedPhotos selector (+ test). - 6-photo preview + "Load more photos" button revealing 50 more; camera-roll LOAD page size 20 -> 50. - "Select More" button (inbox header, right of Delete): opens the system gallery, reads GPS via EXIF, imports geotagged picks into the tag flow; non-geotagged picks skipped with an alert. Architecture: HomeScreen dashboard is now a single virtualized FlashList (fixed sections in ListHeaderComponent, inbox photos as data). Inbox logic extracted to a useInbox hook; InboxSection split into InboxThumbnail/InboxControls/InboxEmpty/ InboxFooter. New i18n strings across all 8 languages; removed the now-dead auto-fill strings. Docs: rewrote MobileGallery.md, touched CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Inbox shows all camera-roll photos again: non-geotagged greyed out, geotagged get a 📍 pin top-right (only geotagged are mappable). selectGeotaggedPhotos -> selectInboxPhotos (returns all). - Grid fix: tiles are flex-sized (flex:1 + aspectRatio:1, matching the app's other FlashList grids) so they're equal/centered on any device instead of fixed-width and left-aligned with dead space; grid inset to a consistent 16px via content padding + a -13 "bleed" on full-width sections so they keep their own padding. Cleaner corners (borderRadius 14, clipped). - Docs: MobileGallery.md + changelog updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lockfile-only, semver-compatible (no --force, no major bumps). axios ≤1.15.2 → 1.17.0 (within ^1.15.0, non-breaking); the rest are transitive/build-test deps (babel transform, picomatch/micromatch, brace-expansion, lodash, yaml, qs, follow-redirects, fast-xml-*, flatted). `npm audit` now reports 0 vulnerabilities; jest 56/56 + eslint green. package.json ranges unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- readme/Considerations.md: limitations when selecting/tagging large or small numbers of photos (Select More EXIF loop, tap-to-tag loads whole set incl. non-geotagged, sequential upload, persisted imagesArray) + 2 "worth fixing". - readme/TODO.md: new "Photo flow" section linking those 2 fixes; ticked off @shopify/flash-list 1 → 2 (on 2.3.1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The repo prefers npm (package-lock.json is the maintained, security-patched lockfile). yarn.lock had drifted and didn't carry the axios fix; dual lockfiles just invite drift. Removed it and dropped the "OR using Yarn" commands from README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… copy - normalizeLevels reads /api/levels XP-keyed object shape (was array-only → always fell back to hardcoded ladder, ignoring backend changes); cache key bumped to xp_levels_cache_v2, fallback titles refreshed, parser exported + tested - store: createListenerMiddleware clears user-scoped AsyncStorage caches (profile_stats_cache, xp_levels_cache_v2) on logout so new accounts can't inherit the previous user's stats/level - copy: inbox empty state "No photos to map right now." → "Select Photos To Tag & Upload" (all 8 languages) - docs: XP.md (backend-sourced titles note), MobileAuth.md (logout cleanup), changelog Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DeviceInfo.getVersion() (shown in Settings) reads the native build version, not package.json — they had drifted (native still 7.7.5). Bump iOS MARKETING_VERSION 7.7.5→7.8.2 / build 81→82 and Android versionName 7.7.5→7.8.2 / versionCode 58→59. Requires a native rebuild to take effect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Confirmed against the live endpoint: { "0": "Noob", "100": "Litter Picker",
... } — the value is the title string. The v7.8.2 parser expected object
values (.title/.name) and produced "Level 1: Unknown". normalizeLevels now
reads string values (still tolerates object values), derives XP from the
numeric key, fixes the top threshold to 1,000,000, and the cache key is
bumped v2→v3 to drop poisoned "Unknown" caches. Test updated to the real
shape (+ an object-value tolerance case). 60 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Settings version (DeviceInfo.getVersion()) reads the native build config, not package.json. Bump package.json + iOS MARKETING_VERSION (build 83) + Android versionName (versionCode 60) all to 7.8.3, and add the native-version bump to the BOOP ritual in CLAUDE.md so the displayed version can't drift again. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Inbox grid: non-geotagged thumbnails are inert in tag mode (Pressable disabled), still selectable in delete mode; grey overlay + pin unchanged - Tag queue: tapping a geotagged photo loads only geotagged photos so the tagger can't dead-end on an un-uploadable photo - Select More: addImages now dedupes on filename (stable across CameraRoll ph:// and the OS picker's temp-file uri) and tracks keys mid-batch, so the same physical photo can't be imported twice - Tests: cross-source + in-batch dedupe (62 passing) - Version: package.json + iOS (build 84) + Android (versionCode 61) -> 7.8.4 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, onboarding) - P0: useTaggingQueue read safeGoBack in a useEffect dep array before its const declaration (TDZ — throws under Hermes' native const). Effect moved after the declaration; fixes the core tap-photo -> tag path. - Auth: checkValidToken only clears the session on a real auth error (401); transient boot failures keep the token (thunk + .rejected reducer gate on isAuthError). Regression test added. - Custom tags: useTagDraft enforces the backend regex /^[\w\s:-]+$/ so invalid characters can't reach upload. - Import index: tap-to-tag and Select More compute the swipe index against the deduped result; all-duplicate Select More shows "Already Added" instead of navigating onto a stale photo. - Onboarding: failed tag write after upload now retries tags-only (reuses the server id) instead of re-uploading the binary. - Docs: actions/types.js -> utils/config.js across CLAUDE.md + readmes; refreshed MobileUpload.md file list; logged the ~68-string translation gap in TODO.md. - Version synced to 7.9.0 (iOS build 85 / Android versionCode 62). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Mobile App Update — Photo Import & Upload Reliability
Sunday 7th June 2026 · Seán Lynch
This release improves one of the most important parts of OpenLitterMap: helping people find, tag, and upload real location-tagged photos from their phone. Following the first TidyTowns webinar, we identified a few points of friction in the mobile app — this update removes them, improves upload reliability, and strengthens the integrity of the data volunteers create in the field.
A better photo inbox
The home screen is rebuilt around a clearer photo selection flow. Instead of showing only recent photos, it now shows all your location-tagged photos, newest first — scroll back through older ones, import more from your gallery, and clearly see what's ready to tag and upload.
Under the hood the home screen now uses a single virtualised list, so it stays smooth even with thousands of photos on the device.
Upload reliability & safer retries
We resolved an issue, reported by real users, where some photos could repeatedly fail when saving tags. Those photos now either save successfully or are safely cleared, instead of retrying the same failed operation.
Tag saving is also now idempotent — a dropped connection or repeated request can no longer double-count litter or award duplicate points. An important data-integrity improvement as usage grows.
Profile & account improvements
Engineering quality
Why this matters
OpenLitterMap depends on people being able to collect evidence quickly and confidently. This release makes the app more reliable for volunteers, improves the quality of the data being produced, and removes friction for new users joining through the TidyTowns campaign. Every smoother upload makes it easier for communities to turn local action into public evidence.