@onekeyfe/react-native-background-thread 3.0.18 → 3.0.20

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.
@@ -129,6 +129,7 @@ static std::atomic<int64_t> gNextTimerId{1};
129
129
  static std::atomic<bool> gTimerWorkerStarted{false};
130
130
  static std::atomic<bool> gTimerWorkerStop{false};
131
131
  static RPCRuntimeExecutor gBgTimerExecutor;
132
+ static std::thread gTimerWorkerThread;
132
133
 
133
134
  static long long nowMs() {
134
135
  using namespace std::chrono;
@@ -160,6 +161,7 @@ static void timerWorkerLoop() {
160
161
  // their callbacks. Captured under the lock; callbacks are invoked on
161
162
  // the JS thread (not here).
162
163
  std::vector<std::pair<int64_t, std::shared_ptr<jsi::Function>>> toFire;
164
+ RPCRuntimeExecutor executor;
163
165
  {
164
166
  std::unique_lock<std::mutex> lock(gTimerMutex);
165
167
  if (gTimers.empty()) {
@@ -218,9 +220,9 @@ static void timerWorkerLoop() {
218
220
  ++it;
219
221
  }
220
222
  }
223
+ executor = gBgTimerExecutor;
221
224
  }
222
225
 
223
- RPCRuntimeExecutor executor = gBgTimerExecutor;
224
226
  if (!executor) {
225
227
  std::this_thread::sleep_for(std::chrono::milliseconds(10));
226
228
  continue;
@@ -238,7 +240,8 @@ static void timerWorkerLoop() {
238
240
  static void ensureTimerWorkerStarted() {
239
241
  bool expected = false;
240
242
  if (gTimerWorkerStarted.compare_exchange_strong(expected, true)) {
241
- std::thread(timerWorkerLoop).detach();
243
+ gTimerWorkerStop.store(false);
244
+ gTimerWorkerThread = std::thread(timerWorkerLoop);
242
245
  LOGI("Timer worker thread started");
243
246
  }
244
247
  }
@@ -593,5 +596,41 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeDestroy(
593
596
  SharedRPC::reset();
594
597
  SharedStore::reset();
595
598
 
599
+ // Stop and join the timer worker before touching any shared state it
600
+ // reads. Without this, the worker could finish one last dispatch after
601
+ // we clear gTimers / gBgTimerExecutor and enqueue stale work against
602
+ // the about-to-be-destroyed runtime.
603
+ gTimerWorkerStop.store(true);
604
+ gTimerCv.notify_all();
605
+ if (gTimerWorkerThread.joinable()) {
606
+ gTimerWorkerThread.join();
607
+ }
608
+ gTimerWorkerStarted.store(false);
609
+
610
+ // Intentionally leak remaining jsi::Function callbacks (same rationale
611
+ // as SharedRPC::reset): destroying them here would run ~jsi::Function
612
+ // on the torn-down runtime and crash.
613
+ {
614
+ std::lock_guard<std::mutex> lock(gTimerMutex);
615
+ for (auto &entry : gTimers) {
616
+ if (entry.second.callback) {
617
+ new std::shared_ptr<jsi::Function>(std::move(entry.second.callback));
618
+ }
619
+ }
620
+ gTimers.clear();
621
+ gBgTimerExecutor = nullptr;
622
+ }
623
+
624
+ // Drain pending cross-runtime work. Each std::function may capture a
625
+ // shared_ptr<jsi::Function> tied to the destroyed runtime; leak them
626
+ // for the same reason as above.
627
+ {
628
+ std::lock_guard<std::mutex> lock(gWorkMutex);
629
+ for (auto &entry : gPendingWork) {
630
+ new std::function<void(jsi::Runtime &)>(std::move(entry.second));
631
+ }
632
+ gPendingWork.clear();
633
+ }
634
+
596
635
  LOGI("Native resources cleaned up");
597
636
  }
@@ -1,6 +1,10 @@
1
1
  package com.backgroundthread
2
2
 
3
+ import android.app.Activity
4
+ import android.content.Intent
3
5
  import android.net.Uri
6
+ import android.os.Handler
7
+ import android.os.Looper
4
8
  import com.facebook.react.ReactPackage
5
9
  import com.facebook.proguard.annotations.DoNotStrip
6
10
  import com.facebook.react.ReactInstanceEventListener
@@ -16,6 +20,7 @@ import com.facebook.react.runtime.ReactHostImpl
16
20
  import com.facebook.react.runtime.hermes.HermesInstance
17
21
  import com.facebook.react.shell.MainReactPackage
18
22
  import java.io.File
23
+ import java.lang.ref.WeakReference
19
24
 
20
25
  /**
21
26
  * Singleton manager for the background React Native runtime.
@@ -31,6 +36,11 @@ class BackgroundThreadManager private constructor() {
31
36
  private var bgReactHost: ReactHostImpl? = null
32
37
  private var reactPackages: List<ReactPackage> = emptyList()
33
38
 
39
+ // Tracks the last resumed Activity so we can replay it onto the bg
40
+ // ReactContext as soon as the bg host finishes initializing (covers the
41
+ // cold-start race where Activity resumes before bg host is ready).
42
+ private var lastResumedActivityRef: WeakReference<Activity> = WeakReference(null)
43
+
34
44
  @Volatile
35
45
  private var bgRuntimePtr: Long = 0
36
46
 
@@ -329,6 +339,11 @@ class BackgroundThreadManager private constructor() {
329
339
  override fun onReactContextInitialized(context: ReactContext) {
330
340
  val initMs = (System.nanoTime() - bgStartTime) / 1_000_000.0
331
341
  BTLogger.info("[SplitBundle] background ReactContext initialized in ${String.format("%.1f", initMs)}ms")
342
+ // Replay the most recent Activity resume so TurboModules on the
343
+ // bg host can see getCurrentActivity()/ActivityEventListeners
344
+ // from the very first call, even when the bg host finishes
345
+ // initializing after the Activity is already resumed.
346
+ replayLastResumedActivityOnUi()
332
347
  context.runOnJSQueueThread {
333
348
  try {
334
349
  val ptr = context.javaScriptContextHolder?.get() ?: 0L
@@ -417,22 +432,328 @@ class BackgroundThreadManager private constructor() {
417
432
  return
418
433
  }
419
434
 
420
- context.runOnJSQueueThread {
435
+ // Use ReactContext.registerSegment which works in both bridge
436
+ // and bridgeless modes.
437
+ try {
438
+ context.registerSegment(segmentId, path) {
439
+ BTLogger.info("Segment registered in background runtime: id=$segmentId, path=$path")
440
+ onComplete(null)
441
+ }
442
+ } catch (e: Exception) {
443
+ BTLogger.error("Failed to register segment in background runtime: ${e.message}")
444
+ onComplete(e)
445
+ }
446
+ }
447
+
448
+ // ── Activity lifecycle bridge (selective, reflection-based) ─────────────
449
+ //
450
+ // Goal: let a small, explicit allowlist of native modules on the bg
451
+ // ReactHost observe Activity-related state (getCurrentActivity(),
452
+ // onActivityResult, onNewIntent, and — for the same allowlist —
453
+ // onHostResume/onHostPause/onHostDestroy) WITHOUT triggering any side
454
+ // effects on other modules that happen to live on the same ReactContext.
455
+ //
456
+ // Why not use ReactHost.onHostResume / onActivityResult directly?
457
+ // Those internally fan out to ALL LifecycleEventListeners and
458
+ // ActivityEventListeners registered on the ReactContext, which would
459
+ // cause every bg TurboModule that tracks host lifecycle (BroadcastReceivers,
460
+ // sensors, keyboard observers, etc.) to receive a second set of callbacks
461
+ // in addition to the UI host. That doubles resource registration, opens
462
+ // requestCode collisions on Activity results, and can tear down the bg
463
+ // ReactContext during rotation. We need fine-grained control, which RN
464
+ // does not expose, so we operate on the underlying fields directly.
465
+ //
466
+ // What we actually do:
467
+ // - Reflect-write bg ReactContext's `mCurrentActivity` so
468
+ // getCurrentActivity() returns the correct Activity for modules that
469
+ // bother to query it. Modules that don't query it are unaffected.
470
+ // - Read `mActivityEventListeners` via reflection and invoke ONLY the
471
+ // listeners whose class FQCN matches an entry in
472
+ // `bgActivityBridgeListenerClassAllowlist` (for onActivityResult/onNewIntent).
473
+ // - Read `mLifecycleEventListeners` via reflection and invoke
474
+ // onHostResume/onHostPause/onHostDestroy on listeners whose class
475
+ // FQCN matches the same allowlist. Non-allowlisted listeners are
476
+ // never fired on the bg host — preserving the pre-existing baseline
477
+ // that bg was "never resumed".
478
+ // - Deliberately do NOT touch `mLifecycleState` — setting it to RESUMED
479
+ // would cause `addLifecycleEventListener(...)` to auto-fire
480
+ // `onHostResume()` on EVERY newly-registered listener (including
481
+ // non-allowlisted ones), reintroducing the double-dispatch we're
482
+ // trying to avoid. Instead we fire onHostResume manually on each
483
+ // dispatchActivityResumed, which covers the common case; a listener
484
+ // registered strictly between two resume events will catch up on
485
+ // the next cycle.
486
+ //
487
+ // Trade-off:
488
+ // We rely on RN-internal field names `mCurrentActivity`,
489
+ // `mActivityEventListeners`, `mLifecycleEventListeners` on
490
+ // `com.facebook.react.bridge.ReactContext`. An RN upgrade that
491
+ // renames or restructures these fields will cause reflection to fail
492
+ // (caught and logged via BTLogger → OneKeyLog). Mitigate with a
493
+ // dev-build smoke assertion at the call site.
494
+ //
495
+ // Thread safety:
496
+ // ReactContext.onHostResume / onActivityResult are @ThreadConfined(UI);
497
+ // we match that by bouncing to the main looper. The underlying sets
498
+ // are CopyOnWriteArraySet, so iterating them off-thread would still be
499
+ // safe, but UI-thread is the documented contract and we stick to it.
500
+
501
+ // ── Allowlist (registered externally by the host app) ───────────────────
502
+ //
503
+ // FQCN prefixes of ActivityEventListener / LifecycleEventListener
504
+ // implementations that are allowed to receive Activity-bound events
505
+ // (onActivityResult, onNewIntent, onHostResume/Pause/Destroy) on the
506
+ // bg ReactHost. Anything not matching a registered prefix is fully
507
+ // ignored on bg, preserving the pre-existing baseline.
508
+ //
509
+ // The bg-thread module deliberately ships an EMPTY default — the host
510
+ // application is the only place that knows which third-party modules
511
+ // are bg-eligible. Register entries early in Application.onCreate via
512
+ // addBgActivityBridgeListenerClassPrefix(...) so the allowlist is populated
513
+ // before the first Activity lifecycle callback can fire.
514
+ //
515
+ // Adding a prefix is a cross-runtime change. Verify:
516
+ // 1. The module's listener is idempotent and safe to fire.
517
+ // 2. ActivityResult requestCodes do not collide with other modules.
518
+ // 3. Any LifecycleEventListener side-effects are double-host safe.
519
+ @Volatile
520
+ private var bgActivityBridgeListenerClassAllowlist: Set<String> = emptySet()
521
+
522
+ /** Add a single FQCN-prefix entry to the allowlist (idempotent). */
523
+ @Synchronized
524
+ fun addBgActivityBridgeListenerClassPrefix(prefix: String) {
525
+ if (prefix.isEmpty()) return
526
+ if (bgActivityBridgeListenerClassAllowlist.contains(prefix)) return
527
+ bgActivityBridgeListenerClassAllowlist = bgActivityBridgeListenerClassAllowlist + prefix
528
+ BTLogger.info("addBgActivityBridgeListenerClassPrefix: $prefix " +
529
+ "(total=${bgActivityBridgeListenerClassAllowlist.size})")
530
+ }
531
+
532
+ /** Replace the allowlist wholesale. Intended for test setup / reload. */
533
+ @Synchronized
534
+ fun setBgActivityBridgeListenerClassAllowlist(prefixes: Set<String>) {
535
+ bgActivityBridgeListenerClassAllowlist = prefixes.toSet()
536
+ BTLogger.info("setBgActivityBridgeListenerClassAllowlist: ${prefixes.size} entries")
537
+ }
538
+
539
+ /** Inspect the current allowlist (snapshot). */
540
+ fun getBgActivityBridgeListenerClassAllowlist(): Set<String> =
541
+ bgActivityBridgeListenerClassAllowlist
542
+
543
+ fun dispatchActivityResumed(activity: Activity) {
544
+ lastResumedActivityRef = WeakReference(activity)
545
+ runOnUiThread {
546
+ writeBgCurrentActivity(activity)
547
+ dispatchLifecycleEventToAllowlisted(LifecycleEvent.RESUME)
548
+ }
549
+ }
550
+
551
+ fun dispatchActivityPaused(activity: Activity) {
552
+ // Keep mCurrentActivity — Activity is still valid until destroyed,
553
+ // and clearing it between pause/resume would flap getCurrentActivity().
554
+ // LifecycleEventListener.onHostPause() IS fired for allowlisted
555
+ // modules so they can quiesce work (mirrors RN's UI-host behaviour).
556
+ runOnUiThread {
557
+ dispatchLifecycleEventToAllowlisted(LifecycleEvent.PAUSE)
558
+ }
559
+ }
560
+
561
+ fun dispatchActivityDestroyed(activity: Activity) {
562
+ if (lastResumedActivityRef.get() === activity) {
563
+ lastResumedActivityRef = WeakReference(null)
564
+ }
565
+ runOnUiThread {
566
+ val ctx = bgReactHost?.currentReactContext ?: return@runOnUiThread
567
+ // Only clear if the bg context's tracked Activity IS the one
568
+ // being destroyed. Guards against a stale clear when the host
569
+ // Activity is swapped (e.g. multi-Activity deep-link flows).
570
+ if (ctx.currentActivity === activity) {
571
+ writeBgCurrentActivity(null)
572
+ dispatchLifecycleEventToAllowlisted(LifecycleEvent.DESTROY)
573
+ }
574
+ }
575
+ }
576
+
577
+ private enum class LifecycleEvent { RESUME, PAUSE, DESTROY }
578
+
579
+ /**
580
+ * Iterate bg ReactContext's LifecycleEventListener set and fire the
581
+ * requested callback on listeners whose class FQCN matches the
582
+ * allowlist. Modules outside the allowlist are completely unaffected
583
+ * (they stay on the pre-existing "never-resumed on bg" baseline).
584
+ *
585
+ * Note on edge case: a LifecycleEventListener registered AFTER the
586
+ * corresponding dispatch event will miss that event, because we don't
587
+ * touch mLifecycleState (doing so would cause RN's
588
+ * addLifecycleEventListener to auto-fire onHostResume on EVERY new
589
+ * listener, including non-allowlisted ones). The next resume/pause/
590
+ * destroy cycle picks it up. This is consistent with how modules that
591
+ * register late behave against the UI host anyway.
592
+ */
593
+ private fun dispatchLifecycleEventToAllowlisted(event: LifecycleEvent) {
594
+ val listeners = readBgLifecycleListeners() ?: return
595
+ for (l in listeners) {
596
+ if (!isBgListenerAllowed(l)) continue
421
597
  try {
422
- if (context.hasCatalystInstance()) {
423
- context.catalystInstance.registerSegment(segmentId, path)
424
- BTLogger.info("Segment registered in background runtime: id=$segmentId, path=$path")
425
- onComplete(null)
426
- } else {
427
- onComplete(IllegalStateException("Background CatalystInstance not available for segment registration"))
598
+ when (event) {
599
+ LifecycleEvent.RESUME -> l.onHostResume()
600
+ LifecycleEvent.PAUSE -> l.onHostPause()
601
+ LifecycleEvent.DESTROY -> l.onHostDestroy()
602
+ }
603
+ } catch (t: Throwable) {
604
+ BTLogger.error("bg lifecycle ${event.name} dispatch (${l.javaClass.name}): ${t.message}")
605
+ }
606
+ }
607
+ }
608
+
609
+ fun dispatchActivityResult(
610
+ activity: Activity,
611
+ requestCode: Int,
612
+ resultCode: Int,
613
+ data: Intent?
614
+ ) {
615
+ runOnUiThread {
616
+ val listeners = readBgActivityListeners() ?: return@runOnUiThread
617
+ for (l in listeners) {
618
+ if (!isBgListenerAllowed(l)) continue
619
+ try {
620
+ l.onActivityResult(activity, requestCode, resultCode, data)
621
+ } catch (t: Throwable) {
622
+ BTLogger.error("bg onActivityResult dispatch (${l.javaClass.name}): ${t.message}")
623
+ }
624
+ }
625
+ }
626
+ }
627
+
628
+ fun dispatchNewIntent(intent: Intent) {
629
+ runOnUiThread {
630
+ val listeners = readBgActivityListeners() ?: return@runOnUiThread
631
+ for (l in listeners) {
632
+ if (!isBgListenerAllowed(l)) continue
633
+ try {
634
+ l.onNewIntent(intent)
635
+ } catch (t: Throwable) {
636
+ BTLogger.error("bg onNewIntent dispatch (${l.javaClass.name}): ${t.message}")
428
637
  }
429
- } catch (e: Exception) {
430
- BTLogger.error("Failed to register segment in background runtime: ${e.message}")
431
- onComplete(e)
432
638
  }
433
639
  }
434
640
  }
435
641
 
642
+ /**
643
+ * Called when the bg ReactContext becomes available so we can install
644
+ * the last resumed Activity right away, covering the window where the
645
+ * host Activity resumes before the bg host finishes initializing.
646
+ */
647
+ private fun replayLastResumedActivityOnUi() {
648
+ val activity = lastResumedActivityRef.get() ?: return
649
+ runOnUiThread {
650
+ if (writeBgCurrentActivity(activity)) {
651
+ BTLogger.info("replayLastResumedActivityOnUi: mCurrentActivity=${activity.javaClass.simpleName}")
652
+ }
653
+ // Fire RESUME for allowlisted modules whose listener was
654
+ // registered before the bg ReactContext finished initializing.
655
+ dispatchLifecycleEventToAllowlisted(LifecycleEvent.RESUME)
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Reflect-write `mCurrentActivity` on the bg ReactContext so
661
+ * TurboModules on that host see a non-null getCurrentActivity().
662
+ *
663
+ * Returns true on success.
664
+ */
665
+ private fun writeBgCurrentActivity(activity: Activity?): Boolean {
666
+ val ctx = bgReactHost?.currentReactContext ?: return false
667
+ return try {
668
+ val field = findField(ctx.javaClass, "mCurrentActivity") ?: run {
669
+ BTLogger.error("writeBgCurrentActivity: mCurrentActivity field not found (RN upgrade?)")
670
+ return false
671
+ }
672
+ field.isAccessible = true
673
+ field.set(ctx, if (activity != null) WeakReference(activity) else null)
674
+ true
675
+ } catch (t: Throwable) {
676
+ BTLogger.error("writeBgCurrentActivity: ${t.message}")
677
+ false
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Reflect-read bg ReactContext's `mActivityEventListeners` set so we
683
+ * can iterate it without going through ReactContext.onActivityResult
684
+ * (which would fan out to every listener unconditionally).
685
+ */
686
+ @Suppress("UNCHECKED_CAST")
687
+ private fun readBgActivityListeners(): Collection<com.facebook.react.bridge.ActivityEventListener>? {
688
+ val ctx = bgReactHost?.currentReactContext ?: return null
689
+ return try {
690
+ val field = findField(ctx.javaClass, "mActivityEventListeners") ?: run {
691
+ BTLogger.error("readBgActivityListeners: field not found (RN upgrade?)")
692
+ return null
693
+ }
694
+ field.isAccessible = true
695
+ field.get(ctx) as? Collection<com.facebook.react.bridge.ActivityEventListener>
696
+ } catch (t: Throwable) {
697
+ BTLogger.error("readBgActivityListeners: ${t.message}")
698
+ null
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Reflect-read bg ReactContext's `mLifecycleEventListeners` set. Same
704
+ * rationale as readBgActivityListeners — we iterate ourselves so we
705
+ * can apply the allowlist filter, avoiding a fan-out to every
706
+ * LifecycleEventListener on the bg ReactContext.
707
+ */
708
+ @Suppress("UNCHECKED_CAST")
709
+ private fun readBgLifecycleListeners(): Collection<com.facebook.react.bridge.LifecycleEventListener>? {
710
+ val ctx = bgReactHost?.currentReactContext ?: return null
711
+ return try {
712
+ val field = findField(ctx.javaClass, "mLifecycleEventListeners") ?: run {
713
+ BTLogger.error("readBgLifecycleListeners: field not found (RN upgrade?)")
714
+ return null
715
+ }
716
+ field.isAccessible = true
717
+ field.get(ctx) as? Collection<com.facebook.react.bridge.LifecycleEventListener>
718
+ } catch (t: Throwable) {
719
+ BTLogger.error("readBgLifecycleListeners: ${t.message}")
720
+ null
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Walks the class hierarchy to find a declared field by name.
726
+ * `mCurrentActivity` and `mActivityEventListeners` live on
727
+ * `ReactContext`, but bg runs `BridgelessReactContext` which subclasses
728
+ * `ReactApplicationContext` which subclasses `ReactContext`, so we
729
+ * can't use getDeclaredField directly on the runtime class.
730
+ */
731
+ private fun findField(cls: Class<*>, name: String): java.lang.reflect.Field? {
732
+ var c: Class<*>? = cls
733
+ while (c != null) {
734
+ try { return c.getDeclaredField(name) } catch (_: NoSuchFieldException) {}
735
+ c = c.superclass
736
+ }
737
+ return null
738
+ }
739
+
740
+ private fun isBgListenerAllowed(listener: Any): Boolean {
741
+ val fqcn = listener.javaClass.name
742
+ val list = bgActivityBridgeListenerClassAllowlist
743
+ for (prefix in list) {
744
+ if (fqcn.startsWith(prefix)) return true
745
+ }
746
+ return false
747
+ }
748
+
749
+ private fun runOnUiThread(block: () -> Unit) {
750
+ if (Looper.myLooper() == Looper.getMainLooper()) {
751
+ block()
752
+ } else {
753
+ Handler(Looper.getMainLooper()).post(block)
754
+ }
755
+ }
756
+
436
757
  // ── Lifecycle ───────────────────────────────────────────────────────────
437
758
 
438
759
  val isBackgroundStarted: Boolean get() = isStarted
@@ -445,5 +766,6 @@ class BackgroundThreadManager private constructor() {
445
766
  bgReactHost?.destroy("BackgroundThreadManager destroyed", null)
446
767
  bgReactHost = null
447
768
  isStarted = false
769
+ lastResumedActivityRef = WeakReference(null)
448
770
  }
449
771
  }
@@ -107,7 +107,12 @@ static NSString *const MODULE_DEBUG_URL = @"http://localhost:8082/apps/mobile/ba
107
107
  self.reactNativeFactoryDelegate = [[BackgroundReactNativeDelegate alloc] init];
108
108
  self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self.reactNativeFactoryDelegate];
109
109
 
110
- [self.reactNativeFactoryDelegate setJsBundleSource:std::string([entryURL UTF8String])];
110
+ // Only set jsBundleSource for debug HTTP URLs or explicit OTA overrides.
111
+ // Leaving the default release name ("background.bundle") unset lets the
112
+ // delegate fall back to split-bundle mode (common.jsbundle + entry).
113
+ if (![entryURL isEqualToString:@"background.bundle"]) {
114
+ [self.reactNativeFactoryDelegate setJsBundleSource:std::string([entryURL UTF8String])];
115
+ }
111
116
 
112
117
  [self.reactNativeFactory.rootViewFactory viewWithModuleName:MODULE_NAME
113
118
  initialProperties:initialProperties
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-background-thread",
3
- "version": "3.0.18",
3
+ "version": "3.0.20",
4
4
  "description": "react-native-background-thread",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",