Skip to content

Cleanup: Improve Photo Handling and Uploads - #223

Merged
xlcrr merged 24 commits into
openlittermap/v7from
bugs/2027/06/general-fixes
Jun 7, 2026
Merged

Cleanup: Improve Photo Handling and Uploads#223
xlcrr merged 24 commits into
openlittermap/v7from
bugs/2027/06/general-fixes

Conversation

@xlcrr

@xlcrr xlcrr commented Jun 7, 2026

Copy link
Copy Markdown
Member

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.

  • Location-tagged photos appear newest first, marked with a pin (📍).
  • Older photos load continuously as you scroll.
  • Non-geotagged photos are visible but can't be tagged or uploaded.
  • A new Select More button imports extra photos from your gallery.
  • Duplicate imports are now prevented.
  • The grid is polished — square tiles, consistent spacing, better alignment across devices.
  • The empty state now reads "Select Photos To Tag & 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

  • Level titles now update correctly.
  • Profile stats and levels clear on logout, strengthening account isolation.
  • New users no longer inherit stale profile data on a shared or reused device.

Engineering quality

  • iOS and Android crash reports are now symbolicated, making production issues far easier to diagnose.
  • The in-app version now matches the actual build version.
  • All 12 npm security advisories resolved.
  • 62 tests passing, including new coverage for level parsing and duplicate-import prevention.
  • All changes remain translated across the 8 supported languages.

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.

xlcrr and others added 14 commits June 7, 2026 12:28
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>
BOOP for the Sentry symbolication work (forward-only native/JS symbolication
on release builds). Patch bump; changelog line tagged v7.7.9. No app behaviour
change. Skills: none in repo.

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>
@xlcrr xlcrr changed the title Bugs/2027/06/general fixes Cleanup: Improve Photo Handling and Uploads Jun 7, 2026
@xlcrr xlcrr self-assigned this Jun 7, 2026
@xlcrr xlcrr added the Cleanup label Jun 7, 2026
xlcrr and others added 10 commits June 7, 2026 18:18
- 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>
@xlcrr
xlcrr merged commit 52d3cb9 into openlittermap/v7 Jun 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant