From cce9f403a0c2cc4af05bbcbf3b66cb2400b4bf4e Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:30:48 -0600 Subject: [PATCH 1/2] fix(android): keep editor load disk and SSL work off the main thread GutenbergView started the upload server and built the window.GBKit JSON on the main thread in onPageStarted, and created its EditorService on the main dispatcher. Hosts running StrictMode with penaltyDeath crashed on every editor launch. Both are now built on Dispatchers.IO before loadUrl, and onPageStarted only injects the prebuilt JSON. The media processor and uploader are still captured on the main thread, one pass after construction. Fixes #848 --- .../org/wordpress/gutenberg/GutenbergView.kt | 155 ++++++++++++------ .../gutenberg/GutenbergViewNavigationTest.kt | 31 ++-- .../GutenbergViewUploadServerTest.kt | 87 +++++----- 3 files changed, 164 insertions(+), 109 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 7b70bf221..e8e358818 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -33,9 +33,13 @@ import android.widget.Toast import androidx.annotation.VisibleForTesting import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewAssetLoader.AssetsPathHandler +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.json.JSONException import org.json.JSONObject import org.wordpress.gutenberg.inserter.BlockPickerDialog @@ -143,8 +147,8 @@ class GutenbergView : FrameLayout { * 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 + * construction (e.g. in the `AndroidView` factory). It is captured once, just + * before the page loads, and advertised to the page then; setting it * afterward has no effect, so the setter throws to surface the mistake. */ var mediaProcessor: MediaProcessor? = null @@ -184,17 +188,34 @@ class GutenbergView : FrameLayout { 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." + "It is captured before the page loads; 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 [mediaProcessor] can no - * longer take effect, so its setter throws. + * True once [loadEditorPage] has captured the upload server's configuration. + * After this the [mediaProcessor] can no longer take effect, so its setter + * throws. */ @Volatile private var hasStartedLoading = false + /** + * The editor globals as JSON. [loadEditorPage] builds them before the page + * loads, and [onEditorPageStarted] gives them to each editor page as + * `window.GBKit`. + */ + private var editorGlobalsJson: String? = null + + /** The work [loadEditorPage] does before loading the page. Cancelled on detach. */ + private var editorPageJob: Job? = null + + /** + * Where [loadEditorPage] does its disk and network setup. Tests replace it to + * run that work in place. + */ + @VisibleForTesting + internal var backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO + private val uploadHttpClient: okhttp3.OkHttpClient by lazy { // The read/write inactivity timeouts mirror URLSession's 60s // timeoutIntervalForRequest default — an inactivity timer that resets on @@ -652,26 +673,32 @@ class GutenbergView : FrameLayout { private fun prepareAndLoadEditor() { Log.i("GutenbergView", "Fetching dependencies...") - coroutineScope.launch { + coroutineScope.launch(Dispatchers.Main) { Log.i("GutenbergView", "In coroutine scope") Log.i("GutenbergView", "Fetching dependencies in IO context") try { - val editorService = EditorService.create( - context = context, - configuration = configuration, - coroutineScope = coroutineScope - ) - Log.i("GutenbergView", "Created editor service") - val fetchedDependencies = editorService.prepare { progress -> - progressView.setProgress(progress) - - Log.i("GutenbergView", "Progress: $progress") + // Creating the service and preparing it both read the disk, and + // creating it sets up an SSL context, so neither runs on the main thread. + val fetchedDependencies = withContext(Dispatchers.IO) { + val editorService = EditorService.create( + context = context, + configuration = configuration, + coroutineScope = coroutineScope + ) + Log.i("GutenbergView", "Created editor service") + editorService.prepare { progress -> + handler.post { progressView.setProgress(progress) } + + Log.i("GutenbergView", "Progress: $progress") + } } Log.i("GutenbergView", "Finished fetching dependencies") // Store dependencies and load the editor loadEditor(fetchedDependencies) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e("GutenbergView", "Failed to load dependencies", e) showErrorPhase(e) @@ -749,6 +776,46 @@ class GutenbergView : FrameLayout { for (cookie in configuration.cookies) { CookieManager.getInstance().setCookie(cookie.key, cookie.value) } + loadEditorPage(editorUrl) + } + } + + /** + * Starts the upload server and builds the editor globals, then loads [editorUrl]. + * + * The page needs both before its scripts run, and both read the disk. Building + * them here, on [backgroundDispatcher], keeps that work off the main thread. + * Hosts that run `StrictMode` would otherwise see violations on every launch. + * + * The work starts on a later pass of the main thread, so a [mediaProcessor] or + * [mediaUploader] set right after construction is still captured. The server is + * assigned on the main thread and stopped in [onDetachedFromWindow], so its + * lifecycle can't race a processor assignment. + */ + private fun loadEditorPage(editorUrl: String) { + editorPageJob = coroutineScope.launch(Dispatchers.Main) { + hasStartedLoading = true + val processor = mediaProcessor + val uploader = mediaUploader + var server: MediaUploadServer? = null + try { + editorGlobalsJson = withContext(backgroundDispatcher) { + val startedServer = startUploadServer(processor, uploader) + server = startedServer + GBKitGlobal.fromConfiguration( + configuration, + dependencies, + nativeUploadPort = startedServer?.port, + nativeUploadToken = startedServer?.token + ).toJsonString() + } + } catch (e: CancellationException) { + // The view was detached while the server started, so nothing else + // will stop it. + server?.stop() + throw e + } + uploadServer = server webView.loadUrl(editorUrl) Log.i("GutenbergView", "Startup Complete") @@ -757,14 +824,8 @@ 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 [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 - * [mediaProcessor] setter keeps its whole lifecycle — start here, stop in - * [onDetachedFromWindow] — on the UI thread, so it can't race a - * background-thread processor assignment. + * every page; for the editor document alone, advertises the editor globals + * (including the upload server's port and token) that [loadEditorPage] built. */ private fun onEditorPageStarted(url: String?) { // Readiness belongs to the page: a new page, including one a reload starts, @@ -778,30 +839,20 @@ class GutenbergView : FrameLayout { // with the site, so the destination is checked rather than assumed. if (url == null || !isEditorDocument(Uri.parse(url))) return - if (!hasStartedLoading) { - hasStartedLoading = true - startUploadServer() - } - setGlobalJavaScriptVariables() + val globalsJson = editorGlobalsJson ?: return + webView.evaluateJavascript("window.GBKit = $globalsJson;", null) } - private fun setGlobalJavaScriptVariables() { - val gbKit = GBKitGlobal.fromConfiguration( - configuration, - dependencies, - nativeUploadPort = uploadServer?.port, - nativeUploadToken = uploadServer?.token - ) - val gbKitJson = gbKit.toJsonString() - - webView.evaluateJavascript("window.GBKit = $gbKitJson;", null) - } - - private fun startUploadServer() { + /** + * Starts the upload server for [processor] and [uploader], or returns null when + * uploads should use the default WebView path. Reads the disk, so it runs off + * the main thread. + */ + private fun startUploadServer(processor: MediaProcessor?, uploader: MediaUploader?): MediaUploadServer? { // 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 + if (processor == null && uploader == null) return null // An InternalMediaClient delivers GutenbergKit-owned uploads (when no uploader // is set) and relays the editor's media DELETEs to the configured site — every @@ -813,7 +864,7 @@ class GutenbergView : FrameLayout { // // 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 + if (!MediaServerCredentials.areUsable(configuration.siteApiRoot, configuration.authHeader)) return null // The editor reaches the loopback server over cleartext http://localhost. If // the host app's network-security config doesn't permit cleartext to @@ -836,27 +887,26 @@ class GutenbergView : FrameLayout { "server can't be reached from the WebView. Permit cleartext to $LOOPBACK_HOST " + "in the app's network security config to enable native media processing." ) - return + return null } - try { + return try { val internalClient = InternalMediaClient( httpClient = uploadHttpClient, siteApiRoot = configuration.siteApiRoot, authHeader = configuration.authHeader, siteApiNamespace = configuration.siteApiNamespace.toList() ) - uploadServer = MediaUploadServer( - processor = mediaProcessor, + MediaUploadServer( + processor = processor, internalClient = internalClient, - uploader = mediaUploader, + uploader = uploader, cacheDir = context.cacheDir, scope = coroutineScope ) - // 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) + null } } @@ -1416,6 +1466,7 @@ class GutenbergView : FrameLayout { override fun onDetachedFromWindow() { super.onDetachedFromWindow() stopNetworkMonitoring() + editorPageJob?.cancel() uploadServer?.stop() uploadServer = null clearConfig() diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index ceb759abc..c28eafb3b 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -3,6 +3,7 @@ package org.wordpress.gutenberg import android.net.Uri import android.os.Looper import android.webkit.WebResourceRequest +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.TestScope import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -28,15 +29,28 @@ class GutenbergViewNavigationTest { private val testScope = TestScope() /** A view whose configuration carries a recognizable credential. */ - private fun configuredSiteView() = GutenbergView( + private fun configuredSiteView() = preparedView( EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/") .setAuthHeader("Bearer secret-credential") - .build(), - EditorDependencies.empty, - testScope, - RuntimeEnvironment.getApplication() + .build() ) + /** + * A view that has built its editor globals, as it does before loading the + * editor page, so `onPageStarted` has something to inject. + */ + private fun preparedView(configuration: EditorConfiguration): GutenbergView { + val view = GutenbergView( + configuration, + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + view.backgroundDispatcher = Dispatchers.Unconfined + shadowOf(Looper.getMainLooper()).idle() + return view + } + /** * The editor document for [siteUrl], mirroring the fallback in `loadEditor` so * this holds whether or not a local `GUTENBERG_EDITOR_URL` dev server is set. @@ -120,13 +134,10 @@ class GutenbergViewNavigationTest { @Test fun `onPageStarted injects the configuration into a local http site's editor document`() { // A local site serves the editor over http, which the asset scheme must follow. - val siteView = GutenbergView( + val siteView = preparedView( EditorConfiguration.builder("http://10.0.2.2:8888", "http://10.0.2.2:8888/wp-json/") .setAuthHeader("Bearer secret-credential") - .build(), - EditorDependencies.empty, - testScope, - RuntimeEnvironment.getApplication() + .build() ) val webView = siteView.editorWebView diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt index 9935fa102..6448b3aa6 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt @@ -2,11 +2,13 @@ package org.wordpress.gutenberg import android.os.Looper import android.view.View +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.TestScope import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.mock @@ -21,17 +23,6 @@ import org.wordpress.gutenberg.model.EditorDependencies @Config(manifest = Config.NONE) class GutenbergViewUploadServerTest { - private companion object { - /** - * The editor document for [makeView]'s site, which the globals are scoped to. - * - * Mirrors the fallback in `loadEditor`, so this holds whether or not a local - * `GUTENBERG_EDITOR_URL` dev server is configured. - */ - val EDITOR_URL = BuildConfig.GUTENBERG_EDITOR_URL - .ifEmpty { "https://example.com/assets/index.html" } - } - private val testScope = TestScope() private fun makeView(authHeader: String = "Bearer test", siteApiRoot: String = "https://example.com/wp-json/"): GutenbergView { @@ -44,7 +35,10 @@ class GutenbergViewUploadServerTest { EditorDependencies.empty, testScope, RuntimeEnvironment.getApplication() - ) + ).apply { + // Run the pre-load setup in place, so idling the main looper finishes it. + backgroundDispatcher = Dispatchers.Unconfined + } } private fun uploadServerOf(view: GutenbergView): Any? { @@ -54,18 +48,10 @@ class GutenbergViewUploadServerTest { } /** - * Invokes the private `onEditorPageStarted` hook (fired from the WebViewClient's - * `onPageStarted`) to simulate the editor page beginning to load — the point at - * which the processor is captured and the upload server starts. + * Lets the view finish the setup it does before loading the editor page — the + * point at which the processor is captured and the upload server starts. */ - private fun startLoading(view: GutenbergView, url: String = EDITOR_URL) { - val method = GutenbergView::class.java.getDeclaredMethod( - "onEditorPageStarted", - String::class.java - ) - method.isAccessible = true - method.invoke(view, url) - } + private fun startLoading() = idle() /** Invokes the protected `onDetachedFromWindow` lifecycle callback. */ private fun detach(view: GutenbergView) { @@ -82,8 +68,7 @@ class GutenbergViewUploadServerTest { try { // A processor provided before load is captured when the page starts. view.mediaProcessor = mock(MediaProcessor::class.java) - startLoading(view) - idle() + startLoading() assertNotNull( "a processor provided before load should bring up the upload server", uploadServerOf(view) @@ -98,8 +83,7 @@ class GutenbergViewUploadServerTest { val view = makeView() try { // No processor provided — uploads should use the default WebView path. - startLoading(view) - idle() + startLoading() assertNull( "with no processor, no upload server should be started", uploadServerOf(view) @@ -117,8 +101,7 @@ class GutenbergViewUploadServerTest { // editor has to the host's upload stack. Without this, `startUploadServer` // could drop the `mediaUploader` clause from its gate and stay green. view.mediaUploader = mock(MediaUploader::class.java) - startLoading(view) - idle() + startLoading() assertNotNull( "an uploader provided before load should bring up the upload server", uploadServerOf(view) @@ -129,27 +112,42 @@ class GutenbergViewUploadServerTest { } @Test - fun `a non-editor page does not start the upload server`() { + fun `the editor globals advertise the upload server's port`() { val view = makeView() try { view.mediaProcessor = mock(MediaProcessor::class.java) - startLoading(view, "https://example.com/assets/support.html") - idle() - assertNull( - "only the editor document should bring up the upload server", - uploadServerOf(view) + startLoading() + val field = GutenbergView::class.java.getDeclaredField("editorGlobalsJson") + field.isAccessible = true + val globals = field.get(view) as String + val port = MediaUploadServer::class.java.getMethod("getPort").invoke(uploadServerOf(view)) + assertTrue( + "the page should learn the port before it loads", + globals.contains("\"nativeUploadPort\":$port") ) } finally { detach(view) } } + @Test + fun `detaching before the page loads leaves no upload server`() { + val view = makeView() + view.mediaProcessor = mock(MediaProcessor::class.java) + detach(view) + startLoading() + + assertNull( + "a view detached before load should never start the upload server", + uploadServerOf(view) + ) + } + @Test fun `setting the uploader after the page has started loading throws`() { val view = makeView() try { - startLoading(view) - idle() + startLoading() assertThrows(IllegalStateException::class.java) { view.mediaUploader = mock(MediaUploader::class.java) } @@ -170,8 +168,7 @@ class GutenbergViewUploadServerTest { } // The failed assignment left nothing behind, so loading still works — // it just falls to the default WebView path. - startLoading(view) - idle() + startLoading() assertNull("no server should be left behind by the rejected uploader", uploadServerOf(view)) } finally { detach(view) @@ -213,8 +210,7 @@ class GutenbergViewUploadServerTest { val view = makeView(siteApiRoot = "example.com/wp-json/") try { view.mediaProcessor = mock(MediaProcessor::class.java) - startLoading(view) - idle() + startLoading() assertNull( "a scheme-less site root should not bring up the server", uploadServerOf(view) @@ -257,8 +253,7 @@ class GutenbergViewUploadServerTest { val view = makeView(authHeader = "") try { view.mediaProcessor = mock(MediaProcessor::class.java) - startLoading(view) - idle() + startLoading() assertNull( "a processor with no credentials should not bring up the server", uploadServerOf(view) @@ -272,8 +267,7 @@ class GutenbergViewUploadServerTest { fun `setting the processor after the page has started loading throws`() { val view = makeView() try { - startLoading(view) - idle() + startLoading() // The processor is captured at load; a later assignment is a programmer // error and must surface loudly rather than silently do nothing. assertThrows(IllegalStateException::class.java) { @@ -288,8 +282,7 @@ class GutenbergViewUploadServerTest { fun `detaching the view stops and clears the upload server`() { val view = makeView() view.mediaProcessor = mock(MediaProcessor::class.java) - startLoading(view) - idle() + startLoading() assertNotNull(uploadServerOf(view)) detach(view) From e08b871a653558743880ba38516192a6cc027df9 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:30:48 -0600 Subject: [PATCH 2/2] chore(android): crash the demo on StrictMode thread violations The library no longer does main-thread disk or SSL work while the editor loads (#848), so the demo's ThreadPolicy adds penaltyDeath. VmPolicy stays on penaltyLog. --- .../gutenbergkit/GutenbergKitApplication.kt | 7 ++-- docs/code/strictmode.md | 42 ++++++++----------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt index 9c393c4b7..e96fb92a8 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt @@ -83,9 +83,9 @@ class GutenbergKitApplication : Application() { } private fun enableStrictMode() { - // ThreadPolicy only logs for now: the library still reads the disk on - // the main thread while the editor page loads, so penaltyDeath would - // crash every editor launch. Switch to penaltyDeath once #848 is fixed. + // A ThreadPolicy violation crashes the demo, so new main-thread disk or + // network work in the library fails loudly during development. The log + // keeps the full violation, not just the crash. // VmPolicy stays on penaltyLog — VM violations like // LeakedClosableObject fire during GC and would crash at unpredictable // times unrelated to the offending code path. @@ -93,6 +93,7 @@ class GutenbergKitApplication : Application() { StrictMode.ThreadPolicy.Builder() .detectAll() .penaltyLog() + .penaltyDeath() .build() ) StrictMode.setVmPolicy( diff --git a/docs/code/strictmode.md b/docs/code/strictmode.md index b2eb9583e..9b3d363fe 100644 --- a/docs/code/strictmode.md +++ b/docs/code/strictmode.md @@ -5,12 +5,8 @@ disk I/O on the main thread, leaked closables, registration leaks, and similar issues introduced inside the GutenbergKit library surface during local development. -For now, violations are **logged, not fatal**. The library still reads the disk -on the main thread while the editor page loads (see -[What's still in the library](#whats-still-in-the-library)), so crashing on -violations would crash every editor launch. Switching `ThreadPolicy` to -`penaltyDeath()` is tracked in -[#848](https://github.com/wordpress-mobile/GutenbergKit/issues/848). +`ThreadPolicy` violations **crash the demo**, so new main-thread disk or network +work fails loudly during development. `VmPolicy` violations are logged only. ## Configuration @@ -22,6 +18,7 @@ StrictMode.setThreadPolicy( StrictMode.ThreadPolicy.Builder() .detectAll() .penaltyLog() + .penaltyDeath() .build() ) StrictMode.setVmPolicy( @@ -42,7 +39,9 @@ Configuration notes: `detectFileUriExposure`, `detectCleartextNetwork`, etc. - **`Application.onCreate()`** is the right place — earlier is better so the policy covers everything that follows. -- **`VmPolicy` stays on `penaltyLog()`, even after #848.** VM violations like +- **`ThreadPolicy` keeps `penaltyLog()` alongside `penaltyDeath()`.** The log + prints the full violation before the crash. +- **`VmPolicy` stays on `penaltyLog()`.** VM violations like `LeakedClosableObject` fire during GC and would crash at unpredictable times unrelated to the offending code path, which is unhelpful as a signal. - **`penaltyDialog()` is deliberately not enabled.** It interrupts manual @@ -103,20 +102,13 @@ exposes a `suspend` API: - `EditorDependenciesSerializer.writeToDisk` / `EditorDependenciesSerializer.readFromDisk`. -### Not wrappable — tracked in #848 +### Editor load -Most of these run on the main thread inside `GutenbergView.onEditorPageStarted`, -which `WebViewClient.onPageStarted` calls. The last one runs from the -`GutenbergView` constructor. The host has no call site to move, so the demo -logs them on every editor launch: - -| Violation | Where | When | -| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------ | -| `DiskReadViolation` | `EditorAssetBundle.getEditorRepresentation`, via `GBKitGlobal.fromConfiguration` | Every editor launched with prepared dependencies | -| `CustomViolation: newSSLContext` | `GutenbergView.uploadHttpClient` (OkHttp SSL setup), built in `startUploadServer` | Native media upload is on | -| `DiskReadViolation` | `GutenbergView.startUploadServer` (`context.cacheDir`) | Native media upload is on | -| `DiskReadViolation` | `HttpServer.start` → `TempFileOwner.cleanOrphans` | Native media upload is on | -| Several `DiskReadViolation`s, `CustomViolation: newSSLContext` | `GutenbergView.prepareAndLoadEditor` → `EditorService.create`, launched on the main dispatcher | The editor starts without prepared dependencies | +`GutenbergView` starts the upload server and builds the editor globals on +`Dispatchers.IO` before it loads the editor page. When the host passes no +prepared dependencies, it also creates and prepares its `EditorService` there. +Opening the editor does no disk or SSL work on the main thread +([#848](https://github.com/wordpress-mobile/GutenbergKit/issues/848)). ### Expected `VmPolicy` noise @@ -137,12 +129,14 @@ actual call site. ## Verified flows -Exercised on an API 36 emulator, with only the violations listed above: +Exercised on an API 36 emulator, with only the `VmPolicy` noise listed above: - App cold launch, including signing in with `make login-android-app`. - **Standalone editor** → **Prepare Editor** → **Start**, then type. - A connected site → **Prepare Editor** → **Browse** → open a post, then type. - A connected site → **Browse** → open a post, without preparing first. +- A connected site → **Start**, with and without **Prepare Editor**, and with + **Enable Native Media Upload** on and off. - Native inserter sheet: category tabs and search. - Background the app, then resume it. @@ -157,8 +151,8 @@ resources: 1. Run the demo in debug mode. 2. Walk through the new code path end-to-end with `adb logcat -s StrictMode` open. -3. If a new `ThreadPolicy` violation shows up, the offending call is in the - stack trace — wrap it in `withContext(Dispatchers.IO)` (or move it into a - coroutine). +3. If the demo crashes with a `ThreadPolicy` violation, the offending call is + in the stack trace — wrap it in `withContext(Dispatchers.IO)` (or move it + into a coroutine). 4. **Never** suppress with `StrictMode.allowThreadDiskReads()` inside the library — that hides the violation from host apps.