From ed272e96ea5e4969ac0118a0f750977e294c67a0 Mon Sep 17 00:00:00 2001
From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
Date: Tue, 28 Apr 2026 14:09:02 -0600
Subject: [PATCH 1/2] feat(android): add photos and camera media strip to block
inserter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the recent-photos / Photos / Camera strip between the inserter
header and category tabs. Three states, picked at runtime:
1. **Permission rationale card** — shown when the host app hasn't yet
been granted READ_MEDIA_IMAGES. The card switches body copy and
primary-button label based on whether we've never asked, were
denied once, or were permanently denied. SharedPreferences tracks
the first prompt because `shouldShowRequestPermissionRationale`
alone can't distinguish "never asked" from "permanently denied".
2. **Recent photos strip** — once granted, queries MediaStore for the
12 most recent images and renders them as 2-row thumbnail tiles.
3. **Photos / Camera tiles** — Photos launches the system photo
picker (permissionless via `PickVisualMedia`); Camera launches
`ACTION_IMAGE_CAPTURE` against a cache-scoped FileProvider URI.
Hand-off of the picked URI to editor insertion is a follow-up.
The library declares its own FileProvider keyed off `${applicationId}`
so it won't collide with one a host app already provides. Host apps
that don't need photo access can `tools:node="remove"` the manifest
permissions.
---
android/Gutenberg/build.gradle.kts | 1 +
.../Gutenberg/src/main/AndroidManifest.xml | 22 ++
.../org/wordpress/gutenberg/GutenbergView.kt | 15 +
.../gutenberg/inserter/BlockPickerDialog.kt | 365 +++++++++++++++++-
.../gutenberg/inserter/PhotoAccessState.kt | 60 +++
.../gutenberg/inserter/RecentImages.kt | 173 +++++++++
.../Gutenberg/src/main/res/values/strings.xml | 9 +
.../src/main/res/xml/gbk_file_paths.xml | 4 +
.../inserter/PhotoAccessStateTest.kt | 72 ++++
.../com/example/gutenbergkit/MainActivity.kt | 10 +
10 files changed, 729 insertions(+), 2 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/main/res/xml/gbk_file_paths.xml
create mode 100644 android/Gutenberg/src/test/java/org/wordpress/gutenberg/inserter/PhotoAccessStateTest.kt
diff --git a/android/Gutenberg/build.gradle.kts b/android/Gutenberg/build.gradle.kts
index 01877207e..126f800a4 100644
--- a/android/Gutenberg/build.gradle.kts
+++ b/android/Gutenberg/build.gradle.kts
@@ -90,6 +90,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..6ad8fed6c 100644
--- a/android/Gutenberg/src/main/AndroidManifest.xml
+++ b/android/Gutenberg/src/main/AndroidManifest.xml
@@ -2,5 +2,27 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
index 138d635a7..d0835e508 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
@@ -37,6 +37,7 @@ import kotlinx.coroutines.launch
import org.json.JSONException
import org.json.JSONObject
import org.wordpress.gutenberg.inserter.BlockPickerDialog
+import org.wordpress.gutenberg.inserter.clearPhotoPreferences
import org.wordpress.gutenberg.model.BlockInserterPayload
import org.wordpress.gutenberg.model.EditorConfiguration
import org.wordpress.gutenberg.model.EditorDependencies
@@ -913,6 +914,11 @@ class GutenbergView : FrameLayout {
private fun presentBlockInserter(payload: BlockInserterPayload) {
blockInserterDialog?.dismiss()
+ // Hide the soft keyboard before showing the bottom sheet — otherwise
+ // the sheet opens above the still-visible keyboard, then visibly
+ // "re-launches" into the freed space once the IME dismisses.
+ (context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)
+ ?.hideSoftInputFromWindow(webView.windowToken, 0)
val dialog = BlockPickerDialog(
context = context,
payload = payload,
@@ -1090,6 +1096,15 @@ class GutenbergView : FrameLayout {
}
companion object {
+ /**
+ * Clears the block inserter's photo-library preferences (rationale
+ * rejection + first-prompt tracking). Call from a host-app settings
+ * screen if you want users to re-see the rationale after dismissing it.
+ */
+ fun resetBlockPickerPhotoPreferences(context: Context) {
+ clearPhotoPreferences(context)
+ }
+
/** Hosts that are safe to serve assets over HTTP (local development only). */
private val LOCAL_HOSTS = setOf("localhost", "127.0.0.1", "10.0.2.2")
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..d908642b4 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,13 @@ 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 android.view.WindowManager
+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 +24,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 +39,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
@@ -53,13 +61,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -67,6 +78,7 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
+import androidx.core.content.FileProvider
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
@@ -78,8 +90,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.Dispatchers
import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
import org.wordpress.gutenberg.R
import androidx.compose.ui.graphics.Color as ComposeColor
import org.wordpress.gutenberg.model.BlockInserterPayload
@@ -113,6 +128,31 @@ 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_STRIP_ITEM_GAP_DP = 8
+private const val MEDIA_STACK_WIDTH_DP = 72
+private const val MEDIA_STACK_HEIGHT_DP = 136
+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 MEDIA_THUMB_SIZE_DP = 64
+private const val MEDIA_THUMB_CORNER_DP = 16
+private const val RECENT_PHOTO_LIMIT = 64
+
+private const val MEDIA_RATIONALE_PAD_DP = 14
+private const val MEDIA_RATIONALE_FONT_SP = 13
+private const val MEDIA_RATIONALE_LINE_HEIGHT_SP = 18
+private const val MEDIA_RATIONALE_BUTTON_GAP_DP = 6
+private const val MEDIA_RATIONALE_BUTTON_HEIGHT_DP = 32
+private const val MEDIA_RATIONALE_BUTTON_CORNER_DP = 16
+private const val MEDIA_RATIONALE_BUTTON_FONT_SP = 12
+
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
@@ -160,8 +200,8 @@ private const val DISABLED_ALPHA = 0.5f
/**
* Bottom-sheet block inserter. The outer shell stays a `BottomSheetDialog` so
* `GutenbergView`'s integration surface is unchanged; everything visible is
- * Compose content matching the Variation B design handoff — header row,
- * pill category tabs, rounded search, 5-column tonal tile grid.
+ * Compose content matching the Variation B design handoff — header row, recent
+ * media strip, pill category tabs, rounded search, 5-column tonal tile grid.
*
* The sheet background and 28dp top corners are drawn by Compose directly; the
* dialog's default white pill background is cleared so it doesn't fight the
@@ -174,6 +214,13 @@ internal class BlockPickerDialog(
) : BottomSheetDialog(context) {
init {
+ // Render at full size regardless of the IME — without this the dialog
+ // opens compressed above an in-flight soft keyboard, then visibly
+ // resizes once the IME dismisses, looking like a "double launch".
+ window?.setSoftInputMode(
+ WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN or
+ WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
+ )
val composeView = ComposeView(context).apply {
setContent {
BlockPickerSheet(
@@ -269,6 +316,7 @@ private fun SheetContent(
) {
DragHandle()
Header(onClose = onClose)
+ MediaStrip()
CategoryTabs(selected = selectedTab, onSelect = onSelectTab)
SearchField(query = query, onQueryChange = onQueryChange)
BlockGridContent(
@@ -401,6 +449,319 @@ private fun CloseButton(onClose: () -> Unit) {
}
}
+@Composable
+private fun MediaStrip() {
+ val access = rememberPhotoAccess(limit = RECENT_PHOTO_LIMIT)
+ val context = LocalContext.current
+ var rejected by remember { mutableStateOf(hasRejectedRationale(context)) }
+ 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),
+ ) {
+ when (resolveMediaStripView(access, rejected)) {
+ MediaStripView.Rationale -> {
+ val needs = access as PhotoAccess.NeedsPermission
+ // Rationale fills the remaining width next to the Photos/Camera
+ // column — no horizontal scroll needed since nothing extends past
+ // the viewport.
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(MEDIA_STRIP_ITEM_GAP_DP.dp),
+ modifier = Modifier.fillMaxWidth().padding(contentPadding),
+ ) {
+ PhotosCameraTile(horizontal = false)
+ PhotoAccessRationale(
+ state = needs.state,
+ onAllow = needs.request,
+ onReject = {
+ markRationaleRejected(context)
+ rejected = true
+ },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ MediaStripView.CompactTiles -> {
+ // Rationale dismissed — keep the Photos/Camera buttons accessible
+ // but flatten them into a single full-width row. Picker and camera
+ // don't need the photo permission; only the recent-photo strip does.
+ PhotosCameraTile(
+ horizontal = true,
+ modifier = Modifier.fillMaxWidth().padding(contentPadding),
+ )
+ }
+ MediaStripView.FullStrip -> {
+ // Granted — thumbnail grid extends past the viewport, so use
+ // horizontalScroll + a zero-consuming vertical relay so vertical
+ // drags still reach BottomSheetBehavior.
+ val scrollState = rememberScrollState()
+ val verticalRelay = rememberScrollableState { 0f }
+ val uris = (access as? PhotoAccess.Granted)?.uris.orEmpty()
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(MEDIA_STRIP_ITEM_GAP_DP.dp),
+ modifier = Modifier
+ .horizontalScroll(scrollState)
+ .scrollable(verticalRelay, Orientation.Vertical)
+ .padding(contentPadding),
+ ) {
+ PhotosCameraTile(horizontal = false)
+ if (uris.isNotEmpty()) MediaThumbnailGrid(uris = uris)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+@Suppress("LongMethod")
+private fun PhotosCameraTile(
+ horizontal: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ // System photo picker is permissionless — our READ_MEDIA_IMAGES permission
+ // only gates the recent-photos strip, not this launch path.
+ val photoPicker = rememberLauncherForActivityResult(
+ ActivityResultContracts.PickVisualMedia()
+ ) { /* picked uri — hand-off to editor insertion is a follow-up */ }
+ // Camera writes into a cache-scoped temp file exposed by the library's
+ // FileProvider; no CAMERA permission needed since we delegate to the
+ // system camera app via ACTION_IMAGE_CAPTURE.
+ 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)
+ }
+ val photosLabel = stringResource(R.string.gbk_block_inserter_photos)
+ val cameraLabel = stringResource(R.string.gbk_block_inserter_camera)
+ if (horizontal) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(MEDIA_STACK_GAP_DP.dp),
+ modifier = modifier.height(MEDIA_STACK_COMPACT_HEIGHT_DP.dp),
+ ) {
+ MediaActionTile(
+ iconVector = Icons.Filled.PhotoLibrary,
+ label = photosLabel,
+ background = MaterialTheme.colorScheme.primaryContainer,
+ foreground = MaterialTheme.colorScheme.onPrimaryContainer,
+ onClick = onPhotosClick,
+ modifier = Modifier.fillMaxHeight().weight(1f),
+ )
+ MediaActionTile(
+ iconVector = Icons.Filled.PhotoCamera,
+ label = cameraLabel,
+ background = MaterialTheme.colorScheme.tertiary,
+ foreground = MaterialTheme.colorScheme.onTertiary,
+ onClick = onCameraClick,
+ modifier = Modifier.fillMaxHeight().weight(1f),
+ )
+ }
+ } else {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(MEDIA_STACK_GAP_DP.dp),
+ modifier = modifier
+ .width(MEDIA_STACK_WIDTH_DP.dp)
+ .height(MEDIA_STACK_HEIGHT_DP.dp),
+ ) {
+ MediaActionTile(
+ iconVector = Icons.Filled.PhotoLibrary,
+ label = photosLabel,
+ background = MaterialTheme.colorScheme.primaryContainer,
+ foreground = MaterialTheme.colorScheme.onPrimaryContainer,
+ onClick = onPhotosClick,
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ )
+ MediaActionTile(
+ iconVector = Icons.Filled.PhotoCamera,
+ label = cameraLabel,
+ background = MaterialTheme.colorScheme.tertiary,
+ foreground = MaterialTheme.colorScheme.onTertiary,
+ onClick = onCameraClick,
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ )
+ }
+ }
+}
+
+private fun createCameraOutputUri(context: Context): Uri {
+ val dir = File(context.cacheDir, "camera").apply { mkdirs() }
+ val file = File(dir, "capture_${System.currentTimeMillis()}.jpg")
+ 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 MediaThumbnailGrid(uris: List) {
+ // Two rows of tiles laid out left-to-right, column-by-column. Only render
+ // as many tiles as we have URIs — empty slots otherwise would render
+ // colorful mock placeholders that flash on the way to the real bitmaps.
+ val columns = (uris.size + 1) / 2
+ Row(horizontalArrangement = Arrangement.spacedBy(MEDIA_STRIP_ITEM_GAP_DP.dp)) {
+ repeat(columns) { col ->
+ Column(verticalArrangement = Arrangement.spacedBy(MEDIA_STRIP_ITEM_GAP_DP.dp)) {
+ RealThumbnail(uri = uris[col * 2])
+ val secondIndex = col * 2 + 1
+ if (secondIndex < uris.size) {
+ RealThumbnail(uri = uris[secondIndex])
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PhotoAccessRationale(
+ state: PromptState,
+ onAllow: () -> Unit,
+ onReject: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val bodyRes = when (state) {
+ PromptState.Unasked -> R.string.gbk_block_inserter_photos_rationale
+ PromptState.Denied -> R.string.gbk_block_inserter_photos_rationale_denied
+ PromptState.PermanentlyDenied -> R.string.gbk_block_inserter_photos_rationale_permanent
+ }
+ val primaryLabelRes = when (state) {
+ PromptState.Unasked -> R.string.gbk_block_inserter_photos_allow
+ PromptState.Denied -> R.string.gbk_block_inserter_photos_try_again
+ PromptState.PermanentlyDenied -> R.string.gbk_block_inserter_photos_open_settings
+ }
+ @Composable
+ fun RationaleButton(label: String, filled: Boolean, onClick: () -> Unit, modifier: Modifier) {
+ val bg = if (filled) MaterialTheme.colorScheme.primary else ComposeColor.Transparent
+ val fg = if (filled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier
+ .height(MEDIA_RATIONALE_BUTTON_HEIGHT_DP.dp)
+ .clip(RoundedCornerShape(MEDIA_RATIONALE_BUTTON_CORNER_DP.dp))
+ .background(bg)
+ .clickable(onClick = onClick),
+ ) {
+ Text(
+ text = label,
+ color = fg,
+ fontSize = MEDIA_RATIONALE_BUTTON_FONT_SP.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ Column(
+ verticalArrangement = Arrangement.SpaceBetween,
+ modifier = modifier
+ .height(MEDIA_STACK_HEIGHT_DP.dp)
+ .clip(RoundedCornerShape(MEDIA_STACK_CORNER_DP.dp))
+ .background(MaterialTheme.colorScheme.secondaryContainer)
+ .padding(MEDIA_RATIONALE_PAD_DP.dp),
+ ) {
+ Text(
+ text = stringResource(bodyRes),
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ fontSize = MEDIA_RATIONALE_FONT_SP.sp,
+ lineHeight = MEDIA_RATIONALE_LINE_HEIGHT_SP.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(MEDIA_RATIONALE_BUTTON_GAP_DP.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ RationaleButton(
+ label = stringResource(primaryLabelRes),
+ filled = true,
+ onClick = onAllow,
+ modifier = Modifier.weight(1f),
+ )
+ RationaleButton(
+ label = stringResource(R.string.gbk_block_inserter_photos_reject),
+ filled = false,
+ onClick = onReject,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+}
+
+@Composable
+private fun RealThumbnail(uri: Uri) {
+ val context = LocalContext.current
+ val sizePx = with(LocalDensity.current) { MEDIA_THUMB_SIZE_DP.dp.roundToPx() }
+ var bitmap by remember(uri) { mutableStateOf(null) }
+ LaunchedEffect(uri, sizePx) {
+ bitmap = withContext(Dispatchers.IO) { loadThumbnail(context, uri, sizePx) }
+ }
+ val bmp = bitmap
+ if (bmp != null) {
+ Image(
+ bitmap = bmp,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(MEDIA_THUMB_SIZE_DP.dp)
+ .clip(RoundedCornerShape(MEDIA_THUMB_CORNER_DP.dp)),
+ )
+ } else {
+ // Neutral loading box — avoids flashing colorful mock-photo gradients
+ // between the URI being known and the bitmap finishing async load.
+ Box(
+ modifier = Modifier
+ .size(MEDIA_THUMB_SIZE_DP.dp)
+ .clip(RoundedCornerShape(MEDIA_THUMB_CORNER_DP.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerHigh),
+ )
+ }
+}
+
@Composable
private fun CategoryTabs(
selected: BlockPickerTab,
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
new file mode 100644
index 000000000..2c5a484ba
--- /dev/null
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/PhotoAccessState.kt
@@ -0,0 +1,60 @@
+package org.wordpress.gutenberg.inserter
+
+import android.net.Uri
+
+/**
+ * Photo-library access state for the inserter's media strip. Distinguishes
+ * "never asked" from "permanently denied" by persisting whether the system
+ * prompt has been shown at least once — Android's
+ * `shouldShowRequestPermissionRationale` alone can't tell those apart.
+ */
+internal sealed interface PhotoAccess {
+ data class Granted(val uris: List) : PhotoAccess
+ data class NeedsPermission(
+ val state: PromptState,
+ val request: () -> Unit,
+ ) : PhotoAccess
+}
+
+/**
+ * Three mutually exclusive states the rationale card needs to distinguish:
+ * never asked, denied once (system will re-prompt), or permanently denied
+ * (system prompt is suppressed, user must enable via Settings).
+ */
+internal enum class PromptState { Unasked, Denied, PermanentlyDenied }
+
+/**
+ * Pure mapping from the two Android signals to the rationale's three-way state.
+ * `canReprompt` is `Activity.shouldShowRequestPermissionRationale(...)`'s result;
+ * `promptedBefore` is our SharedPreferences flag set after the first system prompt.
+ */
+internal fun resolvePromptState(
+ promptedBefore: Boolean,
+ canReprompt: Boolean,
+): PromptState = when {
+ !promptedBefore -> PromptState.Unasked
+ canReprompt -> PromptState.Denied
+ else -> PromptState.PermanentlyDenied
+}
+
+/**
+ * Which of the three media-strip layouts the inserter should render. A granted
+ * permission always wins over a sticky rejection so users get the thumbnail
+ * strip back if they grant access via system Settings after dismissing the
+ * rationale.
+ */
+internal sealed class MediaStripView {
+ object Rationale : MediaStripView()
+ object CompactTiles : MediaStripView()
+ object FullStrip : MediaStripView()
+}
+
+internal fun resolveMediaStripView(
+ access: PhotoAccess,
+ rejected: Boolean,
+): MediaStripView = when {
+ access is PhotoAccess.Granted -> MediaStripView.FullStrip
+ rejected -> MediaStripView.CompactTiles
+ access is PhotoAccess.NeedsPermission -> MediaStripView.Rationale
+ else -> MediaStripView.FullStrip
+}
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
new file mode 100644
index 000000000..4532f9c0b
--- /dev/null
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/RecentImages.kt
@@ -0,0 +1,173 @@
+package org.wordpress.gutenberg.inserter
+
+import android.Manifest
+import android.app.Activity
+import android.content.ContentUris
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import android.provider.Settings
+import android.util.Size as AndroidSize
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+private const val PHOTO_PREFS = "gbk_inserter"
+private const val KEY_PHOTOS_PROMPTED = "photos_prompted"
+private const val KEY_RATIONALE_REJECTED = "rationale_rejected"
+
+@Composable
+internal fun rememberPhotoAccess(limit: Int): PhotoAccess {
+ val context = LocalContext.current
+ val activity = remember(context) { context.findActivity() }
+ var granted by remember { mutableStateOf(hasPhotosPermission(context)) }
+ var promptedBefore by remember { mutableStateOf(hasPromptedForPhotos(context)) }
+ var uris by remember { mutableStateOf>(emptyList()) }
+ // Bumped on every permission result so the rationale state re-evaluates
+ // even when `granted` and `promptedBefore` don't change — without it the
+ // 2nd denial (which transitions Denied → PermanentlyDenied) would be
+ // invisible to recomposition since both flags were already in their
+ // post-deny state, and the "Try Again" button would silently no-op.
+ var permissionTick by remember { mutableStateOf(0) }
+ val launcher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ markPromptedForPhotos(context)
+ promptedBefore = true
+ granted = isGranted
+ permissionTick++
+ }
+ // Re-read permission on every RESUME so we notice grants made via system
+ // Settings (which happen outside the Compose result-callback path).
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ granted = hasPhotosPermission(context)
+ permissionTick++
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+ LaunchedEffect(granted, limit) {
+ if (granted) {
+ uris = withContext(Dispatchers.IO) { queryRecentImages(context, limit) }
+ }
+ }
+ if (granted) return PhotoAccess.Granted(uris)
+ val canReprompt = run {
+ permissionTick // observe the tick so we re-read shouldShowRationale on each result
+ activity?.let { shouldShowRationale(it) } ?: true
+ }
+ val state = resolvePromptState(promptedBefore = promptedBefore, canReprompt = canReprompt)
+ return PhotoAccess.NeedsPermission(
+ state = state,
+ request = {
+ if (state == PromptState.PermanentlyDenied) openAppSettings(context)
+ else launcher.launch(photosPermission())
+ },
+ )
+}
+
+internal fun loadThumbnail(context: Context, uri: Uri, sizePx: Int): ImageBitmap? =
+ runCatching {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ context.contentResolver.loadThumbnail(uri, AndroidSize(sizePx, sizePx), null)
+ .asImageBitmap()
+ } else {
+ null
+ }
+ }.getOrNull()
+
+private fun photosPermission(): String =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Manifest.permission.READ_MEDIA_IMAGES
+ } else {
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ }
+
+private fun hasPhotosPermission(context: Context): Boolean =
+ ContextCompat.checkSelfPermission(context, photosPermission()) == PackageManager.PERMISSION_GRANTED
+
+private fun shouldShowRationale(activity: Activity): Boolean =
+ ActivityCompat.shouldShowRequestPermissionRationale(activity, photosPermission())
+
+private tailrec fun Context.findActivity(): Activity? = when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+}
+
+private fun openAppSettings(context: Context) {
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = Uri.fromParts("package", context.packageName, null)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+}
+
+private fun hasPromptedForPhotos(context: Context): Boolean =
+ context.getSharedPreferences(PHOTO_PREFS, Context.MODE_PRIVATE)
+ .getBoolean(KEY_PHOTOS_PROMPTED, false)
+
+private fun markPromptedForPhotos(context: Context) {
+ context.getSharedPreferences(PHOTO_PREFS, Context.MODE_PRIVATE)
+ .edit().putBoolean(KEY_PHOTOS_PROMPTED, true).apply()
+}
+
+internal fun hasRejectedRationale(context: Context): Boolean =
+ context.getSharedPreferences(PHOTO_PREFS, Context.MODE_PRIVATE)
+ .getBoolean(KEY_RATIONALE_REJECTED, false)
+
+internal fun markRationaleRejected(context: Context) {
+ context.getSharedPreferences(PHOTO_PREFS, Context.MODE_PRIVATE)
+ .edit().putBoolean(KEY_RATIONALE_REJECTED, true).apply()
+}
+
+internal fun clearPhotoPreferences(context: Context) {
+ context.getSharedPreferences(PHOTO_PREFS, Context.MODE_PRIVATE)
+ .edit().clear().apply()
+}
+
+private fun queryRecentImages(context: Context, limit: Int): List {
+ val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
+ } else {
+ @Suppress("DEPRECATION")
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+ }
+ val projection = arrayOf(MediaStore.Images.Media._ID)
+ val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
+ val result = mutableListOf()
+ runCatching {
+ context.contentResolver.query(collection, projection, null, null, sortOrder)?.use { cursor ->
+ val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
+ while (cursor.moveToNext() && result.size < limit) {
+ val id = cursor.getLong(idColumn)
+ result.add(ContentUris.withAppendedId(collection, id))
+ }
+ }
+ }
+ return result
+}
diff --git a/android/Gutenberg/src/main/res/values/strings.xml b/android/Gutenberg/src/main/res/values/strings.xml
index b3518067f..7fcbddcae 100644
--- a/android/Gutenberg/src/main/res/values/strings.xml
+++ b/android/Gutenberg/src/main/res/values/strings.xml
@@ -16,4 +16,13 @@
Clear search
No results
No blocks match “%1$s”
+ Photos
+ Camera
+ Show your photo library here to quickly insert an image
+ Allow access to show your photo library here
+ Enable photo access in Settings to show your library here
+ Allow
+ Try Again
+ Reject
+ Open Settings
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 @@
+
+
+
+
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
new file mode 100644
index 000000000..20f3aa857
--- /dev/null
+++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/inserter/PhotoAccessStateTest.kt
@@ -0,0 +1,72 @@
+package org.wordpress.gutenberg.inserter
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class PhotoAccessStateTest {
+
+ // resolvePromptState
+
+ @Test
+ fun `unasked when never prompted, regardless of canReprompt`() {
+ assertEquals(
+ PromptState.Unasked,
+ resolvePromptState(promptedBefore = false, canReprompt = true),
+ )
+ assertEquals(
+ PromptState.Unasked,
+ resolvePromptState(promptedBefore = false, canReprompt = false),
+ )
+ }
+
+ @Test
+ fun `denied when prompted before and system can re-prompt`() {
+ assertEquals(
+ PromptState.Denied,
+ resolvePromptState(promptedBefore = true, canReprompt = true),
+ )
+ }
+
+ @Test
+ fun `permanently denied when prompted before and system cannot re-prompt`() {
+ assertEquals(
+ PromptState.PermanentlyDenied,
+ resolvePromptState(promptedBefore = true, canReprompt = false),
+ )
+ }
+
+ // resolveMediaStripView
+
+ @Test
+ fun `granted access always shows full strip even after rejection`() {
+ val granted = PhotoAccess.Granted(uris = emptyList())
+ assertEquals(MediaStripView.FullStrip, resolveMediaStripView(granted, rejected = false))
+ assertEquals(MediaStripView.FullStrip, resolveMediaStripView(granted, rejected = true))
+ }
+
+ @Test
+ fun `needs-permission shows rationale when not rejected`() {
+ val needs = needsPermission(PromptState.Denied)
+ assertEquals(MediaStripView.Rationale, resolveMediaStripView(needs, rejected = false))
+ }
+
+ @Test
+ fun `needs-permission shows compact tiles when rejected`() {
+ val needs = needsPermission(PromptState.PermanentlyDenied)
+ assertEquals(MediaStripView.CompactTiles, resolveMediaStripView(needs, rejected = true))
+ }
+
+ @Test
+ fun `rationale shows for every prompt state when not rejected`() {
+ for (state in PromptState.entries) {
+ assertEquals(
+ "state=$state",
+ MediaStripView.Rationale,
+ resolveMediaStripView(needsPermission(state), rejected = false),
+ )
+ }
+ }
+
+ private fun needsPermission(state: PromptState) =
+ PhotoAccess.NeedsPermission(state = state, request = {})
+}
diff --git a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt
index 27ce7170f..e9fa24206 100644
--- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt
+++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt
@@ -36,6 +36,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
+import androidx.compose.ui.platform.LocalContext
+import org.wordpress.gutenberg.GutenbergView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@@ -167,6 +169,7 @@ fun MainScreen(
var showDeleteDialog = remember { mutableStateOf(null) }
var siteUrlInput = remember { mutableStateOf("") }
var showOverflowMenu = remember { mutableStateOf(false) }
+ val context = LocalContext.current
Scaffold(
modifier = Modifier.fillMaxSize(),
@@ -191,6 +194,13 @@ fun MainScreen(
onMediaProxyServer()
}
)
+ DropdownMenuItem(
+ text = { Text("Reset Photo Permissions Prompts") },
+ onClick = {
+ showOverflowMenu.value = false
+ GutenbergView.resetBlockPickerPhotoPreferences(context)
+ }
+ )
}
}
)
From df4931ceedf3b658ea23d694376db2e468702b01 Mon Sep 17 00:00:00 2001
From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
Date: Mon, 4 May 2026 13:38:31 -0600
Subject: [PATCH 2/2] fix(android): address review feedback on block inserter
media strip
- Observe the host Activity's lifecycle (not the BottomSheetDialog's)
so the photo-access state refreshes when the user returns from system
Settings. The dialog's own LifecycleRegistry only dispatches ON_RESUME
from `show()` and never refires on activity resume.
- Switch the recent-photo strip to LazyRow so the 64 thumbnails aren't
all decoded into memory upfront.
- Replace the `permissionTick` snapshot-read trick with a `canReprompt`
state, written explicitly by the launcher callback and resume observer.
- Declare `READ_MEDIA_VISUAL_USER_SELECTED`, request both photo
permissions together via `RequestMultiplePermissions`, detect partial
grants, and surface a "Manage" tile in the strip when only partial is
granted (Android 14+).
- Clear the rationale-rejected flag once the permission is observed
granted, so a later revocation surfaces the rationale again instead of
trapping the user in the compact-tiles state.
- Inflate touch targets on rationale buttons and category chips to the
Material 48dp minimum without changing the visual heights, using a
shared MutableInteractionSource so the ripple still draws inside the
rounded pill.
- Drop the deprecated `androidx.compose.ui.platform.LocalLifecycleOwner`
import.
- Add a TODO for cleaning up orphaned camera capture files when the
editor URI hand-off lands.
- Default the demo's "Enable Native Inserter" toggle to on so reviewers
see the new sheet without flipping a setting.
---
.../Gutenberg/src/main/AndroidManifest.xml | 4 +
.../gutenberg/inserter/BlockPickerDialog.kt | 206 ++++++++++++------
.../gutenberg/inserter/PhotoAccessState.kt | 13 +-
.../gutenberg/inserter/RecentImages.kt | 134 +++++++++---
.../Gutenberg/src/main/res/values/strings.xml | 1 +
.../inserter/PhotoAccessStateTest.kt | 10 +
.../gutenbergkit/SitePreparationViewModel.kt | 2 +-
7 files changed, 277 insertions(+), 93 deletions(-)
diff --git a/android/Gutenberg/src/main/AndroidManifest.xml b/android/Gutenberg/src/main/AndroidManifest.xml
index 6ad8fed6c..7fc944738 100644
--- a/android/Gutenberg/src/main/AndroidManifest.xml
+++ b/android/Gutenberg/src/main/AndroidManifest.xml
@@ -5,6 +5,10 @@
+
+
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 d908642b4..fc5d2c471 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
@@ -13,6 +13,8 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.indication
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
@@ -32,6 +34,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.rememberScrollState
@@ -42,11 +45,13 @@ 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
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
+import androidx.compose.material3.ripple
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
@@ -197,6 +202,13 @@ private const val EMPTY_STATE_FONT_SP = 14
private const val DISABLED_ALPHA = 0.5f
+/**
+ * Material 3 minimum touch-target. Several visual elements here (rationale
+ * buttons, category chips) sit below this for design reasons; we wrap them in
+ * an outer clickable that meets the minimum without changing the visual size.
+ */
+private const val TOUCH_TARGET_MIN_DP = 48
+
/**
* Bottom-sheet block inserter. The outer shell stays a `BottomSheetDialog` so
* `GutenbergView`'s integration surface is unchanged; everything visible is
@@ -454,6 +466,15 @@ private fun MediaStrip() {
val access = rememberPhotoAccess(limit = RECENT_PHOTO_LIMIT)
val context = LocalContext.current
var rejected by remember { mutableStateOf(hasRejectedRationale(context)) }
+ // Clear a prior rejection once the user actually grants the permission.
+ // Without this, a later revocation would leave the user in CompactTiles
+ // with no in-app path back to the rationale.
+ LaunchedEffect(access) {
+ if (access is PhotoAccess.Granted && rejected) {
+ clearRejectedRationale(context)
+ rejected = false
+ }
+ }
val contentPadding = PaddingValues(
start = MEDIA_STRIP_CONTENT_HORIZONTAL_PAD_DP.dp,
end = MEDIA_STRIP_CONTENT_HORIZONTAL_PAD_DP.dp,
@@ -496,28 +517,64 @@ private fun MediaStrip() {
modifier = Modifier.fillMaxWidth().padding(contentPadding),
)
}
- MediaStripView.FullStrip -> {
- // Granted — thumbnail grid extends past the viewport, so use
- // horizontalScroll + a zero-consuming vertical relay so vertical
- // drags still reach BottomSheetBehavior.
- val scrollState = rememberScrollState()
- val verticalRelay = rememberScrollableState { 0f }
- val uris = (access as? PhotoAccess.Granted)?.uris.orEmpty()
- Row(
- horizontalArrangement = Arrangement.spacedBy(MEDIA_STRIP_ITEM_GAP_DP.dp),
- modifier = Modifier
- .horizontalScroll(scrollState)
- .scrollable(verticalRelay, Orientation.Vertical)
- .padding(contentPadding),
- ) {
- PhotosCameraTile(horizontal = false)
- if (uris.isNotEmpty()) MediaThumbnailGrid(uris = uris)
+ MediaStripView.FullStrip -> FullMediaStrip(
+ granted = access as? PhotoAccess.Granted,
+ contentPadding = contentPadding,
+ )
+ }
+ }
+}
+
+@Composable
+private fun FullMediaStrip(granted: PhotoAccess.Granted?, contentPadding: PaddingValues) {
+ // Granted — thumbnail strip extends past the viewport. LazyRow composes
+ // only the visible columns (plus prefetch), so the 64 thumbnails aren't
+ // all decoded into memory upfront. Vertical drags pass through the lazy
+ // 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),
+ contentPadding = contentPadding,
+ 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])
+ val secondIndex = col * 2 + 1
+ if (secondIndex < uris.size) {
+ RealThumbnail(uri = uris[secondIndex])
}
}
}
}
}
+@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(
@@ -527,6 +584,12 @@ private fun PhotosCameraTile(
val context = LocalContext.current
// System photo picker is permissionless — our READ_MEDIA_IMAGES permission
// only gates the recent-photos strip, not this launch path.
+ //
+ // 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 */ }
@@ -601,6 +664,10 @@ private fun PhotosCameraTile(
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",
@@ -640,25 +707,6 @@ private fun MediaActionTile(
}
}
-@Composable
-private fun MediaThumbnailGrid(uris: List) {
- // Two rows of tiles laid out left-to-right, column-by-column. Only render
- // as many tiles as we have URIs — empty slots otherwise would render
- // colorful mock placeholders that flash on the way to the real bitmaps.
- val columns = (uris.size + 1) / 2
- Row(horizontalArrangement = Arrangement.spacedBy(MEDIA_STRIP_ITEM_GAP_DP.dp)) {
- repeat(columns) { col ->
- Column(verticalArrangement = Arrangement.spacedBy(MEDIA_STRIP_ITEM_GAP_DP.dp)) {
- RealThumbnail(uri = uris[col * 2])
- val secondIndex = col * 2 + 1
- if (secondIndex < uris.size) {
- RealThumbnail(uri = uris[secondIndex])
- }
- }
- }
- }
-}
-
@Composable
private fun PhotoAccessRationale(
state: PromptState,
@@ -680,20 +728,38 @@ private fun PhotoAccessRationale(
fun RationaleButton(label: String, filled: Boolean, onClick: () -> Unit, modifier: Modifier) {
val bg = if (filled) MaterialTheme.colorScheme.primary else ComposeColor.Transparent
val fg = if (filled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary
+ // Outer wrapper inflates the touch zone to the 48dp minimum; the inner
+ // Box paints the design's 32dp pill. The shared interaction source lets
+ // the outer absorb taps with no indication while the inner draws the
+ // ripple — otherwise a default ripple on the outer would render as a
+ // square halo around the rounded pill.
+ val interactionSource = remember { MutableInteractionSource() }
Box(
contentAlignment = Alignment.Center,
modifier = modifier
- .height(MEDIA_RATIONALE_BUTTON_HEIGHT_DP.dp)
- .clip(RoundedCornerShape(MEDIA_RATIONALE_BUTTON_CORNER_DP.dp))
- .background(bg)
- .clickable(onClick = onClick),
+ .heightIn(min = TOUCH_TARGET_MIN_DP.dp)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = onClick,
+ ),
) {
- Text(
- text = label,
- color = fg,
- fontSize = MEDIA_RATIONALE_BUTTON_FONT_SP.sp,
- fontWeight = FontWeight.Medium,
- )
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(MEDIA_RATIONALE_BUTTON_HEIGHT_DP.dp)
+ .clip(RoundedCornerShape(MEDIA_RATIONALE_BUTTON_CORNER_DP.dp))
+ .background(bg)
+ .indication(interactionSource, ripple()),
+ ) {
+ Text(
+ text = label,
+ color = fg,
+ fontSize = MEDIA_RATIONALE_BUTTON_FONT_SP.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ }
}
}
Column(
@@ -816,27 +882,43 @@ private fun CategoryChip(
} else {
MaterialTheme.colorScheme.outlineVariant
}
+ // Outer wrapper takes the click and the 48dp minimum-touch height; the
+ // inner Box keeps the design's 36dp pill with border + background. Shared
+ // interaction source so the ripple draws inside the rounded pill instead
+ // of as a square over the wrapper's rectangular bounds.
+ val interactionSource = remember { MutableInteractionSource() }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
- .height(CHIP_HEIGHT_DP.dp)
- .clip(RoundedCornerShape(CHIP_CORNER_DP.dp))
- .background(background)
- .border(
- width = CHIP_BORDER_WIDTH_DP.dp,
- color = borderColor,
- shape = RoundedCornerShape(CHIP_CORNER_DP.dp),
- )
- .clickable(onClick = onClick)
- .padding(horizontal = CHIP_HORIZONTAL_PAD_DP.dp),
+ .heightIn(min = TOUCH_TARGET_MIN_DP.dp)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = onClick,
+ ),
) {
- Text(
- text = label,
- color = textColor,
- fontSize = CHIP_FONT_SP.sp,
- fontWeight = FontWeight.Medium,
- letterSpacing = CHIP_LETTER_SPACING_SP.sp,
- )
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .height(CHIP_HEIGHT_DP.dp)
+ .clip(RoundedCornerShape(CHIP_CORNER_DP.dp))
+ .background(background)
+ .border(
+ width = CHIP_BORDER_WIDTH_DP.dp,
+ color = borderColor,
+ shape = RoundedCornerShape(CHIP_CORNER_DP.dp),
+ )
+ .indication(interactionSource, ripple())
+ .padding(horizontal = CHIP_HORIZONTAL_PAD_DP.dp),
+ ) {
+ Text(
+ text = label,
+ color = textColor,
+ fontSize = CHIP_FONT_SP.sp,
+ fontWeight = FontWeight.Medium,
+ letterSpacing = CHIP_LETTER_SPACING_SP.sp,
+ )
+ }
}
}
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 4532f9c0b..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
@@ -24,11 +24,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -41,51 +41,74 @@ 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()) }
- // Bumped on every permission result so the rationale state re-evaluates
- // even when `granted` and `promptedBefore` don't change — without it the
- // 2nd denial (which transitions Denied → PermanentlyDenied) would be
- // invisible to recomposition since both flags were already in their
- // post-deny state, and the "Try Again" button would silently no-op.
- var permissionTick by remember { mutableStateOf(0) }
+ // `canReprompt` is its own state, recomputed at every permission-relevant
+ // signal (launcher result, lifecycle resume). Compose's snapshot system
+ // can't see the OS-level `shouldShowRequestPermissionRationale` flip from
+ // true → false on the 2nd denial otherwise — `granted` and `promptedBefore`
+ // are already in their post-deny values, so neither would notify Compose
+ // and the rationale would stay stuck on "Try Again".
+ 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()
- ) { isGranted ->
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { _ ->
markPromptedForPhotos(context)
promptedBefore = true
- granted = isGranted
- permissionTick++
+ refreshAccessState()
+ refreshTick++
}
// Re-read permission on every RESUME so we notice grants made via system
// Settings (which happen outside the Compose result-callback path).
- val lifecycleOwner = LocalLifecycleOwner.current
- DisposableEffect(lifecycleOwner) {
+ // Important: observe the host Activity's lifecycle, not the Compose-default
+ // LocalLifecycleOwner. Inside a BottomSheetDialog the latter resolves to the
+ // dialog's own LifecycleRegistry (per ComponentDialog), which only dispatches
+ // ON_RESUME from `show()` and never refires when the user leaves to system
+ // Settings and returns. The Activity's lifecycle does refire ON_RESUME.
+ val activityLifecycle = (activity as? LifecycleOwner)?.lifecycle
+ DisposableEffect(activityLifecycle) {
+ if (activityLifecycle == null) return@DisposableEffect onDispose { }
val observer = LifecycleEventObserver { _, event ->
- if (event == Lifecycle.Event.ON_RESUME) {
- granted = hasPhotosPermission(context)
- permissionTick++
- }
+ if (event == Lifecycle.Event.ON_RESUME) refreshAccessState()
}
- lifecycleOwner.lifecycle.addObserver(observer)
- onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ activityLifecycle.addObserver(observer)
+ onDispose { activityLifecycle.removeObserver(observer) }
}
- LaunchedEffect(granted, limit) {
- if (granted) {
- uris = withContext(Dispatchers.IO) { queryRecentImages(context, limit) }
+ LaunchedEffect(granted, limit, refreshTick) {
+ uris = if (granted) {
+ withContext(Dispatchers.IO) { queryRecentImages(context, limit) }
+ } else {
+ emptyList()
}
}
- if (granted) return PhotoAccess.Granted(uris)
- val canReprompt = run {
- permissionTick // observe the tick so we re-read shouldShowRationale on each result
- activity?.let { shouldShowRationale(it) } ?: true
+ 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())
},
)
}
@@ -107,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())
@@ -145,6 +210,17 @@ internal fun markRationaleRejected(context: Context) {
.edit().putBoolean(KEY_RATIONALE_REJECTED, true).apply()
}
+/**
+ * Clears just the rationale-rejected flag (leaving the prompted-before flag
+ * alone). Called when we observe the photo permission becoming granted, so a
+ * subsequent revocation surfaces the rationale again instead of stranding the
+ * user in CompactTiles with no in-app affordance to re-engage.
+ */
+internal fun clearRejectedRationale(context: Context) {
+ context.getSharedPreferences(PHOTO_PREFS, Context.MODE_PRIVATE)
+ .edit().remove(KEY_RATIONALE_REJECTED).apply()
+}
+
internal fun clearPhotoPreferences(context: Context) {
context.getSharedPreferences(PHOTO_PREFS, Context.MODE_PRIVATE)
.edit().clear().apply()
diff --git a/android/Gutenberg/src/main/res/values/strings.xml b/android/Gutenberg/src/main/res/values/strings.xml
index 7fcbddcae..3173bd4af 100644
--- a/android/Gutenberg/src/main/res/values/strings.xml
+++ b/android/Gutenberg/src/main/res/values/strings.xml
@@ -18,6 +18,7 @@
No blocks match “%1$s”
Photos
Camera
+ Manage
Show your photo library here to quickly insert an image
Allow access to show your photo library here
Enable photo access in Settings to show your library here
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)
diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt
index ea5c558f4..9c8f0311b 100644
--- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt
+++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt
@@ -19,7 +19,7 @@ import rs.wordpress.api.kotlin.WpRequestResult
import uniffi.wp_api.PostType as WpPostType
data class SitePreparationUiState(
- val enableNativeInserter: Boolean = false,
+ val enableNativeInserter: Boolean = true,
val enableNetworkLogging: Boolean = false,
/** All viewable post types fetched from the site, or empty while loading. */
val postTypes: List = emptyList(),