@onekeyfe/react-native-chart-webview 3.0.61 → 3.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/ChartWebview.podspec +0 -3
  2. package/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebview.kt +18 -85
  3. package/android/src/main/java/com/margelo/nitro/chartwebview/PooledChartWebView.kt +26 -253
  4. package/ios/ChartWebview.swift +31 -190
  5. package/lib/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +0 -18
  6. package/lib/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +0 -4
  7. package/lib/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +0 -8
  8. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +0 -12
  9. package/lib/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +0 -14
  10. package/lib/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +0 -10
  11. package/lib/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +0 -2
  12. package/lib/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +0 -48
  13. package/lib/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +0 -4
  14. package/lib/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +0 -4
  15. package/lib/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +0 -24
  16. package/lib/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +0 -2
  17. package/lib/nitrogen/generated/shared/json/ChartWebviewConfig.json +0 -2
  18. package/lib/nitrogen/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +0 -18
  19. package/lib/nitrogen/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +0 -4
  20. package/lib/nitrogen/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +0 -8
  21. package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +0 -12
  22. package/lib/nitrogen/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +0 -14
  23. package/lib/nitrogen/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +0 -10
  24. package/lib/nitrogen/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +0 -2
  25. package/lib/nitrogen/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +0 -48
  26. package/lib/nitrogen/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +0 -4
  27. package/lib/nitrogen/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +0 -4
  28. package/lib/nitrogen/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +0 -24
  29. package/lib/nitrogen/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +0 -2
  30. package/lib/nitrogen/nitrogen/generated/shared/json/ChartWebviewConfig.json +0 -2
  31. package/lib/typescript/src/ChartWebview.nitro.d.ts +0 -2
  32. package/lib/typescript/src/ChartWebview.nitro.d.ts.map +1 -1
  33. package/lib/typescript/src/index.d.ts +0 -2
  34. package/lib/typescript/src/index.d.ts.map +1 -1
  35. package/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +0 -18
  36. package/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +0 -4
  37. package/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +0 -8
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +0 -12
  39. package/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +0 -14
  40. package/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +0 -10
  41. package/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +0 -2
  42. package/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +0 -48
  43. package/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +0 -4
  44. package/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +0 -4
  45. package/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +0 -24
  46. package/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +0 -2
  47. package/nitrogen/generated/shared/json/ChartWebviewConfig.json +0 -2
  48. package/package.json +1 -1
  49. package/src/ChartWebview.nitro.ts +0 -18
@@ -21,9 +21,6 @@ Pod::Spec.new do |s|
21
21
 
22
22
  s.dependency 'React-jsi'
23
23
  s.dependency 'React-callinvoker'
24
- # DEBUG instrumentation: route native chart lifecycle logs into the shared
25
- # OneKeyLog file (app-latest.log) via `import ReactNativeNativeLogger`.
26
- s.dependency 'ReactNativeNativeLogger'
27
24
 
28
25
  load 'nitrogen/generated/ios/ChartWebview+autolinking.rb'
29
26
  add_nitrogen_files(s)
@@ -98,46 +98,34 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
98
98
 
99
99
  // --- Source props ---
100
100
 
101
- // Source/bridge setters call applySource (synchronous) — NOT scheduleReconcile,
102
- // which caused an infinite reconcile loop (reconcile -> setSource -> prop
103
- // re-apply -> reconcile ...). applySource sets warmDriver + warm-boots the page
104
- // even when this host is not the visible owner, so post-attach bridge arrival
105
- // still makes this host the warm driver (page->native callbacks fall back to it).
106
101
  private var _uri: String? = null
107
102
  override var uri: String?
108
103
  get() = _uri
109
- set(value) { _uri = value; applySource() }
104
+ set(value) { _uri = value; applySourceIfOwner() }
110
105
 
111
106
  private var _localBundle: String? = null
112
107
  override var localBundle: String?
113
108
  get() = _localBundle
114
- set(value) { _localBundle = value; applySource() }
109
+ set(value) { _localBundle = value; applySourceIfOwner() }
115
110
 
116
111
  private var _entry: String? = null
117
112
  override var entry: String?
118
113
  get() = _entry
119
- set(value) { _entry = value; applySource() }
114
+ set(value) { _entry = value; applySourceIfOwner() }
120
115
 
121
116
  private var _paramsJson: String? = null
122
117
  override var paramsJson: String?
123
118
  get() = _paramsJson
124
- set(value) { _paramsJson = value; applySource() }
125
-
126
- // ANDROID: the host the offline `localBundle` is served under. When set, the
127
- // offline bundle is served from https://<assetHost>/ so the WebView reuses the
128
- // real chart origin's same-origin storage (zero migration). Falls back to the
129
- // built-in appassets host in the pool when null. (iOS ignores this prop.)
130
- private var _assetHost: String? = null
131
- override var assetHost: String?
132
- get() = _assetHost
133
- set(value) { _assetHost = value; applySource() }
119
+ set(value) { _paramsJson = value; applySourceIfOwner() }
134
120
 
135
121
  // Document-start bridge JS (single source of truth in the TS layer). Stored and
136
122
  // handed to the pooled WebView when we claim it, before its first load.
137
123
  private var _bridgeScript: String? = null
138
124
  override var bridgeScript: String?
139
125
  get() = _bridgeScript
140
- set(value) { _bridgeScript = value; applySource() }
126
+ // Re-apply the source: if this prop lands after the source props (a load can't
127
+ // run before the bridge is registered, so setSource deferred), this triggers it.
128
+ set(value) { _bridgeScript = value; applySourceIfOwner() }
141
129
 
142
130
  // --- Singleton props ---
143
131
  // `pooled` + non-empty `reuseKey` => the backing WebView is shared (keyed by
@@ -174,22 +162,6 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
174
162
  scheduleReconcile()
175
163
  }
176
164
 
177
- // --- Debugging ---
178
- // Make the backing WebView inspectable via chrome://inspect. Driven by the
179
- // app's "Enable Native Webview Debugging" dev-mode toggle, mirroring the main
180
- // react-native-webview (which calls WebView.setWebContentsDebuggingEnabled).
181
- // Stored and applied to the backing WebView both here (on prop change) and at
182
- // host claim, since `backing` is null until the first reconcile assigns it.
183
- // CAVEAT: setWebContentsDebuggingEnabled is PROCESS-GLOBAL — see
184
- // PooledChartWebView.setInspectable.
185
- private var _webviewDebuggingEnabled: Boolean? = null
186
- override var webviewDebuggingEnabled: Boolean?
187
- get() = _webviewDebuggingEnabled
188
- set(value) {
189
- _webviewDebuggingEnabled = value
190
- backing?.setInspectable(value)
191
- }
192
-
193
165
  // --- Event callbacks ---
194
166
  override var onMessage: ((message: String) -> Unit)? = null
195
167
  override var onLoadEnd: (() -> Unit)? = null
@@ -247,9 +219,6 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
247
219
  val key = effectiveKey()
248
220
  val entry = ChartWebviewPool.acquireShared(key, context)
249
221
  backing = entry
250
- // Apply this host's debug preference to the (possibly freshly created) backing
251
- // WebView. Mirrors the main react-native-webview's setWebContentsDebuggingEnabled.
252
- entry.setInspectable(_webviewDebuggingEnabled)
253
222
  // Refcount the pool entry once per host (reconcile runs many times). Balanced
254
223
  // by releaseShared in dispose(). If the reuseKey changed, release the old one.
255
224
  if (adoptedPoolKey != key) {
@@ -257,24 +226,13 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
257
226
  ChartWebviewPool.adopt(key)
258
227
  adoptedPoolKey = key
259
228
  }
260
- // WARM-BOOT (mirror of iOS): boot the shared offline page + register as the
261
- // warm DRIVER as soon as ANY referencing host has the bridge, even when not
262
- // the visible owner (offscreen prewarm runs with attached=false/active=false).
263
- // page->native callbacks fall back to warmDriver when there's no owner, so the
264
- // bars-state / load-end signals aren't dropped during warm — otherwise the
265
- // chart stays on the loading mask. Idempotent: setSource dedupes on same URL,
266
- // setBridgeScript no-ops once registered.
267
- val bs = _bridgeScript
268
- if (!bs.isNullOrEmpty()) {
269
- entry.setBridgeScript(bs)
270
- entry.warmDriver = this
271
- entry.setSource(_uri, _localBundle, _entry, _paramsJson, _assetHost)
272
- }
273
229
  if (wantsOwnership()) {
274
230
  entry.owner = this
275
- // PERF: a host is now showing the chart make sure the renderer is running.
276
- entry.resume()
231
+ // Register the document-start bridge before the first load (the prop is set
232
+ // by now; the pool may have been created earlier by a window-attach reconcile).
233
+ entry.setBridgeScript(_bridgeScript ?: "")
277
234
  entry.attachTo(container)
235
+ entry.setSource(_uri, _localBundle, _entry, _paramsJson)
278
236
  // Keep a fresh frame of OUR content while we own the WebView, so that when
279
237
  // we later go inactive (and the shared WebView reloads the other slot's
280
238
  // chart) our slot freezes to its own last frame, not the other content.
@@ -290,11 +248,6 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
290
248
  if (wasOwner) entry.owner = null
291
249
  stopOwnCapture()
292
250
  showPlaceholder(ownSnapshot)
293
- // PERF: if nobody owns the shared WebView now, pause its renderer so it
294
- // doesn't keep burning a CPU core + GPU + RAM in the background (Android
295
- // doesn't auto-throttle offscreen WebViews like iOS does). Resumed on the
296
- // next CLAIM. No-op until the page's first load completed.
297
- entry.pauseIfIdle()
298
251
  }
299
252
  }
300
253
 
@@ -304,30 +257,19 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
304
257
  if (!wantsOwnership()) return
305
258
  val pooled = backing ?: PooledChartWebView.create(context, effectiveKey())
306
259
  backing = pooled
307
- pooled.setInspectable(_webviewDebuggingEnabled)
308
260
  pooled.owner = this
309
261
  pooled.setBridgeScript(_bridgeScript ?: "")
310
262
  pooled.attachTo(container)
311
- pooled.setSource(_uri, _localBundle, _entry, _paramsJson, _assetHost)
263
+ pooled.setSource(_uri, _localBundle, _entry, _paramsJson)
312
264
  }
313
265
 
314
- // Apply the source synchronously when a source/bridge prop changes. For a
315
- // pooled host this ALSO registers it as the warmDriver and warm-boots the page
316
- // even when it is not the visible owner, so page->native callbacks (bars-state /
317
- // load-end) fall back to it when no host owns the pool. No scheduleReconcile —
318
- // that looped. backing is null until the first reconcile assigns it (the
319
- // reuseKey/pooled/active setters still scheduleReconcile), so warmDriver is set
320
- // on the first prop change after the pool entry is acquired.
321
- private fun applySource() {
266
+ private fun applySourceIfOwner() {
322
267
  val pooled = backing ?: return
323
- val bs = _bridgeScript
324
- if (isPooled() && !bs.isNullOrEmpty()) {
325
- pooled.setBridgeScript(bs)
326
- pooled.warmDriver = this
327
- pooled.setSource(_uri, _localBundle, _entry, _paramsJson, _assetHost)
328
- } else if (pooled.owner == this) {
268
+ if (pooled.owner == this) {
269
+ // Register the bridge before any load setSource may trigger (setSource
270
+ // defers loading until the bridge is registered).
329
271
  pooled.setBridgeScript(_bridgeScript ?: "")
330
- pooled.setSource(_uri, _localBundle, _entry, _paramsJson, _assetHost)
272
+ pooled.setSource(_uri, _localBundle, _entry, _paramsJson)
331
273
  }
332
274
  }
333
275
 
@@ -417,7 +359,7 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
417
359
  // WebView so it doesn't leak a Chromium renderer + JavascriptInterface per
418
360
  // mount/unmount. Pooled (shared) backing is intentionally left alive in the
419
361
  // warm pool — other hosts may still share it; its lifetime is the pool's.
420
- override fun dispose() {
362
+ fun dispose() {
421
363
  stopOwnCapture()
422
364
  container.removeCallbacks(reconcileRunnable)
423
365
  container.removeCallbacks(revealFallbackRunnable)
@@ -426,19 +368,10 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
426
368
  if (pendingRevealHost == this) pendingRevealHost = null
427
369
  val entry = backing
428
370
  if (entry != null && entry.owner == this) entry.owner = null
429
- if (entry != null && entry.warmDriver == this) entry.warmDriver = null
430
371
  // Balance the pool adopt() if this host ever joined a pooled key. Kept warm by
431
372
  // default (single-instance cache); the release path makes destroy() reachable.
432
373
  adoptedPoolKey?.let { ChartWebviewPool.releaseShared(it) }
433
374
  adoptedPoolKey = null
434
- // Pooled host going away: release the shared WebView from THIS host's
435
- // container, which is about to be dropped from the view tree. The pool keeps
436
- // the WebView alive (warm) for the next host, but if we leave it parented to
437
- // our dropped container the next host's attach can't clear that stale parent
438
- // (removeView on a detached container isn't applied synchronously) and retries
439
- // forever → blank/white chart. detachFrom is parent-checked, so if another host
440
- // already re-claimed the WebView we leave it where it is.
441
- if (entry != null && isPooled()) entry.detachFrom(container)
442
375
  // A private (non-pooled) instance is owned solely by this host — tear it down
443
376
  // so it doesn't leak a Chromium renderer + JavascriptInterface.
444
377
  if (entry != null && !isPooled()) entry.destroy()
@@ -25,7 +25,6 @@ import androidx.webkit.WebViewClientCompat
25
25
  import androidx.webkit.WebViewCompat
26
26
  import androidx.webkit.WebViewFeature
27
27
  import org.json.JSONObject
28
- import java.lang.ref.WeakReference
29
28
  import java.net.URLEncoder
30
29
  import java.util.concurrent.atomic.AtomicInteger
31
30
 
@@ -53,9 +52,6 @@ class PooledChartWebView private constructor(
53
52
  const val ASSET_HOST = "appassets.androidplatform.net"
54
53
  private const val DEFAULT_ENTRY = "index.html"
55
54
 
56
- // Operational log tag for this pool (lifecycle + error paths only).
57
- private const val TAG = "ChartWebviewPool"
58
-
59
55
  // Tiny platform transport shim: defines the single hook the shared TS bridge
60
56
  // (CHART_BRIDGE_JS) calls. The bulk of the bridge lives in the TS layer and
61
57
  // arrives via the `bridgeScript` ctor arg — this is the only platform-specific
@@ -127,12 +123,12 @@ class PooledChartWebView private constructor(
127
123
  }
128
124
 
129
125
  // The trusted origin set for the current source: the offline asset origin
130
- // (https://<assetHost>, default appassets.androidplatform.net) always, plus the
131
- // online/fallback `uri` origin when running in online mode. computeTargetUrl
132
- // serves offline content from `assetHost` and online content from `uri`, so
133
- // these are exactly the origins the privileged bridge legitimately runs in.
126
+ // (https://appassets.androidplatform.net) always, plus the online/fallback
127
+ // `uri` origin when running in online mode. computeTargetUrl serves offline
128
+ // content from ASSET_HOST and online content from `uri`, so these are exactly
129
+ // the origins the privileged bridge legitimately runs in.
134
130
  private fun trustedOriginsFor(uri: String?): Set<String> {
135
- val origins = linkedSetOf("https://$assetHost")
131
+ val origins = linkedSetOf("https://$ASSET_HOST")
136
132
  originOf(uri)?.let { origins.add(it) }
137
133
  return origins
138
134
  }
@@ -152,78 +148,11 @@ class PooledChartWebView private constructor(
152
148
  }
153
149
 
154
150
  /** The host currently displaying this WebView; page events route here. */
155
- // Weak (mirror of iOS): the pool entry is immortal, so a strong ref here would
156
- // pin a disposed host — and its ReactContext/Activity — forever. A GC'd host
157
- // reads back as null, which makes `entry.owner == this` correctly false.
158
- private var _ownerRef: WeakReference<HybridChartWebview>? = null
159
- var owner: HybridChartWebview?
160
- get() = _ownerRef?.get()
161
- set(value) {
162
- _ownerRef = value?.let { WeakReference(it) }
163
- }
164
- // The host that warm-booted the page and can drive its symbol / receive its
165
- // callbacks while there is no VISIBLE owner yet. Separate from `owner` (the
166
- // YIELD path clears `owner`); callbacks fall back to this so bars-state /
167
- // load-end aren't dropped during warm. Mirror of the iOS warmDriver.
168
- // Weak for the same immortal-pool reason as `owner` above.
169
- private var _warmDriverRef: WeakReference<HybridChartWebview>? = null
170
- var warmDriver: HybridChartWebview?
171
- get() = _warmDriverRef?.get()
172
- set(value) {
173
- _warmDriverRef = value?.let { WeakReference(it) }
174
- }
175
-
176
- // PERF (Android only): Android's in-process WebView/Chromium does NOT throttle
177
- // an offscreen/unowned page (unlike iOS WKWebView). Left running, the warm
178
- // pooled page keeps its rAF render loop + websockets + compositing alive
179
- // FOREVER after the user leaves the chart — pinning a CPU core, growing RAM to
180
- // OOM, and stealing the GPU from RN's RenderThread (every other screen stalls).
181
- // We pause the WebView whenever no host owns it (after the first load) and
182
- // resume on claim. Uses the PER-INSTANCE onPause()/onResume() — NOT the static
183
- // pauseTimers()/resumeTimers(), which are process-global and would also freeze
184
- // the app's other WebViews (inpage provider / web-embed / dapp browser).
185
- private var paused = false
186
- private var hasLoadedOnce = false
187
-
188
- fun markLoaded() {
189
- hasLoadedOnce = true
190
- }
191
-
192
- // Pause the renderer when nobody owns the shared WebView. onPause() stops
193
- // drawing/compositing/animations (frees the GPU so navigation is smooth again)
194
- // WITHOUT changing the view's visibility/attachment — we must NOT toggle
195
- // visibility here, that left the WebView blank/white after re-claim. Skipped
196
- // until the first load so we never freeze a booting page. Resumed (+ redraw)
197
- // on the next CLAIM so the chart paints again.
198
- fun pauseIfIdle() {
199
- if (paused || !hasLoadedOnce || owner != null) {
200
- return
201
- }
202
- paused = true
203
- android.util.Log.d(TAG, "pool[$key] PAUSE (renderer idle)")
204
- runOnUiThread { webView.onPause() }
205
- }
206
-
207
- fun resume() {
208
- if (!paused) return
209
- paused = false
210
- android.util.Log.d(TAG, "pool[$key] RESUME")
211
- runOnUiThread {
212
- webView.onResume()
213
- webView.invalidate()
214
- }
215
- }
151
+ var owner: HybridChartWebview? = null
216
152
 
217
153
  private var assetLoader: WebViewAssetLoader? = null
218
154
  private var lastLoadedUrl: String? = null
219
155
  private var lastLocalBundle: String? = null
220
- // The host the offline bundle is served under. Defaults to the built-in
221
- // appassets host (old behavior); when the app passes `assetHost` we serve the
222
- // bundle under the real chart origin so the WebView reuses that origin's
223
- // same-origin storage (zero migration). Tracked so an assetHost change rebuilds
224
- // the asset loader + recomputes the target URL, same as a localBundle change.
225
- private var assetHost: String = ASSET_HOST
226
- private var lastAssetHost: String = ASSET_HOST
227
156
 
228
157
  // Last rendered frame, used to mask the brief blank frame while the WebView's
229
158
  // surface is torn down and recreated during a reparent (move). Kept until
@@ -238,9 +167,6 @@ class PooledChartWebView private constructor(
238
167
  // How long the snapshot overlay stays up after a reparent, giving the WebView
239
168
  // time to draw its first frame in the new container (~5 frames).
240
169
  private val overlayHideDelayMs = 80L
241
- // Delay between bounded attach retries (~1 vsync at 60Hz): long enough for the
242
- // old parent's pending layout / removeView to flush, short enough to stay snappy.
243
- private val attachRetryDelayMs = 16L
244
170
  private val attachGeneration = AtomicInteger(0)
245
171
 
246
172
  val webView: WebView = WebView(context).apply {
@@ -253,28 +179,9 @@ class PooledChartWebView private constructor(
253
179
  addJavascriptInterface(ChartBridge(), "AndroidChartBridge")
254
180
  }
255
181
 
256
- // Apply the app's "Enable Native Webview Debugging" dev-mode toggle, mirroring
257
- // how the main react-native-webview calls WebView.setWebContentsDebuggingEnabled.
258
- // Called by the host both on the prop change and at host claim, so the toggle is
259
- // honored even when this entry was created before the prop arrived.
260
- //
261
- // CAVEAT (PROCESS-GLOBAL): setWebContentsDebuggingEnabled is a STATIC method that
262
- // flips remote-debugging for EVERY WebView in the whole process. Once any WebView
263
- // (this chart, the main react-native-webview, etc.) enables it, it stays enabled
264
- // process-wide until the app is killed — Android exposes no per-WebView toggle and
265
- // no way to read the current value. So a null/false preference here cannot turn
266
- // debugging back OFF once another WebView (or a prior true value) turned it ON; it
267
- // simply does not re-enable it. We therefore only ever call the setter with `true`
268
- // when explicitly enabled, leaving the process-global state untouched otherwise.
269
- fun setInspectable(enabled: Boolean?) {
270
- if (enabled == true) {
271
- runOnUiThread { WebView.setWebContentsDebuggingEnabled(true) }
272
- }
273
- }
274
-
275
182
  init {
276
183
  val n = liveCount.incrementAndGet()
277
- android.util.Log.d(TAG, "WebView CREATED key=$key liveCount=$n")
184
+ android.util.Log.d("ChartWebviewPool", "WebView CREATED key=$key liveCount=$n")
278
185
 
279
186
  webView.webViewClient = object : WebViewClientCompat() {
280
187
  override fun shouldInterceptRequest(
@@ -284,12 +191,11 @@ class PooledChartWebView private constructor(
284
191
 
285
192
  override fun onPageFinished(view: WebView, url: String?) {
286
193
  super.onPageFinished(view, url)
287
- markLoaded()
288
194
  // The bridge is delivered exclusively via the origin-scoped document-start
289
195
  // script (see registerBridgeForOrigins). We deliberately do NOT re-inject
290
196
  // it here via evaluateJavascript — that ran on every page event regardless
291
197
  // of URL and would expose the privileged bridge to untrusted pages/frames.
292
- (owner ?: warmDriver)?.dispatchLoadEnd()
198
+ owner?.dispatchLoadEnd()
293
199
  // Prime the snapshot so the first move already has a frame to mask with.
294
200
  refreshSnapshotSoon()
295
201
  }
@@ -311,34 +217,7 @@ class PooledChartWebView private constructor(
311
217
  private inner class ChartBridge {
312
218
  @JavascriptInterface
313
219
  fun postMessage(message: String) {
314
- runOnUiThread {
315
- val target = owner ?: warmDriver
316
- target?.dispatchMessage(message)
317
- }
318
- }
319
- }
320
-
321
- // Robustly detach the WebView from [parent], even when [parent] is a disposed
322
- // host container that is mid-teardown / detached from the window and holds the
323
- // child in a transition / disappearing-children list — where a plain removeView()
324
- // is deferred and leaves webView.parent set, stranding the shared WebView and
325
- // blanking the next chart slot. endViewTransition() clears any pending transition
326
- // hold; removeViewInLayout() is the in-layout fallback if the child is still held.
327
- private fun forceDetach(parent: ViewGroup) {
328
- if (webView.parent !== parent) return
329
- try {
330
- parent.endViewTransition(webView)
331
- } catch (e: Throwable) {
332
- android.util.Log.w(TAG, "forceDetach: endViewTransition failed", e)
333
- }
334
- parent.removeView(webView)
335
- if (webView.parent === parent) {
336
- try {
337
- parent.removeViewInLayout(webView)
338
- } catch (e: Throwable) {
339
- android.util.Log.w(TAG, "forceDetach: removeViewInLayout failed", e)
340
- }
341
- parent.requestLayout()
220
+ runOnUiThread { owner?.dispatchMessage(message) }
342
221
  }
343
222
  }
344
223
 
@@ -346,42 +225,24 @@ class PooledChartWebView private constructor(
346
225
  fun attachTo(container: ViewGroup) {
347
226
  val generation = attachGeneration.incrementAndGet()
348
227
  runOnUiThread {
349
- attachToContainer(container, generation, retriesLeft = 12)
228
+ attachToContainer(container, generation, canRetry = true)
350
229
  }
351
230
  }
352
231
 
353
- private fun attachToContainer(container: ViewGroup, generation: Int, retriesLeft: Int) {
232
+ private fun attachToContainer(container: ViewGroup, generation: Int, canRetry: Boolean) {
354
233
  if (generation != attachGeneration.get()) return
355
234
  if (webView.parent === container) return
356
235
 
357
- (webView.parent as? ViewGroup)?.let { forceDetach(it) }
236
+ (webView.parent as? ViewGroup)?.removeView(webView)
358
237
  val currentParent = webView.parent
359
- if (currentParent != null && currentParent !== container) {
360
- // The old container still hasn't released the WebView (a disposed host's
361
- // container mid-teardown / detached from window: removeView is deferred and
362
- // getParent() stays set). Retry — but on the TARGET container's handler,
363
- // which is attached to the window so its queue keeps draining, instead of the
364
- // detached webView/old parent whose post() runnables may never run.
365
- if (retriesLeft > 0) {
366
- // postDelayed (not a tight container.post spin): a small delay lets the
367
- // old parent's pending layout / removeView flush between attempts, instead
368
- // of re-checking on the very next vsync before anything could change.
369
- container.postDelayed({
370
- attachToContainer(container, generation, retriesLeft = retriesLeft - 1)
371
- }, attachRetryDelayMs)
238
+ if (currentParent != null) {
239
+ if (canRetry) {
240
+ webView.post { attachToContainer(container, generation, canRetry = false) }
372
241
  } else {
373
- // Last resort before giving up: the old parent never released the WebView
374
- // through the normal path. Force-detach it and try the attach once more so
375
- // we don't leave the WebView unparented (the blank-chart symptom).
376
- (currentParent as? ViewGroup)?.let { forceDetach(it) }
377
- if (webView.parent == null || webView.parent === container) {
378
- attachToContainer(container, generation, retriesLeft = 0)
379
- } else {
380
- android.util.Log.w(
381
- TAG,
382
- "Skip attach key=$key after retries because WebView parent was not cleared: $currentParent",
383
- )
384
- }
242
+ android.util.Log.w(
243
+ "ChartWebviewPool",
244
+ "Skip attach key=$key because WebView parent was not cleared: $currentParent",
245
+ )
385
246
  }
386
247
  return
387
248
  }
@@ -399,27 +260,6 @@ class PooledChartWebView private constructor(
399
260
  refreshSnapshotSoon()
400
261
  }
401
262
 
402
- /**
403
- * Remove the WebView from [container] ONLY if it is still parented there.
404
- *
405
- * Called on a pooled host's final teardown (dispose): that host's container is
406
- * about to be dropped from the view tree, and a pooled host deliberately does
407
- * NOT detach on the YIELD path (to avoid racing a rapid re-claim). Without this,
408
- * the shared WebView stays parented to the dropped/detached container; the next
409
- * host's [attachTo] then keeps hitting "old parent not cleared" (removeView on a
410
- * detached container isn't applied synchronously) and retries forever — leaving
411
- * the new chart slot blank/white. The `parent === container` guard makes this
412
- * safe against ordering: if a new host has already re-claimed and reparented the
413
- * WebView, we must NOT rip it back off, so we only remove when WE still hold it.
414
- */
415
- fun detachFrom(container: ViewGroup) {
416
- runOnUiThread {
417
- if (webView.parent === container) {
418
- forceDetach(container)
419
- }
420
- }
421
- }
422
-
423
263
  /** Remove the WebView from its current parent (keeps it alive, warm). */
424
264
  fun detachFromParent() {
425
265
  runOnUiThread {
@@ -549,26 +389,13 @@ class PooledChartWebView private constructor(
549
389
  * so reparenting / redundant prop re-applies never reload (which would lose
550
390
  * chart state, the whole point of pooling).
551
391
  */
552
- fun setSource(
553
- uri: String?,
554
- localBundle: String?,
555
- entry: String?,
556
- paramsJson: String?,
557
- assetHost: String?,
558
- ) {
392
+ fun setSource(uri: String?, localBundle: String?, entry: String?, paramsJson: String?) {
559
393
  // Never load before the document-start bridge is registered — otherwise the
560
394
  // page boots without the bridge and its first $private requests are lost. The
561
395
  // host re-calls setSource once the bridgeScript prop arrives.
562
396
  if (!bridgeRegistered) return
563
- // Per-instance asset host: fall back to the built-in appassets host (old
564
- // behavior) when the app doesn't pass one. Empty string is treated as absent.
565
- // Sanitize untrusted values before they reach the privileged-bridge origin
566
- // ("https://$assetHost") and WebViewAssetLoader.setDomain — see sanitizeAssetHost.
567
- this.assetHost = assetHost?.takeIf { it.isNotEmpty() }
568
- ?.let { sanitizeAssetHost(it) } ?: ASSET_HOST
569
- if (localBundle != lastLocalBundle || this.assetHost != lastAssetHost) {
397
+ if (localBundle != lastLocalBundle) {
570
398
  lastLocalBundle = localBundle
571
- lastAssetHost = this.assetHost
572
399
  rebuildAssetLoader(localBundle)
573
400
  }
574
401
  // Register the document-start bridge scoped to exactly the origin(s) this load
@@ -610,36 +437,7 @@ class PooledChartWebView private constructor(
610
437
  (webView.parent as? ViewGroup)?.removeView(webView)
611
438
  webView.destroy()
612
439
  val n = liveCount.decrementAndGet()
613
- android.util.Log.d(TAG, "WebView DESTROYED key=$key liveCount=$n")
614
- }
615
- }
616
-
617
- // Validate an incoming assetHost prop before it becomes the trusted bridge
618
- // origin ("https://$assetHost") and WebViewAssetLoader.setDomain(assetHost). A
619
- // malformed value (scheme, path, '/', '@'/userinfo, whitespace, port, query)
620
- // could corrupt the privileged-origin allowlist or throw on the UI thread, so a
621
- // candidate that is not a bare hostname falls back to the built-in ASSET_HOST.
622
- private fun sanitizeAssetHost(candidate: String): String {
623
- val invalid = {
624
- android.util.Log.w(
625
- TAG,
626
- "Ignoring invalid assetHost '$candidate'; falling back to default $ASSET_HOST",
627
- )
628
- ASSET_HOST
629
- }
630
- // Cheap rejects first: a bare hostname has no whitespace and no '/'.
631
- if (candidate.any { it.isWhitespace() } || candidate.contains('/')) return invalid()
632
- return try {
633
- val uri = java.net.URI("https://$candidate")
634
- val bareHost =
635
- uri.host == candidate &&
636
- uri.path.isNullOrEmpty() &&
637
- uri.userInfo == null &&
638
- uri.query == null &&
639
- uri.port == -1
640
- if (bareHost) candidate else invalid()
641
- } catch (e: Exception) {
642
- invalid()
440
+ android.util.Log.d("ChartWebviewPool", "WebView DESTROYED key=$key liveCount=$n")
643
441
  }
644
442
  }
645
443
 
@@ -648,28 +446,9 @@ class PooledChartWebView private constructor(
648
446
  assetLoader = null
649
447
  return
650
448
  }
651
- // Narrow the path handler to ONLY the offline bundle subtree
652
- // (/<localBundle>/, e.g. /tradingview-assets/) instead of intercepting all of
653
- // "/". When the bundle is served under the real chart origin (`assetHost`),
654
- // intercepting "/" would shadow every web path on that domain; with the narrow
655
- // handler, unmatched paths fall through to the network — so an `assetHost`
656
- // that is a real public domain keeps behaving normally off-bundle.
657
- // computeTargetUrl produces https://<assetHost>/<localBundle>/<entry>, which
658
- // stays inside this handler's path. (Path built as /<localBundle>/ so a
659
- // localBundle that already has a trailing slash doesn't double it.)
660
- val bundleDir = localBundle.trim('/')
661
- val bundlePath = "/$bundleDir/"
662
- // WebViewAssetLoader strips the registered prefix before calling the handler,
663
- // so AssetsPathHandler alone would resolve the remainder against the assets
664
- // ROOT (assets/<entry>) and lose the bundle namespace. Re-prepend <bundleDir>/
665
- // so we still serve from assets/<localBundle>/<entry>. Returning null on a
666
- // miss lets WebViewAssetLoader fall through to the network.
667
- val assets = WebViewAssetLoader.AssetsPathHandler(webView.context)
668
- val namespacedHandler =
669
- WebViewAssetLoader.PathHandler { suffix -> assets.handle("$bundleDir/$suffix") }
670
449
  assetLoader = WebViewAssetLoader.Builder()
671
- .setDomain(assetHost)
672
- .addPathHandler(bundlePath, namespacedHandler)
450
+ .setDomain(ASSET_HOST)
451
+ .addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(webView.context))
673
452
  .build()
674
453
  }
675
454
 
@@ -683,21 +462,15 @@ class PooledChartWebView private constructor(
683
462
  if (localBundle.isNullOrEmpty()) return null
684
463
  val entryPath = entry?.takeIf { it.isNotEmpty() } ?: DEFAULT_ENTRY
685
464
  val query = buildQueryFromParamsJson(paramsJson)
686
- // Normalize the bundle dir the SAME way rebuildAssetLoader does (trim '/'), so
687
- // the URL has exactly single slashes between host, bundle dir and entry. A raw
688
- // localBundle like "/tradingview-assets/" would otherwise yield
689
- // https://host//tradingview-assets//index.html, which misses the registered
690
- // handler prefix → falls through to the network → blank chart.
691
- val bundleDir = localBundle.trim('/')
692
465
  return buildString {
693
466
  append("https://")
694
- append(assetHost)
467
+ append(ASSET_HOST)
695
468
  append('/')
696
469
  // Serve the offline bundle from assets/<localBundle>/ (namespaced) rather
697
470
  // than the assets root, so it doesn't collide with other bundled assets
698
471
  // (e.g. web-embed). The dist uses relative asset paths (PUBLIC_URL='./'),
699
472
  // so loading the entry from a subpath resolves the rest correctly.
700
- append(bundleDir)
473
+ append(localBundle)
701
474
  append('/')
702
475
  append(entryPath)
703
476
  if (query.isNotEmpty()) {