Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0405c9d
fix(ios): own the media upload delegate instead of holding it weakly
jkmassel Sep 5, 2026
76a13bc
test(ios): pin that owning the media delegate still frees the editor
jkmassel Sep 8, 2026
4440667
fix(ios): release the connection handler when the HTTP server stops
jkmassel Sep 8, 2026
e0a1253
test(ios): use `weak let` for a reference that is never reassigned
jkmassel Sep 8, 2026
7ac484e
docs(ios): correct what holding the delegate strongly trades away
jkmassel Sep 8, 2026
481015b
test(ios): remove the media lifetime test — it pinned nothing
jkmassel Sep 8, 2026
733a2a8
fix(ios)!: own the media upload delegate, and give hosts a way out
jkmassel Sep 15, 2026
aaa76cf
fix(ios): don't start a media upload for a torn-down editor
jkmassel Sep 5, 2026
77656a8
test(ios): drop the host reference before asserting the server holds one
jkmassel Sep 16, 2026
023988f
test(ios): pin that stopping the upload server releases the host's de…
jkmassel Sep 16, 2026
b6cd4f2
docs(ios): say when the delegate is actually released, and where
jkmassel Sep 16, 2026
c72ae94
chore(ios): log the failures the endpoint withdrawal was discarding
jkmassel Sep 16, 2026
4738bcb
fix(ios): don't keep a server that finished binding after a stop
jkmassel Sep 16, 2026
31ca6a4
refactor: rename DefaultMediaUploader to InternalMediaClient (#682)
jkmassel Oct 1, 2026
62206be
feat: add MediaUploader, for a host that owns the whole upload (#683)
jkmassel Oct 1, 2026
4d37f6b
feat!: remove MediaUploadDelegate.uploadFile (#684)
jkmassel Oct 1, 2026
5c56468
refactor!: rename MediaUploadDelegate to MediaProcessor, and drop its…
jkmassel Oct 1, 2026
7615c1f
feat(ios): add HTTPRequestHandler, and serve media uploads from one (…
jkmassel Oct 1, 2026
e98d478
fix: trap when a mediaUploader is set without site credentials (#687)
jkmassel Oct 1, 2026
a836997
docs: state the rule that makes the media field decode safe (#688)
jkmassel Oct 1, 2026
25f8bf3
test(ios): reuse ResizingProcessor instead of a second transcoding mo…
jkmassel Oct 1, 2026
23451f0
fix(ios): keep the dependency fetch running when the editor is covere…
jkmassel Oct 1, 2026
3b8f036
Merge remote-tracking branch 'origin/fix/register-core-media-upload-m…
jkmassel Oct 2, 2026
3d64cab
fix(ios): stop writing a GBKit key to localStorage when media handlin…
jkmassel Oct 2, 2026
295af98
docs(ios): remove a paragraph repeated in MediaProcessor's documentation
jkmassel Oct 2, 2026
685d3b9
fix(ios): free editors mid-fetch, and share site requests in flight (…
jkmassel Oct 2, 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
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 @@ -137,29 +137,60 @@ class GutenbergView : FrameLayout {
var requestInterceptor: GutenbergRequestInterceptor = DefaultGutenbergRequestInterceptor()

/**
* Optional delegate for customizing media upload behavior (resize, transcode,
* custom upload).
* Optional processor that transforms media before upload (resize, transcode,
* strip EXIF).
*
* To perform the upload yourself, set [mediaUploader] instead.
*
* 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.
*/
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.
*
* A [mediaProcessor] can still transform the file first; only delivery moves
* to the uploader.
*/
var mediaUploader: MediaUploader? = null
set(value) {
check(!hasStartedLoading) { lateMediaAssignmentMessage("mediaUploader") }
// An uploader's media deletes still relay through the internal media client,
// which needs a site root and an auth header to reach the configured site.
// Check it here, where the host hands the uploader over, rather than at
// server start: the stack trace names the caller's own line, and the mistake
// can't hide until the page loads. `configuration` is assigned in the
// constructor, so it is always available by the time this runs.
MediaServerCredentials.requireCredentialsForUploader(
siteApiRoot = configuration.siteApiRoot,
authHeader = configuration.authHeader,
hasUploader = value != null
)
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
* configuration has been captured. After this the [mediaProcessor] can no
* longer take effect, so its setter throws.
*/
@Volatile private var hasStartedLoading = false
Expand Down Expand Up @@ -722,13 +753,13 @@ class GutenbergView : FrameLayout {
/**
* Invoked when any page begins loading in the main frame. Resets readiness for
* every page; for the editor document alone, starts the upload server once —
* capturing the [mediaUploadDelegate] provided before load — then advertises the
* capturing the [mediaProcessor] provided before load — then advertises the
* editor globals (including the server's port and token).
*
* Starting the server here, on the UI thread, rather than from the
* [mediaUploadDelegate] setter keeps its whole lifecycle — start here, stop in
* [mediaProcessor] setter keeps its whole lifecycle — start here, stop in
* [onDetachedFromWindow] — on the UI thread, so it can't race a
* background-thread delegate assignment.
* background-thread processor assignment.
*/
private fun onEditorPageStarted(url: String?) {
// Readiness belongs to the page: a new page, including one a reload starts,
Expand Down Expand Up @@ -762,25 +793,37 @@ 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 — leave it down and let uploads fall to the default
// WebView path. (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 the latter because the WebView
// has no auth cookies). Without them 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.
//
// Only a mediaProcessor can reach this return: a mediaUploader without
// credentials already failed in its setter, so by here it has them.
if (!MediaServerCredentials.areUsable(configuration.siteApiRoot, configuration.authHeader)) return

// The editor reaches the loopback server over cleartext http://localhost. If
// the host app's network-security config doesn't permit cleartext to
// localhost, the WebView blocks every upload fetch (ERR_CLEARTEXT_NOT_PERMITTED)
// before it leaves the page. Detect that here and don't start the server, so
// the JS middleware routes uploads down the default path instead of a server
// it can never reach. Hosts that want native media processing must permit
// cleartext to localhost (see the demo's res/xml/network_security_config.xml).
// cleartext to localhost — see "Android: permit cleartext to localhost" in
// docs/integration.md, and the demo's res/xml/network_security_config.xml.
//
// This drops a mediaUploader as silently as missing credentials would, and still
// only warns. The difference is the cause, not the symptom: the configuration is
// sound here — permit cleartext and the same setup works unchanged — so there is
// nothing for the host to fix in what it handed us. See
// MediaServerCredentials.requireCredentialsForUploader.
if (!NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted(LOOPBACK_HOST)) {
Log.w(
TAG,
Expand All @@ -792,15 +835,16 @@ class GutenbergView : FrameLayout {
}

try {
val defaultUploader = DefaultMediaUploader(
val internalClient = InternalMediaClient(
httpClient = uploadHttpClient,
siteApiRoot = configuration.siteApiRoot,
authHeader = configuration.authHeader,
siteApiNamespace = configuration.siteApiNamespace.toList()
)
uploadServer = MediaUploadServer(
uploadDelegate = mediaUploadDelegate,
defaultUploader = defaultUploader,
processor = mediaProcessor,
internalClient = internalClient,
uploader = mediaUploader,
cacheDir = context.cacheDir,
scope = coroutineScope
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package org.wordpress.gutenberg

import android.net.Uri

/**
* Whether the editor configuration can reach the configured site for media, and the
* fail-fast that enforces it.
*
* The counterpart of iOS's `MediaServerCredentials`, kept deliberately close to it.
* This is a *crash* policy, and it has already diverged silently between the platforms
* once: iOS required an absolute site root while this side checked only `isEmpty()`, so
* a scheme-less root trapped on iOS and started a server whose every delete failed on
* Android. Both platforms keep the policy in a type of this name so the two can be
* diffed against each other rather than hunted for across view code.
*/
internal object MediaServerCredentials {
/**
* Whether an [InternalMediaClient] built from this configuration could actually
* reach the site.
*
* Both fields are required. The client delivers GutenbergKit's uploads to the
* configured site, so it needs somewhere to send them and credentials to be
* accepted; with either missing, every media request it makes fails.
*
* "Somewhere to send them" means an *absolute* root, not merely a non-empty one.
* OkHttp rejects a scheme-less URL from `Request.Builder.url` with
* `IllegalArgumentException`, which is not an `IOException` — so it escapes
* [MediaUploadServer]'s delete handler and degrades to a generic 500 the editor
* cannot parse into an error, and the orphan cleanup that delete exists for fails
* silently. An empty root parses to the same nulls, so this still rejects
* everything the older `isEmpty()` check did.
*
* Emptiness is tested as well as nullity because `Uri` and Swift's `URL` disagree
* on how they report a missing authority: `file:///tmp/wp-json` yields a `null`
* host on iOS but an *empty* one here. Treating both as absent is what keeps the
* two predicates answering alike.
*/
fun areUsable(siteApiRoot: String, authHeader: String): Boolean {
if (authHeader.isEmpty()) return false
val uri = Uri.parse(siteApiRoot)
return !uri.scheme.isNullOrEmpty() && !uri.host.isNullOrEmpty()
}

/**
* Throws if the host supplied a [MediaUploader] without usable credentials.
*
* The behavior forks by intent:
*
* - A [MediaProcessor] only enhances GutenbergKit-owned uploads. With no
* credentials there is nothing to deliver through, so nothing to process — the
* server simply stays down and uploads fall to the default WebView path. That is
* [areUsable]'s job, at the point the server would start.
*
* - A [MediaUploader] means the host is *taking over* uploads, and falling back
* would drop that whole stack — its queueing, its retries — while media appeared
* to keep working. Worth failing over rather than logging.
*
* What makes it a *failure* rather than a warning is that the configuration is
* incoherent, not merely unlucky: an uploader's media deletes still relay through
* the internal media client, so there is no site root and auth header under which
* this host's uploader could have worked. Contrast the conditions the host's
* environment imposes at server start — the network policy that blocks cleartext to
* localhost, a port that won't bind — which log and degrade, because the very same
* configuration works once the environment allows it. Dropping the uploader is the
* symptom both share; only this one has a cause the host can fix in the
* configuration it just handed over.
*
* Called from [GutenbergView.mediaUploader]'s setter, not from the server start —
* the earliest point available here, since this platform takes its media handlers
* as mutable properties rather than at construction. Checking where the host hands
* the uploader over puts the caller's own line in the stack trace, instead of
* surfacing the mistake later from inside a page-load callback. (iOS checks in
* `EditorViewController.init`, for the same reason.)
*/
fun requireCredentialsForUploader(siteApiRoot: String, authHeader: String, hasUploader: Boolean) {
if (!hasUploader) return
check(areUsable(siteApiRoot, authHeader)) {
"A mediaUploader needs site credentials so GutenbergKit can relay the " +
"editor's media deletes to the configured site. Set an absolute " +
"siteApiRoot and the auth header in the editor configuration."
}
}
}
Loading
Loading