Skip to content

Commit ef85c41

Browse files
committed
Add asset caching + progress
1 parent daf7305 commit ef85c41

File tree

7 files changed

+376
-59
lines changed

7 files changed

+376
-59
lines changed

android/Gutenberg/src/main/java/org/wordpress/gutenberg/CachedAssetRequestInterceptor.kt

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ package org.wordpress.gutenberg
33
import android.webkit.WebResourceRequest
44
import android.webkit.WebResourceResponse
55
import android.util.Log
6-
import org.wordpress.gutenberg.stores.EditorAssetsLibrary
6+
import org.wordpress.gutenberg.model.EditorAssetBundle
77
import java.io.ByteArrayInputStream
88

99
class CachedAssetRequestInterceptor(
10-
private val library: EditorAssetsLibrary,
10+
private val bundle: EditorAssetBundle,
1111
private val allowedHosts: Set<String> = emptySet()
1212
) : GutenbergRequestInterceptor {
1313
companion object {
@@ -45,28 +45,21 @@ class CachedAssetRequestInterceptor(
4545
}
4646

4747
// Handle asset caching - only serve if already cached
48-
val cachedData = library.getCachedAsset(url)
49-
if (cachedData != null) {
48+
if (bundle.hasAssetData(url)) {
49+
val cachedData = bundle.assetData(url)
5050
Log.d(TAG, "Serving cached asset: $url")
5151
return createResponse(url, cachedData)
5252
}
5353

54-
// Not cached - let WebView fetch normally and cache in background
55-
Log.d(TAG, "Asset not cached, will cache in background: $url")
56-
// Start background caching for next time
57-
library.cacheAssetInBackground(url)
58-
54+
// Not cached - let WebView fetch normally
55+
Log.d(TAG, "Asset not cached: $url")
5956
return null // Let WebView handle the request normally
6057
} catch (e: Exception) {
6158
Log.e(TAG, "Error handling request: $url", e)
6259
return null
6360
}
6461
}
6562

66-
fun shutdown() {
67-
library.shutdown()
68-
}
69-
7063
private fun createResponse(url: String, data: ByteArray): WebResourceResponse {
7164
val mimeType = getMimeType(url)
7265
return WebResourceResponse(
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package org.wordpress.gutenberg
2+
3+
import org.wordpress.gutenberg.model.EditorProgress
4+
5+
/**
6+
* Callback interface for monitoring editor loading state.
7+
*
8+
* Implement this interface to receive updates about the editor's loading progress,
9+
* allowing you to display appropriate UI (progress bar, spinner, etc.) while the
10+
* editor initializes.
11+
*
12+
* ## Loading Flow
13+
*
14+
* When dependencies are **not provided** to `GutenbergView.start()`:
15+
* 1. `onDependencyLoadingStarted()` - Begin showing progress bar
16+
* 2. `onDependencyLoadingProgress()` - Update progress bar (called multiple times)
17+
* 3. `onDependencyLoadingFinished()` - Hide progress bar, show spinner
18+
* 4. `onEditorReady()` - Hide spinner, editor is usable
19+
*
20+
* When dependencies **are provided** to `GutenbergView.start()`:
21+
* 1. `onDependencyLoadingFinished()` - Show spinner (no progress phase)
22+
* 2. `onEditorReady()` - Hide spinner, editor is usable
23+
*/
24+
interface EditorLoadingListener {
25+
/**
26+
* Called when dependency loading begins.
27+
*
28+
* This is the appropriate time to show a progress bar to the user.
29+
* Only called when dependencies were not provided to `start()`.
30+
*/
31+
fun onDependencyLoadingStarted()
32+
33+
/**
34+
* Called periodically with progress updates during dependency loading.
35+
*
36+
* @param progress The current loading progress with completed/total counts.
37+
*/
38+
fun onDependencyLoadingProgress(progress: EditorProgress)
39+
40+
/**
41+
* Called when dependency loading completes.
42+
*
43+
* This is the appropriate time to hide the progress bar and show a spinner
44+
* while the WebView loads and parses the editor JavaScript.
45+
*/
46+
fun onDependencyLoadingFinished()
47+
48+
/**
49+
* Called when the editor has fully loaded and is ready for use.
50+
*
51+
* This is the appropriate time to hide all loading indicators and reveal
52+
* the editor. The editor APIs are safe to call after this callback.
53+
*/
54+
fun onEditorReady()
55+
56+
/**
57+
* Called if dependency loading fails.
58+
*
59+
* @param error The exception that caused the failure.
60+
*/
61+
fun onDependencyLoadingFailed(error: Throwable)
62+
}

android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt

Lines changed: 90 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,17 @@ import android.webkit.WebView
2424
import android.webkit.WebViewClient
2525
import androidx.webkit.WebViewAssetLoader
2626
import androidx.webkit.WebViewAssetLoader.AssetsPathHandler
27+
import kotlinx.coroutines.CoroutineScope
2728
import kotlinx.coroutines.Dispatchers
29+
import kotlinx.coroutines.Job
30+
import kotlinx.coroutines.launch
2831
import kotlinx.coroutines.withContext
2932
import org.json.JSONException
3033
import org.json.JSONObject
3134
import org.wordpress.gutenberg.model.EditorConfiguration
3235
import org.wordpress.gutenberg.model.EditorDependencies
3336
import org.wordpress.gutenberg.model.GBKitGlobal
34-
import org.wordpress.gutenberg.stores.EditorAssetsLibrary
35-
import java.io.File
36-
import java.net.URL
37+
import org.wordpress.gutenberg.services.EditorService
3738
import java.util.Locale
3839

3940
const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html"
@@ -63,6 +64,10 @@ class GutenbergView : WebView {
6364
private var autocompleterTriggeredListener: AutocompleterTriggeredListener? = null
6465
private var modalDialogStateListener: ModalDialogStateListener? = null
6566
private var networkRequestListener: NetworkRequestListener? = null
67+
private var loadingListener: EditorLoadingListener? = null
68+
69+
private val coroutineScope = CoroutineScope(Dispatchers.Main + Job())
70+
private var dependencyLoadingJob: Job? = null
6671

6772
/**
6873
* Stores the contextId from the most recent openMediaLibrary call
@@ -119,6 +124,10 @@ class GutenbergView : WebView {
119124
editorDidBecomeAvailableListener = listener
120125
}
121126

127+
fun setEditorLoadingListener(listener: EditorLoadingListener?) {
128+
loadingListener = listener
129+
}
130+
122131
constructor(context: Context) : super(context)
123132
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
124133
constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(
@@ -262,25 +271,82 @@ class GutenbergView : WebView {
262271
}
263272
}
264273

274+
/**
275+
* Starts the editor with the given configuration and optional dependencies.
276+
*
277+
* ## Loading Flows
278+
*
279+
* **When dependencies are provided (Fast Path):**
280+
* The editor loads immediately using the pre-fetched dependencies.
281+
* Only `onDependencyLoadingFinished()` and `onEditorReady()` callbacks are invoked.
282+
*
283+
* **When dependencies are null (Async Flow):**
284+
* Dependencies are fetched asynchronously with progress reporting.
285+
* All loading callbacks are invoked in order:
286+
* 1. `onDependencyLoadingStarted()`
287+
* 2. `onDependencyLoadingProgress()` (multiple times)
288+
* 3. `onDependencyLoadingFinished()`
289+
* 4. `onEditorReady()`
290+
*
291+
* @param configuration The editor configuration.
292+
* @param dependencies Pre-fetched dependencies, or null to fetch them asynchronously.
293+
*/
265294
fun start(configuration: EditorConfiguration, dependencies: EditorDependencies? = null) {
266295
this.configuration = configuration
267-
this.dependencies = dependencies
268296

269-
// Set up asset caching if enabled
270-
if (configuration.enableAssetCaching) {
271-
val storageRoot = getCacheDirectory(configuration)
272-
val httpClient = EditorHTTPClient(authHeader = configuration.authHeader)
273-
val library = EditorAssetsLibrary(
274-
configuration = configuration,
275-
httpClient = httpClient,
276-
storageRoot = storageRoot
277-
)
278-
val cachedInterceptor = CachedAssetRequestInterceptor(
279-
library,
280-
configuration.cachedAssetHosts
281-
)
282-
requestInterceptor = cachedInterceptor
297+
if (dependencies != null) {
298+
// FAST PATH: Dependencies were provided - load immediately
299+
loadEditor(dependencies)
300+
} else {
301+
// ASYNC FLOW: No dependencies - fetch them asynchronously
302+
prepareAndLoadEditor()
283303
}
304+
}
305+
306+
/**
307+
* Fetches all required dependencies and then loads the editor.
308+
*
309+
* This method is the entry point for the async flow when no dependencies were provided.
310+
*/
311+
private fun prepareAndLoadEditor() {
312+
loadingListener?.onDependencyLoadingStarted()
313+
314+
dependencyLoadingJob = coroutineScope.launch {
315+
try {
316+
val editorService = EditorService.create(
317+
context = context,
318+
configuration = configuration
319+
)
320+
321+
val fetchedDependencies = editorService.prepare { progress ->
322+
loadingListener?.onDependencyLoadingProgress(progress)
323+
}
324+
325+
// Store dependencies and load the editor
326+
loadEditor(fetchedDependencies)
327+
} catch (e: Exception) {
328+
Log.e("GutenbergView", "Failed to load dependencies", e)
329+
loadingListener?.onDependencyLoadingFailed(e)
330+
}
331+
}
332+
}
333+
334+
/**
335+
* Loads the editor with the given dependencies.
336+
*
337+
* This is the shared loading path used by both flows after dependencies are available.
338+
*/
339+
private fun loadEditor(dependencies: EditorDependencies) {
340+
this.dependencies = dependencies
341+
342+
// Set up asset caching
343+
requestInterceptor = CachedAssetRequestInterceptor(
344+
dependencies.assetBundle,
345+
configuration.cachedAssetHosts
346+
)
347+
348+
// Notify that dependency loading is complete (spinner phase begins)
349+
loadingListener?.onDependencyLoadingFinished()
284350

285351
initializeWebView()
286352

@@ -292,13 +358,13 @@ class GutenbergView : WebView {
292358
this.clearCache(true)
293359
// All cookies are third-party cookies because the root of this document
294360
// lives under `https://appassets.androidplatform.net`
295-
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true);
361+
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
296362

297363
// Erase all local cookies before loading the URL – we don't want to persist
298364
// anything between uses – otherwise we might send the wrong cookies
299365
CookieManager.getInstance().removeAllCookies {
300366
CookieManager.getInstance().flush()
301-
for(cookie in configuration.cookies) {
367+
for (cookie in configuration.cookies) {
302368
CookieManager.getInstance().setCookie(cookie.key, cookie.value)
303369
}
304370
this.loadUrl(editorUrl)
@@ -468,6 +534,7 @@ class GutenbergView : WebView {
468534
isEditorLoaded = true
469535
handler.post {
470536
if(!didFireEditorLoaded) {
537+
loadingListener?.onEditorReady()
471538
editorDidBecomeAvailableListener?.onEditorAvailable(this)
472539
this.didFireEditorLoaded = true
473540
this.visibility = VISIBLE
@@ -691,14 +758,15 @@ class GutenbergView : WebView {
691758

692759
override fun onDetachedFromWindow() {
693760
super.onDetachedFromWindow()
761+
dependencyLoadingJob?.cancel()
694762
clearConfig()
695763
this.stopLoading()
696-
(requestInterceptor as? CachedAssetRequestInterceptor)?.shutdown()
697764
FileCache.clearCache(context)
698765
contentChangeListener = null
699766
historyChangeListener = null
700767
featuredImageChangeListener = null
701768
editorDidBecomeAvailableListener = null
769+
loadingListener = null
702770
filePathCallback = null
703771
onFileChooserRequested = null
704772
autocompleterTriggeredListener = null
@@ -708,26 +776,6 @@ class GutenbergView : WebView {
708776
this.destroy()
709777
}
710778

711-
private fun getCacheDirectory(configuration: EditorConfiguration): File {
712-
var siteName = "shared"
713-
714-
if (configuration.siteURL.isNotEmpty()) {
715-
try {
716-
val url = URL(configuration.siteURL)
717-
val host = url.host.replace(":", "-")
718-
val path = url.path.replace("/", "-").trim('-')
719-
siteName = if (path.isEmpty()) host else "$host-$path"
720-
721-
// Remove illegal characters
722-
siteName = siteName.replace(Regex("[/:\\\\?%*|\"<>]"), "-")
723-
} catch (e: Exception) {
724-
Log.w("GutenbergView", "Failed to parse site URL for cache directory", e)
725-
}
726-
}
727-
728-
return File(context.filesDir, "editor-caches/$siteName")
729-
}
730-
731779
companion object {
732780
private const val ASSET_LOADING_TIMEOUT_MS = 5000L
733781

@@ -748,7 +796,7 @@ class GutenbergView : WebView {
748796
// Create dedicated warmup WebView
749797
val webView = GutenbergView(context)
750798
webView.initializeWebView()
751-
webView.start(configuration)
799+
webView.start(configuration, EditorDependencies.empty)
752800
warmupWebView = webView
753801

754802
// Schedule cleanup after assets are loaded

android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorDependencies.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,13 @@ data class EditorDependencies(
2323
* This is `null` if preloading is disabled or no preload data is available.
2424
*/
2525
val preloadList: EditorPreloadList?
26-
)
26+
) {
27+
companion object {
28+
/** Empty dependencies for use when no pre-fetched data is available. */
29+
val empty = EditorDependencies(
30+
editorSettings = EditorSettings.undefined,
31+
assetBundle = EditorAssetBundle.empty,
32+
preloadList = null
33+
)
34+
}
35+
}

0 commit comments

Comments
 (0)