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