@goliapkg/sentori-react-native 6.2.1 → 6.4.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/android/build.gradle +6 -0
- package/android/src/main/java-core/com/sentori/Sentori.kt +33 -4
- package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +1 -1
- package/android/src/main/java-core/com/sentori/SentoriCrashHandler.kt +19 -0
- package/android/src/main/java-core/com/sentori/SentoriFirebaseMessagingService.kt +15 -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 +242 -13
- package/android/src/main/java-core/com/sentori/SentoriPushNotifications.kt +168 -17
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +49 -2
- package/android/src/main/java-core/com/sentori/SentoriTransport.kt +95 -2
- package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
- package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +43 -0
- package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +556 -0
- package/ios/core/SentoriAttachment.swift +129 -0
- package/ios/core/SentoriCrashHandler.swift +15 -0
- package/ios/core/SentoriPushNotifications.swift +8 -0
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +155 -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 +203 -1
- package/src/push.ts +147 -1
- package/src/scope.ts +50 -1
- package/src/transport.ts +1 -1
- package/ios/core/SentoriPush.swift +0 -340
package/android/build.gradle
CHANGED
|
@@ -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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|