@@ -57,7 +57,9 @@ import androidx.compose.material3.dynamicDarkColorScheme
5757import androidx.compose.material3.dynamicLightColorScheme
5858import androidx.compose.material3.lightColorScheme
5959import androidx.compose.runtime.Composable
60+ import androidx.compose.runtime.CompositionLocalProvider
6061import androidx.compose.runtime.LaunchedEffect
62+ import androidx.compose.runtime.compositionLocalOf
6163import androidx.compose.runtime.getValue
6264import androidx.compose.runtime.mutableStateOf
6365import androidx.compose.runtime.remember
@@ -104,13 +106,20 @@ import androidx.compose.ui.unit.sp
104106import androidx.core.content.FileProvider
105107import com.google.android.material.bottomsheet.BottomSheetBehavior
106108import com.google.android.material.bottomsheet.BottomSheetDialog
109+ import android.os.Parcelable
107110import java.io.File
108111import kotlin.math.roundToInt
112+ import kotlinx.coroutines.CoroutineScope
109113import kotlinx.coroutines.Dispatchers
110114import kotlinx.coroutines.delay
115+ import kotlinx.coroutines.launch
111116import kotlinx.coroutines.withContext
117+ import kotlinx.parcelize.Parcelize
112118import org.wordpress.gutenberg.R
119+ import androidx.compose.runtime.rememberCoroutineScope
113120import androidx.compose.ui.graphics.Color as ComposeColor
121+ import org.wordpress.gutenberg.media.MediaFileManager
122+ import org.wordpress.gutenberg.media.MediaInfo
114123import org.wordpress.gutenberg.model.BlockInserterPayload
115124import org.wordpress.gutenberg.model.BlockType
116125
@@ -225,6 +234,7 @@ internal class BlockPickerDialog(
225234 context : Context ,
226235 private val payload : BlockInserterPayload ,
227236 private val onBlockSelected : (BlockType ) -> Unit ,
237+ private val onMediaSelected : (List <MediaInfo >) -> Unit ,
228238) : BottomSheetDialog(context) {
229239
230240 init {
@@ -248,6 +258,10 @@ internal class BlockPickerDialog(
248258 onBlockSelected(block)
249259 dismiss()
250260 },
261+ onMediaSelected = { media ->
262+ onMediaSelected(media)
263+ dismiss()
264+ },
251265 onClose = { dismiss() },
252266 )
253267 }
@@ -270,41 +284,55 @@ internal class BlockPickerDialog(
270284 }
271285}
272286
287+ /* *
288+ * Ambient handoff for inserter-sourced media. Provided once at the sheet root
289+ * so the Photos tile, Camera tile, and recent-photos thumbnails can each fire
290+ * a media insertion without prop-drilling the callback through every layout
291+ * composable in between.
292+ */
293+ private val LocalMediaInsertion =
294+ compositionLocalOf< ((List <MediaInfo >) -> Unit )> {
295+ error(" LocalMediaInsertion not provided" )
296+ }
297+
273298@Composable
274299private fun BlockPickerSheet (
275300 payload : BlockInserterPayload ,
276301 onBlockSelected : (BlockType ) -> Unit ,
302+ onMediaSelected : (List <MediaInfo >) -> Unit ,
277303 onClose : () -> Unit ,
278304) {
279305 val colorScheme = rememberColorScheme()
280306 MaterialTheme (colorScheme = colorScheme) {
281- val allBlocks = remember(payload) { flattenBlocks(payload) }
282- val recentBlocks = remember(payload, allBlocks) { recentBlocks(payload, allBlocks) }
307+ CompositionLocalProvider (LocalMediaInsertion provides onMediaSelected) {
308+ val allBlocks = remember(payload) { flattenBlocks(payload) }
309+ val recentBlocks = remember(payload, allBlocks) { recentBlocks(payload, allBlocks) }
283310
284- var selectedTab by remember { mutableStateOf(BlockPickerTab .Recent ) }
285- var query by remember { mutableStateOf(" " ) }
286- var debounced by remember { mutableStateOf(" " ) }
287- LaunchedEffect (query) {
288- delay(SEARCH_DEBOUNCE_MS )
289- debounced = query.trim()
290- }
311+ var selectedTab by remember { mutableStateOf(BlockPickerTab .Recent ) }
312+ var query by remember { mutableStateOf(" " ) }
313+ var debounced by remember { mutableStateOf(" " ) }
314+ LaunchedEffect (query) {
315+ delay(SEARCH_DEBOUNCE_MS )
316+ debounced = query.trim()
317+ }
291318
292- val filtered = remember(selectedTab, debounced, allBlocks, recentBlocks) {
293- val tabBlocks = filterByTab(selectedTab, allBlocks, recentBlocks)
294- if (debounced.isEmpty()) tabBlocks else searchBlocks(debounced, tabBlocks)
295- }
319+ val filtered = remember(selectedTab, debounced, allBlocks, recentBlocks) {
320+ val tabBlocks = filterByTab(selectedTab, allBlocks, recentBlocks)
321+ if (debounced.isEmpty()) tabBlocks else searchBlocks(debounced, tabBlocks)
322+ }
296323
297- SheetContent (
298- selectedTab = selectedTab,
299- onSelectTab = { selectedTab = it },
300- query = query,
301- onQueryChange = { query = it },
302- debouncedQuery = debounced,
303- blocks = filtered,
304- onBlockSelected = onBlockSelected,
305- onClose = onClose,
306- surface = colorScheme.surfaceContainerLow,
307- )
324+ SheetContent (
325+ selectedTab = selectedTab,
326+ onSelectTab = { selectedTab = it },
327+ query = query,
328+ onQueryChange = { query = it },
329+ debouncedQuery = debounced,
330+ blocks = filtered,
331+ onBlockSelected = onBlockSelected,
332+ onClose = onClose,
333+ surface = colorScheme.surfaceContainerLow,
334+ )
335+ }
308336 }
309337}
310338
@@ -611,41 +639,59 @@ private fun PhotosCameraTile(
611639 modifier : Modifier = Modifier ,
612640) {
613641 val context = LocalContext .current
642+ val scope = rememberCoroutineScope()
643+ val insertMedia = LocalMediaInsertion .current
614644 // Hide the Camera tile on devices without any camera hardware (tablets,
615645 // emulators, e-readers). FEATURE_CAMERA_ANY doesn't require a `<queries>`
616646 // manifest entry, unlike resolving ACTION_IMAGE_CAPTURE directly.
617647 val hasCamera = remember(context) {
618648 context.packageManager.hasSystemFeature(PackageManager .FEATURE_CAMERA_ANY )
619649 }
620- // The result callbacks below are intentionally inert: the picked URI / camera
621- // capture needs to round-trip through `WebViewAssetLoader` so the JS editor
622- // can `fetch()` it, which is a follow-up. Until that lands, this whole sheet
623- // is gated behind the demo app's "Enable Native Inserter" toggle, so users
624- // outside that opt-in won't see the no-op buttons.
625650 val photoPicker = rememberLauncherForActivityResult(
626651 ActivityResultContracts .PickVisualMedia ()
627- ) { /* picked uri — hand-off to editor insertion is a follow-up */ }
652+ ) { uri ->
653+ if (uri != null ) handlePickedUri(scope, context, uri, insertMedia)
654+ }
628655 // Saveable so the URI survives process death while the camera app is in
629656 // the foreground; `Uri` is `Parcelable`, which the default saver handles.
630- // Written on each Camera tap so the follow-up `TakePicture` callback can
631- // read back the capture target — unused by the inert callback today .
632- var pendingCameraUri by rememberSaveable { mutableStateOf<Uri ?>(null ) }
657+ // Written on each Camera tap, read back when the launcher returns to
658+ // resolve the captured file → MediaInfo → editor insertion .
659+ var pendingCameraCapture by rememberSaveable { mutableStateOf<CameraCapture ?>(null ) }
633660 val cameraLauncher = rememberLauncherForActivityResult(
634661 ActivityResultContracts .TakePicture ()
635- ) { /* success:Boolean + pendingCameraUri — hand-off is a follow-up */ }
662+ ) { success ->
663+ val capture = pendingCameraCapture
664+ pendingCameraCapture = null
665+ if (success && capture != null ) {
666+ // Captured file is already inside MediaFileManager's uploads dir,
667+ // so no copy step — hand the MediaInfo straight to the editor.
668+ insertMedia(listOf (MediaFileManager .mediaInfoForFile(File (capture.filePath))))
669+ } else if (capture != null ) {
670+ // Cancellation / failure leaves an empty file behind from the
671+ // FileProvider grant; sweep it eagerly so it doesn't have to wait
672+ // for the 2-day TTL cleanup.
673+ runCatching { File (capture.filePath).delete() }
674+ }
675+ }
636676 val imageOnly = remember { PickVisualMediaRequest (ActivityResultContracts .PickVisualMedia .ImageOnly ) }
637677 val onPhotosClick = { photoPicker.launch(imageOnly) }
638678 val cameraUnavailableMessage = stringResource(R .string.gbk_block_inserter_camera_unavailable)
639679 val onCameraClick = {
640- val uri = createCameraOutputUri(context)
641- pendingCameraUri = uri
680+ val file = MediaFileManager .newCameraOutputFile(context)
681+ val uri = FileProvider .getUriForFile(
682+ context,
683+ " ${context.packageName} .gutenberg.fileprovider" ,
684+ file,
685+ )
686+ pendingCameraCapture = CameraCapture (filePath = file.absolutePath)
642687 try {
643688 cameraLauncher.launch(uri)
644689 } catch (e: ActivityNotFoundException ) {
645690 // Defense-in-depth: hasCamera gates the tile, but corp-locked
646691 // devices can have camera hardware without any handler for
647692 // ACTION_IMAGE_CAPTURE installed.
648- pendingCameraUri = null
693+ pendingCameraCapture = null
694+ runCatching { file.delete() }
649695 Log .w(" BlockPickerDialog" , " No activity to handle ACTION_IMAGE_CAPTURE" , e)
650696 Toast .makeText(context, cameraUnavailableMessage, Toast .LENGTH_SHORT ).show()
651697 }
@@ -705,18 +751,39 @@ private fun PhotosCameraTile(
705751 }
706752}
707753
708- private fun createCameraOutputUri (context : Context ): Uri {
709- val dir = File (context.cacheDir, " camera" ).apply { mkdirs() }
710- val file = File (dir, " capture_${System .currentTimeMillis()} .jpg" )
711- // TODO: clean up captured files once the editor hand-off lands. Each Camera
712- // tap creates a fresh file here; with the result callback inert today, every
713- // capture is orphaned in the cache. When we wire up the URI hand-off, delete
714- // on success/cancel and sweep stale files on next entry.
715- return FileProvider .getUriForFile(
716- context,
717- " ${context.packageName} .gutenberg.fileprovider" ,
718- file,
719- )
754+ /* *
755+ * `Parcelable` payload that lets `rememberSaveable` survive the round-trip to
756+ * the camera app. Only the file path matters across process death — the
757+ * FileProvider URI is rebuilt by the system on launch.
758+ */
759+ @Parcelize
760+ internal data class CameraCapture (val filePath : String ) : Parcelable
761+
762+ private fun handlePickedUri (
763+ scope : CoroutineScope ,
764+ context : Context ,
765+ uri : Uri ,
766+ insertMedia : (List <MediaInfo >) -> Unit ,
767+ ) {
768+ scope.launch {
769+ // Import can fail for transient reasons (cloud-only photo without
770+ // network, MediaStore deletion race, SAF provider crash). On failure,
771+ // surface a toast and leave the sheet open so the user can pick again.
772+ val info = runCatching { MediaFileManager .import(context, uri) }
773+ .onFailure { Log .w(" BlockPickerDialog" , " Failed to import picked URI: $uri " , it) }
774+ .getOrNull()
775+ if (info == null ) {
776+ withContext(Dispatchers .Main ) {
777+ Toast .makeText(
778+ context,
779+ R .string.gbk_block_inserter_media_import_failed,
780+ Toast .LENGTH_SHORT ,
781+ ).show()
782+ }
783+ return @launch
784+ }
785+ insertMedia(listOf (info))
786+ }
720787}
721788
722789@Composable
@@ -851,6 +918,8 @@ private fun PhotoAccessRationale(
851918@Composable
852919private fun RealThumbnail (uri : Uri , onLoadFailed : (Uri ) -> Unit ) {
853920 val context = LocalContext .current
921+ val scope = rememberCoroutineScope()
922+ val insertMedia = LocalMediaInsertion .current
854923 val sizePx = with (LocalDensity .current) { MEDIA_THUMB_SIZE_DP .dp.roundToPx() }
855924 // Seed from the process-wide cache so a scroll-back or dialog-reopen
856925 // skips the grey-placeholder flash entirely. On a cache miss the
@@ -865,22 +934,23 @@ private fun RealThumbnail(uri: Uri, onLoadFailed: (Uri) -> Unit) {
865934 }
866935 }
867936 val bmp = bitmap
937+ val thumbModifier = Modifier
938+ .size(MEDIA_THUMB_SIZE_DP .dp)
939+ .clip(RoundedCornerShape (MEDIA_THUMB_CORNER_DP .dp))
940+ .clickable { handlePickedUri(scope, context, uri, insertMedia) }
941+ .semantics { role = Role .Button }
868942 if (bmp != null ) {
869943 Image (
870944 bitmap = bmp,
871945 contentDescription = null ,
872946 contentScale = ContentScale .Crop ,
873- modifier = Modifier
874- .size(MEDIA_THUMB_SIZE_DP .dp)
875- .clip(RoundedCornerShape (MEDIA_THUMB_CORNER_DP .dp)),
947+ modifier = thumbModifier,
876948 )
877949 } else {
878950 // Neutral loading box — avoids flashing colorful mock-photo gradients
879951 // between the URI being known and the bitmap finishing async load.
880952 Box (
881- modifier = Modifier
882- .size(MEDIA_THUMB_SIZE_DP .dp)
883- .clip(RoundedCornerShape (MEDIA_THUMB_CORNER_DP .dp))
953+ modifier = thumbModifier
884954 .background(MaterialTheme .colorScheme.surfaceContainerHigh),
885955 )
886956 }
0 commit comments