Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
dadd64b
fix(ios): gate the media upload server on the site root too
jkmassel Sep 5, 2026
8827ba4
fix(ios): own the media upload delegate instead of holding it weakly
jkmassel Sep 5, 2026
9944c98
test(ios): pin that owning the media delegate still frees the editor
jkmassel Sep 8, 2026
7124457
fix(ios): release the connection handler when the HTTP server stops
jkmassel Sep 8, 2026
baeede7
test(ios): use `weak let` for a reference that is never reassigned
jkmassel Sep 8, 2026
366f66a
docs(ios): correct what holding the delegate strongly trades away
jkmassel Sep 8, 2026
a89459c
test(ios): remove the media lifetime test — it pinned nothing
jkmassel Sep 8, 2026
359d89a
fix(ios)!: own the media upload delegate, and give hosts a way out
jkmassel Sep 15, 2026
d57b6ac
fix(ios): don't start a media upload for a torn-down editor
jkmassel Sep 5, 2026
bc55f30
test(ios): drop the host reference before asserting the server holds one
jkmassel Sep 16, 2026
cf120e7
test(ios): pin that stopping the upload server releases the host's de…
jkmassel Sep 16, 2026
5e22070
docs(ios): say when the delegate is actually released, and where
jkmassel Sep 16, 2026
90b0e0d
chore(ios): log the failures the endpoint withdrawal was discarding
jkmassel Sep 16, 2026
a05b2fd
fix(ios): don't keep a server that finished binding after a stop
jkmassel Sep 16, 2026
d62a955
refactor: rename DefaultMediaUploader to InternalMediaClient (#682)
jkmassel Oct 1, 2026
217cf8c
feat: add MediaUploader, for a host that owns the whole upload (#683)
jkmassel Oct 1, 2026
53ed32a
feat!: remove MediaUploadDelegate.uploadFile (#684)
jkmassel Oct 1, 2026
0acad18
refactor!: rename MediaUploadDelegate to MediaProcessor, and drop its…
jkmassel Oct 1, 2026
3faf05d
feat(ios): add HTTPRequestHandler, and serve media uploads from one (…
jkmassel Oct 1, 2026
20c657d
fix: trap when a mediaUploader is set without site credentials (#687)
jkmassel Oct 1, 2026
12d4022
docs: state the rule that makes the media field decode safe (#688)
jkmassel Oct 1, 2026
4dc4f04
test(ios): reuse ResizingProcessor instead of a second transcoding mo…
jkmassel Oct 1, 2026
b8d49ad
fix(ios): keep the dependency fetch running when the editor is covere…
jkmassel Oct 1, 2026
c4c039f
fix(ios): surface a listener failure that happens after a successful …
jkmassel Sep 19, 2026
e8da2db
fix(ios): restart the upload server when iOS reclaims its socket
jkmassel Sep 21, 2026
6535b3c
feat(ios): add an upload server diagnostic to the demo app
jkmassel Sep 21, 2026
c3feee8
fix(ios): hold a background-task assertion during a media upload
jkmassel Sep 21, 2026
cf1b7e0
docs(ios): describe the idle-sleep socket sweep, not suspension
jkmassel Sep 25, 2026
9fa6fb8
fix(ios): report a refused upload server port without waiting out the…
jkmassel Sep 25, 2026
30918ed
test: pin that an upload to a dead port fails until the restart re-ad…
jkmassel Sep 25, 2026
92be43d
fix(ios): hold the upload assertion until the response is written
jkmassel Sep 25, 2026
0f5d2e2
fix(ios): restart the upload server once when foreground checks overlap
jkmassel Sep 25, 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 @@ -113,29 +113,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 @@ -643,13 +674,13 @@ class GutenbergView : FrameLayout {

/**
* Invoked when the editor page begins loading. Starts the upload server once —
* capturing the [mediaUploadDelegate] provided before load — then advertises
* capturing the [mediaProcessor] 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
* [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() {
if (!hasStartedLoading) {
Expand All @@ -676,25 +707,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 @@ -706,15 +749,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