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 @@ + + + +