diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt index d015ec94b..7b12163a1 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt @@ -24,13 +24,28 @@ interface EditorHTTPClientProtocol { suspend fun perform(method: EditorHttpMethod, url: String): EditorHTTPClientResponse } +/** + * The response data from an HTTP request, either in-memory bytes or a downloaded file. + */ +sealed class EditorResponseData { + data class Bytes(val data: ByteArray) : EditorResponseData() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Bytes) return false + return data.contentEquals(other.data) + } + override fun hashCode(): Int = data.contentHashCode() + } + data class File(val file: java.io.File) : EditorResponseData() +} + /** * A delegate for observing HTTP requests made by the editor. * * Implement this interface to inspect or log all network requests. */ interface EditorHTTPClientDelegate { - fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: ByteArray) + fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: EditorResponseData) } /** @@ -141,18 +156,32 @@ class EditorHTTPClient( override suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse = withContext(Dispatchers.IO) { + Log.d(TAG, "DOWNLOAD $url") + Log.d(TAG, " Destination: ${destination.absolutePath}") + val request = Request.Builder() .url(url) .addHeader("Authorization", authHeader) .get() .build() - val response = client.newCall(request).execute() + Log.d(TAG, " Request headers: ${redactHeaders(request.headers)}") + + val response: Response + try { + response = client.newCall(request).execute() + } catch (e: IOException) { + Log.e(TAG, "DOWNLOAD $url – network error: ${e.message}", e) + throw e + } + val statusCode = response.code val headers = extractHeaders(response) + Log.d(TAG, "DOWNLOAD $url – $statusCode") + Log.d(TAG, " Response headers: ${redactResponseHeaders(response)}") if (statusCode !in 200..299) { - Log.e(TAG, "HTTP error downloading $url: $statusCode") + Log.e(TAG, "DOWNLOAD $url – HTTP error: $statusCode") throw EditorHTTPClientError.DownloadFailed(statusCode) } @@ -163,8 +192,14 @@ class EditorHTTPClient( input.copyTo(output) } } - Log.d(TAG, "Downloaded file: file=${destination.absolutePath}, size=${destination.length()} bytes, url=$url") - } ?: throw EditorHTTPClientError.DownloadFailed(statusCode) + Log.d(TAG, "DOWNLOAD $url – complete (${destination.length()} bytes)") + Log.d(TAG, " Saved to: ${destination.absolutePath}") + } ?: run { + Log.e(TAG, "DOWNLOAD $url – empty response body") + throw EditorHTTPClientError.DownloadFailed(statusCode) + } + + delegate?.didPerformRequest(url, EditorHttpMethod.GET, response, EditorResponseData.File(destination)) EditorHTTPClientDownloadResponse( file = destination, @@ -175,6 +210,8 @@ class EditorHTTPClient( override suspend fun perform(method: EditorHttpMethod, url: String): EditorHTTPClientResponse = withContext(Dispatchers.IO) { + Log.d(TAG, "$method $url") + // OkHttp requires a body for POST, PUT, PATCH methods // GET, HEAD, OPTIONS, DELETE don't require a body val requiresBody = method in listOf( @@ -190,7 +227,15 @@ class EditorHTTPClient( .method(method.toString(), requestBody) .build() - val response = client.newCall(request).execute() + Log.d(TAG, " Request headers: ${redactHeaders(request.headers)}") + + val response: Response + try { + response = client.newCall(request).execute() + } catch (e: IOException) { + Log.e(TAG, "$method $url – network error: ${e.message}", e) + throw e + } // Note: This loads the entire response into memory. This is acceptable because // this method is only used for WordPress REST API responses (editor settings, post @@ -200,14 +245,22 @@ class EditorHTTPClient( val statusCode = response.code val headers = extractHeaders(response) - delegate?.didPerformRequest(url, method, response, data) + Log.d(TAG, "$method $url – $statusCode (${data.size} bytes)") + Log.d(TAG, " Response headers: ${redactResponseHeaders(response)}") + + delegate?.didPerformRequest(url, method, response, EditorResponseData.Bytes(data)) if (statusCode !in 200..299) { - Log.e(TAG, "HTTP error fetching $url: $statusCode") + Log.e(TAG, "$method $url – HTTP error: $statusCode") + // Log the raw body to aid debugging unexpected error formats. + // This is acceptable because the WordPress REST API should never + // include sensitive information (tokens, credentials) in responses. + Log.e(TAG, " Response body: ${data.toString(Charsets.UTF_8)}") // Try to parse as WordPress error val wpError = tryParseWPError(data) if (wpError != null) { + Log.e(TAG, " WP error – code: ${wpError.code}, message: ${wpError.message}") throw EditorHTTPClientError.WPErrorResponse(wpError) } @@ -259,5 +312,27 @@ class EditorHTTPClient( companion object { private const val TAG = "EditorHTTPClient" private val gson = Gson() + + private val SENSITIVE_HEADERS = setOf("authorization", "cookie", "set-cookie") + + /** + * Returns a string representation of the given OkHttp headers with + * sensitive values (Authorization, Cookie) redacted. + */ + internal fun redactHeaders(headers: okhttp3.Headers): String { + return headers.joinToString(", ") { (name, value) -> + if (name.lowercase() in SENSITIVE_HEADERS) "$name: " else "$name: $value" + } + } + + /** + * Returns a string representation of the given response headers with + * sensitive values (Set-Cookie) redacted. + */ + internal fun redactResponseHeaders(response: Response): String { + return response.headers.joinToString(", ") { (name, value) -> + if (name.lowercase() in SENSITIVE_HEADERS) "$name: " else "$name: $value" + } + } } } diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt deleted file mode 100644 index 978cc5fc8..000000000 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt +++ /dev/null @@ -1,62 +0,0 @@ -package org.wordpress.gutenberg - -import org.wordpress.gutenberg.model.EditorProgress - -/** - * Callback interface for monitoring editor loading state. - * - * Implement this interface to receive updates about the editor's loading progress, - * allowing you to display appropriate UI (progress bar, spinner, etc.) while the - * editor initializes. - * - * ## Loading Flow - * - * When dependencies are **not provided** to `GutenbergView.start()`: - * 1. `onDependencyLoadingStarted()` - Begin showing progress bar - * 2. `onDependencyLoadingProgress()` - Update progress bar (called multiple times) - * 3. `onDependencyLoadingFinished()` - Hide progress bar, show spinner - * 4. `onEditorReady()` - Hide spinner, editor is usable - * - * When dependencies **are provided** to `GutenbergView.start()`: - * 1. `onDependencyLoadingFinished()` - Show spinner (no progress phase) - * 2. `onEditorReady()` - Hide spinner, editor is usable - */ -interface EditorLoadingListener { - /** - * Called when dependency loading begins. - * - * This is the appropriate time to show a progress bar to the user. - * Only called when dependencies were not provided to `start()`. - */ - fun onDependencyLoadingStarted() - - /** - * Called periodically with progress updates during dependency loading. - * - * @param progress The current loading progress with completed/total counts. - */ - fun onDependencyLoadingProgress(progress: EditorProgress) - - /** - * Called when dependency loading completes. - * - * This is the appropriate time to hide the progress bar and show a spinner - * while the WebView loads and parses the editor JavaScript. - */ - fun onDependencyLoadingFinished() - - /** - * Called when the editor has fully loaded and is ready for use. - * - * This is the appropriate time to hide all loading indicators and reveal - * the editor. The editor APIs are safe to call after this callback. - */ - fun onEditorReady() - - /** - * Called if dependency loading fails. - * - * @param error The exception that caused the failure. - */ - fun onDependencyLoadingFailed(error: Throwable) -} 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 348c32365..32c10632f 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -8,8 +8,8 @@ import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper -import android.util.AttributeSet import android.util.Log +import android.view.Gravity import android.view.inputmethod.InputMethodManager import android.webkit.ConsoleMessage import android.webkit.CookieManager @@ -22,16 +22,12 @@ import android.webkit.WebResourceResponse import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient -import androidx.lifecycle.coroutineScope -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope +import android.widget.FrameLayout +import android.widget.ProgressBar import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewAssetLoader.AssetsPathHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONException @@ -40,13 +36,24 @@ import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.GBKitGlobal import org.wordpress.gutenberg.services.EditorService +import org.wordpress.gutenberg.views.EditorErrorView +import org.wordpress.gutenberg.views.EditorProgressView +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.ByteArrayInputStream import java.util.Locale +import java.util.concurrent.TimeUnit const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" /** * A WebView-based Gutenberg block editor for Android. * + * This view manages its own loading UI internally (progress bar during dependency + * fetching, spinner during WebView initialization, error state on failure). + * Consumers do not need to implement loading UI — it is handled automatically. + * * ## Creating a GutenbergView * * This view must be created programmatically - XML layout inflation is not supported. @@ -82,15 +89,40 @@ const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" * - If `dependencies` is provided, the editor loads immediately (fast path) * - If `dependencies` is null, dependencies are fetched asynchronously before loading */ -class GutenbergView : WebView { +class GutenbergView : FrameLayout { + private val webView: WebView private var isEditorLoaded = false private var didFireEditorLoaded = false - private var assetLoader = WebViewAssetLoader.Builder() - .addPathHandler("/assets/", AssetsPathHandler(this.context)) - .build() + private lateinit var assetLoader: WebViewAssetLoader private val configuration: EditorConfiguration private lateinit var dependencies: EditorDependencies + /** + * Hosts that are allowed to receive credentials (Authorization header, cookies) + * through the CORS proxy. Derived from the editor configuration. + */ + private val trustedHosts: Set by lazy { + buildSet { + Uri.parse(configuration.siteApiRoot).host?.let { add(it) } + Uri.parse(configuration.siteURL).host?.let { add(it) } + add("public-api.wordpress.com") + // WordPress.com CDN hosts (Photon image proxy) + add("i0.wp.com") + add("i1.wp.com") + add("i2.wp.com") + add("i3.wp.com") + // Include any CDN hosts configured for asset caching + addAll(configuration.cachedAssetHosts) + } + } + + /** Shared OkHttp client for proxying WebView requests past CORS. */ + private val proxyClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .followRedirects(true) + .build() + private val handler = Handler(Looper.getMainLooper()) var filePathCallback: ValueCallback?>? = null val pickImageRequestCode = 1 @@ -107,7 +139,6 @@ class GutenbergView : WebView { private var autocompleterTriggeredListener: AutocompleterTriggeredListener? = null private var modalDialogStateListener: ModalDialogStateListener? = null private var networkRequestListener: NetworkRequestListener? = null - private var loadingListener: EditorLoadingListener? = null private var latestContentProvider: LatestContentProvider? = null /** @@ -118,12 +149,22 @@ class GutenbergView : WebView { private val coroutineScope: CoroutineScope + // Internal loading overlay views + private val progressView: EditorProgressView + private val spinnerView: ProgressBar + private val errorView: EditorErrorView + + /** + * Provides access to the internal WebView for tests and advanced use cases. + */ + val editorWebView: WebView get() = webView + var textEditorEnabled: Boolean = false set(value) { field = value val mode = if (value) "text" else "visual" handler.post { - this.evaluateJavascript("editor.switchEditorMode('$mode');", null) + webView.evaluateJavascript("editor.switchEditorMode('$mode');", null) } } @@ -171,10 +212,6 @@ class GutenbergView : WebView { editorDidBecomeAvailableListener = listener } - fun setEditorLoadingListener(listener: EditorLoadingListener?) { - loadingListener = listener - } - /** * Creates a new GutenbergView with the specified configuration. * @@ -190,58 +227,200 @@ class GutenbergView : WebView { this.configuration = configuration this.coroutineScope = coroutineScope + // Initialize the asset loader now that context is available + assetLoader = WebViewAssetLoader.Builder() + .addPathHandler("/assets/", AssetsPathHandler(context)) + .build() + + // Create the internal WebView as first child (behind overlays) + webView = WebView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + alpha = 0f + } + addView(webView) + + // Create loading overlay views + progressView = EditorProgressView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER) + loadingText = "Loading Editor..." + visibility = GONE + } + addView(progressView) + + spinnerView = ProgressBar(context).apply { + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER) + isIndeterminate = true + visibility = GONE + } + addView(spinnerView) + + errorView = EditorErrorView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER) + visibility = GONE + } + addView(errorView) + if (dependencies != null) { this.dependencies = dependencies // FAST PATH: Dependencies were provided - load immediately + Log.d(TAG, "Constructor: dependencies provided – using fast path") + showSpinnerPhase() loadEditor(dependencies) } else { // ASYNC FLOW: No dependencies - fetch them asynchronously + Log.d(TAG, "Constructor: no dependencies provided – using async path") + showProgressPhase() prepareAndLoadEditor() } } + /** + * Transitions to the progress bar phase (dependency fetching). + */ + private fun showProgressPhase() { + Log.d(TAG, "Phase transition -> PROGRESS (fetching dependencies)") + handler.post { + progressView.visibility = VISIBLE + spinnerView.visibility = GONE + errorView.visibility = GONE + webView.alpha = 0f + } + } + + /** + * Transitions to the spinner phase (WebView initialization). + */ + private fun showSpinnerPhase() { + Log.d(TAG, "Phase transition -> SPINNER (initializing WebView)") + handler.post { + progressView.animate().alpha(0f).setDuration(200).withEndAction { + progressView.visibility = GONE + }.start() + spinnerView.alpha = 0f + spinnerView.visibility = VISIBLE + spinnerView.animate().alpha(1f).setDuration(200).start() + errorView.visibility = GONE + webView.alpha = 0f + } + } + + /** + * Transitions to the ready phase (editor visible). + */ + private fun showReadyPhase() { + Log.d(TAG, "Phase transition -> READY (editor visible)") + handler.post { + spinnerView.animate().alpha(0f).setDuration(200).withEndAction { + spinnerView.visibility = GONE + }.start() + progressView.animate().alpha(0f).setDuration(200).withEndAction { + progressView.visibility = GONE + }.start() + errorView.visibility = GONE + webView.animate().alpha(1f).setDuration(200).start() + } + } + + /** + * Transitions to the error phase (loading failed). + */ + private fun showErrorPhase(error: Throwable) { + Log.d(TAG, "Phase transition -> ERROR: ${error.message}") + handler.post { + progressView.animate().alpha(0f).setDuration(200).withEndAction { + progressView.visibility = GONE + }.start() + spinnerView.animate().alpha(0f).setDuration(200).withEndAction { + spinnerView.visibility = GONE + }.start() + errorView.setError(error) + errorView.alpha = 0f + errorView.visibility = VISIBLE + errorView.animate().alpha(1f).setDuration(200).start() + webView.alpha = 0f + } + } + @SuppressLint("SetJavaScriptEnabled") // Without JavaScript we have no Gutenberg private fun initializeWebView() { - this.settings.javaScriptCanOpenWindowsAutomatically = true - this.settings.javaScriptEnabled = true - this.settings.domStorageEnabled = true - + Log.d(TAG, "initializeWebView: configuring WebView settings") + webView.settings.javaScriptCanOpenWindowsAutomatically = true + webView.settings.javaScriptEnabled = true + webView.settings.domStorageEnabled = true + // Set custom user agent - val defaultUserAgent = this.settings.userAgentString - this.settings.userAgentString = "$defaultUserAgent GutenbergKit/${GutenbergKitVersion.VERSION}" - - this.addJavascriptInterface(this, "editorDelegate") - this.visibility = GONE + val defaultUserAgent = webView.settings.userAgentString + webView.settings.userAgentString = "$defaultUserAgent GutenbergKit/${GutenbergKitVersion.VERSION}" + Log.d(TAG, "initializeWebView: user agent set to ${webView.settings.userAgentString}") + + Log.d(TAG, "initializeWebView: registering JavaScript interface 'editorDelegate'") + webView.addJavascriptInterface(this, "editorDelegate") - this.webViewClient = object : WebViewClient() { + webView.webViewClient = object : WebViewClient() { override fun onReceivedError( view: WebView?, request: WebResourceRequest?, error: WebResourceError? ) { - Log.e("GutenbergView", error.toString()) + Log.e(TAG, "onReceivedError: url=${request?.url}" + + " isMainFrame=${request?.isForMainFrame} error=$error") super.onReceivedError(view, request, error) } + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse? + ) { + Log.e(TAG, "onReceivedHttpError: url=${request?.url}" + + " status=${errorResponse?.statusCode} reason=${errorResponse?.reasonPhrase}") + super.onReceivedHttpError(view, request, errorResponse) + } + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + Log.d(TAG, "onPageStarted: url=$url") super.onPageStarted(view, url, favicon) setGlobalJavaScriptVariables() } + override fun onPageFinished(view: WebView?, url: String?) { + Log.d(TAG, "onPageFinished: url=$url") + super.onPageFinished(view, url) + } + override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { if (request.url == null) { + Log.d(TAG, "shouldInterceptRequest: null URL – passing to super") return super.shouldInterceptRequest(view, request) - } else if (request.url.host?.contains("appassets.androidplatform.net") == true) { + } + + // Serve bundled assets from the app package + if (request.url.host?.contains("appassets.androidplatform.net") == true) { + Log.d(TAG, "shouldInterceptRequest: asset URL – delegating to assetLoader: ${request.url}") return assetLoader.shouldInterceptRequest(request.url) - } else if (requestInterceptor.canIntercept(request)) { - return requestInterceptor.handleRequest(request) } - return super.shouldInterceptRequest(view, request) + // Try serving from the asset cache + if (requestInterceptor.canIntercept(request)) { + val cached = requestInterceptor.handleRequest(request) + if (cached != null) { + Log.d(TAG, "shouldInterceptRequest: served from cache: ${request.url}") + return cached + } + // Cache miss – fall through to proxy below + } + + // Proxy all other requests natively so they bypass CORS + // (the editor page is served from appassets.androidplatform.net, + // making every API call cross-origin). This doesn't use `EditorHTTPClient` because + // it's not actually a native call – the editor is building the request so we don't + // want to modify it in any way. + Log.d(TAG, "shouldInterceptRequest: proxying request: ${request.url}") + return proxyRequest(request) } override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest): Boolean { @@ -249,37 +428,44 @@ class GutenbergView : WebView { // Allow local file URLs if (url.scheme == "file") { + Log.d(TAG, "shouldOverrideUrlLoading: allowing file:// URL") return false } // Allow blob URLs (used by block inserter) if (url.scheme == "blob") { + Log.d(TAG, "shouldOverrideUrlLoading: allowing blob:// URL") return false } // Allow data URLs (used by block inserter) if (url.scheme == "data") { + Log.d(TAG, "shouldOverrideUrlLoading: allowing data: URL") return false } // Allow about:blank URLs if (url.scheme == "about") { + Log.d(TAG, "shouldOverrideUrlLoading: allowing about: URL") return false } // Allow asset URLs if (url.host == Uri.parse(ASSET_URL).host) { + Log.d(TAG, "shouldOverrideUrlLoading: allowing asset URL") return false } // Allow WordPress.com REST API if (url.host == "public-api.wordpress.com") { + Log.d(TAG, "shouldOverrideUrlLoading: allowing public-api.wordpress.com") return false } // Allow WordPress REST API if (url.host == configuration.siteApiRoot.removePrefix("https://").removePrefix("http://")) { if (url.path?.contains("/wp-json/") == true || url.query?.contains("rest_route=") == true) { + Log.d(TAG, "shouldOverrideUrlLoading: allowing site API request – $url") return false } } @@ -288,11 +474,13 @@ class GutenbergView : WebView { if (BuildConfig.GUTENBERG_EDITOR_URL.isNotEmpty()) { val editorUrl = Uri.parse(BuildConfig.GUTENBERG_EDITOR_URL) if (url.host == editorUrl.host) { + Log.d(TAG, "shouldOverrideUrlLoading: allowing dev server URL") return false } } // For all other URLs, open in external browser + Log.d(TAG, "shouldOverrideUrlLoading: opening in external browser – $url") val intent = Intent(Intent.ACTION_VIEW, url) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) view?.context?.startActivity(intent) @@ -300,12 +488,12 @@ class GutenbergView : WebView { } } - this.webChromeClient = object : WebChromeClient() { + webView.webChromeClient = object : WebChromeClient() { override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { if (consoleMessage != null) { - Log.i("GutenbergView", consoleMessage.message()) + Log.i(TAG, consoleMessage.message()) } else { - Log.i("GutenbergView", "null message") + Log.i(TAG, "null message") } return super.onConsoleMessage(consoleMessage) } @@ -341,39 +529,149 @@ class GutenbergView : WebView { } } + /** + * Proxies an HTTP request natively to bypass CORS restrictions. + * + * The editor page is served from `https://appassets.androidplatform.net`, so every + * `fetch()` call the JavaScript makes to the WordPress REST API is cross-origin. + * Rather than requiring the server to whitelist the synthetic WebView origin, this + * method intercepts the request in [shouldInterceptRequest] and performs it with + * OkHttp, which is not subject to CORS. OkHttp's connection pool handles cleanup + * automatically when the response stream is closed by the WebView. + */ + private fun proxyRequest(request: WebResourceRequest): WebResourceResponse? { + // Handle CORS preflight – return permissive headers immediately + // without forwarding the OPTIONS request to the server. + if (request.method.equals("OPTIONS", ignoreCase = true)) { + Log.d(TAG, "proxyRequest: CORS preflight for ${request.url}") + val requestedHeaders = request.requestHeaders?.get("Access-Control-Request-Headers") + return createCorsPreflightResponse(requestedHeaders) + } + + try { + val method = request.method ?: "GET" + val isTrustedHost = trustedHosts.contains(request.url.host) + + // OkHttp requires a body for POST/PUT/PATCH even if empty + val requiresBody = method in listOf("POST", "PUT", "PATCH") + val body = if (requiresBody) "".toRequestBody(null) else null + + val okRequestBuilder = Request.Builder() + .url(request.url.toString()) + .method(method, body) + + // Only forward credentials (Authorization header, cookies) to hosts + // we trust. Requests to other hosts are still proxied (e.g. CDN or + // embed fetches) but without credentials to prevent token leakage. + request.requestHeaders?.forEach { (key, value) -> + if (!isTrustedHost && key.equals("Authorization", ignoreCase = true)) { + Log.d(TAG, "proxyRequest: stripping Authorization header for untrusted host ${request.url.host}") + return@forEach + } + okRequestBuilder.addHeader(key, value) + } + + if (isTrustedHost) { + val cookies = CookieManager.getInstance().getCookie(request.url.toString()) + if (cookies != null) { + okRequestBuilder.addHeader("Cookie", cookies) + } + } + + val response = proxyClient.newCall(okRequestBuilder.build()).execute() + val statusCode = response.code + val reasonPhrase = response.message.ifEmpty { "OK" } + + // Parse Content-Type for the MIME type and charset + val contentTypeHeader = response.header("Content-Type") ?: "application/octet-stream" + val mimeType = contentTypeHeader.split(";").first().trim() + val charset = contentTypeHeader + .split(";") + .map { it.trim() } + .find { it.startsWith("charset=", ignoreCase = true) } + ?.substringAfter("=") + ?.trim() + ?: "UTF-8" + + val responseHeaders = mutableMapOf() + response.headers.forEach { (key, value) -> + responseHeaders[key] = value + } + + // Add CORS headers so the WebView allows the JavaScript to read the response + addCorsHeaders(responseHeaders) + + // OkHttp manages connections via its pool — when the WebView closes this + // stream, OkHttp automatically returns the connection to the pool. + val inputStream = response.body?.byteStream() ?: ByteArrayInputStream(ByteArray(0)) + + Log.d(TAG, "proxyRequest: $statusCode $method ${request.url}") + return WebResourceResponse(mimeType, charset, statusCode, reasonPhrase, responseHeaders, inputStream) + } catch (e: Exception) { + Log.e(TAG, "proxyRequest: failed to proxy ${request.url}", e) + return null + } + } + + /** + * Returns a synthetic 204 response for CORS preflight (OPTIONS) requests, + * echoing back whatever headers the JavaScript intends to send. + */ + private fun createCorsPreflightResponse(requestedHeaders: String?): WebResourceResponse { + val headers = mutableMapOf() + addCorsHeaders(headers) + headers["Access-Control-Max-Age"] = "86400" + if (!requestedHeaders.isNullOrEmpty()) { + headers["Access-Control-Allow-Headers"] = requestedHeaders + } + + return WebResourceResponse( + "text/plain", "UTF-8", 204, "No Content", + headers, ByteArrayInputStream(ByteArray(0)) + ) + } + + private fun addCorsHeaders(headers: MutableMap) { + val origin = originForUrl(BuildConfig.GUTENBERG_EDITOR_URL) + headers["Access-Control-Allow-Origin"] = origin + headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD" + headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept, X-WP-Nonce" + headers["Access-Control-Allow-Credentials"] = "true" + } + /** * Fetches all required dependencies and then loads the editor. * * This method is the entry point for the async flow when no dependencies were provided. */ private fun prepareAndLoadEditor() { - loadingListener?.onDependencyLoadingStarted() - - Log.i("GutenbergView", "Fetching dependencies...") + val prepareStartTime = System.currentTimeMillis() + Log.d(TAG, "prepareAndLoadEditor: starting async dependency fetch") coroutineScope.launch { - Log.i("GutenbergView", "In coroutine scope") - Log.i("GutenbergView", "Fetching dependencies in IO context") try { + Log.d(TAG, "prepareAndLoadEditor: creating EditorService") val editorService = EditorService.create( context = context, configuration = configuration, coroutineScope = coroutineScope ) - Log.i("GutenbergView", "Created editor service") - val fetchedDependencies = editorService.prepare { progress -> - loadingListener?.onDependencyLoadingProgress(progress) + Log.d(TAG, "prepareAndLoadEditor: EditorService created") - Log.i("GutenbergView", "Progress: $progress") + Log.d(TAG, "prepareAndLoadEditor: calling EditorService.prepare()") + val fetchedDependencies = editorService.prepare { progress -> + progressView.setProgress(progress) + Log.d(TAG, "prepareAndLoadEditor: progress ${progress.completed}/${progress.total}") } - Log.i("GutenbergView", "Finished fetching dependencies") + val elapsed = System.currentTimeMillis() - prepareStartTime + Log.d(TAG, "prepareAndLoadEditor: dependencies fetched in ${elapsed}ms") - // Store dependencies and load the editor loadEditor(fetchedDependencies) } catch (e: Exception) { - Log.e("GutenbergView", "Failed to load dependencies", e) - loadingListener?.onDependencyLoadingFailed(e) + val elapsed = System.currentTimeMillis() - prepareStartTime + Log.e(TAG, "prepareAndLoadEditor: failed after ${elapsed}ms", e) + showErrorPhase(e) } } } @@ -384,43 +682,57 @@ class GutenbergView : WebView { * This is the shared loading path used by both flows after dependencies are available. */ private fun loadEditor(dependencies: EditorDependencies) { + val loadStartTime = System.currentTimeMillis() + Log.d(TAG, "loadEditor: starting") + this.dependencies = dependencies // Set up asset caching + Log.d(TAG, "loadEditor: configuring CachedAssetRequestInterceptor" + + " (cachedAssetHosts=${configuration.cachedAssetHosts})") requestInterceptor = CachedAssetRequestInterceptor( dependencies.assetBundle, configuration.cachedAssetHosts ) - // Notify that dependency loading is complete (spinner phase begins) - loadingListener?.onDependencyLoadingFinished() + // Transition to spinner phase (WebView initialization) + showSpinnerPhase() + Log.d(TAG, "loadEditor: initializing WebView") initializeWebView() + Log.d(TAG, "loadEditor: WebView initialized") val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty { ASSET_URL } + Log.d(TAG, "loadEditor: editor URL = $editorUrl") + Log.d(TAG, "loadEditor: clearing WebStorage and cache") WebStorage.getInstance().deleteAllData() - this.clearCache(true) + webView.clearCache(true) + // All cookies are third-party cookies because the root of this document // lives under `https://appassets.androidplatform.net` - CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true) // Erase all local cookies before loading the URL – we don't want to persist // anything between uses – otherwise we might send the wrong cookies + Log.d(TAG, "loadEditor: clearing cookies and setting ${configuration.cookies.size}" + + " cookie(s) from configuration") CookieManager.getInstance().removeAllCookies { CookieManager.getInstance().flush() for (cookie in configuration.cookies) { CookieManager.getInstance().setCookie(cookie.key, cookie.value) } - this.loadUrl(editorUrl) - Log.i("GutenbergView", "Startup Complete") + val elapsed = System.currentTimeMillis() - loadStartTime + Log.d(TAG, "loadEditor: loading URL (setup took ${elapsed}ms)") + webView.loadUrl(editorUrl) } } private fun setGlobalJavaScriptVariables() { + Log.d(TAG, "setGlobalJavaScriptVariables: injecting GBKit configuration into WebView") val gbKit = GBKitGlobal.fromConfiguration(configuration, dependencies) val gbKitJson = gbKit.toJsonString() val gbKitConfig = """ @@ -428,7 +740,7 @@ class GutenbergView : WebView { localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); """.trimIndent() - this.evaluateJavascript(gbKitConfig, null) + webView.evaluateJavascript(gbKitConfig, null) } @@ -438,25 +750,25 @@ class GutenbergView : WebView { localStorage.removeItem('GBKit'); """.trimIndent() - this.evaluateJavascript(jsCode, null) + webView.evaluateJavascript(jsCode, null) } fun setContent(newContent: String) { if (!isEditorLoaded) { - Log.e("GutenbergView", "You can't change the editor content until it has loaded") + Log.e(TAG, "You can't change the editor content until it has loaded") return } val encodedContent = newContent.encodeForEditor() - this.evaluateJavascript("editor.setContent('$encodedContent');", null) + webView.evaluateJavascript("editor.setContent('$encodedContent');", null) } fun setTitle(newTitle: String) { if (!isEditorLoaded) { - Log.e("GutenbergView", "You can't change the editor content until it has loaded") + Log.e(TAG, "You can't change the editor content until it has loaded") return } val encodedTitle = newTitle.encodeForEditor() - this.evaluateJavascript("editor.setTitle('$encodedTitle');", null) + webView.evaluateJavascript("editor.setTitle('$encodedTitle');", null) } interface TitleAndContentCallback { @@ -541,11 +853,11 @@ class GutenbergView : WebView { fun getTitleAndContent(originalContent: CharSequence, callback: TitleAndContentCallback, completeComposition: Boolean = false) { if (!isEditorLoaded) { - Log.e("GutenbergView", "You can't change the editor content until it has loaded") + Log.e(TAG, "You can't change the editor content until it has loaded") return } handler.post { - this.evaluateJavascript("editor.getTitleAndContent($completeComposition);") { result -> + webView.evaluateJavascript("editor.getTitleAndContent($completeComposition);") { result -> var lastUpdatedTitle: CharSequence? = null var lastUpdatedContent: CharSequence? = null var changed = false @@ -555,7 +867,7 @@ class GutenbergView : WebView { lastUpdatedContent = jsonObject.getString("content") changed = jsonObject.getBoolean("changed") } catch (e: JSONException) { - Log.e("GutenbergView", "Received invalid JSON from editor.getTitleAndContent") + Log.e(TAG, "Received invalid JSON from editor.getTitleAndContent") } val title = lastUpdatedTitle ?: "" @@ -571,58 +883,53 @@ class GutenbergView : WebView { fun undo() { handler.post { - this.evaluateJavascript("editor.undo();", null) + webView.evaluateJavascript("editor.undo();", null) } } fun redo() { handler.post { - this.evaluateJavascript("editor.redo();", null) + webView.evaluateJavascript("editor.redo();", null) } } fun dismissTopModal() { handler.post { - this.evaluateJavascript("editor.dismissTopModal();", null) + webView.evaluateJavascript("editor.dismissTopModal();", null) } } fun appendTextAtCursor(text: String) { if (!isEditorLoaded) { - Log.e("GutenbergView", "You can't append text until the editor has loaded") + Log.e(TAG, "You can't append text until the editor has loaded") return } val encodedText = text.encodeForEditor() handler.post { - this.evaluateJavascript("editor.appendTextAtCursor(decodeURIComponent('$encodedText'));", null) + webView.evaluateJavascript("editor.appendTextAtCursor(decodeURIComponent('$encodedText'));", null) } } @JavascriptInterface fun onEditorLoaded() { - Log.i("GutenbergView", "EditorLoaded received in native code") + Log.d(TAG, "onEditorLoaded: received from JavaScript (didFireEditorLoaded=$didFireEditorLoaded)") isEditorLoaded = true handler.post { if(!didFireEditorLoaded) { - loadingListener?.onEditorReady() + Log.d(TAG, "onEditorLoaded: notifying EditorAvailableListener and transitioning to ready") editorDidBecomeAvailableListener?.onEditorAvailable(this) this.didFireEditorLoaded = true - this.visibility = VISIBLE - this.alpha = 0f - this.animate() - .alpha(1f) - .setDuration(300) - .start() + showReadyPhase() if (configuration.content.isEmpty()) { // Focus the editor content - this.evaluateJavascript("editor.focus();", null) + webView.evaluateJavascript("editor.focus();", null) // Request focus on the WebView and show the soft keyboard handler.postDelayed({ - this.requestFocus() + webView.requestFocus() val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + imm?.showSoftInput(webView, InputMethodManager.SHOW_IMPLICIT) }, 100) } } @@ -647,9 +954,9 @@ class GutenbergView : WebView { @JavascriptInterface fun onBlocksChanged(isEmpty: Boolean) { if(isEmpty) { - Log.i("GutenbergView", "BlocksChanged (empty)") + Log.i(TAG, "BlocksChanged (empty)") } else { - Log.i("GutenbergView", "BlocksChanged (not empty)") + Log.i(TAG, "BlocksChanged (not empty)") } } @@ -698,18 +1005,18 @@ class GutenbergView : WebView { fun setMediaUploadAttachment(media: String) { if (!isEditorLoaded) { - Log.e("GutenbergView", "You can't change the editor content until it has loaded") + Log.e(TAG, "You can't change the editor content until it has loaded") return } val contextId = currentMediaContextId if (contextId == null) { - Log.e("GutenbergView", "setMediaUploadAttachment called without contextId") + Log.e(TAG, "setMediaUploadAttachment called without contextId") return } val escapedContextId = contextId.replace("'", "\\'") - this.evaluateJavascript("editor.setMediaUploadAttachment($media, '$escapedContextId');", null) + webView.evaluateJavascript("editor.setMediaUploadAttachment($media, '$escapedContextId');", null) currentMediaContextId = null } @@ -722,7 +1029,7 @@ class GutenbergView : WebView { @JavascriptInterface fun showBlockPicker() { - Log.i("GutenbergView", "BlockPickerShouldShow") + Log.i(TAG, "BlockPickerShouldShow") } @JavascriptInterface @@ -754,7 +1061,7 @@ class GutenbergView : WebView { val request = RecordedNetworkRequest.fromJson(json) networkRequestListener?.onNetworkRequest(request) } catch (e: Exception) { - Log.e("GutenbergView", "Error parsing network request: ${e.message}") + Log.e(TAG, "Error parsing network request: ${e.message}") } } } @@ -776,7 +1083,7 @@ class GutenbergView : WebView { put("content", content.content) }.toString() } catch (e: JSONException) { - Log.e("GutenbergView", "Failed to serialize latest content", e) + Log.e(TAG, "Failed to serialize latest content", e) null } } @@ -829,15 +1136,15 @@ class GutenbergView : WebView { if (uri.scheme == "content") { if (FileCache.isKnownSafeLocalProvider(uri)) { - Log.i("GutenbergView", "Using local provider URI directly: $uri") + Log.i(TAG, "Using local provider URI directly: $uri") uri } else { val cachedUri = FileCache.copyToCache(context, uri) if (cachedUri != null) { - Log.i("GutenbergView", "Copied content URI to cache: $uri -> $cachedUri") + Log.i(TAG, "Copied content URI to cache: $uri -> $cachedUri") cachedUri } else { - Log.w("GutenbergView", "Failed to copy content URI to cache, using original: $uri") + Log.w(TAG, "Failed to copy content URI to cache, using original: $uri") uri } } @@ -851,13 +1158,20 @@ class GutenbergView : WebView { override fun onDetachedFromWindow() { super.onDetachedFromWindow() clearConfig() - this.stopLoading() + // Cancel in-flight animations to prevent withEndAction callbacks from + // firing on detached views. + progressView.animate().cancel() + spinnerView.animate().cancel() + errorView.animate().cancel() + webView.animate().cancel() + webView.stopLoading() FileCache.clearCache(context) contentChangeListener = null historyChangeListener = null featuredImageChangeListener = null + openMediaLibraryListener = null + logJsExceptionListener = null editorDidBecomeAvailableListener = null - loadingListener = null filePathCallback = null onFileChooserRequested = null autocompleterTriggeredListener = null @@ -866,11 +1180,22 @@ class GutenbergView : WebView { requestInterceptor = DefaultGutenbergRequestInterceptor() latestContentProvider = null handler.removeCallbacksAndMessages(null) - this.destroy() + webView.destroy() } companion object { - private const val ASSET_LOADING_TIMEOUT_MS = 5000L + private const val TAG = "GutenbergView" + private const val DEFAULT_ORIGIN = "https://appassets.androidplatform.net" + + internal fun originForUrl(editorUrl: String): String { + if (editorUrl.isEmpty()) return DEFAULT_ORIGIN + return Uri.parse(editorUrl).buildUpon() + .path(null) + .clearQuery() + .fragment(null) + .build() + .toString() + } // Warmup state management private var warmupHandler: Handler? = null @@ -881,10 +1206,10 @@ class GutenbergView : WebView { * Clean up warmup resources. */ private fun cleanupWarmup() { - warmupWebView?.let { webView -> - webView.stopLoading() - webView.clearConfig() - webView.destroy() + warmupWebView?.let { view -> + view.webView.stopLoading() + view.clearConfig() + view.webView.destroy() } warmupWebView = null warmupHandler = null diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt index 7197695e5..3f5f76dd9 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt @@ -24,10 +24,14 @@ class RESTAPIRepository( private val json = Json { ignoreUnknownKeys = true } private val apiRoot = configuration.siteApiRoot.trimEnd('/') - private val editorSettingsUrl = "$apiRoot$EDITOR_SETTINGS_PATH" - private val activeThemeUrl = "$apiRoot$ACTIVE_THEME_PATH" - private val siteSettingsUrl = "$apiRoot$SITE_SETTINGS_PATH" - private val postTypesUrl = "$apiRoot$POST_TYPES_PATH" + private val namespace = configuration.siteApiNamespace.firstOrNull()?.let { + it.trimEnd('/') + "/" + } + + private val editorSettingsUrl = buildNamespacedURL(EDITOR_SETTINGS_PATH) + private val activeThemeUrl = buildNamespacedURL(ACTIVE_THEME_PATH) + private val siteSettingsUrl = buildNamespacedURL(SITE_SETTINGS_PATH) + private val postTypesUrl = buildNamespacedURL(POST_TYPES_PATH) /** * Cleanup any expired cache entries. @@ -72,7 +76,7 @@ class RESTAPIRepository( } private fun buildPostUrl(id: Int): String { - return "$apiRoot/wp/v2/posts/$id?context=edit" + return buildNamespacedURL("/wp/v2/posts/$id?context=edit") } // MARK: Editor Settings @@ -139,7 +143,7 @@ class RESTAPIRepository( } private fun buildPostTypeUrl(type: String): String { - return "$apiRoot/wp/v2/types/$type?context=edit" + return buildNamespacedURL("/wp/v2/types/$type?context=edit") } // MARK: GET Active Theme @@ -212,6 +216,35 @@ class RESTAPIRepository( return urlResponse } + /** + * Builds a URL by inserting the namespace after the version segment of the path. + * For example: `/wp/v2/posts` with namespace `sites/123/` becomes `/wp/v2/sites/123/posts` + */ + private fun buildNamespacedURL(path: String): String { + if (namespace == null) { + return "$apiRoot$path" + } + + // Path format is typically: /prefix/version/endpoint + // e.g., /wp/v2/posts or /wp-block-editor/v1/settings + val components = path.trimStart('/').split("/", limit = 3) + if (components.size < 2) { + return "$apiRoot$path" + } + + val prefix = components[0] + val version = components[1] + val remainder = if (components.size > 2) components[2] else "" + + // Insert namespace after the version segment + // e.g., /wp/v2/settings -> /wp/v2/sites/123/settings + return if (remainder.isEmpty()) { + "$apiRoot/$prefix/$version/$namespace" + } else { + "$apiRoot/$prefix/$version/$namespace$remainder" + } + } + companion object { private const val EDITOR_SETTINGS_PATH = "/wp-block-editor/v1/settings" private const val ACTIVE_THEME_PATH = "/wp/v2/themes?context=edit&status=active" diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 2e44cbaef..40bfa7d7a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -27,6 +27,12 @@ data class EditorConfiguration( val enableAssetCaching: Boolean = false, val cachedAssetHosts: Set = emptySet(), val editorAssetsEndpoint: String? = null, + /** + * Enables the JavaScript editor to surface network request/response details + * to the native host app (via the bridge). This does **not** control the + * native [EditorHTTPClient][org.wordpress.gutenberg.EditorHTTPClient]'s own + * debug logging, which always runs at the platform debug level. + */ val enableNetworkLogging: Boolean = false, var enableOfflineMode: Boolean = false, ): Parcelable { @@ -76,7 +82,7 @@ data class EditorConfiguration( fun setTitle(title: String) = apply { this.title = title } fun setContent(content: String) = apply { this.content = content } - fun setPostId(postId: Int?) = apply { this.postId = postId } + fun setPostId(postId: Int?) = apply { this.postId = postId.takeIf { it != 0 } } fun setPostType(postType: String) = apply { this.postType = postType } fun setPostStatus(postStatus: String) = apply { this.postStatus = postStatus } fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles } @@ -99,7 +105,7 @@ data class EditorConfiguration( fun build(): EditorConfiguration = EditorConfiguration( title = title, content = content, - postId = postId, + postId = postId.takeIf { it != 0 }, postType = postType, postStatus = postStatus, themeStyles = themeStyles, diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/views/EditorErrorView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/views/EditorErrorView.kt new file mode 100644 index 000000000..995845b76 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/views/EditorErrorView.kt @@ -0,0 +1,84 @@ +package org.wordpress.gutenberg.views + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.widget.TextViewCompat + +/** + * A view displaying an error state with an icon, title, and description. + * + * This view is used inside [org.wordpress.gutenberg.GutenbergView] to show + * an error when editor dependencies fail to load. + * + * ## Usage + * + * ```kotlin + * val errorView = EditorErrorView(context) + * errorView.setError(exception) + * ``` + */ +class EditorErrorView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val icon: ImageView + private val titleText: TextView + private val descriptionText: TextView + + init { + orientation = VERTICAL + gravity = Gravity.CENTER + + // Create error icon + icon = ImageView(context).apply { + layoutParams = LayoutParams(dpToPx(48), dpToPx(48)) + setImageResource(android.R.drawable.ic_dialog_alert) + } + + // Create title + titleText = TextView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + topMargin = dpToPx(16) + marginStart = dpToPx(16) + marginEnd = dpToPx(16) + } + gravity = Gravity.CENTER + TextViewCompat.setTextAppearance(this, android.R.style.TextAppearance_Material_Subhead) + text = "Failed to load editor" + } + + // Create description + descriptionText = TextView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + topMargin = dpToPx(8) + marginStart = dpToPx(16) + marginEnd = dpToPx(16) + } + gravity = Gravity.CENTER + TextViewCompat.setTextAppearance(this, android.R.style.TextAppearance_Material_Body1) + } + + addView(icon) + addView(titleText) + addView(descriptionText) + } + + /** + * Updates the error view with the given error. + * + * @param error The exception that caused the failure. + */ + fun setError(error: Throwable) { + descriptionText.text = error.message ?: "Unknown error" + } + + private fun dpToPx(dp: Int): Int { + return (dp * context.resources.displayMetrics.density).toInt() + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt index 667e00ea8..ebc480b32 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt @@ -263,10 +263,10 @@ class EditorHTTPClientTest { var delegateCalled = false var capturedUrl: String? = null var capturedMethod: EditorHttpMethod? = null - var capturedData: ByteArray? = null + var capturedData: EditorResponseData? = null val delegate = object : EditorHTTPClientDelegate { - override fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: ByteArray) { + override fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: EditorResponseData) { delegateCalled = true capturedUrl = url capturedMethod = method @@ -280,7 +280,8 @@ class EditorHTTPClientTest { assertTrue(delegateCalled) assertTrue(capturedUrl?.contains("test") == true) assertEquals(EditorHttpMethod.GET, capturedMethod) - assertEquals("response data", capturedData?.toString(Charsets.UTF_8)) + val bytes = (capturedData as? EditorResponseData.Bytes)?.data + assertEquals("response data", bytes?.toString(Charsets.UTF_8)) } @Test diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt index b4e15c0c4..b8759203f 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt @@ -65,7 +65,7 @@ class GutenbergViewTest { } // When - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -105,7 +105,7 @@ class GutenbergViewTest { // When `when`(mockFileChooserParams.mode).thenReturn(WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE) - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -131,7 +131,7 @@ class GutenbergViewTest { @Test fun `onShowFileChooser stores file path callback`() { // When - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -145,7 +145,7 @@ class GutenbergViewTest { @Test fun `resetFilePathCallback clears the callback`() { // Given - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -159,13 +159,55 @@ class GutenbergViewTest { null, gutenbergView.filePathCallback) } + // MARK: - originForUrl Tests + + @Test + fun `originForUrl returns default origin for empty string`() { + assertEquals( + "https://appassets.androidplatform.net", + GutenbergView.originForUrl("") + ) + } + + @Test + fun `originForUrl extracts origin from URL with path`() { + assertEquals( + "https://example.com", + GutenbergView.originForUrl("https://example.com/some/path") + ) + } + + @Test + fun `originForUrl retains port`() { + assertEquals( + "http://localhost:8080", + GutenbergView.originForUrl("http://localhost:8080/some/path") + ) + } + + @Test + fun `originForUrl strips query and fragment`() { + assertEquals( + "https://example.com", + GutenbergView.originForUrl("https://example.com/path?query=1#fragment") + ) + } + + @Test + fun `originForUrl handles URL with no path`() { + assertEquals( + "https://example.com", + GutenbergView.originForUrl("https://example.com") + ) + } + @Test fun `GutenbergView sets custom user agent with GutenbergKit identifier`() { // The user agent is set during construction, so we can verify it on the gutenbergView // that was already set up in the @Before method // Then - val userAgent = gutenbergView.settings.userAgentString + val userAgent = gutenbergView.editorWebView.settings.userAgentString assertTrue("User agent should contain GutenbergKit identifier", userAgent.contains("GutenbergKit/")) assertTrue("User agent should contain version number", diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt index e8afa5c3a..f49d9acb5 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt @@ -342,6 +342,131 @@ class RESTAPIRepositoryTest { } } + @Test + fun `URLs include namespace when siteApiNamespace is set`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + + val configuration = EditorConfiguration.builder( + TEST_SITE_URL, + "https://public-api.wordpress.com/wp-json", + "post" + ) + .setSiteApiNamespace(arrayOf("sites/123/")) + .setPlugins(true) + .setThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository(configuration, capturingClient, cache) + + repository.fetchPost(id = 1) + repository.fetchPostType("post") + repository.fetchActiveTheme() + repository.fetchPostTypes() + + val expectedURLs = setOf( + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/posts/1?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types/post?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/themes?context=edit&status=active", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types?context=view" + ) + + assertEquals(expectedURLs, capturedURLs.toSet()) + } + + @Test + fun `URLs include namespace without trailing slash`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + + val configuration = EditorConfiguration.builder( + TEST_SITE_URL, + "https://public-api.wordpress.com/wp-json", + "post" + ) + .setSiteApiNamespace(arrayOf("sites/123")) // No trailing slash + .setPlugins(true) + .setThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository(configuration, capturingClient, cache) + + repository.fetchPost(id = 1) + repository.fetchPostType("post") + repository.fetchActiveTheme() + repository.fetchPostTypes() + + val expectedURLs = setOf( + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/posts/1?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types/post?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/themes?context=edit&status=active", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types?context=view" + ) + + assertEquals(expectedURLs, capturedURLs.toSet()) + } + + @Test + fun `editor settings URL includes namespace`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + + val configuration = EditorConfiguration.builder( + TEST_SITE_URL, + "https://public-api.wordpress.com/wp-json", + "post" + ) + .setSiteApiNamespace(arrayOf("sites/456/")) + .setPlugins(true) + .setThemeStyles(true) + .setAuthHeader("Bearer test") + .setEditorSettings(null) + .build() + + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository(configuration, capturingClient, cache) + + repository.fetchEditorSettings() + + assertEquals(1, capturedURLs.size) + assertEquals( + "https://public-api.wordpress.com/wp-json/wp-block-editor/v1/sites/456/settings", + capturedURLs.first() + ) + } + + @Test + fun `settings options URL includes namespace`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + + val configuration = EditorConfiguration.builder( + TEST_SITE_URL, + "https://public-api.wordpress.com/wp-json", + "post" + ) + .setSiteApiNamespace(arrayOf("sites/789/")) + .setPlugins(true) + .setThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository(configuration, capturingClient, cache) + + repository.fetchSettingsOptions() + + assertEquals(1, capturedURLs.size) + assertEquals( + "https://public-api.wordpress.com/wp-json/wp/v2/sites/789/settings", + capturedURLs.first() + ) + } + @Test fun `post URL includes context=edit query parameter`() = runBlocking { var capturedURL: String? = null diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index e3f9cd0a6..57e414465 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -78,6 +78,15 @@ class EditorConfigurationBuilderTest { assertEquals(123, config.postId) } + @Test + fun `setPostId with zero results in null`() { + val config = builder() + .setPostId(0) + .build() + + assertNull(config.postId) + } + @Test fun `setPostId with null clears postId`() { val config = builder() diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 78ed9dce4..9ff8ac71c 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -13,15 +13,10 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Redo @@ -38,14 +33,11 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.lifecycleScope import com.example.gutenbergkit.ui.theme.AppTheme @@ -53,11 +45,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.GutenbergView -import org.wordpress.gutenberg.EditorLoadingListener import org.wordpress.gutenberg.RecordedNetworkRequest import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.EditorDependenciesSerializer -import org.wordpress.gutenberg.model.EditorProgress class EditorActivity : ComponentActivity() { @@ -126,20 +116,6 @@ class EditorActivity : ComponentActivity() { } } -/** - * Loading state for the editor. - */ -enum class EditorLoadingState { - /** Dependencies are being loaded from the network */ - LOADING_DEPENDENCIES, - /** Dependencies loaded, waiting for WebView to initialize */ - LOADING_EDITOR, - /** Editor is fully ready */ - READY, - /** Loading failed with an error */ - ERROR -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( @@ -156,16 +132,6 @@ fun EditorScreen( var isCodeEditorEnabled by remember { mutableStateOf(false) } var gutenbergViewRef by remember { mutableStateOf(null) } - // Loading state - var loadingState by remember { - mutableStateOf( - if (dependencies != null) EditorLoadingState.LOADING_EDITOR - else EditorLoadingState.LOADING_DEPENDENCIES - ) - } - var loadingProgress by remember { mutableFloatStateOf(0f) } - var loadingError by remember { mutableStateOf(null) } - BackHandler(enabled = isModalDialogOpen) { gutenbergViewRef?.dismissTopModal() } @@ -293,7 +259,7 @@ fun EditorScreen( }) setNetworkRequestListener(object : GutenbergView.NetworkRequestListener { override fun onNetworkRequest(request: RecordedNetworkRequest) { - Log.d("EditorActivity", "🌐 Network Request: ${request.method} ${request.url}") + Log.d("EditorActivity", "Network Request: ${request.method} ${request.url}") Log.d("EditorActivity", " Status: ${request.status} ${request.statusText}, Duration: ${request.duration}ms") // Log request headers @@ -321,29 +287,6 @@ fun EditorScreen( } } }) - setEditorLoadingListener(object : EditorLoadingListener { - override fun onDependencyLoadingStarted() { - loadingState = EditorLoadingState.LOADING_DEPENDENCIES - loadingProgress = 0f - } - - override fun onDependencyLoadingProgress(progress: EditorProgress) { - loadingProgress = progress.fractionCompleted.toFloat() - } - - override fun onDependencyLoadingFinished() { - loadingState = EditorLoadingState.LOADING_EDITOR - } - - override fun onEditorReady() { - loadingState = EditorLoadingState.READY - } - - override fun onDependencyLoadingFailed(error: Throwable) { - loadingState = EditorLoadingState.ERROR - loadingError = error.message ?: "Unknown error" - } - }) // Demo app has no persistence layer, so return null. // In a real app, return the persisted title and content from autosave. setLatestContentProvider(object : GutenbergView.LatestContentProvider { @@ -358,63 +301,5 @@ fun EditorScreen( .fillMaxSize() .padding(innerPadding) ) - - // Loading overlay - when (loadingState) { - EditorLoadingState.LOADING_DEPENDENCIES -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - LinearProgressIndicator( - progress = { loadingProgress }, - modifier = Modifier.fillMaxWidth(0.6f) - ) - Text("Loading Editor...") - } - } - } - EditorLoadingState.LOADING_EDITOR -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator() - Text("Starting Editor...") - } - } - } - EditorLoadingState.ERROR -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("Failed to load editor") - loadingError?.let { Text(it) } - } - } - } - EditorLoadingState.READY -> { - // Editor is ready, no overlay needed - } - } } } diff --git a/android/app/src/main/res/layout/activity_editor.xml b/android/app/src/main/res/layout/activity_editor.xml deleted file mode 100644 index c39ed4db4..000000000 --- a/android/app/src/main/res/layout/activity_editor.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 3f0d56c56..af15553bd 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.7.3" +agp = "8.10.1" kotlin = "2.0.21" kotlinx-serialization = "1.7.3" coreKtx = "1.13.1" diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift index 431a03828..474c5d15c 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift @@ -73,15 +73,34 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { public func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { let configuredRequest = self.configureRequest(urlRequest) - let (data, response) = try await self.urlSession.data(for: configuredRequest) + let url = configuredRequest.url!.absoluteString + let method = configuredRequest.httpMethod ?? "GET" + Logger.http.debug("📡 \(method) \(url)") + Logger.http.debug("📡 Request headers: \(self.redactHeaders(configuredRequest.allHTTPHeaderFields))") + + let (data, response) : (Data, URLResponse) + do { + (data, response) = try await self.urlSession.data(for: configuredRequest) + } catch { + Logger.http.error("📡 \(method) \(url) – network error: \(error.localizedDescription)") + throw error + } + self.delegate?.didPerformRequest(configuredRequest, response: response, data: .bytes(data)) let httpResponse = response as! HTTPURLResponse + Logger.http.debug("📡 \(method) \(url) – \(httpResponse.statusCode) (\(data.count) bytes)") + Logger.http.debug("📡 Response headers: \(self.redactHeaders(httpResponse.allHeaderFields))") guard 200...299 ~= httpResponse.statusCode else { - Logger.http.error("📡 HTTP error fetching \(configuredRequest.url!.absoluteString): \(httpResponse.statusCode)") + Logger.http.error("📡 \(method) \(url) – HTTP error: \(httpResponse.statusCode)") + // Log the raw body to aid debugging unexpected error formats. + // This is acceptable because the WordPress REST API should never + // include sensitive information (tokens, credentials) in responses. + Logger.http.error("📡 Response body: \(String(data: data, encoding: .utf8) ?? "")") if let wpError = try? JSONDecoder().decode(WPError.self, from: data) { + Logger.http.error("📡 WP error – code: \(wpError.code), message: \(wpError.message)") throw ClientError.wpError(wpError) } @@ -94,13 +113,27 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { public func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) { let configuredRequest = self.configureRequest(urlRequest) - let (url, response) = try await self.urlSession.download(for: configuredRequest, delegate: nil) + let requestURL = configuredRequest.url!.absoluteString + Logger.http.debug("📡 DOWNLOAD \(requestURL)") + Logger.http.debug("📡 Request headers: \(self.redactHeaders(configuredRequest.allHTTPHeaderFields))") + + let (url, response): (URL, URLResponse) + do { + (url, response) = try await self.urlSession.download(for: configuredRequest, delegate: nil) + } catch { + Logger.http.error("📡 DOWNLOAD \(requestURL) – network error: \(error.localizedDescription)") + throw error + } + self.delegate?.didPerformRequest(configuredRequest, response: response, data: .file(url)) let httpResponse = response as! HTTPURLResponse + Logger.http.debug("📡 DOWNLOAD \(requestURL) – \(httpResponse.statusCode)") + Logger.http.debug("📡 Downloaded to: \(url.path)") + Logger.http.debug("📡 Response headers: \(self.redactHeaders(httpResponse.allHeaderFields))") guard 200...299 ~= httpResponse.statusCode else { - Logger.http.error("📡 HTTP error fetching \(configuredRequest.url!.absoluteString): \(httpResponse.statusCode)") + Logger.http.error("📡 DOWNLOAD \(requestURL) – HTTP error: \(httpResponse.statusCode)") throw ClientError.downloadFailed(statusCode: httpResponse.statusCode) } @@ -108,6 +141,24 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { return (url, response as! HTTPURLResponse) } + private static let sensitiveHeaders: Set = ["authorization", "cookie", "set-cookie"] + + private func redactHeaders(_ headers: [String: String]?) -> String { + guard let headers else { return "[:]" } + let redacted = headers.map { key, value in + Self.sensitiveHeaders.contains(key.lowercased()) ? "\(key): " : "\(key): \(value)" + } + return "[\(redacted.joined(separator: ", "))]" + } + + private func redactHeaders(_ headers: [AnyHashable: Any]) -> String { + let redacted = headers.map { key, value in + let name = "\(key)" + return Self.sensitiveHeaders.contains(name.lowercased()) ? "\(name): " : "\(name): \(value)" + } + return "[\(redacted.joined(separator: ", "))]" + } + private func configureRequest(_ request: URLRequest) -> URLRequest { var mutableRequest = request mutableRequest.addValue(self.authHeader, forHTTPHeaderField: "Authorization") diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift index d2fd85779..d9e37f2ef 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift @@ -44,7 +44,10 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { public let editorAssetsEndpoint: URL? /// Logs emitted at or above this level will be printed to the debug console public let logLevel: EditorLogLevel - /// Enables logging of all network requests/responses to the native host + /// Enables the JavaScript editor to surface network request/response details + /// to the native host app (via the bridge). This does **not** control the + /// native `EditorHTTPClient`'s own debug logging, which always runs at the + /// platform debug level and is stripped from release builds. public let enableNetworkLogging: Bool /// Don't make HTTP requests public let isOfflineModeEnabled: Bool @@ -77,7 +80,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { ) { self.title = title self.content = content - self.postID = postID + self.postID = postID == 0 ? nil : postID self.postType = postType self.postStatus = postStatus self.shouldUseThemeStyles = shouldUseThemeStyles diff --git a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift index 36aa673d3..089ed4681 100644 --- a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift +++ b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift @@ -61,10 +61,12 @@ public struct RESTAPIRepository: Sendable { /// Builds a URL by inserting the namespace after the version segment of the path. /// For example: `/wp/v2/posts` with namespace `sites/123/` becomes `/wp/v2/sites/123/posts` private static func buildNamespacedURL(apiRoot: URL, path: String, namespace: String?) -> URL { - guard let namespace = namespace else { + guard let rawNamespace = namespace else { return apiRoot.appending(rawPath: path) } + let namespace = rawNamespace.hasSuffix("/") ? rawNamespace : rawNamespace + "/" + // Parse the path to find where to insert the namespace // Path format is typically: /prefix/version/endpoint (e.g., /wp/v2/posts or /wp-block-editor/v1/settings) let components = path.split(separator: "/", omittingEmptySubsequences: true) diff --git a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift index 2e0571501..1477057c3 100644 --- a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift @@ -65,6 +65,15 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { #expect(config.postID == 123) } + @Test("setPostID with zero results in nil") + func setPostIDWithZeroResultsInNil() { + let config = makeConfigurationBuilder() + .setPostID(0) + .build() + + #expect(config.postID == nil) + } + @Test("setPostID with nil clears postID") func setPostIDWithNilClearsPostID() { let config = makeConfigurationBuilder() diff --git a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift index e0002d6c5..d11562f1e 100644 --- a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift +++ b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift @@ -249,6 +249,142 @@ struct RESTAPIRepositoryTests: MakesTestFixtures { #expect(capturedURL?.absoluteString.contains("context=edit") == true) #expect(capturedURL?.absoluteString.contains("/posts/42") == true) } + + @Test("URLs include namespace when siteApiNamespace is set") + func urlsIncludeNamespace() async throws { + let capturingClient = URLCollectingMockHTTPClient() + + let configuration = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://public-api.wordpress.com")!, + siteApiRoot: URL(string: "https://public-api.wordpress.com/wp-json")!, + siteApiNamespace: ["sites/123/"] + ) + .setShouldUsePlugins(true) + .setShouldUseThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + let cache = EditorURLCache(cacheRoot: .randomTemporaryDirectory) + let repository = RESTAPIRepository( + configuration: configuration, + httpClient: capturingClient, + cache: cache + ) + + _ = try await repository.fetchPost(id: 1) + _ = try await repository.fetchPostType(for: "post") + _ = try await repository.fetchActiveTheme() + _ = try await repository.fetchPostTypes() + + let expectedURLs: Set = [ + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/posts/1?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types/post?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/themes?context=edit&status=active", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types?context=view", + ] + + #expect(Set(await capturingClient.capturedURLs) == expectedURLs) + } + + @Test("URLs include namespace without trailing slash") + func urlsIncludeNamespaceWithoutTrailingSlash() async throws { + let capturingClient = URLCollectingMockHTTPClient() + + let configuration = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://public-api.wordpress.com")!, + siteApiRoot: URL(string: "https://public-api.wordpress.com/wp-json")!, + siteApiNamespace: ["sites/123"] // No trailing slash + ) + .setShouldUsePlugins(true) + .setShouldUseThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + let cache = EditorURLCache(cacheRoot: .randomTemporaryDirectory) + let repository = RESTAPIRepository( + configuration: configuration, + httpClient: capturingClient, + cache: cache + ) + + _ = try await repository.fetchPost(id: 1) + _ = try await repository.fetchPostType(for: "post") + _ = try await repository.fetchActiveTheme() + _ = try await repository.fetchPostTypes() + + let expectedURLs: Set = [ + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/posts/1?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types/post?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/themes?context=edit&status=active", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types?context=view", + ] + + #expect(Set(await capturingClient.capturedURLs) == expectedURLs) + } + + @Test("Editor settings URL includes namespace") + func editorSettingsUrlIncludesNamespace() async throws { + let capturingClient = URLCollectingMockHTTPClient() + + let configuration = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://public-api.wordpress.com")!, + siteApiRoot: URL(string: "https://public-api.wordpress.com/wp-json")!, + siteApiNamespace: ["sites/456/"] + ) + .setShouldUsePlugins(true) + .setShouldUseThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + let cache = EditorURLCache(cacheRoot: .randomTemporaryDirectory) + let repository = RESTAPIRepository( + configuration: configuration, + httpClient: capturingClient, + cache: cache + ) + + _ = try await repository.fetchEditorSettings() + + #expect(await capturingClient.capturedURLs.count == 1) + #expect( + await capturingClient.capturedURLs.first + == "https://public-api.wordpress.com/wp-json/wp-block-editor/v1/sites/456/settings" + ) + } + + @Test("Settings options URL includes namespace") + func settingsOptionsUrlIncludesNamespace() async throws { + let capturingClient = URLCollectingMockHTTPClient() + + let configuration = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://public-api.wordpress.com")!, + siteApiRoot: URL(string: "https://public-api.wordpress.com/wp-json")!, + siteApiNamespace: ["sites/789/"] + ) + .setShouldUsePlugins(true) + .setShouldUseThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + let cache = EditorURLCache(cacheRoot: .randomTemporaryDirectory) + let repository = RESTAPIRepository( + configuration: configuration, + httpClient: capturingClient, + cache: cache + ) + + _ = try await repository.fetchSettingsOptions() + + #expect(await capturingClient.capturedURLs.count == 1) + #expect( + await capturingClient.capturedURLs.first + == "https://public-api.wordpress.com/wp-json/wp/v2/sites/789/settings" + ) + } } // MARK: - URL Capturing Mock Client @@ -274,3 +410,27 @@ final class URLCapturingMockHTTPClient: EditorHTTPClientProtocol, @unchecked Sen ) } } + +actor URLCollectingMockHTTPClient: EditorHTTPClientProtocol { + private(set) var capturedURLs: [String] = [] + + func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { + let url = try #require(urlRequest.url) + capturedURLs.append(url.absoluteString) + return ( + Data(#"{}"#.utf8), + HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + ) + } + + func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) { + let url = try #require(urlRequest.url) + capturedURLs.append(url.absoluteString) + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data().write(to: tempURL) + return ( + tempURL, + HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + ) + } +} diff --git a/src/utils/bridge.js b/src/utils/bridge.js index fd2acf3b3..b9e0caae1 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -299,7 +299,7 @@ export async function getPost() { if ( hostContent ) { debug( 'Using content from native host' ); return { - id: post?.id ?? -1, + id: post?.id || -1, type: post?.type || 'post', status: post?.status || 'draft', title: { raw: hostContent.title }, @@ -310,7 +310,7 @@ export async function getPost() { if ( post ) { debug( 'Native bridge unavailable, using GBKit initial content' ); return { - id: post.id, + id: post.id || -1, type: post.type || 'post', status: post.status || 'draft', title: { raw: decodeURIComponent( post.title ) },