@goliapkg/sentori-react-native 6.2.0 → 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 (56) hide show
  1. package/SentoriReactNative.podspec +11 -4
  2. package/android/build.gradle +21 -1
  3. package/android/src/main/java-core/com/sentori/Sentori.kt +273 -0
  4. package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
  5. package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
  6. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
  7. package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +22 -0
  8. package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
  9. package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +18 -0
  10. package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
  11. package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
  12. package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
  13. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
  14. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
  15. package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
  16. package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
  17. package/android/src/main/java-core/com/sentori/SentoriPush.kt +588 -0
  18. package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +171 -17
  19. package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
  20. package/android/src/main/java-core/com/sentori/SentoriScope.kt +107 -0
  21. package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
  22. package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
  23. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +476 -0
  24. package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
  25. package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
  26. package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
  27. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +149 -0
  28. package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
  29. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +707 -0
  30. package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
  31. package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
  32. package/ios/core/SentoriAttachment.swift +129 -0
  33. package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +31 -0
  34. package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
  35. package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
  36. package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
  37. package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +50 -0
  38. package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
  39. package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
  40. package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
  41. package/lib/push.d.ts.map +1 -1
  42. package/lib/push.js +51 -1
  43. package/lib/push.js.map +1 -1
  44. package/lib/scope.d.ts +6 -0
  45. package/lib/scope.d.ts.map +1 -1
  46. package/lib/scope.js +45 -1
  47. package/lib/scope.js.map +1 -1
  48. package/lib/transport.js +1 -1
  49. package/package.json +2 -2
  50. package/src/__tests__/push.test.ts +99 -1
  51. package/src/push.ts +49 -1
  52. package/src/scope.ts +50 -1
  53. package/src/transport.ts +1 -1
  54. package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
  55. package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
  56. package/ios/Tests/SentoriThreadSamplerTests.swift +0 -96
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriFirebaseMessagingService.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  // v2.10 — FCM message routing service.
2
5
  //
3
6
  // Manifest-registered in `AndroidManifest.xml`. Firebase's manifest
@@ -30,6 +33,11 @@ class SentoriFirebaseMessagingService : FirebaseMessagingService() {
30
33
  super.onNewToken(token)
31
34
  try {
32
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)
33
41
  } catch (_: Throwable) {
34
42
  // never crash a Firebase callback
35
43
  }
@@ -49,6 +57,16 @@ class SentoriFirebaseMessagingService : FirebaseMessagingService() {
49
57
  notif.channelId?.let { payload["channelId"] = it }
50
58
  }
51
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
+ }
52
70
  } catch (_: Throwable) {
53
71
  // never crash a Firebase callback
54
72
  }
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriForegroundActivity.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  package com.sentori
2
5
 
3
6
  import android.app.Activity
@@ -0,0 +1,101 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriIdentity.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import java.security.MessageDigest
7
+
8
+ /**
9
+ * Turns a raw identity into the key that travels on the wire.
10
+ *
11
+ * Every event carries `userKey`, and a registered device carries the
12
+ * same one — that join is what lets an issue reach the people it
13
+ * happened to. The raw value never leaves the device; only this hash
14
+ * does.
15
+ *
16
+ * Three implementations now compute it: TypeScript in
17
+ * `sdk/core/src/identity.ts`, Swift, and this. They must agree byte
18
+ * for byte. If they drift, a device stops matching the events from
19
+ * the same person: the push reaches nobody and nothing reports it,
20
+ * which is precisely why a second implementation is a bad idea unless
21
+ * something is holding the three together.
22
+ *
23
+ * That something is `sdk/native/fixtures/identity-vectors.json`,
24
+ * generated by the TypeScript module and asserted by both native
25
+ * suites.
26
+ *
27
+ * The client hash is unsalted. The server layers its own per-scope
28
+ * salt before storing a fingerprint; several comments in this
29
+ * codebase call the client value "salted", and they are wrong.
30
+ */
31
+ object SentoriIdentity {
32
+
33
+ /**
34
+ * SHA-256 of the normalised value, lowercase hex. `null` for an
35
+ * empty or whitespace-only input — no key beats a meaningless
36
+ * one, and the caller treats absent as "broadcast only".
37
+ */
38
+ @JvmStatic
39
+ fun hash(keyType: String, value: String?): String? {
40
+ if (value == null) return null
41
+ val normalised = normalise(keyType, value)
42
+ if (normalised.isEmpty()) return null
43
+ val digest = MessageDigest.getInstance("SHA-256")
44
+ .digest(normalised.toByteArray(Charsets.UTF_8))
45
+ val out = StringBuilder(64)
46
+ for (b in digest) {
47
+ val v = b.toInt() and 0xff
48
+ out.append(HEX[v ushr 4]).append(HEX[v and 0x0f])
49
+ }
50
+ return out.toString()
51
+ }
52
+
53
+ /**
54
+ * The key for a user, matching `scope.ts`: the id if there is one,
55
+ * otherwise the email. Not both — one device, one key.
56
+ */
57
+ @JvmStatic
58
+ fun userKey(id: String?, email: String?): String? =
59
+ hash("id", id) ?: hash("email", email)
60
+
61
+ /**
62
+ * Well-known normalisations, mirroring `normalise` in
63
+ * `identity.ts`. A type this does not know about gets a plain
64
+ * trim, which is that function's `default` branch.
65
+ */
66
+ /**
67
+ * What the other two implementations strip.
68
+ *
69
+ * Kotlin's `trim()` uses `Character.isWhitespace`, which counts
70
+ * neither U+00A0 (no-break space) nor U+FEFF (byte-order mark).
71
+ * ECMAScript's `WhiteSpace` production counts both, so JavaScript
72
+ * strips them. Swift's `.whitespacesAndNewlines` counts the first
73
+ * and not the second, so it needed a fix of its own.
74
+ *
75
+ * Three languages, three definitions of blank. A value pasted from
76
+ * a web page routinely carries these, and any of the defaults
77
+ * would have made one device hash differently from its own events
78
+ * — for one user, silently, on the path nobody watches.
79
+ *
80
+ * Caught by the shared vectors before it shipped, which is what
81
+ * they are for.
82
+ */
83
+ private fun String.trimLikeTheOthers(): String =
84
+ trim { it.isWhitespace() || it == '\u00a0' || it == '\ufeff' }
85
+
86
+ @JvmStatic
87
+ internal fun normalise(keyType: String, raw: String): String = when (keyType) {
88
+ "email", "username" -> raw.trimLikeTheOthers().lowercase()
89
+ // Everything that is not `+` or an ASCII digit goes, so
90
+ // "+81 (90) 1234-5678" and "+819012345678" agree. Deliberately
91
+ // not E.164 validation — that is the host's business; this
92
+ // only removes formatting noise.
93
+ "phone" -> raw.filter { it == '+' || it in '0'..'9' }
94
+ // OAuth subject claims are opaque. Touching them would change
95
+ // an identifier the provider defined.
96
+ "googleSub", "appleSub", "metaSub" -> raw
97
+ else -> raw.trimLikeTheOthers()
98
+ }
99
+
100
+ private val HEX = "0123456789abcdef".toCharArray()
101
+ }
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriMobileVitals.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  package com.sentori
2
5
 
3
6
  import android.os.Process
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriNativeExceptionBridge.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  package com.sentori
2
5
 
3
6
  import java.util.concurrent.ConcurrentLinkedDeque
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriNativeSignals.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  package com.sentori
2
5
 
3
6
  /**
@@ -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
+ }