Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
155 changes: 103 additions & 52 deletions android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
}
}

Expand Down Expand Up @@ -1416,6 +1466,7 @@ class GutenbergView : FrameLayout {
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
stopNetworkMonitoring()
editorPageJob?.cancel()
uploadServer?.stop()
uploadServer = null
clearConfig()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading