{"id":51197042,"url":"https://github.com/warting/exif_picker_lab","last_synced_at":"2026-06-27T21:31:06.582Z","repository":{"id":354348643,"uuid":"1223226317","full_name":"warting/exif_picker_lab","owner":"warting","description":"Android reference app comparing 5 image-pickers × 4 EXIF read methods to show which combinations preserve GPS metadata. Settles 'does this picker preserve EXIF on this device' empirically.","archived":false,"fork":false,"pushed_at":"2026-04-28T06:38:32.000Z","size":97,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-28T08:24:31.983Z","etag":null,"topics":["access-media-location","activity-result-contracts","android","android-sample","android-tutorial","exif","exifinterface","geotag","gps","jetpack-compose","kotlin","mediastore","photo-picker"],"latest_commit_sha":null,"homepage":"https://github.com/warting/exif_picker_lab","language":"Kotlin","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/warting.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-04-28T05:57:03.000Z","updated_at":"2026-04-28T06:38:36.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/warting/exif_picker_lab","commit_stats":null,"previous_names":["warting/exif_picker_lab"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/warting/exif_picker_lab","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/warting%2Fexif_picker_lab","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/warting%2Fexif_picker_lab/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/warting%2Fexif_picker_lab/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/warting%2Fexif_picker_lab/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/warting","download_url":"https://codeload.github.com/warting/exif_picker_lab/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/warting%2Fexif_picker_lab/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34869004,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-27T02:00:06.362Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["access-media-location","activity-result-contracts","android","android-sample","android-tutorial","exif","exifinterface","geotag","gps","jetpack-compose","kotlin","mediastore","photo-picker"],"created_at":"2026-06-27T21:31:04.212Z","updated_at":"2026-06-27T21:31:06.562Z","avatar_url":"https://github.com/warting.png","language":"Kotlin","funding_links":[],"categories":[],"sub_categories":[],"readme":"# EXIF Picker Lab\n\n\u003e **Why does my picked photo have no EXIF GPS on Android?** Because the picker stripped it. This app shows you which combinations of *picker* and *EXIF read method* actually preserve `GPSLatitude` / `GPSLongitude` on real Android devices — side by side, on one screen, with explanatory annotations.\n\n[![Android](https://img.shields.io/badge/Android-API%2026%2B-3DDC84?logo=android)](https://developer.android.com/)\n[![Kotlin](https://img.shields.io/badge/Kotlin-2.3-7F52FF?logo=kotlin)](https://kotlinlang.org/)\n[![Jetpack Compose](https://img.shields.io/badge/Jetpack-Compose-4285F4?logo=jetpackcompose)](https://developer.android.com/jetpack/compose)\n\n## What this is\n\nA reference Android app that pits **5 image pickers** against **4 EXIF read methods** so you can see which combinations preserve GPS metadata on the device you actually ship to. Tap a picker, pick the same known-GPS photo, watch the read-method cards turn green or red.\n\nBuilt because the documentation on this is scattered and the failure modes are silent — `PickVisualMedia` strips EXIF GPS by design, `setRequireOriginal()` throws `SecurityException` on the wrong URI kind, `OpenDocument` sometimes routes through redacting providers. There is no single source of truth that maps picker → EXIF availability across devices, so this app exists to *measure* on yours.\n\n## Demo\n\nPick the same known-GPS photo through each picker; each pick is read four ways:\n\n```\n┌──────────────────────────────────────────────────┐\n│ Picker: GetContent                               │\n│ URI: content://com.android.providers.media...    │\n│ Authority: com.android.providers.media.documents │\n│ Size: 4 832 110 bytes  MIME: image/jpeg          │\n├──────────────────────────────────────────────────┤\n│ openFileDescriptor → FileDescriptor              │\n│ ✓ GPS recovered                                  │\n│ lat=59.236123  lon=17.982456                     │\n├──────────────────────────────────────────────────┤\n│ openInputStream                                  │\n│ ✓ GPS recovered                                  │\n├──────────────────────────────────────────────────┤\n│ setRequireOriginal → FileDescriptor              │\n│ ✗ SecurityException                              │\n│ Permission Denial: reading ?requireOriginal=1    │\n│ requires ACTION_OPEN_DOCUMENT or related APIs    │\n└──────────────────────────────────────────────────┘\n```\n\n## Pickers compared\n\n| Picker | Contract | What it does |\n|---|---|---|\n| **PickVisualMedia** | `ACTION_PICK_IMAGES` | Android's modern photo picker. Returns a redacted copy — EXIF GPS is stripped *by design*. `setRequireOriginal()` throws `UnsupportedOperationException` for picker URIs. **No recovery path.** |\n| **OpenDocument** | `ACTION_OPEN_DOCUMENT` | Storage Access Framework. Returns a `com.android.providers.media.documents` URI; `openFileDescriptor` / `openInputStream` reads give the original file with **EXIF intact**. `setRequireOriginal()` throws `SecurityException` but you don't need it. |\n| **GetContent** | `ACTION_GET_CONTENT` | Legacy on paper — but the system now routes it through a picker variant whose URI (`content://media/picker_get_content/…`) is treated as a MediaStore URI. **EXIF intact AND `setRequireOriginal()` succeeds.** Recommended when you need EXIF GPS from a user-picked photo. |\n| **TakePicture** | `ACTION_IMAGE_CAPTURE` | Camera writes a fresh JPEG to a `FileProvider` URI we own. EXIF is whatever the camera app stamps. **GPS is gated by the camera app's own \"Save location\" toggle** — Pixel Camera defaults to OFF, independent of system location permission. If GPS is missing, the user has to flip that toggle. |\n| **MediaStore (latest)** | `MediaStore.Images.Media` | Direct query. Returns the only kind of URI `setRequireOriginal()` actually upgrades. Requires `READ_MEDIA_IMAGES` (API 33+). |\n\n## EXIF read methods compared\n\n| Method | When to use |\n|---|---|\n| `openFileDescriptor` → `ExifInterface(FileDescriptor)` | **Recommended**. Most robust — lets ExifInterface mmap and seek freely. |\n| `openInputStream` → `ExifInterface(InputStream)` | Stream-only. Works for most JPEGs but can't seek. |\n| `openInputStream` → `ExifInterface(stream, STREAM_TYPE_FULL_IMAGE_DATA)` | Tells ExifInterface to scan the whole stream. Edge cases for HEIF / non-JPEG. |\n| `MediaStore.setRequireOriginal(uri)` → FileDescriptor | Upgrades the URI to `?requireOriginal=1` first. Only succeeds on MediaStore URIs *and* with `ACCESS_MEDIA_LOCATION` granted. Throws `SecurityException` on picker-supplied URIs. |\n\n## Findings (Pixel 10 Pro XL, Android 16)\n\n| | PickVisualMedia | OpenDocument | GetContent | TakePicture | MediaStore (latest) |\n|---|---|---|---|---|---|\n| `openFileDescriptor → FD` | ✗ stripped | ✓ | ✓ | ✗ camera didn't stamp | ✓ |\n| `openInputStream` | ✗ | ✓ | ✓ | ✗ | ✓ |\n| `STREAM_TYPE_FULL_IMAGE_DATA` | ✗ | ✓ | ✓ | ✗ | ✓ |\n| `setRequireOriginal → FD` | ✗ `UnsupportedOperationException` | ✗ `SecurityException` | ✓ | ✗ | ✓ |\n\nTake-aways:\n\n1. **Three pickers give you EXIF on Android 16:** `OpenDocument`, `GetContent`, and `MediaStore`. Pick whichever has the UX you want.\n2. **`setRequireOriginal()` is the wrong tool for picker URIs** — it throws on `PickVisualMedia` (`UnsupportedOperationException`) and `OpenDocument` (`SecurityException`). Use it only against URIs you fetched from `MediaStore.Images.Media` directly, or via `GetContent` (whose URI now happens to be MediaStore-backed).\n3. **`PickVisualMedia` is unrecoverable** if you need EXIF GPS — switch pickers, don't try to massage the URI.\n4. **`TakePicture` only gets GPS if the camera app stamped it.** That's gated by the camera app's own \"Save location\" toggle (Pixel Camera defaults to OFF), independent of system location permission. Live device GPS at the moment of capture is a fine fallback for fresh photos.\n\n## Build \u0026 run\n\n```bash\ngit clone https://github.com/warting/exif_picker_lab\ncd exif_picker_lab\n./gradlew :app:installDebug\nadb shell am start -n se.premex.exifdebug/.MainActivity\nadb logcat -s ExifDebug\n```\n\n## Guided test mode\n\nTap **▶ Run guided test (5 pickers)** in the app to walk through every picker in order. The screen hides everything except the current step's single button; permissions are requested up front in one batch so the run isn't interrupted mid-test.\n\nWhen all five pickers are recorded the screen shows a phone-friendly summary — one row per picker, green/red badge for \"did *any* read method recover GPS\", four small chips showing the per-method outcome. **No Markdown shown to the user** — humans don't want to read that on a phone.\n\n### For AI agents\n\nThe full Markdown report (device facts + picker × read-method matrix + per-picker URI / authority / MIME / size / per-method status + error strings) is logged to logcat under tag `ExifDebug` between explicit start/end markers:\n\n```bash\nadb logcat -s ExifDebug | sed -n '/=== TEST REPORT START ===/,/=== TEST REPORT END ===/p'\n```\n\nIf you're an AI coding agent helping the user root-cause an EXIF GPS bug:\n\n1. Have the user run the guided test in this app.\n2. Capture the bracketed logcat output above.\n3. Use the matrix to identify which picker(s) preserve GPS on the user's device.\n4. Cross-reference against the `Pickers.kt` annotations in this repo for *why* each picker behaves as it does.\n5. Open a PR against the user's app changing whichever picker contract they're using to one that preserves EXIF (typically `ACTION_GET_CONTENT`), with the relevant logcat excerpt cited in the PR description as evidence.\n\nSee [AGENTS.md](AGENTS.md) for project-internal conventions.\n\n## Permissions\n\n| Permission | Why |\n|---|---|\n| `ACCESS_MEDIA_LOCATION` | Required (API 29+) for `setRequireOriginal()` to ever return GPS. Runtime-granted; in-app button asks. |\n| `READ_MEDIA_IMAGES` | Required (API 33+) for the MediaStore-query picker. |\n| `CAMERA` | Required for the TakePicture path. |\n\n## When to use each picker in production\n\n```text\nNeed user-facing photo pick?\n├── Need original EXIF (GPS, date)?\n│   ├── Yes → use ACTION_GET_CONTENT\n│   └── No  → use PickVisualMedia (zero permissions, polished UX)\n└── Need to enumerate / list photos?\n    └── Use MediaStore + setRequireOriginal + ACCESS_MEDIA_LOCATION\n```\n\n## Project layout\n\n```\nexif_picker_lab/\n├── app/\n│   └── src/main/\n│       ├── AndroidManifest.xml\n│       ├── java/se/premex/exifdebug/\n│       │   ├── MainActivity.kt        ← UI, picker launchers\n│       │   ├── ExifReadMethod.kt      ← the 4 ways to read EXIF\n│       │   └── Pickers.kt             ← annotations per picker\n│       └── res/xml/file_paths.xml     ← FileProvider for TakePicture\n├── README.md                          ← you are here\n├── AGENTS.md                          ← project map for AI coding agents\n├── llms.txt                           ← machine-readable summary for LLMs\n└── LICENSE\n```\n\n## Contributing\n\nPRs welcome — especially device-specific findings. Open an issue with:\n\n1. Device + Android version\n2. Which picker\n3. Which read method\n4. Result (✓ / ✗) + EXIF dump from logcat (`adb logcat -s ExifDebug`)\n\n## License\n\nApache 2.0 — see [LICENSE](LICENSE).\n\n## Keywords\n\nAndroid EXIF · `PickVisualMedia` strips GPS · `setRequireOriginal` `SecurityException` · `ACCESS_MEDIA_LOCATION` · `ACTION_OPEN_DOCUMENT` vs `ACTION_GET_CONTENT` · ExifInterface FileDescriptor · GPSLatitude GPSLongitude · Android 13 photo picker EXIF · `MediaStore.Images.Media` · `requireOriginal=1` · Jetpack Compose\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwarting%2Fexif_picker_lab","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fwarting%2Fexif_picker_lab","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwarting%2Fexif_picker_lab/lists"}