Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions android/Gutenberg/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@

See docs/integration.md (Android → Manifest Permissions) for details. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- Android 14+ partial-access opt-in. Declaring this lets the system offer
"Select photos and videos" alongside the all/none choice and lets us
detect partial grants so we can surface a "Manage selection" affordance. -->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
Expand Down Expand Up @@ -570,6 +571,7 @@ private fun FullMediaStrip(granted: PhotoAccess.Granted?, contentPadding: Paddin
val sourceUris = granted?.uris.orEmpty()
var failed by remember(granted) { mutableStateOf<Set<Uri>>(emptySet()) }
val uris = sourceUris.filterNot { it in failed }
val partialAccess = granted?.partialAccess
val columns = (uris.size + 1) / 2
val onThumbnailFailed: (Uri) -> Unit = { failed = failed + it }
LazyRow(
Expand All @@ -578,6 +580,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], onLoadFailed = onThumbnailFailed)
Expand All @@ -590,6 +600,20 @@ private fun FullMediaStrip(granted: PhotoAccess.Granted?, contentPadding: Paddin
}
}

@Composable
private fun ManageSelectionTile(onClick: () -> Unit) {
MediaActionTile(
icon = 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),
)
}

// Photos uses the permissionless system photo picker; Camera uses
// ACTION_IMAGE_CAPTURE against a cache-scoped FileProvider URI — no CAMERA
// permission required since we delegate to the system camera app. The
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uri>) : 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<Uri>,
val partialAccess: PartialAccess? = null,
) : PhotoAccess
data class NeedsPermission(
val state: PromptState,
val request: () -> Unit,
) : PhotoAccess

data class PartialAccess(val onManageSelection: () -> Unit)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ internal fun rememberPhotoAccess(limit: Int, initialPromptedBefore: Boolean): Ph
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(initialPromptedBefore) }
var uris by remember { mutableStateOf<List<Uri>>(emptyList()) }
// `canReprompt` is its own state, recomputed at every permission-relevant
Expand All @@ -136,16 +137,22 @@ internal fun rememberPhotoAccess(limit: Int, initialPromptedBefore: Boolean): Ph
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).
Expand All @@ -163,20 +170,29 @@ internal fun rememberPhotoAccess(limit: Int, initialPromptedBefore: Boolean): Ph
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())
},
)
}
Expand Down Expand Up @@ -211,6 +227,23 @@ private fun photosPermission(): String =
Manifest.permission.READ_EXTERNAL_STORAGE
}

/**
* 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<String> = 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)
}

private fun hasPhotosPermission(context: Context): Boolean {
if (ContextCompat.checkSelfPermission(context, photosPermission()) ==
PackageManager.PERMISSION_GRANTED
Expand All @@ -225,10 +258,6 @@ private fun hasPhotosPermission(context: Context): Boolean {
// is granted, and MediaStore automatically scopes our query to the
// user-selected items. Treat that as granted so we don't tell the user
// they have no access when they actually granted partial access.
//
// Declaring `READ_MEDIA_VISUAL_USER_SELECTED` in the library manifest
// (which would unlock the "Select more photos" re-prompt affordance)
// is the follow-up PR.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
return ContextCompat.checkSelfPermission(
context,
Expand All @@ -238,6 +267,20 @@ private fun hasPhotosPermission(context: Context): Boolean {
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())

Expand Down
1 change: 1 addition & 0 deletions android/Gutenberg/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<string name="gbk_block_inserter_photos_try_again">Try Again</string>
<string name="gbk_block_inserter_photos_reject">Reject</string>
<string name="gbk_block_inserter_photos_open_settings">Open Settings</string>
<string name="gbk_block_inserter_photos_manage">Manage</string>

<!-- Editor states -->
<string name="gbk_editor_load_failed_title">Failed to load editor</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading