@goliapkg/sentori-react-native 6.2.1 → 6.3.0

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 (33) hide show
  1. package/android/build.gradle +6 -0
  2. package/android/src/main/java-core/com/sentori/Sentori.kt +33 -4
  3. package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
  4. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +1 -1
  5. package/android/src/main/java-core/com/sentori/SentoriCrashHandler.kt +19 -0
  6. package/android/src/main/java-core/com/sentori/SentoriFirebaseMessagingService.kt +15 -0
  7. package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
  8. package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
  9. package/android/src/main/java-core/com/sentori/SentoriPush.kt +242 -13
  10. package/android/src/main/java-core/com/sentori/SentoriPushNotifications.kt +168 -17
  11. package/android/src/main/java-core/com/sentori/SentoriScope.kt +49 -2
  12. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +95 -2
  13. package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
  14. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +43 -0
  15. package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
  16. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +556 -0
  17. package/ios/core/SentoriAttachment.swift +129 -0
  18. package/ios/core/SentoriCrashHandler.swift +15 -0
  19. package/ios/core/SentoriPushNotifications.swift +8 -0
  20. package/lib/push.d.ts.map +1 -1
  21. package/lib/push.js +51 -1
  22. package/lib/push.js.map +1 -1
  23. package/lib/scope.d.ts +6 -0
  24. package/lib/scope.d.ts.map +1 -1
  25. package/lib/scope.js +45 -1
  26. package/lib/scope.js.map +1 -1
  27. package/lib/transport.js +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/push.test.ts +99 -1
  30. package/src/push.ts +49 -1
  31. package/src/scope.ts +50 -1
  32. package/src/transport.ts +1 -1
  33. package/ios/core/SentoriPush.swift +0 -340
@@ -77,6 +77,12 @@ dependencies {
77
77
  // unresolved reference. Nothing noticed because nothing ran them —
78
78
  // the CI job that was supposed to had been failing for months on a
79
79
  // missing Gradle wrapper.
80
+ // The mirrored tests move with the code they cover, and one of
81
+ // them drives a real `RemoteMessage` through the messaging
82
+ // service. `compileOnly` above does not reach the test source
83
+ // set, so without this the mirror compiles and its tests do not —
84
+ // which is how this module went red while `sdk/native` was green.
85
+ testImplementation 'com.google.firebase:firebase-messaging:24.0.3'
80
86
  testImplementation 'junit:junit:4.13.2'
81
87
  testImplementation 'org.robolectric:robolectric:4.13'
82
88
  testImplementation 'androidx.test:core:1.6.1'
@@ -46,18 +46,47 @@ object Sentori {
46
46
  SentoriConfig.set(config)
47
47
  SentoriDevice.bind(context)
48
48
  SentoriTransport.start(context?.let { java.io.File(it.filesDir, "sentori") })
49
+
50
+ // Crash capture needs a Context for its storage, so an app
51
+ // that starts without one gets everything except this.
52
+ if (context != null) {
53
+ // The handler runs inside an uncaught-exception handler
54
+ // where almost nothing is safe, so it reads its release
55
+ // and environment from SharedPreferences rather than from
56
+ // a live object. Without this call every crash ships as
57
+ // `unknown` / `prod` — matching no release, so
58
+ // symbolicating against nothing.
59
+ SentoriCrashHandler.setConfig(
60
+ mapOf("release" to config.release, "environment" to config.environment)
61
+ )
62
+ SentoriCrashHandler.register(context)
63
+
64
+ // The crash that killed the last launch. Until now the
65
+ // handler wrote files into a directory nothing emptied —
66
+ // a crash reporter that captured crashes and never sent
67
+ // one.
68
+ SentoriPendingCrash.ship()
69
+ }
49
70
  }
50
71
 
51
72
  /**
52
73
  * Identify the person using the app. Only a hash of [id] (or
53
74
  * [email] when there is no id) travels; the raw values stay here.
54
75
  *
55
- * Call this before registering for push if you want the device to
56
- * be reachable from an issue — a device with no user key receives
57
- * broadcasts and nothing else.
76
+ * Order does not matter with push: a device that has registered
77
+ * updates itself when this changes, so signing in after
78
+ * registering makes the device reachable straight away. It said to
79
+ * call this first, which was advice for a defect rather than a
80
+ * design — most apps learn who the user is well after launch.
81
+ *
82
+ * [traits] are attributes a push campaign can select on: plan,
83
+ * cohort, org. They travel raw, unlike [id] and [email], so put
84
+ * nothing there that identifies the person.
58
85
  */
59
86
  @JvmStatic
60
- fun user(id: String?, email: String?) = SentoriScope.setUser(id, email)
87
+ @JvmOverloads
88
+ fun user(id: String?, email: String?, traits: Map<String, Any?>? = null) =
89
+ SentoriScope.setUser(id, email, traits)
61
90
 
62
91
  /** Merge keys into the ambient context that rides every event. */
63
92
  @JvmStatic
@@ -0,0 +1,153 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriAttachment.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import java.net.HttpURLConnection
7
+ import java.net.URL
8
+ import java.net.URLEncoder
9
+
10
+ /**
11
+ * Blobs that belong to an event: the screenshot taken as the app died,
12
+ * the view tree behind it.
13
+ *
14
+ * They upload *after* the event, never before. The server keys an
15
+ * attachment on an event id it must already know, so an upload that
16
+ * races the batch 404s — and it always wins that race, because the
17
+ * batch waits for a flush and the upload does not.
18
+ *
19
+ * The multipart body is built by hand. React Native learned why the
20
+ * expensive way: its `FormData` file part wants a `uri`, the `data:`
21
+ * form throws a bare network error on iOS, and every JS attachment
22
+ * died silently for a release. Doing it literally is both shorter and
23
+ * checkable.
24
+ *
25
+ * Mirrors `SentoriAttachment.swift` part for part.
26
+ */
27
+ internal object SentoriAttachment {
28
+
29
+ /**
30
+ * The kinds the server's CHECK constraint accepts, from
31
+ * `handlers/sdk/events_attachments.rs`. Anything else is a 400,
32
+ * so it is dropped here with the round trip saved.
33
+ *
34
+ * Worth keeping honest: the first version of this list had three
35
+ * entries, and the crash handler's view tree — written on both
36
+ * platforms, kind `viewTree` — was not one of them. It would have
37
+ * been dropped on the way out by the very code meant to deliver
38
+ * it, silently, with the event arriving intact and the evidence
39
+ * missing.
40
+ */
41
+ val KNOWN =
42
+ setOf(
43
+ "logTail",
44
+ "replay",
45
+ "screens",
46
+ "screenshot",
47
+ "sessionTrail",
48
+ "stateSnapshot",
49
+ "viewTree",
50
+ )
51
+
52
+ /** `android` | `ios` | `js`, likewise CHECK-constrained. */
53
+ val KNOWN_SOURCES = setOf("android", "ios", "js")
54
+
55
+ /**
56
+ * Test seam: capture instead of sending.
57
+ *
58
+ * What is worth asserting is *what* would be uploaded and *when* —
59
+ * the kind, the event it is keyed on, the body, and that none of
60
+ * it moves before the batch lands. None of that is about TCP; the
61
+ * wire has its own gate in `android-live-ingest`.
62
+ */
63
+ internal var recorderForTests: ((String, String, String) -> Unit)? = null
64
+
65
+ private val worker = java.util.concurrent.Executors.newSingleThreadExecutor { r ->
66
+ Thread(r, "sentori-attachment").apply { isDaemon = true }
67
+ }
68
+
69
+ /**
70
+ * Upload one blob against an event the server already has.
71
+ *
72
+ * Fire and forget: nothing here reaches the caller, and a failure
73
+ * costs the attachment rather than the crash report it belongs to.
74
+ */
75
+ fun upload(
76
+ eventId: String,
77
+ kind: String,
78
+ base64: String,
79
+ mediaType: String,
80
+ source: String = "android",
81
+ completion: ((Boolean) -> Unit)? = null,
82
+ ) {
83
+ val config = SentoriConfig.current
84
+ if (config == null || kind !in KNOWN || source !in KNOWN_SOURCES) {
85
+ completion?.invoke(false)
86
+ return
87
+ }
88
+
89
+ val boundary = "----sentori-$eventId"
90
+ val body = multipartBody(boundary, kind, mediaType, base64, source)
91
+
92
+ recorderForTests?.let {
93
+ it(eventId, kind, body)
94
+ completion?.invoke(true)
95
+ return
96
+ }
97
+
98
+ worker.execute {
99
+ var conn: HttpURLConnection? = null
100
+ var ok = false
101
+ try {
102
+ val url =
103
+ "${config.ingestUrl}/v1/events/" +
104
+ URLEncoder.encode(eventId, "UTF-8") +
105
+ "/attachments/" +
106
+ URLEncoder.encode(kind, "UTF-8")
107
+ conn = URL(url).openConnection() as HttpURLConnection
108
+ conn.requestMethod = "POST"
109
+ conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
110
+ conn.setRequestProperty("Authorization", "Bearer ${config.token}")
111
+ conn.setRequestProperty("Sentori-Sdk", "kotlin/${SentoriVersion.CURRENT}")
112
+ conn.connectTimeout = 15_000
113
+ conn.readTimeout = 30_000
114
+ conn.doOutput = true
115
+ conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
116
+ ok = conn.responseCode in 200..299
117
+ } catch (_: Throwable) {
118
+ // An attachment that will not go is an attachment lost,
119
+ // and never the host app's problem.
120
+ } finally {
121
+ conn?.disconnect()
122
+ completion?.invoke(ok)
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * The multipart body, visible so a test can read what would go on
129
+ * the wire. Building it correctly is the whole job here, and the
130
+ * failure mode is a 2xx that stored the wrong bytes.
131
+ */
132
+ internal fun multipartBody(
133
+ boundary: String,
134
+ kind: String,
135
+ mediaType: String,
136
+ base64: String,
137
+ source: String,
138
+ ): String =
139
+ buildString {
140
+ append("--$boundary\r\n")
141
+ append("Content-Disposition: form-data; name=\"file\"; filename=\"$kind.bin\"\r\n")
142
+ append("Content-Type: $mediaType\r\n")
143
+ // The server decodes when told to. Without this header it
144
+ // stores the base64 text as the image, which renders as
145
+ // nothing and looks like a capture problem.
146
+ append("Content-Transfer-Encoding: base64\r\n")
147
+ append("\r\n$base64\r\n")
148
+ append("--$boundary\r\n")
149
+ append("Content-Disposition: form-data; name=\"source\"\r\n")
150
+ append("\r\n$source\r\n")
151
+ append("--$boundary--\r\n")
152
+ }
153
+ }
@@ -57,5 +57,5 @@ constructor(
57
57
  * direction of a lie.
58
58
  */
59
59
  object SentoriVersion {
60
- const val CURRENT = "1.0.0"
60
+ const val CURRENT = "1.7.1"
61
61
  }
@@ -196,6 +196,25 @@ object SentoriCrashHandler {
196
196
  }
197
197
  }
198
198
 
199
+ /**
200
+ * Test-only seam that writes a crash file verbatim.
201
+ *
202
+ * `persistForTesting(throwable)` goes through `write`, which
203
+ * composes the file from a live throwable and a live window —
204
+ * neither of which a Robolectric test can arrange. Delivery tests
205
+ * need to state the file they are about to deliver, particularly
206
+ * the pre-death attachments, which only exist when there was a
207
+ * window to capture.
208
+ */
209
+ internal fun persistRawForTesting(event: JSONObject) {
210
+ val dir = pendingDir() ?: return
211
+ try {
212
+ File(dir, "${uuidLower()}.json").writeText(event.toString())
213
+ } catch (_: Throwable) {
214
+ // best-effort
215
+ }
216
+ }
217
+
199
218
  private fun attachPending(event: JSONObject) {
200
219
  val snap = try {
201
220
  SentoriScreenshotCapture.captureKeyWindow()
@@ -33,6 +33,11 @@ class SentoriFirebaseMessagingService : FirebaseMessagingService() {
33
33
  super.onNewToken(token)
34
34
  try {
35
35
  SentoriPushNotifications.handleRegisteredToken(token)
36
+ // And tell the server, now. Buffering it in a field was
37
+ // the whole of what happened here before, so a rotation
38
+ // was invisible until the host next called `register` —
39
+ // and the device received nothing in between.
40
+ SentoriPush.handleRotatedToken(applicationContext, token)
36
41
  } catch (_: Throwable) {
37
42
  // never crash a Firebase callback
38
43
  }
@@ -52,6 +57,16 @@ class SentoriFirebaseMessagingService : FirebaseMessagingService() {
52
57
  notif.channelId?.let { payload["channelId"] = it }
53
58
  }
54
59
  SentoriPushNotifications.handleIncomingNotification(payload)
60
+
61
+ // A `data` message is invisible until somebody draws it,
62
+ // and the Sentori server sends `data` on purpose so that
63
+ // the tap comes back through a pending intent we own. If
64
+ // the system already drew this one — a `notification`
65
+ // message from some other sender — leave it alone rather
66
+ // than showing it twice.
67
+ if (message.notification == null) {
68
+ SentoriPushNotifications.postNotification(applicationContext, message.data)
69
+ }
55
70
  } catch (_: Throwable) {
56
71
  // never crash a Firebase callback
57
72
  }
@@ -0,0 +1,148 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriNotificationTap.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import android.app.Activity
7
+ import android.app.PendingIntent
8
+ import android.content.Context
9
+ import android.content.Intent
10
+ import android.os.Bundle
11
+
12
+ /**
13
+ * Taps, delivered without asking the host for anything.
14
+ *
15
+ * `handleNotificationTap` used to be the only way a tap reached
16
+ * `register(onTap:)`, and its own comment said the host wires this in
17
+ * `Activity.onCreate to forward the intent extras`. Nothing in the SDK
18
+ * called it, the public docs never mentioned it, and so a native host
19
+ * passed an `onTap` callback that could not fire. insight measured
20
+ * that: the tray entry appeared, the app opened, and the callback
21
+ * stayed silent. It is the same shape as the permission bug — a
22
+ * premise carried over from the React Native integration, where the
23
+ * host module did the forwarding, and quietly false everywhere else.
24
+ *
25
+ * There are two ways a tap can reach an app, and this covers both.
26
+ *
27
+ * **A notification the SDK posted.** The pending intent is ours, so
28
+ * the tap arrives here with the message that produced it. Nothing can
29
+ * intercept it and nothing needs to cooperate. This is the path the
30
+ * Sentori server produces, because it sends `data` messages for
31
+ * exactly this reason.
32
+ *
33
+ * **A notification the system posted**, from a `notification` message
34
+ * some other sender emitted. The system opens the launcher activity
35
+ * with the payload in the intent extras and never calls our service.
36
+ * All that can be done is to read the intent the Activity was handed,
37
+ * which `register` does. A tap that arrives while the app is already
38
+ * running reaches the host's `onNewIntent`, and only a host that
39
+ * calls `setIntent` makes it visible to anyone else — so that case
40
+ * still needs the host, and the documentation says so rather than
41
+ * pretending otherwise.
42
+ */
43
+ object SentoriNotificationTap {
44
+
45
+ /** Extras on our own pending intent. */
46
+ const val EXTRA_MARKER = "com.sentori.tap"
47
+
48
+ /**
49
+ * FCM stamps this on anything it delivers. Its presence is how an
50
+ * ordinary launch is told apart from a notification tap when the
51
+ * system posted the notification.
52
+ */
53
+ private const val FCM_MESSAGE_ID = "google.message_id"
54
+
55
+ private val lock = Any()
56
+ private val seen = ArrayDeque<String>()
57
+ private const val SEEN_CAP = 32
58
+
59
+ /**
60
+ * A tap is delivered once.
61
+ *
62
+ * The same intent is visible to `register` on a cold start and to
63
+ * the pending intent that caused it, and an Activity that is
64
+ * recreated on rotation hands back the intent it launched with.
65
+ * Reporting a tap twice makes a host count one open as two.
66
+ */
67
+ private fun firstTime(id: String): Boolean = synchronized(lock) {
68
+ if (seen.contains(id)) return false
69
+ seen.addLast(id)
70
+ while (seen.size > SEEN_CAP) seen.removeFirst()
71
+ true
72
+ }
73
+
74
+ /** The pending intent attached to a notification the SDK posts. */
75
+ fun pendingIntent(ctx: Context, data: Map<String, String>, requestCode: Int): PendingIntent? {
76
+ // `getLaunchIntentForPackage` returns null more often than it
77
+ // looks: an app with no exported launcher activity, and any
78
+ // Robolectric host. The first version gave up there and
79
+ // posted a notification with no content intent — a tray entry
80
+ // that swallows the tap, which is worse than the bug this
81
+ // file exists to fix, because it looks like it works.
82
+ val launch = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
83
+ ?: Intent(Intent.ACTION_MAIN)
84
+ .addCategory(Intent.CATEGORY_LAUNCHER)
85
+ .setPackage(ctx.packageName)
86
+ launch.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
87
+ launch.putExtra(EXTRA_MARKER, true)
88
+ for ((k, v) in data) launch.putExtra(k, v)
89
+ return PendingIntent.getActivity(
90
+ ctx,
91
+ requestCode,
92
+ launch,
93
+ // Immutable is required from Android 12 and correct
94
+ // everywhere: nothing downstream should be able to rewrite
95
+ // the payload this carries.
96
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
97
+ )
98
+ }
99
+
100
+ /**
101
+ * Read whatever launched this Activity and report a tap if that is
102
+ * what it was.
103
+ *
104
+ * Called by `register`, so a host that does nothing at all still
105
+ * gets its `onTap`.
106
+ */
107
+ fun consume(activity: Activity?) {
108
+ consume(activity?.intent?.extras)
109
+ }
110
+
111
+ /**
112
+ * Report a tap from intent extras, if that is what they are.
113
+ *
114
+ * Public because the documentation asks hosts to call it. A tap
115
+ * arriving while the app is already running reaches the host's
116
+ * `onNewIntent` and nowhere else, and the docs said so — while
117
+ * this object was `internal`, so the only reachable alternative
118
+ * was `handleNotificationTap`, which records whatever it is
119
+ * given. insight ended up copying the two-key test out of here by
120
+ * hand, including a constant that exists only in this file.
121
+ *
122
+ * Safe to call with any intent's extras: an ordinary launch is
123
+ * not a tap and is ignored, and the same tap is reported once.
124
+ */
125
+ fun consume(extras: Bundle?) {
126
+ val bundle = extras ?: return
127
+ val ours = bundle.getBoolean(EXTRA_MARKER, false)
128
+ val fcm = bundle.containsKey(FCM_MESSAGE_ID)
129
+ // An ordinary launch has neither, and reporting it as a tap
130
+ // would put a notification in every session that never had
131
+ // one.
132
+ if (!ours && !fcm) return
133
+
134
+ val payload = mutableMapOf<String, Any?>()
135
+ for (key in bundle.keySet()) {
136
+ if (key == EXTRA_MARKER) continue
137
+ payload[key] = bundle.get(key)
138
+ }
139
+ val id = (bundle.getString(FCM_MESSAGE_ID) ?: payload["id"] as? String)
140
+ ?: payload.toString()
141
+ if (!firstTime(id)) return
142
+ SentoriPushNotifications.handleNotificationTap(payload)
143
+ }
144
+
145
+ internal fun resetForTests() {
146
+ synchronized(lock) { seen.clear() }
147
+ }
148
+ }
@@ -0,0 +1,154 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriPendingCrash.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import org.json.JSONArray
7
+ import org.json.JSONObject
8
+
9
+ /**
10
+ * The crash that killed the app, sent on the next launch.
11
+ *
12
+ * [SentoriCrashHandler] writes one JSON file per crash from inside an
13
+ * uncaught-exception handler, where almost nothing is safe to do — so
14
+ * it writes a flat, older shape and gets out. Something has to read
15
+ * those files, convert them to the wire format and enqueue them, and
16
+ * until now nothing in this package did: the crash handler filled a
17
+ * directory nobody emptied.
18
+ *
19
+ * React Native has had the missing half since the beginning, in
20
+ * `sdk/react-native/src/native-pending.ts`. This is the same
21
+ * conversion as that and as the Swift one, so a crash arrives
22
+ * identical whichever SDK sent it — the dashboard cannot tell and
23
+ * should not have to.
24
+ *
25
+ * Attachments captured before death are carried in the file and are
26
+ * **not** uploaded yet: that needs the attachment endpoint this
27
+ * package does not speak. They are left in place rather than silently
28
+ * dropped, and the event ships without them — a crash report with no
29
+ * screenshot beats no crash report.
30
+ */
31
+ internal object SentoriPendingCrash {
32
+
33
+ /** Drain, convert, enqueue. Called once by [Sentori.start]. */
34
+ fun ship() {
35
+ val files = SentoriCrashHandler.consumePending()
36
+ if (files.isEmpty()) return
37
+
38
+ for (text in files) {
39
+ val raw =
40
+ try {
41
+ JSONObject(text)
42
+ } catch (_: Throwable) {
43
+ // One corrupt file must not cost the others.
44
+ continue
45
+ }
46
+ // The screenshot and view tree the handler captured as the
47
+ // app died. They travel in the file and never on the wire —
48
+ // the server takes them separately, keyed on an event it
49
+ // must already have.
50
+ val blobs = raw.optJSONArray("_pendingAttachments")
51
+ raw.remove("_pendingAttachments")
52
+ val wire = toWire(raw)
53
+ val id = wire["id"] as? String
54
+
55
+ // Registered before this event is queued, and keyed on its
56
+ // id. Two races close here, and one of them was real: the
57
+ // first version registered after `flush`, which hands the
58
+ // send to a worker and returns — so on a fast network the
59
+ // batch was accepted before the block existed and the
60
+ // attachments never uploaded at all. Registering before the
61
+ // loop's flush is not enough either, since `enqueue` sends
62
+ // of its own accord once ten events are queued, and a run
63
+ // with ten crash files would flush mid-loop.
64
+ if (blobs != null && blobs.length() > 0 && id != null) {
65
+ SentoriTransport.afterDelivery(setOf(id)) {
66
+ for (i in 0 until blobs.length()) {
67
+ val blob = blobs.optJSONObject(i) ?: continue
68
+ val kind = blob.optString("kind", "")
69
+ val base64 = blob.optString("base64", "")
70
+ if (kind.isEmpty() || base64.isEmpty()) continue
71
+ SentoriAttachment.upload(
72
+ eventId = id,
73
+ kind = kind,
74
+ base64 = base64,
75
+ mediaType =
76
+ blob.optString("mediaType", "").ifEmpty {
77
+ "application/octet-stream"
78
+ },
79
+ source = blob.optString("source", "").ifEmpty { "android" },
80
+ )
81
+ }
82
+ }
83
+ }
84
+ SentoriTransport.enqueue(wire)
85
+ }
86
+ SentoriTransport.flush()
87
+ }
88
+
89
+ /**
90
+ * The flat on-disk shape into the nested wire one. Mirrors
91
+ * `toWire` in `native-pending.ts` and `SentoriPendingCrash.swift`
92
+ * field for field.
93
+ */
94
+ fun toWire(raw: JSONObject): Map<String, Any?> {
95
+ val rawError = raw.optJSONObject("error") ?: JSONObject()
96
+ val rawStack = rawError.optJSONArray("stack") ?: JSONArray()
97
+ val frames =
98
+ (0 until rawStack.length()).mapNotNull { i ->
99
+ val f = rawStack.optJSONObject(i) ?: return@mapNotNull null
100
+ buildMap<String, Any?> {
101
+ f.optString("file", "").takeIf { it.isNotEmpty() }?.let { put("file", it) }
102
+ f.optString("function", "").takeIf { it.isNotEmpty() }
103
+ ?.let { put("function", it) }
104
+ if (f.has("line")) put("line", f.optInt("line"))
105
+ if (f.has("inApp")) put("inApp", f.optBoolean("inApp"))
106
+ }
107
+ }
108
+
109
+ val payload =
110
+ mutableMapOf<String, Any?>(
111
+ "error" to
112
+ mapOf(
113
+ // A crash with no type is still a crash. The
114
+ // default names what it is rather than leaving
115
+ // the issue title empty.
116
+ "type" to
117
+ rawError.optString("type", "").ifEmpty { "NativeCrash" },
118
+ "message" to rawError.optString("message", ""),
119
+ "stack" to frames,
120
+ ),
121
+ // Tells the dashboard this arrived from the grave
122
+ // rather than from a caught error — the difference
123
+ // between "the app died" and "the app noticed".
124
+ "nativeCrash" to true,
125
+ )
126
+ raw.optJSONObject("device")?.let { payload["device"] = it.toMap() }
127
+ raw.optJSONObject("app")?.let { payload["app"] = it.toMap() }
128
+
129
+ return mapOf(
130
+ "id" to raw.optString("id", "").ifEmpty { Sentori.newEventId() },
131
+ "kind" to "error",
132
+ // The file says `timestamp`; the wire says `occurredAt`.
133
+ // Keeping the crash's own time matters — it is the moment
134
+ // the app died, not the moment the next launch noticed.
135
+ "occurredAt" to
136
+ raw.optString("timestamp", "").ifEmpty {
137
+ Sentori.iso8601(java.util.Date())
138
+ },
139
+ "platform" to if (raw.optString("platform") == "ios") "ios" else "android",
140
+ "release" to raw.optString("release", ""),
141
+ "environment" to raw.optString("environment", ""),
142
+ "payload" to payload,
143
+ )
144
+ }
145
+
146
+ private fun JSONObject.toMap(): Map<String, Any?> =
147
+ keys().asSequence().associateWith { k ->
148
+ when (val v = get(k)) {
149
+ is JSONObject -> v.toMap()
150
+ JSONObject.NULL -> null
151
+ else -> v
152
+ }
153
+ }
154
+ }