@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.
- package/BuoyBugLinks.podspec +18 -0
- package/LICENSE +58 -0
- package/README.md +25 -2
- package/android/build.gradle +31 -0
- package/android/consumer-rules.pro +8 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/gg/buoy/buglinks/frames/BuoyFramesModule.kt +53 -0
- package/android/src/main/java/gg/buoy/buglinks/frames/BuoyFramesPackage.kt +11 -0
- package/android/src/main/java/gg/buoy/buglinks/frames/FrameEngine.kt +707 -0
- package/android/src/main/java/gg/buoy/buglinks/frames/FrameWalker.kt +181 -0
- package/ios/BuoyFrames.m +17 -0
- package/ios/BuoyFrames.swift +640 -0
- package/ios/BuoyFramesWalk.swift +213 -0
- package/lib/commonjs/components/BugLinksBody.js +1 -0
- package/lib/commonjs/components/TeamBugs.js +1 -0
- package/lib/commonjs/components/focus.js +1 -0
- package/lib/commonjs/components/overlays.js +1 -0
- package/lib/commonjs/components/topBanners.js +1 -0
- package/lib/commonjs/engine/client.js +1 -0
- package/lib/commonjs/engine/credential.js +1 -0
- package/lib/commonjs/engine/linksStore.js +1 -0
- package/lib/commonjs/engine/loader.js +1 -0
- package/lib/commonjs/engine/opener.js +1 -0
- package/lib/commonjs/engine/otherTabs.js +1 -0
- package/lib/commonjs/engine/phoneCheck.js +1 -0
- package/lib/commonjs/engine/phoneCrypto.js +1 -0
- package/lib/commonjs/engine/randomBytes.js +1 -0
- package/lib/commonjs/engine/reporter.js +1 -0
- package/lib/commonjs/engine/sandbox.js +1 -0
- package/lib/commonjs/engine/settings.js +1 -0
- package/lib/commonjs/engine/tabStorage.js +1 -0
- package/lib/commonjs/engine/teamKeys.js +1 -0
- package/lib/commonjs/engine/teamList.js +1 -0
- package/lib/commonjs/index.js +1 -0
- package/lib/commonjs/media/BuoyMask.js +1 -0
- package/lib/commonjs/media/frames.js +1 -0
- package/lib/commonjs/media/nativeFrames.js +1 -0
- package/lib/commonjs/media/nativeFrames.web.js +1 -0
- package/lib/commonjs/media/webFrames.js +14 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/preset.js +1 -0
- package/lib/commonjs/sync/bugLinksSyncAdapter.js +1 -0
- package/lib/module/components/BugLinksBody.js +1 -0
- package/lib/module/components/TeamBugs.js +1 -0
- package/lib/module/components/focus.js +1 -0
- package/lib/module/components/overlays.js +1 -0
- package/lib/module/components/topBanners.js +1 -0
- package/lib/module/engine/client.js +1 -0
- package/lib/module/engine/credential.js +1 -0
- package/lib/module/engine/linksStore.js +1 -0
- package/lib/module/engine/loader.js +1 -0
- package/lib/module/engine/opener.js +1 -0
- package/lib/module/engine/otherTabs.js +1 -0
- package/lib/module/engine/phoneCheck.js +1 -0
- package/lib/module/engine/phoneCrypto.js +1 -0
- package/lib/module/engine/randomBytes.js +1 -0
- package/lib/module/engine/reporter.js +1 -0
- package/lib/module/engine/sandbox.js +1 -0
- package/lib/module/engine/settings.js +1 -0
- package/lib/module/engine/tabStorage.js +1 -0
- package/lib/module/engine/teamKeys.js +1 -0
- package/lib/module/engine/teamList.js +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/media/BuoyMask.js +1 -0
- package/lib/module/media/frames.js +1 -0
- package/lib/module/media/nativeFrames.js +1 -0
- package/lib/module/media/nativeFrames.web.js +1 -0
- package/lib/module/media/webFrames.js +14 -0
- package/lib/module/preset.js +1 -0
- package/lib/module/sync/bugLinksSyncAdapter.js +1 -0
- package/lib/typescript/components/BugLinksBody.d.ts +2 -0
- package/lib/typescript/components/TeamBugs.d.ts +8 -0
- package/lib/typescript/components/focus.d.ts +6 -0
- package/lib/typescript/components/overlays.d.ts +10 -0
- package/lib/typescript/components/topBanners.d.ts +35 -0
- package/lib/typescript/engine/client.d.ts +78 -0
- package/lib/typescript/engine/credential.d.ts +11 -0
- package/lib/typescript/engine/linksStore.d.ts +132 -0
- package/lib/typescript/engine/loader.d.ts +172 -0
- package/lib/typescript/engine/opener.d.ts +21 -0
- package/lib/typescript/engine/otherTabs.d.ts +52 -0
- package/lib/typescript/engine/phoneCheck.d.ts +8 -0
- package/lib/typescript/engine/phoneCrypto.d.ts +16 -0
- package/lib/typescript/engine/randomBytes.d.ts +7 -0
- package/lib/typescript/engine/reporter.d.ts +53 -0
- package/lib/typescript/engine/sandbox.d.ts +83 -0
- package/lib/typescript/engine/settings.d.ts +42 -0
- package/lib/typescript/engine/tabStorage.d.ts +6 -0
- package/lib/typescript/engine/teamKeys.d.ts +37 -0
- package/lib/typescript/engine/teamList.d.ts +63 -0
- package/lib/typescript/index.d.ts +23 -0
- package/lib/typescript/media/BuoyMask.d.ts +19 -0
- package/lib/typescript/media/frames.d.ts +34 -0
- package/lib/typescript/media/nativeFrames.d.ts +2 -0
- package/lib/typescript/media/nativeFrames.web.d.ts +2 -0
- package/lib/typescript/media/webFrames.d.ts +178 -0
- package/lib/typescript/preset.d.ts +50 -0
- package/lib/typescript/sync/bugLinksSyncAdapter.d.ts +41 -0
- package/lib/web/index.mjs +20 -0
- 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
|
+
}
|