@onekeyfe/react-native-chart-webview 3.0.57 → 3.0.59

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.
@@ -25,6 +25,7 @@ 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
28
29
  import java.net.URLEncoder
29
30
  import java.util.concurrent.atomic.AtomicInteger
30
31
 
@@ -151,21 +152,25 @@ class PooledChartWebView private constructor(
151
152
  }
152
153
 
153
154
  /** The host currently displaying this WebView; page events route here. */
154
- private var _owner: HybridChartWebview? = null
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
155
159
  var owner: HybridChartWebview?
156
- get() = _owner
160
+ get() = _ownerRef?.get()
157
161
  set(value) {
158
- _owner = value
162
+ _ownerRef = value?.let { WeakReference(it) }
159
163
  }
160
164
  // The host that warm-booted the page and can drive its symbol / receive its
161
165
  // callbacks while there is no VISIBLE owner yet. Separate from `owner` (the
162
166
  // YIELD path clears `owner`); callbacks fall back to this so bars-state /
163
167
  // load-end aren't dropped during warm. Mirror of the iOS warmDriver.
164
- private var _warmDriver: HybridChartWebview? = null
168
+ // Weak for the same immortal-pool reason as `owner` above.
169
+ private var _warmDriverRef: WeakReference<HybridChartWebview>? = null
165
170
  var warmDriver: HybridChartWebview?
166
- get() = _warmDriver
171
+ get() = _warmDriverRef?.get()
167
172
  set(value) {
168
- _warmDriver = value
173
+ _warmDriverRef = value?.let { WeakReference(it) }
169
174
  }
170
175
 
171
176
  // PERF (Android only): Android's in-process WebView/Chromium does NOT throttle
@@ -195,14 +200,14 @@ class PooledChartWebView private constructor(
195
200
  return
196
201
  }
197
202
  paused = true
198
- android.util.Log.i(TAG, "pool[$key] PAUSE (renderer idle)")
203
+ android.util.Log.d(TAG, "pool[$key] PAUSE (renderer idle)")
199
204
  runOnUiThread { webView.onPause() }
200
205
  }
201
206
 
202
207
  fun resume() {
203
208
  if (!paused) return
204
209
  paused = false
205
- android.util.Log.i(TAG, "pool[$key] RESUME")
210
+ android.util.Log.d(TAG, "pool[$key] RESUME")
206
211
  runOnUiThread {
207
212
  webView.onResume()
208
213
  webView.invalidate()
@@ -233,6 +238,9 @@ class PooledChartWebView private constructor(
233
238
  // How long the snapshot overlay stays up after a reparent, giving the WebView
234
239
  // time to draw its first frame in the new container (~5 frames).
235
240
  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
236
244
  private val attachGeneration = AtomicInteger(0)
237
245
 
238
246
  val webView: WebView = WebView(context).apply {
@@ -318,10 +326,18 @@ class PooledChartWebView private constructor(
318
326
  // hold; removeViewInLayout() is the in-layout fallback if the child is still held.
319
327
  private fun forceDetach(parent: ViewGroup) {
320
328
  if (webView.parent !== parent) return
321
- try { parent.endViewTransition(webView) } catch (e: Throwable) {}
329
+ try {
330
+ parent.endViewTransition(webView)
331
+ } catch (e: Throwable) {
332
+ android.util.Log.w(TAG, "forceDetach: endViewTransition failed", e)
333
+ }
322
334
  parent.removeView(webView)
323
335
  if (webView.parent === parent) {
324
- try { parent.removeViewInLayout(webView) } catch (e: Throwable) {}
336
+ try {
337
+ parent.removeViewInLayout(webView)
338
+ } catch (e: Throwable) {
339
+ android.util.Log.w(TAG, "forceDetach: removeViewInLayout failed", e)
340
+ }
325
341
  parent.requestLayout()
326
342
  }
327
343
  }
@@ -347,14 +363,25 @@ class PooledChartWebView private constructor(
347
363
  // which is attached to the window so its queue keeps draining, instead of the
348
364
  // detached webView/old parent whose post() runnables may never run.
349
365
  if (retriesLeft > 0) {
350
- container.post {
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({
351
370
  attachToContainer(container, generation, retriesLeft = retriesLeft - 1)
352
- }
371
+ }, attachRetryDelayMs)
353
372
  } else {
354
- android.util.Log.w(
355
- TAG,
356
- "Skip attach key=$key after retries because WebView parent was not cleared: $currentParent",
357
- )
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
+ }
358
385
  }
359
386
  return
360
387
  }
@@ -535,7 +562,10 @@ class PooledChartWebView private constructor(
535
562
  if (!bridgeRegistered) return
536
563
  // Per-instance asset host: fall back to the built-in appassets host (old
537
564
  // behavior) when the app doesn't pass one. Empty string is treated as absent.
538
- this.assetHost = assetHost?.takeIf { it.isNotEmpty() } ?: ASSET_HOST
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
539
569
  if (localBundle != lastLocalBundle || this.assetHost != lastAssetHost) {
540
570
  lastLocalBundle = localBundle
541
571
  lastAssetHost = this.assetHost
@@ -584,6 +614,35 @@ class PooledChartWebView private constructor(
584
614
  }
585
615
  }
586
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()
643
+ }
644
+ }
645
+
587
646
  private fun rebuildAssetLoader(localBundle: String?) {
588
647
  if (localBundle.isNullOrEmpty()) {
589
648
  assetLoader = null
@@ -624,6 +683,12 @@ class PooledChartWebView private constructor(
624
683
  if (localBundle.isNullOrEmpty()) return null
625
684
  val entryPath = entry?.takeIf { it.isNotEmpty() } ?: DEFAULT_ENTRY
626
685
  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('/')
627
692
  return buildString {
628
693
  append("https://")
629
694
  append(assetHost)
@@ -632,7 +697,7 @@ class PooledChartWebView private constructor(
632
697
  // than the assets root, so it doesn't collide with other bundled assets
633
698
  // (e.g. web-embed). The dist uses relative asset paths (PUBLIC_URL='./'),
634
699
  // so loading the entry from a subpath resolves the rest correctly.
635
- append(localBundle)
700
+ append(bundleDir)
636
701
  append('/')
637
702
  append(entryPath)
638
703
  if (query.isNotEmpty()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-chart-webview",
3
- "version": "3.0.57",
3
+ "version": "3.0.59",
4
4
  "description": "react-native-chart-webview",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",