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(),