Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f591f1f
fix: let a delegate handle its own media deletions
dcalhoun Aug 21, 2026
9732e1e
docs: trim implementation detail from the wp-env media failure guide
dcalhoun Aug 21, 2026
ebb67a5
refactor: extract the native media upload helper
dcalhoun Aug 21, 2026
c31573a
fix: surface a delegate's real media deletion failure on Android
dcalhoun Aug 21, 2026
0bf40ad
fix: let a delegate's Content-Type win over the JSON default on iOS
dcalhoun Aug 21, 2026
ed031c3
docs: tell a delegate to decline media deletions it does not own
dcalhoun Aug 21, 2026
02ee530
fix: read the wp-env credentials path as an argument, not as source
dcalhoun Aug 21, 2026
6140749
fix: match a delegate's Content-Type case-insensitively on Android
dcalhoun Aug 21, 2026
910fc77
fix: don't log a recoverable media upload 5xx as a failure
jkmassel Sep 3, 2026
1247d09
refactor: split the media delegate into MediaProcessor and MediaUploader
jkmassel Sep 3, 2026
4a03bcc
refactor: tighten the MediaProcessor/MediaUploader contract
jkmassel Sep 4, 2026
be5678a
fix: hand a media uploader its form fields as an ordered list
jkmassel Sep 4, 2026
23f919d
fix: keep a media handler alive for the whole upload request
jkmassel Sep 5, 2026
60eae7a
fix: don't start a media upload for a torn-down editor
jkmassel Sep 5, 2026
689bc98
refactor: hand the media relay handlers only the client they use
jkmassel Sep 5, 2026
95331ed
feat: let an HTTPServer serve requests from a handler object
jkmassel Sep 5, 2026
380c42d
refactor: serve media uploads from a handler object, not statics
jkmassel Sep 5, 2026
8c59ceb
fix: give a media upload's form fields a named type
jkmassel Sep 5, 2026
397a976
fix: gate the media uploader trap on the site root too, on iOS
jkmassel Sep 5, 2026
83d9077
test: cover the media credentials trap with exit tests
jkmassel Sep 5, 2026
2bbc65d
docs: state the invariant that makes the media field decode safe
jkmassel Sep 5, 2026
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ wp-env-android-reset: ## Remove the Android emulator URL remap and restart
@RESET=1 $(MAKE) wp-env-start

.PHONY: wp-env-media-failure
wp-env-media-failure: ## Report the media upload failure simulation mode (MODE=off|recover|always to set it)
wp-env-media-failure: ## Report the media upload failure simulation mode (set via MODE=off|recover|always)
@MODE=$(MODE) bash bin/wp-env-media-failure.sh

################################################################################
Expand Down
1 change: 1 addition & 0 deletions android/Gutenberg/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<ID>ExplicitItLambdaParameter:EditorAssetsLibrary.kt$EditorAssetsLibrary${ str, it -&gt; str + "%02x".format(it) }</ID>
<ID>FunctionNaming:EditorURLCache.kt$EditorURLCache$private fun __store( response: EditorURLResponse, url: String, httpMethod: EditorHttpMethod, currentDate: Date )</ID>
<ID>LargeClass:GutenbergView.kt$GutenbergView : FrameLayout</ID>
<ID>LargeClass:MediaUploadServerTest.kt$MediaUploadServerTest</ID>
<ID>LongMethod:FixtureTests.kt$FixtureTests$@Test fun `request parsing - all basic cases pass`()</ID>
<ID>LongMethod:FixtureTests.kt$FixtureTests$@Test fun `request parsing - all incremental cases pass`()</ID>
<ID>LongMethod:HTTPRequestParser.kt$HTTPRequestParser$fun append(data: ByteArray): Unit</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,30 +113,54 @@ class GutenbergView : FrameLayout {
var requestInterceptor: GutenbergRequestInterceptor = DefaultGutenbergRequestInterceptor()

/**
* Optional delegate for customizing media upload behavior (resize, transcode,
* custom upload).
* Transforms media (resize, transcode, …) before GutenbergKit delivers it to
* the configured site. The safe, common extension point — a processor never
* performs the upload itself, so it cannot deliver media to the wrong place.
*
* Provide this **before the editor loads** — typically right after
* construction (e.g. in the `AndroidView` factory). It is captured once, when
* the page begins loading, and advertised to the page then; setting it
* afterward has no effect, so the setter throws to surface the mistake.
* the page begins loading; setting it afterward has no effect, so the setter
* throws to surface the mistake.
*
* This view owns the processor for its lifetime, so you don't need to keep a
* reference after assigning it — and avoid strongly retaining this [GutenbergView]
* from your processor in return, so the two don't form a reference cycle.
*/
var mediaUploadDelegate: MediaUploadDelegate? = null
var mediaProcessor: MediaProcessor? = null
set(value) {
check(!hasStartedLoading) {
"mediaUploadDelegate must be set before the editor loads (e.g. right " +
"after construction). It is captured when the page begins loading; " +
"setting it afterward has no effect."
}
check(!hasStartedLoading) { lateMediaAssignmentMessage("mediaProcessor") }
field = value
}

/**
* Takes over media upload on the host's own stack (background service, offline
* queue, resumable transport). Setting it makes the host own every upload and
* its whole lifecycle; GutenbergKit stays out of the network entirely for media.
*
* Same lifecycle rules as [mediaProcessor]: set it before the editor loads, and
* this view owns it for its lifetime — so you needn't retain it yourself, just
* don't strongly retain this [GutenbergView] from your uploader.
*
* Requires site credentials in the editor configuration: media deletes always
* relay to the configured site, so an uploader set without a site root and auth
* header is a configuration error and throws at load.
*/
var mediaUploader: MediaUploader? = null
set(value) {
check(!hasStartedLoading) { lateMediaAssignmentMessage("mediaUploader") }
field = value
}

private fun lateMediaAssignmentMessage(name: String) =
"$name must be set before the editor loads (e.g. right after construction). " +
"It is captured when the page begins loading; setting it afterward has no effect."

@Volatile private var uploadServer: MediaUploadServer? = null

/**
* True once the editor page has begun loading and the upload server's
* configuration has been captured. After this the [mediaUploadDelegate] can no
* longer take effect, so its setter throws.
* configuration has been captured. After this the [mediaProcessor]/[mediaUploader]
* can no longer take effect, so their setters throw.
*/
@Volatile private var hasStartedLoading = false

Expand Down Expand Up @@ -638,13 +662,13 @@ class GutenbergView : FrameLayout {

/**
* Invoked when the editor page begins loading. Starts the upload server once —
* capturing the [mediaUploadDelegate] provided before load — then advertises
* the editor globals (including the server's port and token) to the page.
* capturing the [mediaProcessor]/[mediaUploader] provided before load — then
* advertises the editor globals (including the server's port and token) to the page.
*
* Starting the server here, on the UI thread, rather than from the
* [mediaUploadDelegate] setter keeps its whole lifecycle — start here, stop in
* [onDetachedFromWindow] — on the UI thread, so it can't race a
* background-thread delegate assignment.
* [mediaProcessor]/[mediaUploader] setters keeps its whole lifecycle — start
* here, stop in [onDetachedFromWindow] — on the UI thread, so it can't race a
* background-thread assignment.
*/
private fun onEditorPageStarted() {
if (!hasStartedLoading) {
Expand All @@ -671,17 +695,32 @@ class GutenbergView : FrameLayout {
}

private fun startUploadServer() {
// No delegate means nothing wants to customize uploads, so there's no reason
// to route them through the native server — leave it down and let uploads
// fall to the default WebView path. (Matches iOS.)
if (mediaUploadDelegate == null) return

// The native upload server relays through DefaultMediaUploader, which needs a
// site root and an auth header (every host provides one — the editor injects
// it because the WebView has no auth cookies). Without both there is nothing
// to upload through, so leave the server down and let uploads fall to the
// default WebView path rather than start a server that could only fail.
if (configuration.siteApiRoot.isEmpty() || configuration.authHeader.isEmpty()) return
// Nothing to route through the native server unless the host provided a
// processor or an uploader. (Matches iOS.)
if (mediaProcessor == null && mediaUploader == null) return

// An InternalMediaClient delivers GutenbergKit-owned uploads (when no uploader
// is set) and relays the editor's media DELETEs to the configured site — every
// attachment lives there, even one a host uploader delivered. It needs a site
// root and an auth header (the editor injects it because the WebView has no
// auth cookies). Without them the behavior forks by intent:
//
// - A mediaProcessor only enhances GutenbergKit-owned uploads; with no
// credentials there's nothing to deliver through, so nothing to process —
// leave the server down and let uploads fall to the default WebView path.
//
// - A mediaUploader means the host is taking over uploads. Falling back would
// silently drop it, and its media deletes still need the internal media client to
// reach the configured site. A host that sets an uploader must provide
// credentials too; omitting them is a configuration error, so fail fast.
if (configuration.siteApiRoot.isEmpty() || configuration.authHeader.isEmpty()) {
check(mediaUploader == null) {
"A mediaUploader needs site credentials so GutenbergKit can relay the " +
"editor's media deletes to the configured site. Set siteApiRoot and " +
"the auth header in the editor configuration."
}
return
}

// The editor reaches the loopback server over cleartext http://localhost. If
// the host app's network-security config doesn't permit cleartext to
Expand All @@ -701,15 +740,19 @@ class GutenbergView : FrameLayout {
}

try {
val defaultUploader = DefaultMediaUploader(
// Credentials are present (checked above), so always build a default
// uploader: it delivers GutenbergKit-owned uploads and relays the editor's
// media DELETEs to the configured site.
val internalClient = InternalMediaClient(
httpClient = uploadHttpClient,
siteApiRoot = configuration.siteApiRoot,
authHeader = configuration.authHeader,
siteApiNamespace = configuration.siteApiNamespace.toList()
)
uploadServer = MediaUploadServer(
uploadDelegate = mediaUploadDelegate,
defaultUploader = defaultUploader,
processor = mediaProcessor,
uploader = mediaUploader,
internalClient = internalClient,
cacheDir = context.cacheDir,
scope = coroutineScope
)
Expand Down
Loading
Loading