@onekeyfe/react-native-perf-stats 3.0.35 → 3.0.36

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 (35) hide show
  1. package/android/src/main/java/com/margelo/nitro/reactnativeperfstats/PerfStatsInitProvider.kt +11 -4
  2. package/android/src/main/java/com/margelo/nitro/reactnativeperfstats/ReactNativePerfStats.kt +333 -24
  3. package/ios/ReactNativePerfStats.swift +360 -15
  4. package/lib/module/index.js +77 -5
  5. package/lib/module/index.js.map +1 -1
  6. package/lib/typescript/src/ReactNativePerfStats.nitro.d.ts +109 -1
  7. package/lib/typescript/src/ReactNativePerfStats.nitro.d.ts.map +1 -1
  8. package/lib/typescript/src/index.d.ts +31 -0
  9. package/lib/typescript/src/index.d.ts.map +1 -1
  10. package/nitrogen/generated/android/c++/JFunc_void_MemoryWarningEvent.hpp +79 -0
  11. package/nitrogen/generated/android/c++/JHybridReactNativePerfStatsSpec.cpp +24 -0
  12. package/nitrogen/generated/android/c++/JHybridReactNativePerfStatsSpec.hpp +3 -0
  13. package/nitrogen/generated/android/c++/JMemoryWarningEvent.hpp +66 -0
  14. package/nitrogen/generated/android/c++/JMemoryWarningLevel.hpp +59 -0
  15. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeperfstats/Func_void_MemoryWarningEvent.kt +80 -0
  16. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeperfstats/HybridReactNativePerfStatsSpec.kt +17 -0
  17. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeperfstats/MemoryWarningEvent.kt +44 -0
  18. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeperfstats/MemoryWarningLevel.kt +21 -0
  19. package/nitrogen/generated/android/reactnativeperfstatsOnLoad.cpp +2 -0
  20. package/nitrogen/generated/ios/ReactNativePerfStats-Swift-Cxx-Bridge.cpp +8 -0
  21. package/nitrogen/generated/ios/ReactNativePerfStats-Swift-Cxx-Bridge.hpp +37 -0
  22. package/nitrogen/generated/ios/ReactNativePerfStats-Swift-Cxx-Umbrella.hpp +7 -0
  23. package/nitrogen/generated/ios/c++/HybridReactNativePerfStatsSpecSwift.hpp +27 -0
  24. package/nitrogen/generated/ios/swift/Func_void_MemoryWarningEvent.swift +47 -0
  25. package/nitrogen/generated/ios/swift/HybridReactNativePerfStatsSpec.swift +3 -0
  26. package/nitrogen/generated/ios/swift/HybridReactNativePerfStatsSpec_cxx.swift +39 -0
  27. package/nitrogen/generated/ios/swift/MemoryWarningEvent.swift +58 -0
  28. package/nitrogen/generated/ios/swift/MemoryWarningLevel.swift +40 -0
  29. package/nitrogen/generated/shared/c++/HybridReactNativePerfStatsSpec.cpp +3 -0
  30. package/nitrogen/generated/shared/c++/HybridReactNativePerfStatsSpec.hpp +7 -0
  31. package/nitrogen/generated/shared/c++/MemoryWarningEvent.hpp +84 -0
  32. package/nitrogen/generated/shared/c++/MemoryWarningLevel.hpp +76 -0
  33. package/package.json +1 -1
  34. package/src/ReactNativePerfStats.nitro.ts +114 -1
  35. package/src/index.tsx +90 -5
@@ -7,19 +7,26 @@ import android.database.Cursor
7
7
  import android.net.Uri
8
8
 
9
9
  /**
10
- * Auto-initialises [Overlay] before Application.onCreate().
10
+ * Auto-initialises [Overlay] and [MemoryWarningCenter] before
11
+ * Application.onCreate().
11
12
  *
12
13
  * ContentProvider.onCreate() runs between Application.attachBaseContext()
13
14
  * and Application.onCreate(), so registering ActivityLifecycleCallbacks
14
- * here guarantees we capture the launcher Activity's first onResumed
15
- * event. Without this hook, JS code calling showOverlay() after the React
16
- * tree mounts would arrive too late and `currentActivity` would stay null.
15
+ * and ComponentCallbacks2 here guarantees:
16
+ * - we capture the launcher Activity's first onResumed event (Overlay),
17
+ * - we are subscribed before the system can fire its first low-memory
18
+ * callback (MemoryWarningCenter).
19
+ *
20
+ * Without this hook, JS code calling showOverlay() / addMemoryWarningListener
21
+ * after the React tree mounts would arrive after early-boot memory
22
+ * pressure events and would miss them.
17
23
  */
18
24
  class PerfStatsInitProvider : ContentProvider() {
19
25
 
20
26
  override fun onCreate(): Boolean {
21
27
  val app = context?.applicationContext as? Application ?: return true
22
28
  Overlay.bootstrap(app)
29
+ MemoryWarningCenter.bootstrap(app)
23
30
  return true
24
31
  }
25
32
 
@@ -3,7 +3,10 @@ package com.margelo.nitro.reactnativeperfstats
3
3
  import android.annotation.SuppressLint
4
4
  import android.app.Activity
5
5
  import android.app.Application
6
+ import android.content.ComponentCallbacks
7
+ import android.content.ComponentCallbacks2
6
8
  import android.content.Context
9
+ import android.content.res.Configuration
7
10
  import android.graphics.Color
8
11
  import android.graphics.PixelFormat
9
12
  import android.os.Bundle
@@ -18,6 +21,8 @@ import android.view.MotionEvent
18
21
  import android.view.WindowManager
19
22
  import android.widget.TextView
20
23
  import java.util.concurrent.atomic.AtomicInteger
24
+ import java.util.concurrent.atomic.AtomicLong
25
+ import java.util.concurrent.atomic.AtomicReference
21
26
  import com.facebook.proguard.annotations.DoNotStrip
22
27
  import com.margelo.nitro.NitroModules
23
28
  import com.margelo.nitro.core.Promise
@@ -28,6 +33,10 @@ import java.util.Locale
28
33
 
29
34
  private const val TAG = "PerfStats"
30
35
  private const val MIN_INTERVAL_MS = 200L
36
+ // 24 h. `Double.POSITIVE_INFINITY.toLong()` saturates to Long.MAX_VALUE
37
+ // and `postDelayed(_, Long.MAX_VALUE)` would silently never fire again,
38
+ // so we cap before handing to the scheduler. Mirrors iOS kMaxIntervalMs.
39
+ private const val MAX_INTERVAL_MS = 86_400_000L
31
40
  // Standard Android USER_HZ. If a device differs the absolute CPU% scales
32
41
  // accordingly, but values stay self-consistent across samples.
33
42
  private const val CLOCK_TICKS_PER_SECOND = 100L
@@ -56,7 +65,11 @@ private const val JS_FPS_HINT_TTL_MS = 2_000L
56
65
  class ReactNativePerfStats : HybridReactNativePerfStatsSpec() {
57
66
 
58
67
  override fun start(intervalMs: Double) {
59
- Sampler.start(intervalMs.toLong().coerceAtLeast(MIN_INTERVAL_MS))
68
+ // Drop NaN/Inf at the JS↔native boundary: `Double.NaN.toLong()`
69
+ // returns 0 (then clamps to MIN), but `Infinity.toLong()` saturates
70
+ // to Long.MAX_VALUE and would freeze the sampler indefinitely.
71
+ val safe = if (intervalMs.isFinite()) intervalMs.toLong() else 1000L
72
+ Sampler.start(safe.coerceIn(MIN_INTERVAL_MS, MAX_INTERVAL_MS))
60
73
  }
61
74
 
62
75
  override fun stop() {
@@ -73,13 +86,35 @@ class ReactNativePerfStats : HybridReactNativePerfStatsSpec() {
73
86
 
74
87
  override fun sample(): Promise<PerfSample> {
75
88
  return Promise.async {
76
- Sampler.takeSample()
89
+ // recordBaseline=false: one-shot reads must not write the
90
+ // CPU baseline used by the periodic tick. See takeSample.
91
+ Sampler.takeSample(recordBaseline = false)
77
92
  }
78
93
  }
79
94
 
80
95
  override fun setJsFpsHint(fps: Double) {
96
+ // Drop NaN/Inf at the boundary; JsFpsHolder caches the value and
97
+ // the overlay would happily render "inf fps" otherwise.
98
+ if (!fps.isFinite()) return
81
99
  JsFpsHolder.set(fps)
82
100
  }
101
+
102
+ override fun addMemoryWarningListener(
103
+ callback: (MemoryWarningEvent) -> Unit,
104
+ ): Double {
105
+ return MemoryWarningCenter.add(callback).toDouble()
106
+ }
107
+
108
+ override fun removeMemoryWarningListener(id: Double) {
109
+ MemoryWarningCenter.remove(id.toLong())
110
+ }
111
+
112
+ override fun cleanupNativeCaches() {
113
+ // Same hint that the LOW / CRITICAL warning path runs. ART may
114
+ // ignore Runtime.gc() at low pressure, but the cost of asking
115
+ // is negligible.
116
+ MemoryWarningCenter.performNativeCleanup()
117
+ }
83
118
  }
84
119
 
85
120
  // ---- Sampler ----------------------------------------------------------
@@ -130,8 +165,35 @@ private object Sampler {
130
165
 
131
166
  fun start(intervalMsNew: Long) {
132
167
  synchronized(schedulerLock) {
168
+ val prevInterval = intervalMs
133
169
  intervalMs = intervalMsNew
134
- if (running) return
170
+ if (running) {
171
+ // Mirror iOS dispatch_source schedule() on a running timer:
172
+ // a fresh start() with a new interval re-paces the loop
173
+ // immediately rather than waiting for the old period to
174
+ // elapse. Bump generation so the in-flight tick (if any)
175
+ // self-rejects before rescheduling on the stale cadence.
176
+ if (prevInterval != intervalMsNew) {
177
+ generation++
178
+ handler?.removeCallbacksAndMessages(null)
179
+ scheduleTick(generation, handler!!)
180
+ }
181
+ return
182
+ }
183
+ // Cold-start path: reset CPU baseline now, before any tick is
184
+ // scheduled. Doing it here rather than in stop() is the only
185
+ // race-free option — stop()'s `quitSafely` doesn't synchronously
186
+ // drain a tick whose `active` check already passed, so a
187
+ // reset in stop() can be silently undone by that in-flight
188
+ // tick writing its captured cpuTicks back into lastCpuTicks.
189
+ // takeSample() called from one-shot sample() (recordBaseline
190
+ // = false) also wouldn't be able to clobber this, so the
191
+ // first periodic tick after start() always begins from a
192
+ // clean slate.
193
+ synchronized(lock) {
194
+ lastCpuTicks = -1L
195
+ lastMonoNs = -1L
196
+ }
135
197
  if (handler == null) {
136
198
  val ht = HandlerThread("PerfStatsSampler").apply { start() }
137
199
  handlerThread = ht
@@ -155,6 +217,11 @@ private object Sampler {
155
217
  handler = null
156
218
  lastUiFps = 0.0
157
219
  }
220
+ // No CPU baseline reset here on purpose: an in-flight tick that
221
+ // already passed the `active` check would race with us and write
222
+ // its captured cpuTicks back, undoing the reset. start() resets
223
+ // on its cold-start path instead, where no sampler thread is
224
+ // running yet.
158
225
  UiFpsMonitor.stop()
159
226
  Overlay.hide()
160
227
  }
@@ -275,7 +342,19 @@ private object Sampler {
275
342
  }
276
343
  }
277
344
 
278
- fun takeSample(): PerfSample {
345
+ /** Public alias used by [MemoryWarningCenter] to attach an RSS reading
346
+ * to each emitted event. Safe to call from any thread. */
347
+ fun residentBytesPublic(): Long = readResidentBytes()
348
+
349
+ /**
350
+ * @param recordBaseline true for periodic ticks (the next sample's
351
+ * delta is computed against this read). false for one-shot
352
+ * sample() calls so they don't pollute the periodic CPU% baseline
353
+ * — between stop() and start(), or between two periodic ticks,
354
+ * a sample() would otherwise insert a new baseline that the next
355
+ * periodic tick reads, producing a CPU% covering the gap.
356
+ */
357
+ fun takeSample(recordBaseline: Boolean = true): PerfSample {
279
358
  val nowMonoNs = System.nanoTime()
280
359
  val cpuTicks = readProcessCpuTicks()
281
360
  val rssBytes = readResidentBytes()
@@ -294,7 +373,7 @@ private object Sampler {
294
373
  cpuPct = (cpuSec / wallSec) * 100.0
295
374
  }
296
375
  }
297
- if (cpuTicks != null) {
376
+ if (recordBaseline && cpuTicks != null) {
298
377
  lastCpuTicks = cpuTicks
299
378
  lastMonoNs = nowMonoNs
300
379
  }
@@ -384,24 +463,29 @@ private object UiFpsMonitor {
384
463
  }
385
464
 
386
465
  fun start() {
387
- if (registered) return
388
- registered = true
466
+ // Idempotency check runs inside the main-thread lambda so two
467
+ // concurrent start() calls can't both pass the gate and post the
468
+ // callback twice — Choreographer happily enqueues the same instance
469
+ // multiple times, which would double (then exponentially grow) the
470
+ // frame count and repost cadence.
389
471
  mainHandler.post {
390
- // Re-check inside the post: a stop() may have raced ahead
391
- // before this lambda ran.
392
- if (!registered) return@post
472
+ if (registered) return@post
473
+ registered = true
393
474
  Choreographer.getInstance().postFrameCallback(callback)
394
475
  }
395
476
  }
396
477
 
397
478
  fun stop() {
398
- if (!registered) return
399
- registered = false
479
+ // Mirror start(): serialize the toggle on the main looper so a
480
+ // start/stop pair posted from a non-main thread can't interleave
481
+ // out of order.
400
482
  mainHandler.post {
483
+ if (!registered) return@post
484
+ registered = false
401
485
  Choreographer.getInstance().removeFrameCallback(callback)
486
+ frameCounter.set(0)
487
+ lastReadMonoNs = -1L
402
488
  }
403
- frameCounter.set(0)
404
- lastReadMonoNs = -1L
405
489
  }
406
490
 
407
491
  /**
@@ -430,20 +514,21 @@ private object UiFpsMonitor {
430
514
  // stopped or has not yet booted.
431
515
 
432
516
  private object JsFpsHolder {
433
- @Volatile private var lastFps: Double = 0.0
434
- @Volatile private var lastSetMonoNs: Long = -1L
517
+ // Pair holds (fps, lastSetMonoNs). Stored together so a concurrent
518
+ // reader can't observe a new timestamp paired with a stale fps (or
519
+ // vice versa) — which two independent @Volatile fields would allow.
520
+ private val state = AtomicReference(Pair(0.0, -1L))
435
521
 
436
522
  fun set(fps: Double) {
437
- lastFps = fps
438
- lastSetMonoNs = System.nanoTime()
523
+ state.set(Pair(fps, System.nanoTime()))
439
524
  }
440
525
 
441
526
  fun read(nowMonoNs: Long): Double {
442
- val last = lastSetMonoNs
527
+ val (fps, last) = state.get()
443
528
  if (last < 0) return 0.0
444
529
  val ageMs = (nowMonoNs - last) / 1_000_000L
445
530
  if (ageMs > JS_FPS_HINT_TTL_MS) return 0.0
446
- return lastFps
531
+ return fps
447
532
  }
448
533
  }
449
534
 
@@ -488,11 +573,27 @@ internal object Overlay : Application.ActivityLifecycleCallbacks {
488
573
  @Volatile private var posX: Int = -1
489
574
  @Volatile private var posY: Int = -1
490
575
 
576
+ // Configuration callback for rotation / split-screen / foldable
577
+ // size changes. Activities declaring android:configChanges (a common
578
+ // optimization in RN apps) don't re-create on rotation, so
579
+ // attachIfPossible's posted clamp never re-runs — handle it here.
580
+ private val configCallbacks = object : ComponentCallbacks {
581
+ override fun onConfigurationChanged(newConfig: Configuration) {
582
+ val view = overlayView ?: return
583
+ mainHandler.post { clampOverlayToWindow(view) }
584
+ }
585
+ override fun onLowMemory() {
586
+ // No-op. Memory pressure is handled by MemoryWarningCenter
587
+ // via ComponentCallbacks2 on a separate registration.
588
+ }
589
+ }
590
+
491
591
  /** Called from PerfStatsInitProvider at process start so we never miss
492
592
  * the launcher Activity's onResumed event. */
493
593
  fun bootstrap(app: Application) {
494
594
  if (registered) return
495
595
  app.registerActivityLifecycleCallbacks(this)
596
+ app.registerComponentCallbacks(configCallbacks)
496
597
  registered = true
497
598
  }
498
599
 
@@ -541,6 +642,7 @@ internal object Overlay : Application.ActivityLifecycleCallbacks {
541
642
  return
542
643
  }
543
644
  app.registerActivityLifecycleCallbacks(this)
645
+ app.registerComponentCallbacks(configCallbacks)
544
646
  registered = true
545
647
  }
546
648
 
@@ -584,11 +686,48 @@ internal object Overlay : Application.ActivityLifecycleCallbacks {
584
686
  overlayView = tv
585
687
  attachDragListener(tv)
586
688
  lastSample?.let { tv.text = renderText(it) }
689
+ // posX/posY are persisted from previous orientations and
690
+ // sessions; after rotation or a fold/unfold the saved
691
+ // position can land off the new window. Posted after addView
692
+ // so tv.width/height are measured, then re-clamped once.
693
+ tv.post { clampOverlayToWindow(tv) }
587
694
  } catch (e: Exception) {
588
695
  OneKeyLog.warn(TAG, "Failed to addView overlay: ${e.message}")
589
696
  }
590
697
  }
591
698
 
699
+ /** Preferred window size for clamping: the host Activity's decorView
700
+ * (correct under split-screen / foldable, where the Activity owns
701
+ * only part of the physical display), falling back to displayMetrics
702
+ * if the Activity / decorView isn't ready yet. */
703
+ private fun resolveWindowSize(view: TextView): Pair<Int, Int> {
704
+ val activity = view.context as? Activity
705
+ val decor = activity?.window?.decorView
706
+ val metrics = view.context.resources.displayMetrics
707
+ val w = decor?.width?.takeIf { it > 0 } ?: metrics.widthPixels
708
+ val h = decor?.height?.takeIf { it > 0 } ?: metrics.heightPixels
709
+ return w to h
710
+ }
711
+
712
+ /** Re-clamps the overlay's current params to the host window. Called
713
+ * after addView (when posX/posY may have come from a different
714
+ * orientation) and could be reused on configuration changes. */
715
+ private fun clampOverlayToWindow(tv: TextView) {
716
+ val (winW, winH) = resolveWindowSize(tv)
717
+ val maxX = (winW - tv.width).coerceAtLeast(0)
718
+ val maxY = (winH - tv.height).coerceAtLeast(0)
719
+ val curParams = (tv.layoutParams as? WindowManager.LayoutParams) ?: return
720
+ val newX = curParams.x.coerceIn(0, maxX)
721
+ val newY = curParams.y.coerceIn(0, maxY)
722
+ if (newX == curParams.x && newY == curParams.y) return
723
+ curParams.x = newX
724
+ curParams.y = newY
725
+ posX = newX
726
+ posY = newY
727
+ val wm = tv.context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
728
+ try { wm?.updateViewLayout(tv, curParams) } catch (_: Exception) {}
729
+ }
730
+
592
731
  private fun detach() {
593
732
  val view = overlayView ?: return
594
733
  if (attachedToWindowManager) {
@@ -640,17 +779,34 @@ internal object Overlay : Application.ActivityLifecycleCallbacks {
640
779
  true
641
780
  }
642
781
  MotionEvent.ACTION_MOVE -> {
782
+ // Clamp to the host Activity window — without this the
783
+ // overlay can be dragged off-screen and stays
784
+ // unreachable, since hit-testing follows params.x/y.
785
+ // resolveWindowSize uses decorView dimensions so
786
+ // split-screen / foldable sessions don't let the
787
+ // overlay wander into another app's pane (where the
788
+ // overlay's window token can't receive touches).
643
789
  val newX = (event.rawX - dX).toInt()
644
790
  val newY = (event.rawY - dY).toInt()
645
- params.x = newX
646
- params.y = newY
647
- posX = newX
648
- posY = newY
791
+ val (winW, winH) = resolveWindowSize(v as TextView)
792
+ val maxX = (winW - v.width).coerceAtLeast(0)
793
+ val maxY = (winH - v.height).coerceAtLeast(0)
794
+ val clampedX = newX.coerceIn(0, maxX)
795
+ val clampedY = newY.coerceIn(0, maxY)
796
+ params.x = clampedX
797
+ params.y = clampedY
798
+ posX = clampedX
799
+ posY = clampedY
649
800
  val wm = v.context.getSystemService(Context.WINDOW_SERVICE)
650
801
  as? WindowManager
651
802
  try { wm?.updateViewLayout(v, params) } catch (_: Exception) {}
652
803
  true
653
804
  }
805
+ // Terminate the gesture cleanly so the touch sequence
806
+ // we claimed in ACTION_DOWN doesn't leak to whatever is
807
+ // below the overlay window on the up-stroke.
808
+ MotionEvent.ACTION_UP,
809
+ MotionEvent.ACTION_CANCEL -> true
654
810
  else -> false
655
811
  }
656
812
  }
@@ -692,3 +848,156 @@ internal object Overlay : Application.ActivityLifecycleCallbacks {
692
848
  }
693
849
  }
694
850
  }
851
+
852
+ // ---- MemoryWarningCenter ---------------------------------------------
853
+ //
854
+ // Bridges Android's ComponentCallbacks2 callbacks to Nitro listeners.
855
+ //
856
+ // Levels are normalised to match iOS:
857
+ // - TRIM_MEMORY_RUNNING_MODERATE / TRIM_MEMORY_RUNNING_LOW -> "low"
858
+ // - TRIM_MEMORY_RUNNING_CRITICAL -> "critical"
859
+ // - onLowMemory() (deprecated, fires alongside CRITICAL or
860
+ // standalone on older devices) -> "critical"
861
+ // - TRIM_MEMORY_UI_HIDDEN and TRIM_MEMORY_BACKGROUND/MODERATE/COMPLETE
862
+ // are ignored. UI_HIDDEN is a backgrounding signal, not memory
863
+ // pressure; the BACKGROUND tier is too late to be useful for a
864
+ // foreground-pressure response (process is already targeted for
865
+ // LRU eviction). Subscribers who care about backgrounding can use
866
+ // AppState directly.
867
+ //
868
+ // Registration is process-wide via `Application.registerComponentCallbacks`,
869
+ // triggered lazily by [PerfStatsInitProvider] or the first `add()` call.
870
+ // Listeners are invoked on the main thread (the thread the callbacks
871
+ // fire on). Nitro's dispatcher hops back to the JS thread before the JS
872
+ // closure runs.
873
+ //
874
+ // We coalesce duplicate critical events that arrive within
875
+ // [DEDUP_WINDOW_MS]: Android often fires onLowMemory() right after
876
+ // TRIM_MEMORY_RUNNING_CRITICAL, and we don't want JS to receive two
877
+ // back-to-back cache-purge signals.
878
+
879
+ internal object MemoryWarningCenter : ComponentCallbacks2 {
880
+ private const val MEMORY_TAG = "PerfStats.Memory"
881
+ private const val DEDUP_WINDOW_MS = 500L
882
+
883
+ private val lock = Any()
884
+ private val listeners = LinkedHashMap<Long, (MemoryWarningEvent) -> Unit>()
885
+ private val nextId = AtomicLong(1)
886
+ @Volatile private var registered = false
887
+ private val lastEmitTimestamp = AtomicLong(0L)
888
+
889
+ /** Called from [PerfStatsInitProvider] at process start so we never
890
+ * miss the first memory-pressure event on Android 14+ where the
891
+ * system may fire one shortly after Application onCreate. */
892
+ fun bootstrap(app: Application) {
893
+ ensureRegistered(app)
894
+ }
895
+
896
+ fun add(callback: (MemoryWarningEvent) -> Unit): Long {
897
+ val id = nextId.getAndIncrement()
898
+ synchronized(lock) { listeners[id] = callback }
899
+ // Late-init fallback: PerfStatsInitProvider normally registers
900
+ // us, but defend against test harnesses or stripped manifests.
901
+ ensureRegistered(null)
902
+ return id
903
+ }
904
+
905
+ fun remove(id: Long) {
906
+ synchronized(lock) { listeners.remove(id) }
907
+ }
908
+
909
+ private fun ensureRegistered(appHint: Application?) {
910
+ if (registered) return
911
+ val app = appHint
912
+ ?: (NitroModules.applicationContext as? Application)
913
+ ?: run {
914
+ OneKeyLog.warn(MEMORY_TAG, "applicationContext is null; skipping registration")
915
+ return
916
+ }
917
+ synchronized(lock) {
918
+ if (registered) return
919
+ app.registerComponentCallbacks(this)
920
+ registered = true
921
+ }
922
+ }
923
+
924
+ override fun onTrimMemory(level: Int) {
925
+ val normalised = when (level) {
926
+ ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE,
927
+ ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> MemoryWarningLevel.LOW
928
+ ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> MemoryWarningLevel.CRITICAL
929
+ else -> return // background / UI_HIDDEN — see header comment
930
+ }
931
+ emit(normalised, "onTrimMemory($level)")
932
+ }
933
+
934
+ override fun onLowMemory() {
935
+ emit(MemoryWarningLevel.CRITICAL, "onLowMemory")
936
+ }
937
+
938
+ override fun onConfigurationChanged(newConfig: Configuration) {
939
+ // No-op. We only implement ComponentCallbacks2 for the memory
940
+ // callbacks above; the configuration channel is unused.
941
+ }
942
+
943
+ private fun emit(level: MemoryWarningLevel, source: String) {
944
+ val nowMs = System.currentTimeMillis()
945
+ if (level == MemoryWarningLevel.CRITICAL) {
946
+ // Suppress: TRIM_MEMORY_RUNNING_CRITICAL + onLowMemory() often
947
+ // arrive within tens of ms of each other; one cache purge is
948
+ // enough. ComponentCallbacks2 fires on the main thread in
949
+ // practice, but we use CAS so the read/check/write window is
950
+ // closed even if a future Android version posts these from a
951
+ // worker thread.
952
+ val prev = lastEmitTimestamp.get()
953
+ if (nowMs - prev < DEDUP_WINDOW_MS) return
954
+ if (!lastEmitTimestamp.compareAndSet(prev, nowMs)) return
955
+ }
956
+ val rssBytes = Sampler.residentBytesPublic()
957
+ val event = MemoryWarningEvent(
958
+ level = level,
959
+ rss = rssBytes.toDouble(),
960
+ timestamp = nowMs.toDouble(),
961
+ )
962
+ OneKeyLog.warn(
963
+ MEMORY_TAG,
964
+ String.format(
965
+ Locale.US,
966
+ "Memory warning received (%s) from %s, RSS=%.1f MB",
967
+ level.name.lowercase(Locale.US), source, rssBytes / 1024.0 / 1024.0,
968
+ ),
969
+ )
970
+
971
+ // Native cleanup before notifying JS subscribers. On Android the
972
+ // analogues of iOS's URLCache / malloc_pressure are far weaker:
973
+ // - Dalvik/ART has no equivalent of malloc_zone_pressure_relief;
974
+ // `System.gc()` is a hint only, the runtime decides whether to
975
+ // honour it. We still call it because under TRIM_MEMORY_RUNNING_*
976
+ // the runtime is more likely to act on the hint.
977
+ // - HTTP caches live inside each OkHttpClient and there's no
978
+ // process-wide shared instance to drop, so we leave that to JS-
979
+ // side subscribers that own their clients.
980
+ // - WebView cache clearing needs a WebView instance; defer to JS.
981
+ //
982
+ // Fires on both LOW and CRITICAL: iOS triggers cleanup on every
983
+ // warning (there's only one level), and JS handlers reasonably
984
+ // assume "native already attempted a reclaim before this event"
985
+ // across platforms. Runtime.gc() is a hint anyway, so even when
986
+ // ART ignores it on LOW the cost is negligible.
987
+ performNativeCleanup()
988
+
989
+ val snapshot = synchronized(lock) { listeners.values.toList() }
990
+ for (cb in snapshot) cb(event)
991
+ }
992
+
993
+ internal fun performNativeCleanup() {
994
+ // Hint to ART; safe to call from main, returns quickly. ART may
995
+ // ignore under low pressure but under TRIM_MEMORY_RUNNING_CRITICAL
996
+ // it generally promotes to a concurrent collection.
997
+ try {
998
+ Runtime.getRuntime().gc()
999
+ } catch (e: Throwable) {
1000
+ OneKeyLog.warn(MEMORY_TAG, "Runtime.gc() failed: ${e.message}")
1001
+ }
1002
+ }
1003
+ }