@keepsnappy/react-native 0.0.0-stage → 0.1.0-alpha.11

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.
@@ -0,0 +1,445 @@
1
+ package expo.modules.perflab
2
+
3
+ import android.app.Activity
4
+ import android.content.BroadcastReceiver
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.content.IntentFilter
8
+ import android.os.Build
9
+ import android.graphics.Color
10
+ import android.os.Handler
11
+ import android.os.Looper
12
+ import android.os.SystemClock
13
+ import android.os.Trace
14
+ import android.util.Log
15
+ import android.view.Gravity
16
+ import android.view.View
17
+ import android.view.ViewGroup
18
+ import android.widget.FrameLayout
19
+ import android.widget.TextView
20
+ import expo.modules.kotlin.modules.Module
21
+ import expo.modules.kotlin.modules.ModuleDefinition
22
+ import java.io.File
23
+ import java.io.FileOutputStream
24
+ import java.io.RandomAccessFile
25
+ import java.net.HttpURLConnection
26
+ import java.net.URL
27
+ import java.security.DigestOutputStream
28
+ import java.security.MessageDigest
29
+ import java.util.concurrent.ConcurrentHashMap
30
+ import java.util.concurrent.ExecutorService
31
+ import java.util.concurrent.Executors
32
+ import java.util.zip.GZIPOutputStream
33
+
34
+ private const val TAG = "PERF_LAB"
35
+
36
+ // The element the test driver waits for after the flush link; same id as on iOS.
37
+ private const val FLUSH_ELEMENT_ID = "perflab.flushed"
38
+ private const val FLUSH_ELEMENT_TAG = "perflab.flushed.view"
39
+ private const val FLUSH_ELEMENT_DP = 8
40
+ // Alpha 1 of 255: drawn, so it counts as visible, but no pixel changes on screen.
41
+ private const val FLUSH_ELEMENT_BACKGROUND = 0x01000000
42
+
43
+
44
+ // logcat drops the tail of a line past ~4 KB, so long payloads go out in chunks.
45
+ private const val LOG_CHUNK = 3000
46
+
47
+ // The SDK keeps the journal near 2x its queue cap (16 MB); a larger file is read from its tail.
48
+ private const val SPOOL_READ_MAX = 16L * 1024 * 1024
49
+
50
+ // The API parses at most 64 MiB of a raw profile file (MAX_RAW_BYTES in apps/api cpu-worker.ts).
51
+ private const val ARTIFACT_MAX_BYTES = 64L * 1024 * 1024
52
+ private const val COPY_BUFFER = 64 * 1024
53
+
54
+ // Files that lived in external storage before the move to filesDir.
55
+ private val LEGACY_STATE_FILES = listOf("activation.json", "push-spool.jsonl")
56
+
57
+ // Created by Expo at app start whether or not the SDK measures: nothing here may
58
+ // do work before a function is called (a dormant build calls activationRead only).
59
+ // UNTESTED ON DEVICE (activation, spool): compiled only, like the sampler.
60
+ // NOT YET COMPILED: moveLegacyState and the streamed uploadArtifact (night of 2026-10-14).
61
+ class PerfLabModule : Module() {
62
+ private val mainHandler = Handler(Looper.getMainLooper())
63
+
64
+ // GC before the action: the driver's command arrives as a broadcast, which pauses no activity (a link does, and the app
65
+ // reacts to that pause). Registered only while a run with a token measures; the result of the broadcast is the answer.
66
+ private var commandReceiver: BroadcastReceiver? = null
67
+ private val pendingCommands = ConcurrentHashMap<Int, BroadcastReceiver.PendingResult>()
68
+ // A pending broadcast is finished by native at the latest here, whatever JS does: the system raises an ANR after 10 s on
69
+ // the foreground queue. A safety bound on the wait, never a window edge; JS skips a command native already gave up on.
70
+ private val commandDeadlineMs = 5000L
71
+
72
+ private fun commandListen(on: Boolean) {
73
+ val context = appContext.reactContext?.applicationContext ?: return
74
+ synchronized(this) {
75
+ if (on && commandReceiver == null) {
76
+ val receiver = object : BroadcastReceiver() {
77
+ override fun onReceive(c: Context, intent: Intent) {
78
+ val cmd = intent.getStringExtra("cmd")
79
+ // `am broadcast --es n 3` and `--ei n 3` both reach here.
80
+ @Suppress("DEPRECATION")
81
+ val n = intent.extras?.get("n")?.toString()?.toIntOrNull()
82
+ if (cmd != "gc" || n == null || n < 1) {
83
+ resultCode = Activity.RESULT_CANCELED
84
+ resultData = "bad command"
85
+ return
86
+ }
87
+ val tNative = perfLabMonotonicMs()
88
+ pendingCommands.remove(n)?.finish()
89
+ val pending = goAsync()
90
+ pendingCommands[n] = pending
91
+ mainHandler.postDelayed({
92
+ if (pendingCommands.remove(n, pending)) {
93
+ pending.setResult(Activity.RESULT_CANCELED, "gc:$n timeout", null)
94
+ pending.finish()
95
+ }
96
+ }, commandDeadlineMs)
97
+ sendEvent("perflabCommand", mapOf("cmd" to cmd, "n" to n, "tNative" to tNative))
98
+ }
99
+ }
100
+ val filter = IntentFilter("${context.packageName}.perflab.COMMAND")
101
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
102
+ context.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
103
+ } else {
104
+ context.registerReceiver(receiver, filter)
105
+ }
106
+ commandReceiver = receiver
107
+ writeLog("PERF_LAB command receiver on")
108
+ } else if (!on && commandReceiver != null) {
109
+ try { context.unregisterReceiver(commandReceiver) } catch (e: Exception) {}
110
+ commandReceiver = null
111
+ for (n in pendingCommands.keys.toList()) pendingCommands.remove(n)?.finish()
112
+ writeLog("PERF_LAB command receiver off")
113
+ }
114
+ }
115
+ }
116
+
117
+ private fun showFlushElement(status: String) {
118
+ val activity = appContext.currentActivity
119
+ if (activity == null) {
120
+ writeLog("PERF_LAB flush element not shown: no foreground activity ($status)")
121
+ return
122
+ }
123
+ mainHandler.post {
124
+ val decor = activity.window?.decorView as? ViewGroup ?: return@post
125
+ decor.findViewWithTag<View>(FLUSH_ELEMENT_TAG)?.let { decor.removeView(it) }
126
+ val view = TextView(activity).apply {
127
+ tag = FLUSH_ELEMENT_TAG
128
+ text = status
129
+ setTextColor(Color.TRANSPARENT)
130
+ // UiAutomator2 drops nodes Android reports as not visible to the user, and a 1 px view in the corner under the system bars was one. Measured on a Galaxy S23 (Android 16): found at 8 dp on the left edge, middle of the screen.
131
+ setBackgroundColor(FLUSH_ELEMENT_BACKGROUND)
132
+ contentDescription = FLUSH_ELEMENT_ID
133
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
134
+ isClickable = false
135
+ isFocusable = false
136
+ }
137
+ val density = activity.resources.displayMetrics.density
138
+ val side = (FLUSH_ELEMENT_DP * density).toInt().coerceAtLeast(1)
139
+ decor.addView(view, FrameLayout.LayoutParams(side, side, Gravity.CENTER_VERTICAL or Gravity.START))
140
+ }
141
+ }
142
+
143
+ private var samplerCreated = false
144
+ private val sampler by lazy {
145
+ samplerCreated = true
146
+ PerfLabSampler({ appContext.reactContext }, { appContext.currentActivity }, ::writeLog)
147
+ }
148
+ private val io: ExecutorService by lazy {
149
+ Executors.newSingleThreadExecutor { r -> Thread(r, "perflab-io") }
150
+ }
151
+
152
+ // activation.json and the push journal (run tokens) live in internal storage: on Android 9
153
+ // and older another app with storage permission could read or plant files in external storage.
154
+ private fun stateDir(create: Boolean): File? {
155
+ val context = appContext.reactContext ?: return null
156
+ val dir = File(context.filesDir ?: return null, "perf-lab")
157
+ moveLegacyState(context, dir)
158
+ if (create) dir.mkdirs()
159
+ return dir
160
+ }
161
+
162
+ @Volatile private var legacyChecked = false
163
+
164
+ // Once per process, on the first state access: an install from before the move still has
165
+ // activation.json and the journal (run tokens) in external storage. Each is moved in when
166
+ // internal storage has none yet, and the external copy is deleted either way.
167
+ private fun moveLegacyState(context: Context, dir: File) {
168
+ if (legacyChecked) return
169
+ synchronized(this) {
170
+ if (legacyChecked) return
171
+ legacyChecked = true
172
+ val external = try { context.getExternalFilesDir(null) } catch (e: Exception) { null }
173
+ val old = external?.let { File(it, "perf-lab") } ?: return
174
+ for (name in LEGACY_STATE_FILES) {
175
+ val from = File(old, name)
176
+ if (!from.exists()) continue
177
+ try {
178
+ val to = File(dir, name)
179
+ if (!to.exists()) {
180
+ dir.mkdirs()
181
+ from.copyTo(to)
182
+ }
183
+ } catch (e: Exception) {
184
+ // the line or activation is lost; the copy that anyone could read is not kept
185
+ }
186
+ from.delete()
187
+ }
188
+ }
189
+ }
190
+
191
+ private fun readTail(file: File, max: Long): String? = try {
192
+ RandomAccessFile(file, "r").use { raf ->
193
+ val length = raf.length()
194
+ val start = if (length > max) length - max else 0L
195
+ val bytes = ByteArray((length - start).toInt())
196
+ raf.seek(start)
197
+ raf.readFully(bytes)
198
+ val text = String(bytes, Charsets.UTF_8)
199
+ if (start == 0L) text else text.substringAfter('\n', "")
200
+ }
201
+ } catch (e: Exception) {
202
+ null
203
+ }
204
+
205
+ private fun writeAtomically(file: File, content: String): Boolean = try {
206
+ val tmp = File(file.parentFile, file.name + ".tmp")
207
+ tmp.writeText(content)
208
+ tmp.renameTo(file)
209
+ } catch (e: Exception) {
210
+ false
211
+ }
212
+
213
+ // A raw profile file of a run (plan 40 §3.6), from where writeText puts it: gzip,
214
+ // sha256 of the gzip, PUT to the signed URL the API handed out. status 0: no HTTP answer.
215
+ // The gzip is streamed to a temp file (hashed while written) and PUT from it, so neither
216
+ // the file nor its gzip is held in memory; a file over the API's cap is not read at all.
217
+ private fun uploadArtifact(fileName: String, url: String, headers: Map<String, String>, timeoutMs: Double): Map<String, Any?> {
218
+ val context = appContext.reactContext
219
+ val base = context?.getExternalFilesDir(null)
220
+ val file = base?.let { File(File(it, "perf-lab"), fileName.replace('/', '_')) }
221
+ if (context == null || file == null || !file.exists()) {
222
+ return mapOf("status" to 0, "error" to "file missing")
223
+ }
224
+ val length = file.length()
225
+ if (length > ARTIFACT_MAX_BYTES) {
226
+ return mapOf("status" to 413, "error" to "file is $length bytes, over $ARTIFACT_MAX_BYTES")
227
+ }
228
+ val gzFile = try {
229
+ File.createTempFile("perflab-upload-", ".gz", context.cacheDir)
230
+ } catch (e: Exception) {
231
+ return mapOf("status" to 0, "bytes" to length.toInt(), "error" to "temp file: $e")
232
+ }
233
+ try {
234
+ val digest = MessageDigest.getInstance("SHA-256")
235
+ try {
236
+ GZIPOutputStream(DigestOutputStream(FileOutputStream(gzFile), digest), COPY_BUFFER).use { gz ->
237
+ file.inputStream().use { it.copyTo(gz, COPY_BUFFER) }
238
+ }
239
+ } catch (e: Exception) {
240
+ return mapOf("status" to 0, "bytes" to length.toInt(), "error" to "gzip: $e")
241
+ }
242
+ val sha = digest.digest().joinToString("") { "%02x".format(it) }
243
+ val gzBytes = gzFile.length()
244
+ val out = mutableMapOf<String, Any?>("bytes" to length.toInt(), "gzBytes" to gzBytes.toInt(), "sha256" to sha)
245
+ var connection: HttpURLConnection? = null
246
+ try {
247
+ val conn = URL(url).openConnection() as HttpURLConnection
248
+ connection = conn
249
+ conn.requestMethod = "PUT"
250
+ conn.doOutput = true
251
+ conn.connectTimeout = timeoutMs.toInt()
252
+ conn.readTimeout = timeoutMs.toInt()
253
+ conn.setFixedLengthStreamingMode(gzBytes)
254
+ for ((key, value) in headers) {
255
+ conn.setRequestProperty(key, value)
256
+ }
257
+ conn.outputStream.use { sink -> gzFile.inputStream().use { it.copyTo(sink, COPY_BUFFER) } }
258
+ out["status"] = conn.responseCode
259
+ } catch (e: Exception) {
260
+ out["status"] = 0
261
+ out["error"] = e.toString()
262
+ } finally {
263
+ connection?.disconnect()
264
+ }
265
+ return out
266
+ } finally {
267
+ gzFile.delete()
268
+ }
269
+ }
270
+
271
+ private fun writeLog(message: String) {
272
+ if (message.length <= LOG_CHUNK) {
273
+ Log.i(TAG, message)
274
+ } else {
275
+ var start = 0
276
+ while (start < message.length) {
277
+ val end = minOf(start + LOG_CHUNK, message.length)
278
+ Log.i(TAG, message.substring(start, end))
279
+ start = end
280
+ }
281
+ }
282
+ }
283
+
284
+ override fun definition() = ModuleDefinition {
285
+ Name("PerfLab")
286
+
287
+ Events("perflabCommand")
288
+
289
+ // Sync functions: called on the JS thread through JSI, no bridge hop, so a
290
+ // marker lands at the moment JS asks for it.
291
+ Function("beginAsyncSection") { name: String, cookie: Int ->
292
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
293
+ Trace.beginAsyncSection(name, cookie)
294
+ }
295
+ }
296
+
297
+ Function("endAsyncSection") { name: String, cookie: Int ->
298
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
299
+ Trace.endAsyncSection(name, cookie)
300
+ }
301
+ }
302
+
303
+ Function("setCounter") { name: String, value: Double ->
304
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
305
+ Trace.setCounter(name, value.toLong())
306
+ }
307
+ }
308
+
309
+ Function("isTracing") {
310
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Trace.isEnabled() else false
311
+ }
312
+
313
+ Function("log") { message: String ->
314
+ writeLog(message)
315
+ }
316
+
317
+ Function("samplerConfigure") { mode: String, intervalMs: Double ->
318
+ sampler.configure(mode, intervalMs)
319
+ }
320
+
321
+ Function("samplerSample") { reason: String ->
322
+ sampler.sample(reason)
323
+ }
324
+
325
+ Function("samplerRequest") { reason: String ->
326
+ sampler.request(reason)
327
+ }
328
+
329
+ Function("samplerTake") { reason: String ->
330
+ sampler.take(reason)
331
+ }
332
+
333
+ Function("samplerRecent") { sinceT: Double ->
334
+ sampler.recent(sinceT)
335
+ }
336
+
337
+ Function("samplerNow") {
338
+ perfLabMonotonicMs()
339
+ }
340
+
341
+ OnDestroy {
342
+ if (samplerCreated) sampler.configure("off", 0.0)
343
+ commandListen(false)
344
+ }
345
+
346
+ Function("commandListen") { on: Boolean ->
347
+ commandListen(on)
348
+ }
349
+
350
+ Function("commandPending") { n: Int -> pendingCommands.containsKey(n) }
351
+
352
+ Function("commandDone") { n: Int, data: String ->
353
+ val pending = pendingCommands.remove(n) ?: return@Function false
354
+ pending.setResult(Activity.RESULT_OK, data, null)
355
+ pending.finish()
356
+ true
357
+ }
358
+
359
+ Function("activationRead") {
360
+ val file = stateDir(false)?.let { File(it, "activation.json") }
361
+ if (file != null && file.exists()) {
362
+ try { file.readText() } catch (e: Exception) { null }
363
+ } else {
364
+ null
365
+ }
366
+ }
367
+
368
+ Function("activationWrite") { json: String ->
369
+ val dir = stateDir(true) ?: return@Function false
370
+ writeAtomically(File(dir, "activation.json"), json)
371
+ }
372
+
373
+ // logcat only, never a file: a dormant build's answer to `dump=1`.
374
+ Function("osLog") { message: String ->
375
+ writeLog(message)
376
+ }
377
+
378
+ // iOS shows the flush result as an accessibility element; Android adds a 1x1 transparent view
379
+ // (contentDescription `perflab.flushed`, text = status) to the foreground activity's decor view;
380
+ // like iOS it stays until the next flush replaces it or the process ends. Always logged too.
381
+ Function("flushSignal") { status: String, detail: String ->
382
+ writeLog("PERF_LAB flushed $status $detail")
383
+ showFlushElement(status)
384
+ }
385
+
386
+ Function("spoolAppend") { record: String ->
387
+ io.execute {
388
+ val dir = stateDir(true) ?: return@execute
389
+ try {
390
+ FileOutputStream(File(dir, "push-spool.jsonl"), true).use { it.write((record + "\n").toByteArray()) }
391
+ } catch (e: Exception) {
392
+ // a lost journal line only loses the resume of that line
393
+ }
394
+ }
395
+ }
396
+
397
+ Function("spoolRead") {
398
+ io.submit<String?> {
399
+ val file = stateDir(false)?.let { File(it, "push-spool.jsonl") }
400
+ if (file != null && file.exists()) readTail(file, SPOOL_READ_MAX) else null
401
+ }.get()
402
+ }
403
+
404
+ Function("spoolReplace") { content: String ->
405
+ io.submit<Boolean> {
406
+ val dir = stateDir(true) ?: return@submit false
407
+ val file = File(dir, "push-spool.jsonl")
408
+ if (content.isEmpty()) {
409
+ !file.exists() || file.delete()
410
+ } else {
411
+ writeAtomically(file, content)
412
+ }
413
+ }.get()
414
+ }
415
+
416
+ // System.nanoTime() is CLOCK_MONOTONIC on Android, the same clock as
417
+ // performance.now() in RN and the MONOTONIC snapshot in Perfetto.
418
+ Function("clockSnapshot") {
419
+ mapOf(
420
+ "nanoTimeMs" to System.nanoTime() / 1_000_000.0,
421
+ "uptimeMillis" to SystemClock.uptimeMillis().toDouble(),
422
+ "elapsedRealtimeMs" to SystemClock.elapsedRealtimeNanos() / 1_000_000.0,
423
+ "wallMs" to System.currentTimeMillis().toDouble(),
424
+ "sdkInt" to Build.VERSION.SDK_INT,
425
+ )
426
+ }
427
+
428
+ // Called at flush only, never inside a measured window; runs off the JS thread.
429
+ AsyncFunction("artifactUpload") { fileName: String, url: String, headers: Map<String, String>, timeoutMs: Double ->
430
+ uploadArtifact(fileName, url, headers, timeoutMs)
431
+ }
432
+
433
+ // getExternalFilesDir needs no permission and `adb pull` reads it from a
434
+ // release (non-debuggable) build, where `run-as` does not work.
435
+ Function("writeText") { fileName: String, content: String ->
436
+ val context = appContext.reactContext ?: return@Function null
437
+ val base = context.getExternalFilesDir(null) ?: return@Function null
438
+ val dir = File(base, "perf-lab")
439
+ dir.mkdirs()
440
+ val file = File(dir, fileName.replace('/', '_'))
441
+ file.writeText(content)
442
+ file.absolutePath
443
+ }
444
+ }
445
+ }