@buoy-gg/bug-links 0.0.0-stage → 7.0.80

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 (100) hide show
  1. package/BuoyBugLinks.podspec +18 -0
  2. package/LICENSE +58 -0
  3. package/README.md +25 -2
  4. package/android/build.gradle +31 -0
  5. package/android/consumer-rules.pro +8 -0
  6. package/android/src/main/AndroidManifest.xml +1 -0
  7. package/android/src/main/java/gg/buoy/buglinks/frames/BuoyFramesModule.kt +53 -0
  8. package/android/src/main/java/gg/buoy/buglinks/frames/BuoyFramesPackage.kt +11 -0
  9. package/android/src/main/java/gg/buoy/buglinks/frames/FrameEngine.kt +707 -0
  10. package/android/src/main/java/gg/buoy/buglinks/frames/FrameWalker.kt +181 -0
  11. package/ios/BuoyFrames.m +17 -0
  12. package/ios/BuoyFrames.swift +640 -0
  13. package/ios/BuoyFramesWalk.swift +213 -0
  14. package/lib/commonjs/components/BugLinksBody.js +1 -0
  15. package/lib/commonjs/components/TeamBugs.js +1 -0
  16. package/lib/commonjs/components/focus.js +1 -0
  17. package/lib/commonjs/components/overlays.js +1 -0
  18. package/lib/commonjs/components/topBanners.js +1 -0
  19. package/lib/commonjs/engine/client.js +1 -0
  20. package/lib/commonjs/engine/credential.js +1 -0
  21. package/lib/commonjs/engine/linksStore.js +1 -0
  22. package/lib/commonjs/engine/loader.js +1 -0
  23. package/lib/commonjs/engine/opener.js +1 -0
  24. package/lib/commonjs/engine/otherTabs.js +1 -0
  25. package/lib/commonjs/engine/phoneCheck.js +1 -0
  26. package/lib/commonjs/engine/phoneCrypto.js +1 -0
  27. package/lib/commonjs/engine/randomBytes.js +1 -0
  28. package/lib/commonjs/engine/reporter.js +1 -0
  29. package/lib/commonjs/engine/sandbox.js +1 -0
  30. package/lib/commonjs/engine/settings.js +1 -0
  31. package/lib/commonjs/engine/tabStorage.js +1 -0
  32. package/lib/commonjs/engine/teamKeys.js +1 -0
  33. package/lib/commonjs/engine/teamList.js +1 -0
  34. package/lib/commonjs/index.js +1 -0
  35. package/lib/commonjs/media/BuoyMask.js +1 -0
  36. package/lib/commonjs/media/frames.js +1 -0
  37. package/lib/commonjs/media/nativeFrames.js +1 -0
  38. package/lib/commonjs/media/nativeFrames.web.js +1 -0
  39. package/lib/commonjs/media/webFrames.js +14 -0
  40. package/lib/commonjs/package.json +1 -0
  41. package/lib/commonjs/preset.js +1 -0
  42. package/lib/commonjs/sync/bugLinksSyncAdapter.js +1 -0
  43. package/lib/module/components/BugLinksBody.js +1 -0
  44. package/lib/module/components/TeamBugs.js +1 -0
  45. package/lib/module/components/focus.js +1 -0
  46. package/lib/module/components/overlays.js +1 -0
  47. package/lib/module/components/topBanners.js +1 -0
  48. package/lib/module/engine/client.js +1 -0
  49. package/lib/module/engine/credential.js +1 -0
  50. package/lib/module/engine/linksStore.js +1 -0
  51. package/lib/module/engine/loader.js +1 -0
  52. package/lib/module/engine/opener.js +1 -0
  53. package/lib/module/engine/otherTabs.js +1 -0
  54. package/lib/module/engine/phoneCheck.js +1 -0
  55. package/lib/module/engine/phoneCrypto.js +1 -0
  56. package/lib/module/engine/randomBytes.js +1 -0
  57. package/lib/module/engine/reporter.js +1 -0
  58. package/lib/module/engine/sandbox.js +1 -0
  59. package/lib/module/engine/settings.js +1 -0
  60. package/lib/module/engine/tabStorage.js +1 -0
  61. package/lib/module/engine/teamKeys.js +1 -0
  62. package/lib/module/engine/teamList.js +1 -0
  63. package/lib/module/index.js +1 -0
  64. package/lib/module/media/BuoyMask.js +1 -0
  65. package/lib/module/media/frames.js +1 -0
  66. package/lib/module/media/nativeFrames.js +1 -0
  67. package/lib/module/media/nativeFrames.web.js +1 -0
  68. package/lib/module/media/webFrames.js +14 -0
  69. package/lib/module/preset.js +1 -0
  70. package/lib/module/sync/bugLinksSyncAdapter.js +1 -0
  71. package/lib/typescript/components/BugLinksBody.d.ts +2 -0
  72. package/lib/typescript/components/TeamBugs.d.ts +8 -0
  73. package/lib/typescript/components/focus.d.ts +6 -0
  74. package/lib/typescript/components/overlays.d.ts +10 -0
  75. package/lib/typescript/components/topBanners.d.ts +35 -0
  76. package/lib/typescript/engine/client.d.ts +78 -0
  77. package/lib/typescript/engine/credential.d.ts +11 -0
  78. package/lib/typescript/engine/linksStore.d.ts +132 -0
  79. package/lib/typescript/engine/loader.d.ts +172 -0
  80. package/lib/typescript/engine/opener.d.ts +21 -0
  81. package/lib/typescript/engine/otherTabs.d.ts +52 -0
  82. package/lib/typescript/engine/phoneCheck.d.ts +8 -0
  83. package/lib/typescript/engine/phoneCrypto.d.ts +16 -0
  84. package/lib/typescript/engine/randomBytes.d.ts +7 -0
  85. package/lib/typescript/engine/reporter.d.ts +53 -0
  86. package/lib/typescript/engine/sandbox.d.ts +83 -0
  87. package/lib/typescript/engine/settings.d.ts +42 -0
  88. package/lib/typescript/engine/tabStorage.d.ts +6 -0
  89. package/lib/typescript/engine/teamKeys.d.ts +37 -0
  90. package/lib/typescript/engine/teamList.d.ts +63 -0
  91. package/lib/typescript/index.d.ts +23 -0
  92. package/lib/typescript/media/BuoyMask.d.ts +19 -0
  93. package/lib/typescript/media/frames.d.ts +34 -0
  94. package/lib/typescript/media/nativeFrames.d.ts +2 -0
  95. package/lib/typescript/media/nativeFrames.web.d.ts +2 -0
  96. package/lib/typescript/media/webFrames.d.ts +178 -0
  97. package/lib/typescript/preset.d.ts +50 -0
  98. package/lib/typescript/sync/bugLinksSyncAdapter.d.ts +41 -0
  99. package/lib/web/index.mjs +20 -0
  100. package/package.json +119 -3
@@ -0,0 +1,707 @@
1
+ // Bug Links pictures and video on Android (research/bug-links-2026-10/media/SPEC.md).
2
+ //
3
+ // The way to take a frame won a race on a real Pixel (SPEC section 6, a4):
4
+ // PixelCopy of the window straight into a 1x bitmap, off the main thread.
5
+ //
6
+ // Safety, from the 2026-10-26 compatibility review:
7
+ // - Android 7.0 and 7.1 have no window PixelCopy: no frames, with a reason;
8
+ // - a protected window (FLAG_SECURE) gives no frame;
9
+ // - an RN Modal is its own window: it is copied and walked too, or the frame
10
+ // is dropped;
11
+ // - boxes are read a tick before, right before and right after the copy (H13);
12
+ // - every job carries a run token and a deadline, so a late or lost copy can't
13
+ // touch a newer run or keep the lane busy.
14
+ package gg.buoy.buglinks.frames
15
+
16
+ import android.app.Activity
17
+ import android.app.Dialog
18
+ import android.graphics.Bitmap
19
+ import android.graphics.Canvas
20
+ import android.graphics.Color
21
+ import android.graphics.Paint
22
+ import android.graphics.Rect
23
+ import android.graphics.RectF
24
+ import android.content.pm.ApplicationInfo
25
+ import android.os.Build
26
+ import android.os.Handler
27
+ import android.os.HandlerThread
28
+ import android.os.Looper
29
+ import android.os.SystemClock
30
+ import android.util.Base64
31
+ import android.view.Choreographer
32
+ import android.view.MotionEvent
33
+ import android.view.PixelCopy
34
+ import android.view.View
35
+ import android.view.Window
36
+ import android.view.WindowManager
37
+ import com.facebook.react.bridge.Arguments
38
+ import com.facebook.react.bridge.LifecycleEventListener
39
+ import com.facebook.react.bridge.ReactApplicationContext
40
+ import com.facebook.react.bridge.ReadableMap
41
+ import com.facebook.react.bridge.WritableMap
42
+ import com.facebook.react.common.LifecycleState
43
+ import java.io.ByteArrayOutputStream
44
+ import kotlin.math.max
45
+ import kotlin.math.min
46
+ import kotlin.math.roundToInt
47
+
48
+ /** C2: video frames this close to a "Bug here" mark are kept first. */
49
+ private const val NEAR_MARK_MS = 3000.0
50
+
51
+ /** 0 until n, in an order where every start of the list spans the range (ends, middle, quarters…). */
52
+ internal fun spreadOrder(n: Int): List<Int> {
53
+ val out = ArrayList<Int>(n)
54
+ if (n <= 0) return out
55
+ val used = BooleanArray(n)
56
+ fun add(i: Int) { if (!used[i]) { used[i] = true; out.add(i) } }
57
+ add(0); add(n - 1)
58
+ var parts = 2
59
+ while (out.size < n) {
60
+ var k = 1
61
+ while (k < parts) { add(Math.round((n - 1).toDouble() * k / parts).toInt()); k += 2 }
62
+ if (parts > n * 2) for (i in 0 until n) add(i)
63
+ parts *= 2
64
+ }
65
+ return out
66
+ }
67
+
68
+ class Frame(val t: Double, val kind: String, val w: Int, val h: Int, val jpeg: ByteArray, val boxes: Int, val moved: Boolean)
69
+
70
+ class FrameEngine(private val context: ReactApplicationContext) {
71
+ private val main = Handler(Looper.getMainLooper())
72
+ private val thread = HandlerThread("buoy-frames").apply { start() }
73
+ private val bg = Handler(thread.looper)
74
+
75
+ // Settings (S1, M1)
76
+ private var pictures = true
77
+ private var customer = false
78
+ private val masks = MaskSettings()
79
+ private var videoFps = 2.0
80
+ private var enabled = false
81
+
82
+ // Rings (F2, F3, F4). Main thread only.
83
+ private val taps = ArrayList<Frame>()
84
+ private val video = ArrayList<Frame>()
85
+ private var last: Frame? = null
86
+ private val marks = ArrayList<Double>()
87
+ private val dropped = HashMap<String, Int>()
88
+ private var frozen = false
89
+ private var lastReason: String? = null
90
+ // F8: the time of the last frame taken when Buoy's button was touched, and
91
+ // whether the touch going on now started on Buoy (no tap frame for it).
92
+ private var buttonLastT: Double? = null
93
+ private var downOnBuoy = false
94
+
95
+ // One job at a time. `run` goes up on every stop, so late jobs are ignored.
96
+ private var run = 0
97
+ private var busy = false
98
+ private var slowInRow = 0
99
+ private var stoppedReason: String? = null
100
+
101
+ // Video
102
+ private var videoOn = false
103
+ private var videoAlways = false
104
+ private var videoUntil = 0L
105
+ private var nextVideoDue = 0L
106
+ private val videoClock = object : Choreographer.FrameCallback {
107
+ override fun doFrame(frameTimeNanos: Long) {
108
+ if (!videoOn) return
109
+ videoTick()
110
+ if (videoOn) Choreographer.getInstance().postFrameCallback(this)
111
+ }
112
+ }
113
+
114
+ // Taps
115
+ private var wrapped: Window? = null
116
+ private var watcher: TapCallback? = null
117
+ private val tapRunnable = Runnable { take("tap", 1, null) }
118
+
119
+ companion object {
120
+ private const val MAX_LONG_SIDE = 1280f
121
+ private const val MAX_VIDEO_BYTES = 6 * 1024 * 1024
122
+ private const val COPY_DEADLINE_MS = 1000L
123
+ private val BOX = Paint().apply { color = Color.rgb(0x1F, 0x1F, 0x1F); style = Paint.Style.FILL } // H3
124
+ }
125
+
126
+ // MARK: settings
127
+
128
+ // A new Activity (the system can rebuild it) has a new window: watch it as soon as the app is back.
129
+ private val lifecycle = object : LifecycleEventListener {
130
+ override fun onHostResume() { if (enabled) attachWatcher() }
131
+ override fun onHostPause() {}
132
+ override fun onHostDestroy() {}
133
+ }
134
+ private var listening = false
135
+
136
+ fun configure(o: ReadableMap) {
137
+ if (!listening) { context.addLifecycleEventListener(lifecycle); listening = true }
138
+ val wasCustomer = customer
139
+ val old = Triple(masks.text, masks.images, masks.inputs)
140
+ pictures = !o.hasKey("pictures") || o.getBoolean("pictures")
141
+ customer = o.hasKey("mode") && o.getString("mode") == "customer"
142
+ masks.text = !o.hasKey("maskAllText") || o.getBoolean("maskAllText")
143
+ masks.images = !o.hasKey("maskAllImages") || o.getBoolean("maskAllImages")
144
+ masks.inputs = !o.hasKey("maskAllInputs") || o.getBoolean("maskAllInputs")
145
+ // A hiding setting that turned on (or customer mode): frames already taken hid less, so they go.
146
+ // Here, not only in JS: a JS reload brings fresh settings to frames an older JS took.
147
+ if ((!old.first && masks.text) || (!old.second && masks.images) || (!old.third && masks.inputs) || (customer && !wasCustomer)) clear()
148
+ if (o.hasKey("videoFps")) videoFps = o.getDouble("videoFps").coerceIn(0.5, 10.0)
149
+ enabled = true
150
+ attachWatcher()
151
+ val v = if (o.hasKey("video")) o.getString("video") else null
152
+ if (v == "always" && !customer) startVideo(true) else if (videoAlways) stopVideo()
153
+ }
154
+
155
+ fun status(): WritableMap = Arguments.createMap().apply {
156
+ putBoolean("available", Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
157
+ putBoolean("enabled", enabled)
158
+ putString("mode", if (customer) "customer" else "internal")
159
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) putString("stopped", "pictures need Android 8.0 or newer")
160
+ else stoppedReason?.let { putString("stopped", it) }
161
+ }
162
+
163
+ // MARK: taps
164
+
165
+ private fun attachWatcher() {
166
+ val window = context.currentActivity?.window ?: return
167
+ // Other SDKs (Sentry) wrap the callback too, so ours may not be on top:
168
+ // remember the window instead of checking the callback, or every frame
169
+ // would wrap it again.
170
+ if (wrapped === window) return
171
+ detachWatcher()
172
+ val base = window.callback ?: return
173
+ val w = TapCallback(base, ::touchDown, ::tapped, ::activityFocus)
174
+ window.callback = w
175
+ wrapped = window
176
+ watcher = w
177
+ // A Modal may already be open when pictures start.
178
+ watchModals()
179
+ }
180
+
181
+ // An RN Modal is its own window, so its taps never reach the Activity's.
182
+ // The Activity loses focus when one opens: watch the Modal's window then.
183
+ private val modalWatchers = java.util.WeakHashMap<Window, TapCallback>()
184
+
185
+ /** When each Modal window was first seen, so stacked Modals draw in the order they opened. */
186
+ private val modalOrder = java.util.WeakHashMap<Window, Long>()
187
+ private var modalSeq = 0L
188
+
189
+ private fun activityFocus(hasFocus: Boolean) {
190
+ if (!hasFocus) main.post { watchModals() }
191
+ }
192
+
193
+ private fun watchModals() {
194
+ val decor = context.currentActivity?.window?.decorView ?: return
195
+ val hosts = ArrayList<View>()
196
+ collectModals(decor, hosts)
197
+ for (host in hosts) {
198
+ val w = modalDialog(host)?.window ?: continue
199
+ if (!modalOrder.containsKey(w)) modalOrder[w] = ++modalSeq
200
+ if (modalWatchers.containsKey(w)) continue
201
+ val base = w.callback ?: continue
202
+ // A touch in a Modal is never on Buoy's floating button. A Modal that
203
+ // loses focus may have opened another above it; a closed one leaves the map.
204
+ val cb = TapCallback(base, { _, _ -> downOnBuoy = false }, ::tapped, ::activityFocus, { modalWatchers.remove(w) })
205
+ w.callback = cb
206
+ modalWatchers[w] = cb
207
+ }
208
+ }
209
+
210
+ private fun unwatchModals() {
211
+ for ((w, cb) in modalWatchers) if (w.callback === cb) w.callback = cb.base else cb.live = false
212
+ modalWatchers.clear()
213
+ }
214
+
215
+ private fun detachWatcher() {
216
+ unwatchModals()
217
+ val w = wrapped ?: return
218
+ val mine = watcher
219
+ // If another SDK wrapped ours, it can't come out: turn it off instead.
220
+ if (mine != null && w.callback === mine) w.callback = mine.base else mine?.live = false
221
+ wrapped = null
222
+ watcher = null
223
+ }
224
+
225
+ /** A touch on Buoy's floating button: take the last frame now, before the dial draws (F8). */
226
+ private fun touchDown(x: Float, y: Float) {
227
+ val decor = context.currentActivity?.window?.decorView ?: return
228
+ downOnBuoy = onBuoyButton(decor, x, y)
229
+ if (!downOnBuoy || !enabled || frozen || busy) return
230
+ take("last", 1) { ok -> if (ok) buttonLastT = last?.t }
231
+ }
232
+
233
+ private fun onBuoyButton(decor: View, x: Float, y: Float): Boolean {
234
+ val root = findById(decor, FrameWalker.BUOY_ROOT) ?: return false
235
+ val loc = IntArray(2)
236
+ val stack = ArrayList<View>().apply { add(root) }
237
+ while (stack.isNotEmpty()) {
238
+ val v = stack.removeAt(stack.size - 1)
239
+ if (v.visibility != View.VISIBLE) continue
240
+ if (v !== root) {
241
+ v.getLocationInWindow(loc)
242
+ if (x >= loc[0] && x < loc[0] + v.width && y >= loc[1] && y < loc[1] + v.height &&
243
+ v.width * v.height < decor.width * decor.height / 2) return true
244
+ }
245
+ if (v is android.view.ViewGroup) for (i in 0 until v.childCount) v.getChildAt(i)?.let { stack.add(it) }
246
+ }
247
+ return false
248
+ }
249
+
250
+ private fun findById(root: View, id: String): View? {
251
+ val stack = ArrayList<View>().apply { add(root) }
252
+ while (stack.isNotEmpty()) {
253
+ val v = stack.removeAt(stack.size - 1)
254
+ if (FrameWalker.nativeId(v) == id) return v
255
+ if (v is android.view.ViewGroup) for (i in 0 until v.childCount) v.getChildAt(i)?.let { stack.add(it) }
256
+ }
257
+ return null
258
+ }
259
+
260
+ private fun tapped() {
261
+ // A tap on Buoy's own button would show Buoy's toast or dial, not the app.
262
+ if (downOnBuoy) { downOnBuoy = false; return }
263
+ if (!enabled || !pictures || customer || frozen || stoppedReason != null) return
264
+ main.removeCallbacks(tapRunnable)
265
+ main.postDelayed(tapRunnable, 300) // F2
266
+ }
267
+
268
+ // MARK: video
269
+
270
+ fun startVideo(always: Boolean) {
271
+ if (!enabled || customer) return
272
+ videoAlways = always
273
+ if (!always) { video.clear(); marks.clear() }
274
+ videoUntil = if (always) Long.MAX_VALUE else SystemClock.uptimeMillis() + 60_000 // F3
275
+ nextVideoDue = SystemClock.uptimeMillis()
276
+ if (!videoOn) {
277
+ videoOn = true
278
+ Choreographer.getInstance().postFrameCallback(videoClock)
279
+ }
280
+ }
281
+
282
+ fun stopVideo() {
283
+ videoOn = false
284
+ videoAlways = false
285
+ Choreographer.getInstance().removeFrameCallback(videoClock)
286
+ }
287
+
288
+ fun mark() { if (videoOn) marks.add(System.currentTimeMillis().toDouble()) }
289
+
290
+ private fun videoTick() {
291
+ val now = SystemClock.uptimeMillis()
292
+ if (now > videoUntil) { stopVideo(); return }
293
+ if (frozen || stoppedReason != null || now < nextVideoDue) return
294
+ nextVideoDue = now + (1000 / videoFps).toLong() // from now: never a backlog
295
+ if (!busy) take("video", 1, null)
296
+ }
297
+
298
+ // MARK: the case
299
+
300
+ fun captureLast(done: (Boolean, String?) -> Unit) {
301
+ // Buoy's toast is up by now, so the frame from the button touch is the
302
+ // better last frame while it is new.
303
+ val b = buttonLastT
304
+ if (b != null && last?.t == b && System.currentTimeMillis() - b < 10_000) { done(true, null); return }
305
+ take("last", if (customer) 6 else 1) { ok -> done(ok, if (ok) null else lastReason ?: "no frame") }
306
+ }
307
+
308
+ fun takeMedia(): WritableMap {
309
+ frozen = true // F8
310
+ // C2's order: the last frame, taps (newest first), video near a "Bug here"
311
+ // mark (closest first), then the rest of the video spread over the clip.
312
+ val all = ArrayList<Frame>()
313
+ last?.let { all.add(it) }
314
+ all.addAll(taps.asReversed())
315
+ val gap = { f: Frame -> marks.minOfOrNull { kotlin.math.abs(it - f.t) } ?: Double.MAX_VALUE }
316
+ val near = video.filter { gap(it) <= NEAR_MARK_MS }.sortedBy(gap)
317
+ all.addAll(near)
318
+ // The rest in spread order, so a size cut keeps frames from the whole clip.
319
+ val rest = video.filter { v -> near.none { it === v } }
320
+ for (i in spreadOrder(rest.size)) all.add(rest[i])
321
+ val kept = ArrayList<Frame>()
322
+ var bytes = 0
323
+ var cut = 0
324
+ for (f in all) {
325
+ if (kept.size >= 150 || bytes + f.jpeg.size > 4 * 1024 * 1024) { cut++; continue } // C2, before base64
326
+ kept.add(f)
327
+ bytes += f.jpeg.size
328
+ }
329
+ kept.sortBy { it.t }
330
+ val frames = Arguments.createArray()
331
+ for (f in kept) frames.pushMap(Arguments.createMap().apply {
332
+ putDouble("t", f.t); putString("kind", f.kind); putInt("w", f.w); putInt("h", f.h)
333
+ putInt("boxes", f.boxes); putBoolean("moved", f.moved)
334
+ putString("jpeg", Base64.encodeToString(f.jpeg, Base64.NO_WRAP))
335
+ })
336
+ val reasons = Arguments.createMap().apply { for ((k, v) in dropped) putInt(k, v) }
337
+ return Arguments.createMap().apply {
338
+ putArray("frames", frames)
339
+ putArray("marks", Arguments.createArray().apply { marks.forEach { pushDouble(it) } })
340
+ putInt("dropped", cut + dropped.values.sum())
341
+ putMap("reasons", reasons)
342
+ stoppedReason?.let { putString("stopped", it) }
343
+ last?.let { putDouble("lastAgeMs", System.currentTimeMillis() - it.t) }
344
+ putMap("settings", Arguments.createMap().apply {
345
+ putBoolean("maskAllText", masks.text); putBoolean("maskAllImages", masks.images); putBoolean("maskAllInputs", masks.inputs)
346
+ })
347
+ }
348
+ }
349
+
350
+ fun resume() { frozen = false }
351
+
352
+ fun clear() {
353
+ // A frame still being made belongs to the old settings: a new run token drops it.
354
+ run++
355
+ busy = false
356
+ main.removeCallbacks(tapRunnable)
357
+ taps.clear(); video.clear(); last = null; marks.clear(); dropped.clear(); frozen = false
358
+ }
359
+
360
+ fun stopAll() {
361
+ run++
362
+ stopVideo()
363
+ main.removeCallbacks(tapRunnable)
364
+ detachWatcher()
365
+ busy = false
366
+ enabled = false
367
+ }
368
+
369
+ /** The module is gone (reload or app close): free the thread too. */
370
+ fun destroy() {
371
+ stopAll()
372
+ if (listening) { context.removeLifecycleEventListener(lifecycle); listening = false }
373
+ thread.quitSafely()
374
+ }
375
+
376
+ // MARK: one frame
377
+
378
+ private fun drop(reason: String) {
379
+ dropped[reason] = (dropped[reason] ?: 0) + 1
380
+ lastReason = reason
381
+ }
382
+
383
+ private class Target(val window: Window, val root: WalkRoot)
384
+
385
+ private class Found(val targets: List<Target>, val frameW: Float, val frameH: Float, val density: Float, val reason: String?)
386
+
387
+ /** The app's windows: the Activity, plus any RN Modal on top. */
388
+ private fun findWindows(activity: Activity): Found {
389
+ val window = activity.window
390
+ val decor = window.decorView
391
+ val density = decor.resources.displayMetrics.density
392
+ val w = decor.width / density
393
+ val h = decor.height / density
394
+ fun no(reason: String) = Found(emptyList(), w, h, density, reason)
395
+ if (decor.width <= 0 || decor.height <= 0) return no("no app window")
396
+ if (window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0) return no("this screen is protected") // H5
397
+ val targets = arrayListOf(Target(window, WalkRoot(decor, 0f, 0f)))
398
+ val base = IntArray(2).also { decor.getLocationOnScreen(it) }
399
+ val dialogs = ArrayList<View>()
400
+ collectModals(decor, dialogs)
401
+ for (host in dialogs) {
402
+ val d = modalDialog(host) ?: return no("a popup is open that Buoy can't read")
403
+ if (!d.isShowing) continue
404
+ val dw = d.window ?: return no("a popup is open that Buoy can't read")
405
+ if (dw.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0) return no("this screen is protected")
406
+ val dDecor = dw.decorView
407
+ val loc = IntArray(2).also { dDecor.getLocationOnScreen(it) }
408
+ if (!modalOrder.containsKey(dw)) modalOrder[dw] = ++modalSeq
409
+ targets.add(Target(dw, WalkRoot(dDecor, (loc[0] - base[0]).toFloat(), (loc[1] - base[1]).toFloat())))
410
+ }
411
+ // Draw Modals in the order they opened, not their order in the JSX, and
412
+ // the one with focus on top: it's the one in front.
413
+ if (targets.size > 2) {
414
+ val modals = targets.subList(1, targets.size).sortedWith(
415
+ compareBy({ if (it.window.decorView.hasWindowFocus()) 1 else 0 }, { modalOrder[it.window] ?: 0L })
416
+ )
417
+ for (i in modals.indices) targets[i + 1] = modals[i]
418
+ }
419
+ // Something else on top (a system sheet, another SDK's dialog): the
420
+ // Activity has lost focus to a window we can't see.
421
+ // With Modals open too: when none of our windows has focus, something we can't see is in front.
422
+ if (!decor.hasWindowFocus() && !anyFocused(targets)) return no("another window is on top")
423
+ return Found(targets, w, h, density, null)
424
+ }
425
+
426
+ private fun anyFocused(t: List<Target>) = t.any { it.window.decorView.hasWindowFocus() }
427
+
428
+ private fun collectModals(root: View, out: ArrayList<View>) {
429
+ val stack = ArrayList<View>().apply { add(root) }
430
+ while (stack.isNotEmpty()) {
431
+ val v = stack.removeAt(stack.size - 1)
432
+ if (v.javaClass.name.contains("ReactModalHostView")) out.add(v)
433
+ if (v is android.view.ViewGroup) for (i in 0 until v.childCount) v.getChildAt(i)?.let { stack.add(it) }
434
+ }
435
+ }
436
+
437
+ private fun modalDialog(host: View): Dialog? {
438
+ var c: Class<*>? = host.javaClass
439
+ while (c != null) {
440
+ try {
441
+ val f = c.getDeclaredField("dialog")
442
+ f.isAccessible = true
443
+ return f.get(host) as? Dialog
444
+ } catch (_: NoSuchFieldException) {
445
+ c = c.superclass
446
+ } catch (_: Throwable) {
447
+ return null
448
+ }
449
+ }
450
+ return null
451
+ }
452
+
453
+ // A debuggable build runs this code about 8 times slower (13 ms vs 1.6 ms on
454
+ // a Pixel 4 XL), so it gets more time before a frame is dropped.
455
+ private val walkBudgetMs: Long =
456
+ if (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) 160 else 40
457
+
458
+ private fun walk(found: Found): Walk =
459
+ FrameWalker.walk(found.targets.map { it.root }, found.frameW, found.frameH, found.density, masks, walkBudgetMs)
460
+
461
+ private fun appInFront(): Boolean = context.lifecycleState == LifecycleState.RESUMED
462
+
463
+ /** One frame over two display ticks: boxes a tick before, then right before and right after the copy (H13). */
464
+ private fun take(kind: String, attempts: Int, done: ((Boolean) -> Unit)?) {
465
+ if (!enabled) { done?.invoke(false); return }
466
+ // Customer mode keeps the last frame only (M1); a tap queued before the switch adds nothing.
467
+ if (kind == "tap" && (customer || !pictures)) { done?.invoke(false); return }
468
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { drop("pictures need Android 8.0 or newer"); done?.invoke(false); return }
469
+ attachWatcher()
470
+ if (busy) { drop("busy"); done?.invoke(false); return }
471
+ if (!appInFront()) { drop("app in background"); done?.invoke(false); return } // F7
472
+ val activity = context.currentActivity ?: run { drop("no app window"); done?.invoke(false); return }
473
+ busy = true
474
+ val token = run
475
+ val foundA = findWindows(activity)
476
+ val walkA = if (foundA.reason == null) walk(foundA) else null
477
+ Choreographer.getInstance().postFrameCallback {
478
+ if (token != run) { done?.invoke(false); return@postFrameCallback }
479
+ shoot(kind, activity, foundA, walkA, token) { ok, retry ->
480
+ if (!ok && retry && attempts > 1) {
481
+ main.postDelayed({
482
+ if (token == run) take(kind, attempts - 1, done) else done?.invoke(false)
483
+ }, 100)
484
+ } else done?.invoke(ok)
485
+ }
486
+ }
487
+ }
488
+
489
+ private fun shoot(kind: String, activity: Activity, foundA: Found, walkA: Walk?, token: Int, done: (Boolean, Boolean) -> Unit) {
490
+ val t0 = SystemClock.uptimeMillis()
491
+ fun fail(reason: String, retry: Boolean = false) { busy = false; drop(reason); done(false, retry) }
492
+ if (!appInFront() || context.currentActivity !== activity) return fail("app in background")
493
+ foundA.reason?.let { return fail(it) }
494
+ val found = findWindows(activity)
495
+ found.reason?.let { return fail(it) }
496
+ if (!sameWindows(foundA, found)) return fail("the window changed")
497
+ val walkB = walk(found)
498
+ (walkA?.failed ?: walkB.failed)?.let { return fail(it) }
499
+ if (walkA?.buoyOpen == true || walkB.buoyOpen) return fail("a Buoy screen was open") // F5
500
+
501
+ val scale = min(1f, MAX_LONG_SIDE / max(found.frameW, found.frameH))
502
+ val outW = max(1, (found.frameW * scale).roundToInt())
503
+ val outH = max(1, (found.frameH * scale).roundToInt())
504
+ val copies = arrayOfNulls<Bitmap>(found.targets.size)
505
+ val mainMs = SystemClock.uptimeMillis() - t0
506
+
507
+ // Copy each window in turn on the background thread, with a deadline.
508
+ var settled = false
509
+ val deadline = Runnable {
510
+ if (settled) return@Runnable
511
+ // A newer run (clear, stop): still answer, so a waiting report never hangs.
512
+ if (token != run) { settled = true; copies.forEach { it?.recycle() }; done(false, false); return@Runnable }
513
+ settled = true
514
+ copies.forEach { it?.recycle() }
515
+ fail("the copy took too long")
516
+ }
517
+ main.postDelayed(deadline, COPY_DEADLINE_MS)
518
+
519
+ fun finish(ok: Boolean, why: String?) {
520
+ main.post {
521
+ main.removeCallbacks(deadline)
522
+ if (settled) return@post
523
+ if (token != run) { settled = true; copies.forEach { it?.recycle() }; done(false, false); return@post }
524
+ settled = true
525
+ if (!ok) { copies.forEach { it?.recycle() }; return@post fail(why ?: "the copy failed") }
526
+ // H11 / H13: read the boxes again now the pixels are in.
527
+ val tC = SystemClock.uptimeMillis()
528
+ val foundC = findWindows(activity)
529
+ if (foundC.reason != null || !sameWindows(found, foundC)) { copies.forEach { it?.recycle() }; return@post fail(foundC.reason ?: "the window changed") }
530
+ val walkC = walk(foundC)
531
+ walkC.failed?.let { copies.forEach { b -> b?.recycle() }; return@post fail(it) }
532
+ if (walkC.buoyOpen) { copies.forEach { it?.recycle() }; return@post fail("a Buoy screen was open") }
533
+ val boxes = HashMap<View, RectF>(walkA?.parts ?: emptyMap())
534
+ var moved = walkA == null
535
+ for (parts in listOf(walkB.parts, walkC.parts)) for ((v, r) in parts) {
536
+ val old = boxes[v]
537
+ if (old == null) { boxes[v] = RectF(r); moved = true }
538
+ else { if (old != r) moved = true; old.union(r) }
539
+ }
540
+ if (walkA != null && walkA.parts.keys.any { it !in walkC.parts }) moved = true
541
+ if (moved && customer) { copies.forEach { it?.recycle() }; return@post fail("the screen was moving", retry = true) }
542
+ noteSpeed(mainMs + (SystemClock.uptimeMillis() - tC), kind)
543
+ val rects = merge(boxes.values.toList())
544
+ val offsets = found.targets.map { Pair(it.root.dx / found.density, it.root.dy / found.density) }
545
+ val wall = System.currentTimeMillis().toDouble()
546
+ bg.post {
547
+ val jpeg = try { compose(copies, offsets, outW, outH, scale, rects) } catch (_: Throwable) { null }
548
+ copies.forEach { it?.recycle() }
549
+ main.post {
550
+ if (token != run) { done(false, false); return@post }
551
+ busy = false
552
+ if (jpeg == null) { drop("couldn't make the picture"); done(false, false); return@post }
553
+ keep(Frame(wall, kind, found.frameW.roundToInt(), found.frameH.roundToInt(), jpeg, rects.size, moved))
554
+ done(true, false)
555
+ }
556
+ }
557
+ }
558
+ }
559
+
560
+ fun copy(i: Int) {
561
+ if (i == found.targets.size) { finish(true, null); return }
562
+ val t = found.targets[i]
563
+ val d = t.root.view
564
+ val bw = max(1, (d.width / found.density * scale).roundToInt())
565
+ val bh = max(1, (d.height / found.density * scale).roundToInt())
566
+ val bmp = try { Bitmap.createBitmap(bw, bh, Bitmap.Config.ARGB_8888) } catch (_: Throwable) { null }
567
+ ?: return finish(false, "not enough memory for a picture")
568
+ copies[i] = bmp
569
+ try {
570
+ PixelCopy.request(t.window, bmp, { result ->
571
+ if (result == PixelCopy.SUCCESS) copy(i + 1)
572
+ else finish(false, "the copy failed (code $result)")
573
+ }, bg)
574
+ } catch (e: Throwable) {
575
+ finish(false, "the copy failed")
576
+ }
577
+ }
578
+ copy(0)
579
+ }
580
+
581
+ private fun sameWindows(a: Found, b: Found): Boolean =
582
+ a.targets.size == b.targets.size && a.frameW == b.frameW && a.frameH == b.frameH &&
583
+ a.targets.indices.all { a.targets[it].window === b.targets[it].window }
584
+
585
+ private fun compose(copies: Array<Bitmap?>, offsets: List<Pair<Float, Float>>, w: Int, h: Int, scale: Float, rects: List<RectF>): ByteArray? {
586
+ val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
587
+ try {
588
+ val c = Canvas(out)
589
+ c.drawColor(Color.BLACK)
590
+ for (i in copies.indices) {
591
+ val b = copies[i] ?: return null
592
+ c.drawBitmap(b, offsets[i].first * scale, offsets[i].second * scale, null)
593
+ }
594
+ for (r in rects) c.drawRect(r.left * scale, r.top * scale, r.right * scale, r.bottom * scale, BOX)
595
+ val bytes = ByteArrayOutputStream()
596
+ if (!out.compress(Bitmap.CompressFormat.JPEG, 60, bytes)) return null // F1
597
+ return bytes.toByteArray()
598
+ } finally {
599
+ out.recycle()
600
+ }
601
+ }
602
+
603
+ private fun keep(f: Frame) {
604
+ if (frozen && f.kind != "last") return // F8
605
+ when (f.kind) {
606
+ "last" -> last = f
607
+ "tap" -> { taps.add(f); while (taps.size > 10) taps.removeAt(0) } // F2
608
+ else -> {
609
+ video.add(f)
610
+ val cutoff = f.t - if (videoAlways) 30_000 else 60_000 // F3, F3a
611
+ video.removeAll { it.t < cutoff }
612
+ var total = video.sumOf { it.jpeg.size }
613
+ while (total > MAX_VIDEO_BYTES && video.isNotEmpty()) total -= video.removeAt(0).jpeg.size // F4
614
+ }
615
+ }
616
+ }
617
+
618
+ /** F6 and M1: slow phones take fewer frames, then stop. */
619
+ private fun noteSpeed(ms: Long, kind: String) {
620
+ if (kind == "last") return
621
+ when {
622
+ ms > 250 -> { stoppedReason = "pictures stopped: this phone took $ms ms for one"; stopVideo() }
623
+ ms > 100 -> { slowInRow++; if (slowInRow >= 2) { videoFps = max(0.5, videoFps / 2); slowInRow = 0 } }
624
+ else -> slowInRow = 0
625
+ }
626
+ }
627
+
628
+ private fun merge(rects: List<RectF>): List<RectF> {
629
+ val sorted = rects.sortedByDescending { it.width() * it.height() }
630
+ val out = ArrayList<RectF>()
631
+ for (r in sorted) if (out.none { it.contains(r) }) out.add(r)
632
+ return out
633
+ }
634
+
635
+ // MARK: test hook
636
+
637
+ /** The testID for a boxed view: its own, or the nearest parent's when the box covers most of it. */
638
+ private fun probeId(v: View): String {
639
+ FrameWalker.testId(v)?.takeIf { it.isNotEmpty() }?.let { return it }
640
+ var p = v.parent
641
+ while (p is View) {
642
+ val id = FrameWalker.testId(p)
643
+ if (!id.isNullOrEmpty()) return if (v.width * v.height >= p.width * p.height * 0.8) id else ""
644
+ p = p.parent
645
+ }
646
+ return ""
647
+ }
648
+
649
+ fun probe(done: (WritableMap) -> Unit) {
650
+ val activity = context.currentActivity ?: return done(Arguments.createMap().apply { putBoolean("ok", false); putString("reason", "no app window") })
651
+ val found = findWindows(activity)
652
+ val parts = Arguments.createArray()
653
+ var failed: String? = found.reason
654
+ var buoyOpen = false
655
+ if (found.reason == null) {
656
+ val w = FrameWalker.walk(found.targets.map { it.root }, found.frameW, found.frameH, found.density, masks, 200)
657
+ failed = w.failed
658
+ buoyOpen = w.buoyOpen
659
+ for ((v, r) in w.parts) parts.pushMap(Arguments.createMap().apply {
660
+ putString("testID", probeId(v)); putString("cls", v.javaClass.name); putDouble("x", r.left.toDouble()); putDouble("y", r.top.toDouble())
661
+ putDouble("w", r.width().toDouble()); putDouble("h", r.height().toDouble())
662
+ })
663
+ }
664
+ val wasFrozen = frozen
665
+ frozen = false
666
+ take("last", 1) { ok ->
667
+ frozen = wasFrozen
668
+ done(Arguments.createMap().apply {
669
+ putBoolean("ok", ok)
670
+ if (!ok) putString("reason", lastReason)
671
+ putArray("parts", parts)
672
+ failed?.let { putString("failed", it) }
673
+ putBoolean("buoyOpen", buoyOpen)
674
+ if (ok) last?.let { putString("jpeg", Base64.encodeToString(it.jpeg, Base64.NO_WRAP)) }
675
+ putDouble("w", found.frameW.toDouble()); putDouble("h", found.frameH.toDouble())
676
+ })
677
+ }
678
+ }
679
+
680
+ /** Watches touches without taking them: every call goes on to the app as before. */
681
+ private class TapCallback(
682
+ val base: Window.Callback,
683
+ val down: (Float, Float) -> Unit,
684
+ val up: () -> Unit,
685
+ val focus: ((Boolean) -> Unit)? = null,
686
+ val detached: (() -> Unit)? = null,
687
+ ) : Window.Callback by base {
688
+ var live = true
689
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
690
+ base.onWindowFocusChanged(hasFocus)
691
+ if (live) try { focus?.invoke(hasFocus) } catch (_: Throwable) {}
692
+ }
693
+ override fun onDetachedFromWindow() {
694
+ base.onDetachedFromWindow()
695
+ try { detached?.invoke() } catch (_: Throwable) {}
696
+ }
697
+ override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
698
+ if (event != null && live) try {
699
+ when (event.actionMasked) {
700
+ MotionEvent.ACTION_DOWN -> down(event.x, event.y)
701
+ MotionEvent.ACTION_UP -> up()
702
+ }
703
+ } catch (_: Throwable) {}
704
+ return base.dispatchTouchEvent(event)
705
+ }
706
+ }
707
+ }