Skip to content

Commit 726a400

Browse files
committed
feat(android): add photo-permission rationale and recent-photos strip
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\`).
1 parent 229de4e commit 726a400

14 files changed

Lines changed: 1163 additions & 39 deletions

File tree

android/Gutenberg/src/main/AndroidManifest.xml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@
33

44
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
55

6+
<!-- Used by the block inserter media strip to preview recent device photos.
7+
Inherited in the merged manifest, so it shows up in the host's Play
8+
Store data-safety disclosure. Hosts that don't render the inserter or
9+
already declare these permissions for their own media flows can opt
10+
out via the manifest merger:
11+
12+
<uses-permission
13+
android:name="android.permission.READ_MEDIA_IMAGES"
14+
tools:node="remove" />
15+
<uses-permission
16+
android:name="android.permission.READ_EXTERNAL_STORAGE"
17+
tools:node="remove" />
18+
19+
See docs/integration.md (Android → Manifest Permissions) for details. -->
20+
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
21+
<uses-permission
22+
android:name="android.permission.READ_EXTERNAL_STORAGE"
23+
android:maxSdkVersion="32" />
24+
625
<application>
726
<!-- Exposes a writeable temp-file URI to the camera app so captures can
827
come back to us without the host app having to configure its own

android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import kotlinx.coroutines.launch
3737
import org.json.JSONException
3838
import org.json.JSONObject
3939
import org.wordpress.gutenberg.inserter.BlockPickerDialog
40+
import org.wordpress.gutenberg.inserter.clearPhotoPreferences
41+
import org.wordpress.gutenberg.inserter.warmupPhotoPrefs
4042
import org.wordpress.gutenberg.model.BlockInserterPayload
4143
import org.wordpress.gutenberg.model.EditorConfiguration
4244
import org.wordpress.gutenberg.model.EditorDependencies
@@ -219,6 +221,12 @@ class GutenbergView : FrameLayout {
219221
this.configuration = configuration
220222
this.coroutineScope = coroutineScope
221223

224+
// Warm the block-inserter's photo-prefs cache off the main thread now,
225+
// well before the user can navigate to the inserter. By the time they
226+
// tap `+`, the prefs read in `MediaStrip` is synchronous from the
227+
// process-wide cache — no async-load placeholder, no visible flash.
228+
warmupPhotoPrefs(context)
229+
222230
// Initialize the asset loader now that context is available
223231
assetLoader = WebViewAssetLoader.Builder()
224232
.addPathHandler("/assets/", AssetsPathHandler(context))
@@ -1090,6 +1098,20 @@ class GutenbergView : FrameLayout {
10901098
}
10911099

10921100
companion object {
1101+
/**
1102+
* Clears the block inserter's photo-library preferences (rationale
1103+
* rejection + first-prompt tracking). Call from a host-app settings
1104+
* screen if you want users to re-see the rationale after dismissing it.
1105+
*
1106+
* The OS-level photo permission itself is not affected — only the
1107+
* in-app flags. The library's media permissions are declared in the
1108+
* library's manifest and inherited via manifest merging; see
1109+
* docs/integration.md (Android → Manifest Permissions) for the opt-out.
1110+
*/
1111+
fun resetBlockPickerPhotoPreferences(context: Context) {
1112+
clearPhotoPreferences(context)
1113+
}
1114+
10931115
/** Hosts that are safe to serve assets over HTTP (local development only). */
10941116
private val LOCAL_HOSTS = setOf("localhost", "127.0.0.1", "10.0.2.2")
10951117

0 commit comments

Comments
 (0)