Skip to content
Merged
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
c8cc8cb
fix: bound upload-server body read by idle timeout, not a total read cap
jkmassel Jul 22, 2026
1046811
fix(ios): trap when mediaUploadDelegate is set too late or released e…
jkmassel Jul 22, 2026
20f9855
fix(ios): guard temp-file cleanup so concurrent servers don't wipe li…
jkmassel Jul 22, 2026
9daf52f
fix(ios): tear down the upload body writer thread on every exit
jkmassel Jul 22, 2026
b3bcac7
test(js): make the upload-cancel tests exercise the abort-vs-network …
jkmassel Jul 22, 2026
d603c0b
fix(android): don't resurrect the upload server on a detached view
jkmassel Jul 22, 2026
d4b1b74
fix(android): cancel the upload server's coroutine scope when it owns it
jkmassel Jul 22, 2026
a18a48d
fix(ios): escape multipart header values to prevent injection
jkmassel Jul 22, 2026
c6a84a5
docs(ios): document the upload body's non-replayable-stream limitation
jkmassel Jul 22, 2026
65f58ed
fix(js): reject with a canonical AbortError when an aborted signal ha…
jkmassel Jul 22, 2026
0ef2fb1
fix(ios): cancel the upload relay when the client aborts the connection
jkmassel Jul 22, 2026
3f87758
fix(android): cancel the upload relay when the client aborts the conn…
jkmassel Jul 22, 2026
cf89a83
fix(ios): don't apply the REST request timeout to media uploads
jkmassel Jul 22, 2026
eedc409
fix(android): capture the media upload delegate once at load, matchin…
jkmassel Jul 22, 2026
f689b3f
fix(ios): bound the HTTP server's wait for the listener to become ready
jkmassel Jul 22, 2026
061c0ed
fix(ios): don't send a truncated multipart when the upload file can't…
jkmassel Jul 22, 2026
06a1853
fix(ios): don't write a response to a connection cancelled mid-handler
jkmassel Jul 22, 2026
1016092
fix(android): rethrow coroutine cancellation instead of mapping it to…
jkmassel Jul 22, 2026
58c48a5
fix(js): treat an abort during the response body read as a cancel, no…
jkmassel Jul 22, 2026
5eca958
feat(ios): let the delegate decline a file by metadata to skip the te…
jkmassel Jul 23, 2026
c756650
feat(android): let the delegate decline a file by metadata to skip th…
jkmassel Jul 23, 2026
52da689
fix(android): start connection handlers ATOMIC so a shutdown race can…
jkmassel Jul 23, 2026
a324a0b
fix(js): normalize a native-upload transport failure to api-fetch's e…
jkmassel Jul 23, 2026
4c90a2a
docs(ios): explain the mediaUploadDelegate precondition is a delibera…
jkmassel Jul 23, 2026
9eb898a
refactor(ios): route recoverable parse errors through an HTTPServerDe…
jkmassel Jul 23, 2026
feabd00
refactor(android): route recoverable parse errors through an HttpServ…
jkmassel Jul 23, 2026
d1460bc
docs: explain why the upload server's CORS is `*` and not an origin a…
jkmassel Jul 23, 2026
6b0d2d5
refactor(js): only route a genuine File through the native upload path
jkmassel Jul 24, 2026
7b34e48
fix(android): resume the upload coroutine when the response-body read…
jkmassel Aug 12, 2026
8e3cc9d
fix(ios): carry the request-observing delegate into the upload client
jkmassel Aug 12, 2026
6634894
test: pin the racing-close half-close behavior and document it
jkmassel Aug 12, 2026
3634a02
test(ios): reconcile parent's start/cleanup tests after reparenting o…
jkmassel Aug 12, 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
Original file line number Diff line number Diff line change
Expand Up @@ -112,29 +112,34 @@ class GutenbergView : FrameLayout {

var requestInterceptor: GutenbergRequestInterceptor = DefaultGutenbergRequestInterceptor()

/** Optional delegate for customizing media upload behavior (resize, transcode, custom upload). */
/**
* Optional delegate for customizing media upload behavior (resize, transcode,
* custom upload).
*
* 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
set(value) {
if (field === value) return
field = value
// Stop any previously running server before starting a new one.
uploadServer?.stop()
uploadServer = null
// (Re)start the upload server so it captures the delegate. This
// handles the common case where the delegate is set after
// construction but before the editor finishes loading.
if (value != null) {
startUploadServer()
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."
}
// Reflect the resulting server state in the page: advertise a freshly
// (re)started server, or clear the port when the server did NOT
// (re)start (delegate cleared, auth missing, or cleartext-to-localhost
// blocked) so the JS upload middleware routes to the default path
// instead of fetching a now-dead port.
syncUploadServerJavaScriptVariables()
field = value
}

private var uploadServer: MediaUploadServer? = null
@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.
*/
@Volatile private var hasStartedLoading = false

private val uploadHttpClient: okhttp3.OkHttpClient by lazy {
// The read/write inactivity timeouts mirror URLSession's 60s
// timeoutIntervalForRequest default — an inactivity timer that resets on
Expand Down Expand Up @@ -405,7 +410,7 @@ class GutenbergView : FrameLayout {

override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
setGlobalJavaScriptVariables()
onEditorPageStarted()
}

override fun shouldInterceptRequest(
Expand Down Expand Up @@ -631,6 +636,24 @@ 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.
*
* 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.
*/
private fun onEditorPageStarted() {
if (!hasStartedLoading) {
hasStartedLoading = true
startUploadServer()
}
setGlobalJavaScriptVariables()
}

private fun setGlobalJavaScriptVariables() {
val gbKit = GBKitGlobal.fromConfiguration(
configuration,
Expand All @@ -647,37 +670,12 @@ class GutenbergView : FrameLayout {
webView.evaluateJavascript(gbKitConfig, null)
}

/**
* Syncs the current upload server's port and token into the already-loaded
* page.
*
* Advertises a running server so JS uploads route through it, and clears them
* (to `null`) when the server is stopped or was never started — so the JS
* upload middleware routes to the default path instead of fetching a now-dead
* port. The initial injection is handled by [setGlobalJavaScriptVariables]
* from `onPageStarted`; this keeps JS in sync when the server (re)starts or
* stops *after* the page has loaded (e.g. when [mediaUploadDelegate] is
* assigned, replaced, or cleared).
*
* The `window.GBKit` guard makes this a no-op before the page has loaded, so
* it is safe to call on the initial start too.
*/
private fun syncUploadServerJavaScriptVariables() {
val portJs = uploadServer?.port?.toString() ?: "null"
val tokenJs = uploadServer?.token?.let { JSONObject.quote(it) } ?: "null"
val js = """
if (window.GBKit) {
window.GBKit.nativeUploadPort = $portJs;
window.GBKit.nativeUploadToken = $tokenJs;
localStorage.setItem('GBKit', JSON.stringify(window.GBKit));
}
""".trimIndent()
// evaluateJavascript must run on the WebView's (UI) thread; post it so a
// delegate set from a background thread doesn't throw thread-affinity.
webView.post { webView.evaluateJavascript(js, null) }
}

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
Expand Down Expand Up @@ -715,7 +713,8 @@ class GutenbergView : FrameLayout {
cacheDir = context.cacheDir,
scope = coroutineScope
)
// JS is synced by the mediaUploadDelegate setter after this returns.
// The page globals (including the server's port/token) are injected by
// onEditorPageStarted after this returns.
} catch (e: Exception) {
Log.w(TAG, "Failed to start upload server", e)
}
Expand Down
Loading
Loading