Skip to content

Android Asset Caching Implementation#153

Merged
oguzkocer merged 19 commits intotrunkfrom
android-asset-caching
Jul 18, 2025
Merged

Android Asset Caching Implementation#153
oguzkocer merged 19 commits intotrunkfrom
android-asset-caching

Conversation

@oguzkocer
Copy link
Contributor

@oguzkocer oguzkocer commented Jul 9, 2025

Summary

This PR implements the Android counterpart for iOS editor asset caching (PR #148), enabling performance improvements for remote editor functionality by caching JavaScript, CSS, and source map files locally.

Implementation Overview

Key Components

  1. CachedAssetRequestInterceptor - Intercepts WebView requests for cacheable assets
  2. EditorAssetsLibrary - Manages HTTP operations, caching, and cleanup
  3. EditorConfiguration - Extended with caching-related properties
  4. JavaScript Bridge - Updated to support Android asset fetching

Architecture Decisions

  • Direct Request Interception: Uses Android's native WebView request interception instead of URL scheme modification (unlike iOS)
  • Non-blocking Approach: Serves cached assets immediately while caching new ones in background to prevent ANR
  • Time-based Cleanup: Removes cache files older than 7 days, leveraging asset versioning for natural cleanup
  • No HTML Parsing: Simplified implementation that doesn't require parsing HTML or JSON manifests

Technical Details

Caching Strategy

  • Cacheable Assets: .js, .css, .js.map files from configured hosts
  • Storage Location: context.filesDir/editor-caches/{site-name} (matches iOS structure)
  • Lifecycle Management: Proper cleanup on WebView destruction
  • Thread Safety: All network operations on background threads with proper coroutine management

Configuration

val config = EditorConfiguration.builder()
    .setEnableAssetCaching(true)
    .setCachedAssetHosts(setOf("wp.com", "wordpress.com"))
    .setEditorAssetsEndpoint("https://example.com/wp-json/wpcom/v2/editor-assets") // Optional
    .build()

URL Construction

Matches iOS implementation:

  • Uses editorAssetsEndpoint if configured
  • Falls back to {siteApiRoot}wpcom/v2/editor-assets
  • Consistent behavior across platforms

oguzkocer added 14 commits July 9, 2025 15:08
This commit adds asset caching support for Android that mirrors the iOS implementation from PR #148, while leveraging Android's native capabilities to avoid URL scheme modification.

- **EditorAssetsLibrary**: Manages fetching manifest from `wpcom/v2/editor-assets` endpoint and caching individual JS/CSS assets
- **EditorAssetsManifest**: Parses HTML content from manifest using JSoup to extract asset URLs
- **EditorAssetsProvider**: JavaScript interface that provides manifest to WebView via `loadFetchedEditorAssets`
- **CachedAssetRequestInterceptor**: Intercepts and caches asset requests using the existing `GutenbergRequestInterceptor` interface

- Added `editorAssetsEndpoint` property to `EditorConfiguration`
- Added `enableAssetCaching` flag to enable/disable caching
- Added `cachedAssetHosts` to specify which hosts to cache from

- Updated `GutenbergView` to set up caching when enabled
- Added `warmup()` method for preloading assets
- Integrated JavaScript bridge support for both iOS and Android in `bridge.js`
- Added JSoup dependency for HTML parsing

- No URL scheme modification needed - Android intercepts requests directly
- Uses existing `GutenbergRequestInterceptor` infrastructure
- Simpler implementation while maintaining feature parity

- 7-day cache expiration (matching iOS)
- Cache directory structure matches iOS pattern
- Only caches successful responses (2xx status codes)
- Supports .js, .css, and .js.map files

🤖 Generated with [Claude Code](https://claude.ai/code)
Replace direct readText() call on InputStream with bufferedReader().readText() to properly convert the stream to text content when fetching the manifest.
Removes the JavaScript interface approach in favor of direct request interception, making the Android implementation cleaner while maintaining the same functionality.

- Remove EditorAssetsProvider JavaScript interface
- Update CachedAssetRequestInterceptor to intercept manifest endpoint requests
- Implement fetchEditorAssets() in bridge.js for Android using apiFetch
- Simplify remote-editor.js to use fetchEditorAssets() for both platforms
- Add header forwarding support for manifest requests

Now Android intercepts the /wpcom/v2/editor-assets API call directly instead of using a JavaScript bridge, leveraging Android's native request interception capabilities while maintaining feature parity with iOS.
Simplifies the Android implementation by removing HTML parsing since Android doesn't need to modify URLs like iOS does. Android can intercept requests directly without URL scheme changes.

- Remove EditorAssetsManifest.kt class and JSoup dependency
- Simplify manifestContentForEditor() to return raw JSON without parsing
- Remove fetchAssets() method - assets are cached on-demand when requested
- Update warmup() to only preload manifest, not individual assets
- Remove Gson import and JSON parsing logic

- Significantly reduced complexity and dependencies
- More efficient - no unnecessary HTML parsing or JSON manipulation
- Leverages Android's native request interception capabilities
- Assets are cached lazily as they're actually requested by the editor

The Android implementation now simply:
1. Intercepts /wpcom/v2/editor-assets → returns raw JSON
2. Intercepts asset requests → caches and serves them
3. No URL modification or HTML parsing required
Replace runBlocking calls in WebView request interceptor with non-blocking approach:
- Only serve assets that are already cached
- Start background caching for future requests when asset not found
- Let WebView handle manifest and uncached requests normally
- Prevents ANR issues while maintaining caching functionality
Add try-finally blocks around HttpURLConnection usage to guarantee
connection.disconnect() is called in all scenarios including:
- Network timeouts and errors
- HTTP error responses
- IOException during data reading
- Any other exceptions

Prevents accumulation of unclosed connections that could exhaust
system resources, especially important on mobile devices.
…management

- Replace deprecated GlobalScope.launch with proper CoroutineScope
- Use SupervisorJob + Dispatchers.IO for isolated error handling and IO optimization
- Add cancelWarmup() method for explicit control over background operations
- Add explicit coroutines dependency and replace star imports with specific imports
- Prevents memory leaks and provides proper lifecycle management for warmup operations
Implements periodic cleanup of old cached assets to prevent unlimited storage growth. Since assets are versioned with URLs that change when updated, old versions become unused naturally. The cleanup removes files older than 7 days and provides logging for monitoring.
- Update fetchEditorAssets() to use editorAssetsEndpoint if configured
- Add editorAssetsEndpoint to GBKit JavaScript configuration
- Match the native Android implementation's fallback logic
- Ensure consistent URL construction between native and bridge code
@dcalhoun dcalhoun marked this pull request as draft July 10, 2025 19:59
@dcalhoun
Copy link
Member

Thank you! This caching will have a big impact on performance and stability.

While testing this, I discovered I recently shipped a regression to WPCOM Simple sites that broke loading of Jetpack blocks in GBK. During your own testing, if you ever successfully loaded the editor for a WPCOM Simple site and found no Jetpack blocks, this regression was likely the cause. I corrected this in Automattic/jetpack#44274.

Successful testing

After fixing the regression, I successfully loaded Jetpack blocks using the caching mechanism—both WPCOM Simple and Atomic sites. I used the following patch for WP-Android to ensure the correct configuration.

WP-Android cache configuration diff
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt
index 135861253d6..6c9e98c611d 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt
@@ -2520,6 +2520,7 @@ class EditPostActivity : BaseAppCompatActivity(), EditorFragmentActivity, Editor
             )
 
             val postType = if (editPostRepository.isPage) "page" else "post"
+            // TODO: Update this to set the correct siteApiRoot for self-hosted sites
             val siteApiRoot = if (isWpCom) "https://public-api.wordpress.com/" else ""
             val authToken = accountStore.accessToken
             val authHeader = "Bearer $authToken"
@@ -2533,6 +2534,7 @@ class EditPostActivity : BaseAppCompatActivity(), EditorFragmentActivity, Editor
                 "postType" to postType,
                 "postTitle" to editPostRepository.getPost()?.title,
                 "postContent" to editPostRepository.getPost()?.content,
+                "siteURL" to siteModel.url,
                 "siteApiRoot" to siteApiRoot,
                 "namespaceExcludedPaths" to arrayOf("/wpcom/v2/following/recommendations", "/wpcom/v2/following/mine"),
                 "authHeader" to authHeader,
diff --git a/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergKitEditorFragment.java b/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergKitEditorFragment.java
index 4686f4d3b79..5f41b0ccd50 100644
--- a/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergKitEditorFragment.java
+++ b/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergKitEditorFragment.java
@@ -41,6 +41,7 @@ import org.wordpress.android.util.AppLog;
 import org.wordpress.android.util.AppLog.T;
 import org.wordpress.android.util.PermissionUtils;
 import org.wordpress.android.util.ProfilingUtils;
+import org.wordpress.android.util.UrlUtils;
 import org.wordpress.android.util.helpers.MediaFile;
 import org.wordpress.android.util.helpers.MediaGallery;
 import org.wordpress.aztec.IHistoryListener;
@@ -58,6 +59,7 @@ import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.CountDownLatch;
 
 import static org.wordpress.gutenberg.Media.createMediaUsingMimeType;
@@ -565,13 +567,19 @@ public class GutenbergKitEditorFragment extends EditorFragmentAbstract implement
             postId = -1;
         }
 
+        var siteURL = (String) mSettings.get("siteURL");
+        var siteApiRoot = (String) mSettings.get("siteApiRoot");
+        var siteApiNamespace = (String[]) mSettings.get("siteApiNamespace");
+        // TODO: Update to set correct endpoint for self-hosted sites
+        var editorAssetsEndpoint = siteApiRoot + "wpcom/v2/" + siteApiNamespace[0] + "/editor-assets";
+
         EditorConfiguration config = new EditorConfiguration.Builder()
                 .setTitle((String) mSettings.get("postTitle"))
                 .setContent((String) mSettings.get("postContent"))
                 .setPostId(postId)
                 .setPostType((String) mSettings.get("postType"))
                 .setThemeStyles((Boolean) mSettings.get("themeStyles"))
-                .setPlugins((Boolean) mSettings.get("plugins"))
+                .setPlugins((Boolean) true)
                 .setSiteApiRoot((String) mSettings.get("siteApiRoot"))
                 .setSiteApiNamespace((String[]) mSettings.get("siteApiNamespace"))
                 .setNamespaceExcludedPaths((String[]) mSettings.get("namespaceExcludedPaths"))
@@ -579,6 +587,9 @@ public class GutenbergKitEditorFragment extends EditorFragmentAbstract implement
                 .setWebViewGlobals((List<WebViewGlobal>) mSettings.get("webViewGlobals"))
                 .setEditorSettings(editorSettings)
                 .setLocale((String) mSettings.get("locale"))
+                .setEditorAssetsEndpoint(editorAssetsEndpoint)
+                .setCachedAssetHosts(Set.of("s0.wp.com", UrlUtils.getHost(siteURL)))
+                .setEnableAssetCaching(true)
                 .build();
 
         mGutenbergView.start(config);

While testing this in WPAndroid, I noticed that we may not be using the correct siteApiRoot [...]

You are correct. In WP-iOS, we set the correct API root for self-hosted sites; we need to implement the same in WP-Android now that application passwords are available.

Does self-hosted sites support /editor-assets in any form? If so, do we have a static url to provide the assets?

They do, but it requires the Jetpack plugin to be installed and activated, as the editor assets endpoint is not yet available in WordPress Core. The endpoint URL would resemble https://example.com/wp-json/wpcom/v2/editor-assets.

Suggestions

We might consider implementing similar navigation as the iOS GBK Demo app, where one can test either the bundled editor a configured remote editor.

iOS GBK Demo app navigation

gbk-ios-editors-navigation.mp4

Given my success testing the current changes, I suggest we move forward refining the code in preparation for review. Please let me know if there are any aspects with which you would like my help.

@oguzkocer
Copy link
Contributor Author

We might consider implementing similar navigation as the iOS GBK Demo app, where one can test either the bundled editor a configured remote editor.

This suggestion has been implemented in #155 with a minor follow up fix in #156.

…endency

Replaced order-dependent map lookup with explicit `when` expression to determine mime types. This eliminates the risk of incorrect mime type matching when `.css` would match before `.css?inline`.

- Remove `MIME_TYPES` constant in favor of `getMimeType()` function
- Add comment explaining why `.css?inline` files are served as `application/javascript`
- Reference `use-editor-styles.js` where Vite transforms CSS files with `?inline` parameter into JavaScript modules that export CSS as strings
@oguzkocer oguzkocer force-pushed the android-asset-caching branch from 87a986b to 33a5382 Compare July 17, 2025 21:55
@oguzkocer oguzkocer marked this pull request as ready for review July 17, 2025 22:10
@oguzkocer oguzkocer enabled auto-merge (squash) July 17, 2025 22:10
@oguzkocer oguzkocer requested a review from dcalhoun July 17, 2025 22:10
@oguzkocer
Copy link
Contributor Author

@dcalhoun This is now ready for review. The force-push was about me trying to fix the lint error, so there are no changes to the existing commits from before your first review.

Comment on lines +92 to +93
private var enableAssetCaching: Boolean = false
private var cachedAssetHosts: Set<String> = emptySet()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The iOS implementation lacks these options. Do you believe we should add them to iOS?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure about cachedAssetHosts, but I think enableAssetCaching is useful. It's much easier to turn off a flag for debugging than to have to do an included build, or publish a different version of the library to disable caching.

What do you think @crazytonyli?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we should use the same flag for "asset caching" and "is using remote editor", because we'd always want to cache assets for remote editors.

There is also an editorAssetsEndpoint property, which can be used as a flag. Asset caching is only possible if that property is set.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@crazytonyli I had similar thoughts regarding using editorAssetsEndpoint as both the toggle for plugins and the configuration for fetching remote editor assets. My only concern is naming it in a way that clearly communicates its intent and impact. Should we keep editorAssetsEndpoint or name it something else—remoteEditorAssetsEndpoint, pluginAssetsEndpoint, etc? It returns both plugin scripts and core WP scripts.

My understanding is that editorAssetsEndpoint is currently not required as both platforms have a fallback value of <site-host>/wp-json/wpcom/v2/editor-assets. I am in favor of removing the fallback and requiring a configured path. This would allow use to remove the wpcom reference from this codebase that we hope to merge into the community-centric Gutenberg project.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we should use the same flag for "asset caching" and "is using remote editor", because we'd always want to cache assets for remote editors.

That may be true for production, but I don't agree with it for debugging. It's a good idea to have a way to turn something off without having to change the source code.


One improvement that might address our concerns is to group caching-related properties into a single container object. Currently, caching arguments are scattered as separate parameters, making their relationships unclear without prior knowledge.

With a container approach, we can:

  • Use constructors that automatically set sensible defaults
  • Set some properties based on others internally
  • Reduce the number of constructor arguments
class CacheConfig {
  constructor(endpoint) {
    this.enabled = true;
    this.endpoint = endpoint;
  }
}

@oguzkocer oguzkocer merged commit 50ca05d into trunk Jul 18, 2025
11 checks passed
@oguzkocer oguzkocer deleted the android-asset-caching branch July 18, 2025 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants