From 0a64afc418be87999af846f5971ebf7c0c356c55 Mon Sep 17 00:00:00 2001
From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
Date: Wed, 13 May 2026 10:53:50 -0600
Subject: [PATCH 1/3] feat(android): add Photos and Camera tiles to block
inserter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
android/Gutenberg/build.gradle.kts | 1 +
.../Gutenberg/src/main/AndroidManifest.xml | 18 ++-
.../gutenberg/inserter/BlockPickerDialog.kt | 137 ++++++++++++++++++
.../Gutenberg/src/main/res/values/strings.xml | 2 +
.../src/main/res/xml/gbk_file_paths.xml | 4 +
5 files changed, 161 insertions(+), 1 deletion(-)
create mode 100644 android/Gutenberg/src/main/res/xml/gbk_file_paths.xml
diff --git a/android/Gutenberg/build.gradle.kts b/android/Gutenberg/build.gradle.kts
index 4b091f423..02aab6734 100644
--- a/android/Gutenberg/build.gradle.kts
+++ b/android/Gutenberg/build.gradle.kts
@@ -171,6 +171,7 @@ dependencies {
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
+ implementation(libs.androidx.activity.compose)
testImplementation(libs.junit)
testImplementation(kotlin("test"))
diff --git a/android/Gutenberg/src/main/AndroidManifest.xml b/android/Gutenberg/src/main/AndroidManifest.xml
index cbb96c156..e7915a736 100644
--- a/android/Gutenberg/src/main/AndroidManifest.xml
+++ b/android/Gutenberg/src/main/AndroidManifest.xml
@@ -3,4 +3,20 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/BlockPickerDialog.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/BlockPickerDialog.kt
index b960bb7ea..d1a76c242 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/BlockPickerDialog.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/BlockPickerDialog.kt
@@ -2,8 +2,12 @@ package org.wordpress.gutenberg.inserter
import android.content.Context
import android.graphics.Color
+import android.net.Uri
import android.os.Build
import android.view.ViewGroup
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -19,6 +23,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -33,6 +38,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.PhotoCamera
+import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.Icon
@@ -54,6 +61,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.geometry.Offset
@@ -76,8 +84,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.core.content.FileProvider
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
+import java.io.File
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import org.wordpress.gutenberg.R
@@ -113,6 +123,17 @@ private const val HEADER_TITLE_LETTER_SPACING_SP = -0.1
private const val ICON_BUTTON_SIZE_DP = 40
private const val ICON_SIZE_DP = 20
+private const val MEDIA_STRIP_TOP_PAD_DP = 4
+private const val MEDIA_STRIP_BOTTOM_PAD_DP = 10
+private const val MEDIA_STRIP_CONTENT_HORIZONTAL_PAD_DP = 20
+private const val MEDIA_STRIP_CONTENT_TOP_PAD_DP = 2
+private const val MEDIA_STRIP_CONTENT_BOTTOM_PAD_DP = 6
+private const val MEDIA_STACK_COMPACT_HEIGHT_DP = 88
+private const val MEDIA_STACK_GAP_DP = 4
+private const val MEDIA_STACK_CORNER_DP = 18
+private const val MEDIA_STACK_ICON_SIZE_DP = 28
+private const val MEDIA_STACK_LABEL_SP = 13
+
private const val TABS_VERTICAL_PAD_DP = 4
private const val TABS_BOTTOM_PAD_DP = 6
private const val TABS_CONTENT_VERTICAL_PAD_DP = 4
@@ -269,6 +290,7 @@ private fun SheetContent(
) {
DragHandle()
Header(onClose = onClose)
+ MediaStrip()
CategoryTabs(selected = selectedTab, onSelect = onSelectTab)
SearchField(query = query, onQueryChange = onQueryChange)
BlockGridContent(
@@ -401,6 +423,121 @@ private fun CloseButton(onClose: () -> Unit) {
}
}
+@Composable
+private fun MediaStrip() {
+ val contentPadding = PaddingValues(
+ start = MEDIA_STRIP_CONTENT_HORIZONTAL_PAD_DP.dp,
+ end = MEDIA_STRIP_CONTENT_HORIZONTAL_PAD_DP.dp,
+ top = MEDIA_STRIP_CONTENT_TOP_PAD_DP.dp,
+ bottom = MEDIA_STRIP_CONTENT_BOTTOM_PAD_DP.dp,
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = MEDIA_STRIP_TOP_PAD_DP.dp, bottom = MEDIA_STRIP_BOTTOM_PAD_DP.dp),
+ ) {
+ // Photos and Camera tiles. Photos uses the permissionless system photo
+ // picker; Camera uses ACTION_IMAGE_CAPTURE against a cache-scoped
+ // FileProvider URI. Neither requires READ_MEDIA_IMAGES — the recent-
+ // photos thumbnail strip that needs it lands in a follow-up.
+ PhotosCameraTile(modifier = Modifier.fillMaxWidth().padding(contentPadding))
+ }
+}
+
+@Composable
+private fun PhotosCameraTile(modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+ // The result callbacks below are intentionally inert: the picked URI / camera
+ // capture needs to round-trip through `WebViewAssetLoader` so the JS editor
+ // can `fetch()` it, which is a follow-up. Until that lands, this whole sheet
+ // is gated behind the demo app's "Enable Native Inserter" toggle, so users
+ // outside that opt-in won't see the no-op buttons.
+ val photoPicker = rememberLauncherForActivityResult(
+ ActivityResultContracts.PickVisualMedia()
+ ) { /* picked uri — hand-off to editor insertion is a follow-up */ }
+ var pendingCameraUri by remember { mutableStateOf(null) }
+ val cameraLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.TakePicture()
+ ) { /* success:Boolean + pendingCameraUri — hand-off is a follow-up */ }
+ val onPhotosClick = {
+ photoPicker.launch(
+ PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
+ )
+ }
+ val onCameraClick = {
+ val uri = createCameraOutputUri(context)
+ pendingCameraUri = uri
+ cameraLauncher.launch(uri)
+ }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(MEDIA_STACK_GAP_DP.dp),
+ modifier = modifier.height(MEDIA_STACK_COMPACT_HEIGHT_DP.dp),
+ ) {
+ MediaActionTile(
+ iconVector = Icons.Filled.PhotoLibrary,
+ label = stringResource(R.string.gbk_block_inserter_photos),
+ background = MaterialTheme.colorScheme.primaryContainer,
+ foreground = MaterialTheme.colorScheme.onPrimaryContainer,
+ onClick = onPhotosClick,
+ modifier = Modifier.fillMaxHeight().weight(1f),
+ )
+ MediaActionTile(
+ iconVector = Icons.Filled.PhotoCamera,
+ label = stringResource(R.string.gbk_block_inserter_camera),
+ background = MaterialTheme.colorScheme.tertiary,
+ foreground = MaterialTheme.colorScheme.onTertiary,
+ onClick = onCameraClick,
+ modifier = Modifier.fillMaxHeight().weight(1f),
+ )
+ }
+}
+
+private fun createCameraOutputUri(context: Context): Uri {
+ val dir = File(context.cacheDir, "camera").apply { mkdirs() }
+ val file = File(dir, "capture_${System.currentTimeMillis()}.jpg")
+ // TODO: clean up captured files once the editor hand-off lands. Each Camera
+ // tap creates a fresh file here; with the result callback inert today, every
+ // capture is orphaned in the cache. When we wire up the URI hand-off, delete
+ // on success/cancel and sweep stale files on next entry.
+ return FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.gutenberg.fileprovider",
+ file,
+ )
+}
+
+@Composable
+private fun MediaActionTile(
+ iconVector: ImageVector,
+ label: String,
+ background: ComposeColor,
+ foreground: ComposeColor,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = modifier
+ .clip(RoundedCornerShape(MEDIA_STACK_CORNER_DP.dp))
+ .background(background)
+ .clickable(onClick = onClick),
+ ) {
+ Icon(
+ imageVector = iconVector,
+ contentDescription = null,
+ tint = foreground,
+ modifier = Modifier.size(MEDIA_STACK_ICON_SIZE_DP.dp),
+ )
+ Text(
+ text = label,
+ color = foreground,
+ fontSize = MEDIA_STACK_LABEL_SP.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+}
+
@Composable
private fun CategoryTabs(
selected: BlockPickerTab,
diff --git a/android/Gutenberg/src/main/res/values/strings.xml b/android/Gutenberg/src/main/res/values/strings.xml
index b3518067f..c697b692c 100644
--- a/android/Gutenberg/src/main/res/values/strings.xml
+++ b/android/Gutenberg/src/main/res/values/strings.xml
@@ -16,4 +16,6 @@
Clear search
No results
No blocks match “%1$s”
+ Photos
+ Camera
diff --git a/android/Gutenberg/src/main/res/xml/gbk_file_paths.xml b/android/Gutenberg/src/main/res/xml/gbk_file_paths.xml
new file mode 100644
index 000000000..2aed291b2
--- /dev/null
+++ b/android/Gutenberg/src/main/res/xml/gbk_file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
From 0ea1a835b520a3ae547865fa68120d8a61bb0459 Mon Sep 17 00:00:00 2001
From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
Date: Wed, 13 May 2026 11:03:41 -0600
Subject: [PATCH 2/3] 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 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.
---
.../Gutenberg/src/main/AndroidManifest.xml | 6 +
.../org/wordpress/gutenberg/GutenbergView.kt | 10 +
.../gutenberg/inserter/BlockPickerDialog.kt | 326 ++++++++++++++++--
.../gutenberg/inserter/PhotoAccessState.kt | 60 ++++
.../gutenberg/inserter/RecentImages.kt | 191 ++++++++++
.../Gutenberg/src/main/res/values/strings.xml | 7 +
.../inserter/PhotoAccessStateTest.kt | 72 ++++
.../com/example/gutenbergkit/MainActivity.kt | 10 +
.../gutenbergkit/SitePreparationViewModel.kt | 2 +-
9 files changed, 653 insertions(+), 31 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
diff --git a/android/Gutenberg/src/main/AndroidManifest.xml b/android/Gutenberg/src/main/AndroidManifest.xml
index e7915a736..c2422ec2a 100644
--- a/android/Gutenberg/src/main/AndroidManifest.xml
+++ b/android/Gutenberg/src/main/AndroidManifest.xml
@@ -2,6 +2,12 @@
+
+
+
+
+
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/BlockPickerDialog.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/BlockPickerDialog.kt
index 5375cf2aa..bb9e6f9fe 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/BlockPickerDialog.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/BlockPickerDialog.kt
@@ -45,6 +45,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -532,6 +533,7 @@ private fun FullMediaStrip(granted: PhotoAccess.Granted?, contentPadding: Paddin
// list's nested-scroll dispatch up to BottomSheetBehavior; no manual
// relay needed.
val uris = granted?.uris.orEmpty()
+ val partialAccess = granted?.partialAccess
val columns = (uris.size + 1) / 2
LazyRow(
horizontalArrangement = Arrangement.spacedBy(MEDIA_STRIP_ITEM_GAP_DP.dp),
@@ -539,6 +541,14 @@ private fun FullMediaStrip(granted: PhotoAccess.Granted?, contentPadding: Paddin
modifier = Modifier.fillMaxWidth(),
) {
item(key = "actions") { PhotosCameraTile(horizontal = false) }
+ if (partialAccess != null) {
+ // Sits right after Photos/Camera so the affordance is visible without
+ // scrolling — partial-access users won't otherwise have any in-app
+ // path to update their selection.
+ item(key = "manage") {
+ ManageSelectionTile(onClick = partialAccess.onManageSelection)
+ }
+ }
items(columns, key = { uris[it * 2].toString() }) { col ->
Column(verticalArrangement = Arrangement.spacedBy(MEDIA_STRIP_ITEM_GAP_DP.dp)) {
RealThumbnail(uri = uris[col * 2])
@@ -551,6 +561,20 @@ private fun FullMediaStrip(granted: PhotoAccess.Granted?, contentPadding: Paddin
}
}
+@Composable
+private fun ManageSelectionTile(onClick: () -> Unit) {
+ MediaActionTile(
+ iconVector = Icons.Filled.Tune,
+ label = stringResource(R.string.gbk_block_inserter_photos_manage),
+ background = MaterialTheme.colorScheme.secondaryContainer,
+ foreground = MaterialTheme.colorScheme.onSecondaryContainer,
+ onClick = onClick,
+ modifier = Modifier
+ .width(MEDIA_STACK_WIDTH_DP.dp)
+ .height(MEDIA_STACK_HEIGHT_DP.dp),
+ )
+}
+
@Composable
@Suppress("LongMethod")
private fun PhotosCameraTile(
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/PhotoAccessState.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/PhotoAccessState.kt
index 2c5a484ba..ba3aae551 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/PhotoAccessState.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/PhotoAccessState.kt
@@ -9,11 +9,22 @@ import android.net.Uri
* `shouldShowRequestPermissionRationale` alone can't tell those apart.
*/
internal sealed interface PhotoAccess {
- data class Granted(val uris: List) : PhotoAccess
+ /**
+ * Permission has been granted. `partialAccess` is non-null on Android 14+
+ * when the user picked "Select photos and videos" rather than full access —
+ * its `onManageSelection` reopens the system picker so the user can update
+ * the selection without leaving the app.
+ */
+ data class Granted(
+ val uris: List,
+ val partialAccess: PartialAccess? = null,
+ ) : PhotoAccess
data class NeedsPermission(
val state: PromptState,
val request: () -> Unit,
) : PhotoAccess
+
+ data class PartialAccess(val onManageSelection: () -> Unit)
}
/**
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/RecentImages.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/RecentImages.kt
index c60e782bf..104621183 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/RecentImages.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/RecentImages.kt
@@ -41,6 +41,7 @@ internal fun rememberPhotoAccess(limit: Int): PhotoAccess {
val context = LocalContext.current
val activity = remember(context) { context.findActivity() }
var granted by remember { mutableStateOf(hasPhotosPermission(context)) }
+ var partial by remember { mutableStateOf(isPartialPhotoAccess(context)) }
var promptedBefore by remember { mutableStateOf(hasPromptedForPhotos(context)) }
var uris by remember { mutableStateOf>(emptyList()) }
// `canReprompt` is its own state, recomputed at every permission-relevant
@@ -52,16 +53,22 @@ internal fun rememberPhotoAccess(limit: Int): PhotoAccess {
var canReprompt by remember {
mutableStateOf(activity?.let { shouldShowRationale(it) } ?: true)
}
+ // Bumped after every launcher result. Keys the MediaStore re-query so a
+ // partial-access selection update (granted stays true, uris content changes)
+ // refreshes the strip — `granted`/`limit` alone wouldn't trigger that.
+ var refreshTick by remember { mutableStateOf(0) }
val refreshAccessState = {
granted = hasPhotosPermission(context)
+ partial = isPartialPhotoAccess(context)
canReprompt = activity?.let { shouldShowRationale(it) } ?: true
}
val launcher = rememberLauncherForActivityResult(
- ActivityResultContracts.RequestPermission()
+ ActivityResultContracts.RequestMultiplePermissions()
) { _ ->
markPromptedForPhotos(context)
promptedBefore = true
refreshAccessState()
+ refreshTick++
}
// Re-read permission on every RESUME so we notice grants made via system
// Settings (which happen outside the Compose result-callback path).
@@ -79,20 +86,29 @@ internal fun rememberPhotoAccess(limit: Int): PhotoAccess {
activityLifecycle.addObserver(observer)
onDispose { activityLifecycle.removeObserver(observer) }
}
- LaunchedEffect(granted, limit) {
+ LaunchedEffect(granted, limit, refreshTick) {
uris = if (granted) {
withContext(Dispatchers.IO) { queryRecentImages(context, limit) }
} else {
emptyList()
}
}
- if (granted) return PhotoAccess.Granted(uris)
+ if (granted) {
+ return PhotoAccess.Granted(
+ uris = uris,
+ partialAccess = if (partial) {
+ PhotoAccess.PartialAccess(
+ onManageSelection = { launcher.launch(photosPermissions()) },
+ )
+ } else null,
+ )
+ }
val state = resolvePromptState(promptedBefore = promptedBefore, canReprompt = canReprompt)
return PhotoAccess.NeedsPermission(
state = state,
request = {
if (state == PromptState.PermanentlyDenied) openAppSettings(context)
- else launcher.launch(photosPermission())
+ else launcher.launch(photosPermissions())
},
)
}
@@ -114,8 +130,50 @@ private fun photosPermission(): String =
Manifest.permission.READ_EXTERNAL_STORAGE
}
-private fun hasPhotosPermission(context: Context): Boolean =
- ContextCompat.checkSelfPermission(context, photosPermission()) == PackageManager.PERMISSION_GRANTED
+/**
+ * The permission set to request together. On Android 14+ we ask for both the
+ * full and partial-access permissions in one prompt — the system surfaces the
+ * "Select photos and videos" affordance, and on subsequent calls reopens the
+ * picker so the user can update a partial-access selection without leaving us.
+ */
+private fun photosPermissions(): Array = when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> arrayOf(
+ Manifest.permission.READ_MEDIA_IMAGES,
+ Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED,
+ )
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> arrayOf(
+ Manifest.permission.READ_MEDIA_IMAGES,
+ )
+ else -> arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
+}
+
+/** True when either full or partial-access reads are permitted. */
+private fun hasPhotosPermission(context: Context): Boolean {
+ if (ContextCompat.checkSelfPermission(context, photosPermission()) == PackageManager.PERMISSION_GRANTED) {
+ return true
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ return ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED,
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+ return false
+}
+
+/** True only when the user picked "Select photos and videos" (Android 14+). */
+private fun isPartialPhotoAccess(context: Context): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return false
+ val full = ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_MEDIA_IMAGES,
+ ) == PackageManager.PERMISSION_GRANTED
+ if (full) return false
+ return ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED,
+ ) == PackageManager.PERMISSION_GRANTED
+}
private fun shouldShowRationale(activity: Activity): Boolean =
ActivityCompat.shouldShowRequestPermissionRationale(activity, photosPermission())
diff --git a/android/Gutenberg/src/main/res/values/strings.xml b/android/Gutenberg/src/main/res/values/strings.xml
index 7fcbddcae..f0a32606f 100644
--- a/android/Gutenberg/src/main/res/values/strings.xml
+++ b/android/Gutenberg/src/main/res/values/strings.xml
@@ -25,4 +25,5 @@
Try Again
Reject
Open Settings
+ Manage
diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/inserter/PhotoAccessStateTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/inserter/PhotoAccessStateTest.kt
index 20f3aa857..89f937f2f 100644
--- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/inserter/PhotoAccessStateTest.kt
+++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/inserter/PhotoAccessStateTest.kt
@@ -44,6 +44,16 @@ class PhotoAccessStateTest {
assertEquals(MediaStripView.FullStrip, resolveMediaStripView(granted, rejected = true))
}
+ @Test
+ fun `partial access still shows full strip`() {
+ val partial = PhotoAccess.Granted(
+ uris = emptyList(),
+ partialAccess = PhotoAccess.PartialAccess(onManageSelection = {}),
+ )
+ assertEquals(MediaStripView.FullStrip, resolveMediaStripView(partial, rejected = false))
+ assertEquals(MediaStripView.FullStrip, resolveMediaStripView(partial, rejected = true))
+ }
+
@Test
fun `needs-permission shows rationale when not rejected`() {
val needs = needsPermission(PromptState.Denied)