Skip to content

Commit c5b4b81

Browse files
committed
feat(android): hand inserter media to the editor via MediaFileManager + /media/
Wires the three dead-end callbacks left in the native block inserter (Photos tile, Camera tile, recent-photos thumbnail tap) to the JS editor via `window.blockInserter.insertMedia(...)`. Mirrors iOS's MediaFileManager + MediaInfo shape so the JS side is reused unchanged. Storage / serving: - `MediaFileManager` (singleton) owns `filesDir/GutenbergKit/Uploads/`. Imports copy via `ContentResolver`, returning a `MediaInfo` whose URL points at `appassets.androidplatform.net/media/<uuid>.<ext>`. 2-day TTL cleanup on first touch per process. - `MediaPathHandler` registers under `/media/` on the existing `WebViewAssetLoader`, so the JS editor `fetch()`s the file same-origin against the asset host — no CORS plumbing, no custom URL scheme. MIME resolution chain: `ContentResolver.getType(uri)` → `MimeTypeMap` on extension → magic-byte sniff covering JPEG / PNG / GIF / WebP / HEIC / AVIF (the formats the system tables miss most often) → `application/octet-stream`. `BlockPickerDialog` exposes a `LocalMediaInsertion` `CompositionLocal` so the Photos tile, Camera tile, and `RealThumbnail` can each fire `insertMedia` without prop-drilling the callback through every layout composable in between. Camera output now writes directly into the managed Uploads dir, so a successful capture is already-imported — cancellation eagerly deletes the empty FileProvider-grant file rather than waiting for TTL.
1 parent 64a055f commit c5b4b81

6 files changed

Lines changed: 379 additions & 55 deletions

File tree

android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ import org.json.JSONObject
3939
import org.wordpress.gutenberg.inserter.BlockPickerDialog
4040
import org.wordpress.gutenberg.inserter.clearPhotoPreferences
4141
import org.wordpress.gutenberg.inserter.warmupPhotoPrefs
42+
import org.wordpress.gutenberg.media.MediaFileManager
43+
import org.wordpress.gutenberg.media.MediaInfo
44+
import org.wordpress.gutenberg.media.MediaPathHandler
45+
import org.wordpress.gutenberg.media.toJsonArray
4246
import org.wordpress.gutenberg.model.BlockInserterPayload
4347
import org.wordpress.gutenberg.model.EditorConfiguration
4448
import org.wordpress.gutenberg.model.EditorDependencies
@@ -230,6 +234,10 @@ class GutenbergView : FrameLayout {
230234
// Initialize the asset loader now that context is available
231235
assetLoader = WebViewAssetLoader.Builder()
232236
.addPathHandler("/assets/", AssetsPathHandler(context))
237+
.addPathHandler(
238+
MediaFileManager.MEDIA_PATH_PREFIX,
239+
MediaPathHandler(MediaFileManager.uploadsDir(context)),
240+
)
233241
.build()
234242

235243
// Create the internal WebView as first child (behind overlays)
@@ -887,6 +895,23 @@ class GutenbergView : FrameLayout {
887895
}
888896
}
889897

898+
/**
899+
* Hands a list of inserter-sourced media to the JS editor via
900+
* `window.blockInserter.insertMedia(...)`. URLs in each MediaInfo must be
901+
* loadable by the WebView — the canonical source is `MediaFileManager`,
902+
* which produces same-origin URLs under `/media/`.
903+
*/
904+
internal fun insertMediaFromInserter(media: List<MediaInfo>) {
905+
if (!isEditorLoaded || media.isEmpty()) return
906+
val payload = media.toJsonArray().toString()
907+
handler.post {
908+
webView.evaluateJavascript(
909+
"window.blockInserter?.insertMedia($payload);",
910+
null,
911+
)
912+
}
913+
}
914+
890915
private fun dismissBlockInserter() {
891916
if (!isEditorLoaded) return
892917
handler.post {
@@ -925,6 +950,7 @@ class GutenbergView : FrameLayout {
925950
context = context,
926951
payload = payload,
927952
onBlockSelected = { block -> insertBlock(block.id) },
953+
onMediaSelected = { media -> insertMediaFromInserter(media) },
928954
)
929955
dialog.setOnDismissListener {
930956
if (blockInserterDialog === dialog) {

android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/BlockPickerDialog.kt

Lines changed: 125 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ import androidx.compose.material3.dynamicDarkColorScheme
5757
import androidx.compose.material3.dynamicLightColorScheme
5858
import androidx.compose.material3.lightColorScheme
5959
import androidx.compose.runtime.Composable
60+
import androidx.compose.runtime.CompositionLocalProvider
6061
import androidx.compose.runtime.LaunchedEffect
62+
import androidx.compose.runtime.compositionLocalOf
6163
import androidx.compose.runtime.getValue
6264
import androidx.compose.runtime.mutableStateOf
6365
import androidx.compose.runtime.remember
@@ -104,13 +106,20 @@ import androidx.compose.ui.unit.sp
104106
import androidx.core.content.FileProvider
105107
import com.google.android.material.bottomsheet.BottomSheetBehavior
106108
import com.google.android.material.bottomsheet.BottomSheetDialog
109+
import android.os.Parcelable
107110
import java.io.File
108111
import kotlin.math.roundToInt
112+
import kotlinx.coroutines.CoroutineScope
109113
import kotlinx.coroutines.Dispatchers
110114
import kotlinx.coroutines.delay
115+
import kotlinx.coroutines.launch
111116
import kotlinx.coroutines.withContext
117+
import kotlinx.parcelize.Parcelize
112118
import org.wordpress.gutenberg.R
119+
import androidx.compose.runtime.rememberCoroutineScope
113120
import androidx.compose.ui.graphics.Color as ComposeColor
121+
import org.wordpress.gutenberg.media.MediaFileManager
122+
import org.wordpress.gutenberg.media.MediaInfo
114123
import org.wordpress.gutenberg.model.BlockInserterPayload
115124
import 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
274299
private 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
852919
private 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

Comments
 (0)