@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.
- package/SentoriReactNative.podspec +11 -4
- package/android/build.gradle +21 -1
- package/android/src/main/java-core/com/sentori/Sentori.kt +273 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +22 -0
- package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +18 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
- package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
- package/android/src/main/java-core/com/sentori/SentoriPush.kt +588 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +171 -17
- package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +107 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
- package/android/src/main/java-core/com/sentori/SentoriTransport.kt +476 -0
- package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
- package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
- package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
- package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +149 -0
- package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +707 -0
- package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
- package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
- package/ios/core/SentoriAttachment.swift +129 -0
- package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +31 -0
- package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
- package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
- package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
- package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +50 -0
- package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
- package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
- package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +51 -1
- package/lib/push.js.map +1 -1
- package/lib/scope.d.ts +6 -0
- package/lib/scope.d.ts.map +1 -1
- package/lib/scope.js +45 -1
- package/lib/scope.js.map +1 -1
- package/lib/transport.js +1 -1
- package/package.json +2 -2
- package/src/__tests__/push.test.ts +99 -1
- package/src/push.ts +49 -1
- package/src/scope.ts +50 -1
- package/src/transport.ts +1 -1
- package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
- package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
- 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
|
}
|
|
@@ -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/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
|
|
@@ -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
|
+
}
|