@keepsnappy/react-native 0.0.0-stage → 0.1.0-alpha.12
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/README.md +260 -2
- package/android/build.gradle +35 -0
- package/android/src/main/java/expo/modules/perflab/PerfLabModule.kt +445 -0
- package/android/src/main/java/expo/modules/perflab/PerfLabSampler.kt +515 -0
- package/app.plugin.js +105 -0
- package/expo-module.config.json +9 -0
- package/ios/PerfLab.podspec +29 -0
- package/ios/PerfLabHermes.h +18 -0
- package/ios/PerfLabHermes.mm +64 -0
- package/ios/PerfLabModule.swift +371 -0
- package/ios/PerfLabSampler.swift +364 -0
- package/js/babel-transformer.js +71 -0
- package/js/hook.polyfill.js +333 -0
- package/js/renderer-switch.js +16 -0
- package/js/sdk/activation.ts +375 -0
- package/js/sdk/active.ts +62 -0
- package/js/sdk/artifacts.ts +632 -0
- package/js/sdk/boot.ts +21 -0
- package/js/sdk/causes.ts +932 -0
- package/js/sdk/core.ts +2403 -0
- package/js/sdk/cpuprof.ts +133 -0
- package/js/sdk/dormant.ts +77 -0
- package/js/sdk/env.d.ts +2 -0
- package/js/sdk/fiber.ts +591 -0
- package/js/sdk/interactions.ts +531 -0
- package/js/sdk/links.ts +201 -0
- package/js/sdk/nav.ts +383 -0
- package/js/sdk/net-install.ts +49 -0
- package/js/sdk/net.ts +684 -0
- package/js/sdk/profile-export.ts +257 -0
- package/js/sdk/profile.ts +539 -0
- package/js/sdk/push-install.ts +158 -0
- package/js/sdk/push.ts +819 -0
- package/js/sdk/retain-floor.ts +11 -0
- package/js/sdk/sampler.ts +236 -0
- package/js/sdk/screens.ts +586 -0
- package/js/sdk/stream.ts +125 -0
- package/js/zustand-vanilla.js +31 -0
- package/metro.js +237 -0
- package/package.json +26 -4
|
@@ -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
|
+
}
|