From 726a400f20720772ffa3609c903fa9d9a8128750 Mon Sep 17 00:00:00 2001
From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
Date: Fri, 15 May 2026 16:27:39 -0600
Subject: [PATCH] feat(android): add photo-permission rationale and
recent-photos strip
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Layers on the Photos / Camera tiles introduced in #509 with a full
permission state machine, a rationale card, and a recent-photos
thumbnail strip.
- Three media-strip states resolved at runtime by `resolveMediaStripView`
in `PhotoAccessState.kt`:
1. **Rationale card** — shown when `READ_MEDIA_IMAGES` isn't granted
and the user hasn't dismissed it. Body copy and primary-button
label switch across three sub-states (`Unasked` / `Denied` /
`PermanentlyDenied`); SharedPreferences tracks the first-prompt
flag because `shouldShowRequestPermissionRationale` alone can't
tell "never asked" from "permanently denied".
2. **Compact tiles** — once the rationale is rejected, the Photos /
Camera column flattens into a full-width 88dp row. The rejection
auto-clears when permission is later granted, so a future
revocation surfaces the rationale again.
3. **Full strip** — once granted, queries MediaStore for the 64 most
recent images and renders them as a horizontally-scrolling 2-row
thumbnail grid. LazyRow keeps off-screen thumbnails out of memory.
- Strip is gated to Android 10+ (`ContentResolver.loadThumbnail` is
API 29+); on Android 7-9 the inserter falls back to the permissionless
Photos/Camera tile row and never requests the media permission.
- Process-wide thumbnail cache keyed on (uri, sizePx) — 32 entries,
~6MB worst case. `RealThumbnail` seeds from the cache synchronously
so scroll-back and dialog reopen skip the grey-placeholder flash.
Failed-to-load URIs drop from the displayed strip and re-attempt on
the next dialog open.
- Android 14+ partial-grant treated as granted by also checking
`READ_MEDIA_VISUAL_USER_SELECTED` when `READ_MEDIA_IMAGES` is denied.
Without this, a "Select photos" choice fell through to the
rationale's "Open Settings" state for a permission the user had just
granted.
- Photo-prefs reads off the main thread via a process-wide cache
warmed from `GutenbergView`'s constructor on `Dispatchers.IO`.
Writes update the cache synchronously and queue
`SharedPreferences.edit().apply()` on the IO scope.
- Soft-input mode `STATE_HIDDEN | ADJUST_RESIZE` — `STATE_HIDDEN`
dismisses an in-flight IME on open; `ADJUST_RESIZE` lets the sheet
shrink to make room when the user taps the in-dialog search field.
- Observes the host Activity's lifecycle (not the BottomSheetDialog's
own) for `ON_RESUME`, so grants made via system Settings update the
strip on return without restart.
- `GutenbergView.resetBlockPickerPhotoPreferences(context)` exposed for
host apps that want to clear the rationale-rejection / first-prompt
flags from a settings screen — also wired into the demo's `⋮` menu
as **Manage Permissions**. Demo uses a Settings hand-off rather than
`revokeSelfPermissionOnKill` (API 33+, demo's `minSdk = 24`).
- Library declares `READ_MEDIA_IMAGES` and `READ_EXTERNAL_STORAGE`
(max SDK 32). Host apps can opt out via `tools:node="remove"`;
documented in `docs/integration.md` (Android → Manifest Permissions),
including the `xmlns:tools` namespace requirement, with the opt-out
XML inlined as a comment in `AndroidManifest.xml` for auditors
diffing the merged manifest.
- Demo's "Enable Native Inserter" toggle defaults to on so reviewers
see the new strip without flipping a setting; the standalone-editor
E2E test toggles it off so the existing web-inserter assertions
still resolve.
Touch targets on the rationale buttons meet the Material 48dp minimum
via a wrapper that keeps the visual 32dp pill while inflating the click
area; a shared `MutableInteractionSource` keeps the ripple drawing
inside the pill instead of as a square halo.
Verified on Pixel 9 Pro XL with \`./gradlew :Gutenberg:detekt
:Gutenberg:assembleDebug :Gutenberg:testDebugUnitTest\` (includes
\`PhotoAccessStateTest\`).
---
.../Gutenberg/src/main/AndroidManifest.xml | 19 +
.../org/wordpress/gutenberg/GutenbergView.kt | 22 +
.../gutenberg/inserter/BlockPickerDialog.kt | 402 ++++++++++++++++--
.../gutenberg/inserter/PhotoAccessState.kt | 60 +++
.../gutenberg/inserter/RecentImages.kt | 327 ++++++++++++++
.../Gutenberg/src/main/res/values/strings.xml | 7 +
.../inserter/PhotoAccessStateTest.kt | 72 ++++
.../example/gutenbergkit/EditorTestHelpers.kt | 6 +-
android/app/src/main/AndroidManifest.xml | 3 +
.../com/example/gutenbergkit/MainActivity.kt | 12 +
.../gutenbergkit/ManagePermissionsActivity.kt | 236 ++++++++++
.../gutenbergkit/SitePreparationActivity.kt | 7 +-
.../gutenbergkit/SitePreparationViewModel.kt | 2 +-
docs/integration.md | 27 ++
14 files changed, 1163 insertions(+), 39 deletions(-)
create mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/PhotoAccessState.kt
create mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/RecentImages.kt
create mode 100644 android/Gutenberg/src/test/java/org/wordpress/gutenberg/inserter/PhotoAccessStateTest.kt
create mode 100644 android/app/src/main/java/com/example/gutenbergkit/ManagePermissionsActivity.kt
diff --git a/android/Gutenberg/src/main/AndroidManifest.xml b/android/Gutenberg/src/main/AndroidManifest.xml
index e7915a736..4f9c40e92 100644
--- a/android/Gutenberg/src/main/AndroidManifest.xml
+++ b/android/Gutenberg/src/main/AndroidManifest.xml
@@ -3,6 +3,25 @@
+
+
+
+