Skip to content

feat(android): support Android 14 partial photo access#511

Draft
jkmassel wants to merge 3 commits into
jkmassel/block-picker-photo-permissionsfrom
jkmassel/block-picker-partial-access
Draft

feat(android): support Android 14 partial photo access#511
jkmassel wants to merge 3 commits into
jkmassel/block-picker-photo-permissionsfrom
jkmassel/block-picker-partial-access

Conversation

@jkmassel
Copy link
Copy Markdown
Contributor

Summary

Layers on top of #510 with proper handling for Android 14's "Select photos and videos" partial-access flow.

Android 14 added a third option to the photo-permission dialog: users can pick Allow, Select photos and videos, or Don't allow. Without opting in to the partial-access permission, the system still shows the three-way prompt, but the app sees a partial grant as full access — and the user has no in-app way to update which photos they've shared. The strip would show the same 3 photos forever, with the only path to add more being a trip to system Settings.

This PR closes that gap.

Changes

  • Declare READ_MEDIA_VISUAL_USER_SELECTED in the manifest. This is the partial-access opt-in that signals to the system that we understand and handle partial grants.

    Permission SDK range Purpose
    READ_MEDIA_VISUAL_USER_SELECTED 34+ Detect partial grants, expose a Manage tile
  • Switch the launcher from RequestPermission to RequestMultiplePermissions, requesting both READ_MEDIA_IMAGES and READ_MEDIA_VISUAL_USER_SELECTED together via photosPermissions(): Array<String> (API-tiered). When partial is already granted, calling the launcher again reopens the photo picker so the user can update the selection — that's the in-app "Manage" path.

  • hasPhotosPermission now accepts either full or partial reads. isPartialPhotoAccess distinguishes "selected photos only" so the strip can surface a Manage affordance.

  • PhotoAccess.PartialAccess data class carries onManageSelection. PhotoAccess.Granted gains an optional partialAccess: PartialAccess? field — non-null only when the user picked partial.

  • ManageSelectionTile sits right after the Photos/Camera column when partial access is active. Uses the Tune icon and secondaryContainer colour so it reads as an editorial control distinct from Photos/Camera. Visible without scrolling so partial-access users find it immediately.

  • refreshTick keys the MediaStore re-query so a selection update (where granted stays true but the visible set changes) refreshes the strip — granted / limit alone wouldn't trigger that.

Test plan

Requires an Android 14+ device.

  • First launch + tap Allow in the rationale → system shows three-way prompt → pick Select photos and videos → choose 2-3 photos → strip shows those photos plus a Manage tile after the Photos/Camera column.
  • Tap Manage → system reopens the photo picker → add another photo → return → strip updates to include the new selection.
  • Tap Manage → system reopens the photo picker → remove one photo → return → strip updates to reflect the smaller selection.
  • In system Settings, change from partial to full access → return to the app → Manage tile disappears, full thumbnail strip remains.
  • In system Settings, revoke entirely → return to the app → rationale card returns (sticky rejected flag was cleared on grant in the parent PR).
  • On Android 13 (no READ_MEDIA_VISUAL_USER_SELECTED available) → partial-access path is unreachable. Granting READ_MEDIA_IMAGES works as before.
  • PhotoAccessStateTest::partial access still shows full strip passes.
  • ./gradlew :Gutenberg:detekt :Gutenberg:assembleDebug :Gutenberg:testDebugUnitTest passes.

jkmassel added 3 commits May 13, 2026 10:53
Adds a Photos / Camera quick-launch row between the inserter's header
and category tabs:

- Photos tile uses the permissionless system photo picker
  (`ActivityResultContracts.PickVisualMedia`) — no manifest permission
  required.
- Camera tile uses `ACTION_IMAGE_CAPTURE` against a cache-scoped
  FileProvider URI; no `CAMERA` permission required since we delegate
  to the system camera app.

The library declares its own FileProvider keyed off
`${applicationId}.gutenberg.fileprovider` so it can't collide with one
a host app already declares. No `uses-permission` lines added at this
stage — the recent-photos thumbnail strip that needs
`READ_MEDIA_IMAGES` lands in a follow-up.

The picker / camera result callbacks are intentionally inert until the
WebViewAssetLoader-based URI hand-off lands; the sheet is gated behind
the demo's "Enable Native Inserter" toggle in the meantime.
Layers on the Photos / Camera tiles introduced in the previous PR 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.
- 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 **Reset Photo Permissions Prompts**.
- Library declares `READ_MEDIA_IMAGES` and `READ_EXTERNAL_STORAGE`
  (max SDK 32). Host apps can opt out via `tools:node="remove"`.
- Demo's "Enable Native Inserter" toggle now defaults to on so
  reviewers see the new strip without flipping a setting.

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` so the ripple still draws
inside the pill instead of as a square halo.
Layers on the rationale + recent-photos strip with proper handling for
Android 14's "Select photos and videos" partial-access flow.

- Declares `READ_MEDIA_VISUAL_USER_SELECTED` in the manifest so the
  system surfaces the three-way prompt (Allow / Select / Don't allow)
  and grants the new permission on partial selections.
- Switches from `RequestPermission` to `RequestMultiplePermissions` and
  requests both `READ_MEDIA_IMAGES` and `READ_MEDIA_VISUAL_USER_SELECTED`
  together. On subsequent calls when partial is already granted, the
  system reopens the photo picker so the user can update the selection.
- `hasPhotosPermission` accepts either full or partial-access reads.
- `isPartialPhotoAccess` distinguishes "selected photos only" so the
  strip can surface a **Manage** tile, sitting right after the
  Photos/Camera column. The tile re-launches the multi-permission
  request, which reopens the system picker.
- `refreshTick` keys the MediaStore re-query so a selection update
  (where `granted` stays true but the visible set changes) refreshes
  the strip.

Without this PR, users on Android 14+ who pick "Select photos and
videos" see a working strip but have no in-app affordance to update
the selection — they'd have to leave for system Settings.
@wpmobilebot
Copy link
Copy Markdown

XCFramework Build

This PR's XCFramework is available for testing. Add the following to your Package.swift:

.package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/511")

Built from 06bb7a9

@jkmassel jkmassel marked this pull request as draft May 13, 2026 19:14
@jkmassel jkmassel force-pushed the jkmassel/block-picker-photo-permissions branch 3 times, most recently from 7ab5b51 to 47a22e0 Compare May 14, 2026 19:04
jkmassel added a commit that referenced this pull request May 14, 2026
… strip

`hasPhotosPermission` previously only checked `READ_MEDIA_IMAGES`. On
Android 14+ (targetSdk >= 34), the system shows a three-option dialog
("Allow all" / "Select photos" / "Don't allow") whenever an app
requests `READ_MEDIA_IMAGES` — empirically verified on Pixel 9 Pro XL
running Android 16 with this PR's manifest, which does *not* declare
`READ_MEDIA_VISUAL_USER_SELECTED`. The system still surfaces the
"Select photos" option without an explicit opt-in.

When the user picked "Select photos", `READ_MEDIA_IMAGES` stayed denied,
our state machine landed on `PermanentlyDenied`, and the rationale told
the user to open Settings — for a permission they had just granted.
This made the partial-grant experience strictly worse than full denial,
since the user knew they'd granted access and saw the app gaslight them.

Also check `READ_MEDIA_VISUAL_USER_SELECTED` (API 34+) when
`READ_MEDIA_IMAGES` is denied. MediaStore automatically scopes our
recent-images query to the user-selected items, so the existing query
path produces the right strip with no further changes.

Declaring `READ_MEDIA_VISUAL_USER_SELECTED` in the library manifest —
which enables the "Select more photos" re-prompt affordance and the
fuller partial-access UX — is the next PR in the stack (#511).

- [x] Verified on Pixel 9 Pro XL / Android 16: fresh app data → tap
  Allow → three-option dialog → "Select photos" → choose 3 → strip
  renders those 3 thumbnails instead of falling through to the
  rationale's "Open Settings" state.
@jkmassel jkmassel force-pushed the jkmassel/block-picker-photo-permissions branch from 04fa2fc to d7bd4ad Compare May 14, 2026 23:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants