@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
@@ -10,15 +10,22 @@ Pod::Spec.new do |s|
10
10
  s.license = 'TBD'
11
11
  s.author = { 'Sentori' => 'support@sentori.golia.jp' }
12
12
  s.homepage = 'https://sentori.golia.jp'
13
- s.platforms = { ios: '13.4', tvos: '13.4' }
13
+ # 13.4 was a claim nothing compiled. `SentoriPushNotifications`
14
+ # uses `UNNotificationPresentationOptions.banner` and `.list`, both
15
+ # iOS 14 — building the sources against the declared floor for the
16
+ # first time (as a Swift package, 2026-08-11) failed on exactly
17
+ # that. The floor now matches what the code needs.
18
+ s.platforms = { ios: '14.0', tvos: '14.0' }
14
19
  s.swift_version = '5.4'
15
20
  s.source = { git: '' }
16
21
  s.static_framework = true
17
22
 
18
23
  s.dependency 'ExpoModulesCore'
19
24
 
25
+ # `ios/SentoriModule.swift` is the bridge; `ios/core/` is a mirror of
26
+ # `sdk/native/ios/Sources/Sentori`, written by
27
+ # `scripts/sync-native-core.mjs` and gated byte-for-byte. Edit the
28
+ # package, never the mirror — a pod cannot reach outside its own
29
+ # directory, which is the only reason a copy exists at all.
20
30
  s.source_files = 'ios/**/*.{h,m,mm,swift,hpp,cpp}'
21
- # XCTest only links into test targets; including ios/Tests/** in the
22
- # main pod target makes app builds fail with `no such module 'XCTest'`.
23
- s.exclude_files = 'ios/Tests/**'
24
31
  end
@@ -18,7 +18,21 @@ android {
18
18
  sourceSets {
19
19
  main {
20
20
  manifest.srcFile 'src/main/AndroidManifest.xml'
21
- java.srcDirs = ['src/main/java']
21
+ // `java` holds the bridge — `SentoriModule.kt`, the one
22
+ // file here that knows Expo exists. `java-core` is a
23
+ // generated mirror of `sdk/native/android`, written by
24
+ // `scripts/sync-native-core.mjs` and gated byte-for-byte.
25
+ // Two roots rather than one directory because the sync
26
+ // deletes its target before writing, and the bridge must
27
+ // not live somewhere that gets wiped.
28
+ java.srcDirs = ['src/main/java', 'src/main/java-core']
29
+ }
30
+ test {
31
+ // The Robolectric suites cover the core, so they moved
32
+ // with it. Mirrored back so `android-unit` keeps running
33
+ // them — dropping them here would have left the job green
34
+ // while testing strictly less.
35
+ java.srcDirs = ['src/test/java', 'src/test/java-core']
22
36
  }
23
37
  }
24
38
 
@@ -63,6 +77,12 @@ dependencies {
63
77
  // unresolved reference. Nothing noticed because nothing ran them —
64
78
  // the CI job that was supposed to had been failing for months on a
65
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'
66
86
  testImplementation 'junit:junit:4.13.2'
67
87
  testImplementation 'org.robolectric:robolectric:4.13'
68
88
  testImplementation 'androidx.test:core:1.6.1'
@@ -0,0 +1,273 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/Sentori.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import android.content.Context
7
+ import java.text.SimpleDateFormat
8
+ import java.util.Date
9
+ import java.util.Locale
10
+ import java.util.TimeZone
11
+ import kotlin.random.Random
12
+
13
+ /**
14
+ * The surface a host app writes against.
15
+ *
16
+ * Sentori.error(e) what went wrong?
17
+ * Sentori.warn("checkout.slow") where did the user struggle?
18
+ * Sentori.trace("cart.opened") what happened here?
19
+ * Sentori.assert("total", ok) should this hold?
20
+ * Sentori.probe("SEN-482") is that bug back?
21
+ *
22
+ * Every one is synchronous, returns the event id it minted, and never
23
+ * throws — including before [start], where each is a no-op that still
24
+ * hands back an id. An app that mis-wires its token gets a silent SDK,
25
+ * not an exception on a path it did not know it had.
26
+ *
27
+ * Same semantics as the Swift and React Native surfaces, because the
28
+ * dashboard reading them cannot tell which one sent an event and
29
+ * should not have to.
30
+ */
31
+ object Sentori {
32
+
33
+ // ── lifecycle ─────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Configure and start. [context] is used for one thing: the
37
+ * directory events spill into when the network is gone. Pass the
38
+ * application context, not an Activity.
39
+ *
40
+ * Nothing here reaches the network; the first request happens when
41
+ * there is something to send.
42
+ */
43
+ @JvmStatic
44
+ @JvmOverloads
45
+ fun start(config: SentoriConfig, context: Context? = null) {
46
+ SentoriConfig.set(config)
47
+ SentoriDevice.bind(context)
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
+ }
70
+ }
71
+
72
+ /**
73
+ * Identify the person using the app. Only a hash of [id] (or
74
+ * [email] when there is no id) travels; the raw values stay here.
75
+ *
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.
85
+ */
86
+ @JvmStatic
87
+ @JvmOverloads
88
+ fun user(id: String?, email: String?, traits: Map<String, Any?>? = null) =
89
+ SentoriScope.setUser(id, email, traits)
90
+
91
+ /** Merge keys into the ambient context that rides every event. */
92
+ @JvmStatic
93
+ fun context(patch: Map<String, Any?>) = SentoriScope.patchContext(patch)
94
+
95
+ /**
96
+ * Record a signal for the last-sixty-seconds ring that ships with
97
+ * an error. Any [kind] is accepted; the dashboard reads `http` as
98
+ * `{ method, url, status, ms }` and `trace` as a quiet breadcrumb.
99
+ *
100
+ * This SDK deliberately does not install an OkHttp interceptor:
101
+ * watching the host's traffic is the host's decision, not ours to
102
+ * make silently.
103
+ */
104
+ @JvmStatic
105
+ @JvmOverloads
106
+ fun pushSignal(kind: String, data: Map<String, Any?>? = null) =
107
+ SentoriSignalRing.push(kind, data)
108
+
109
+ /**
110
+ * `Sentori.push.register(...)`, matching `sentori.push.register()`
111
+ * in the React Native SDK and `Sentori.push` in Swift.
112
+ */
113
+ @JvmStatic val push: SentoriPush get() = SentoriPush
114
+
115
+ // ── the verbs ─────────────────────────────────────────────────
116
+
117
+ /** Something went wrong. */
118
+ @JvmStatic
119
+ @JvmOverloads
120
+ fun error(err: Throwable, data: Map<String, Any?>? = null): String =
121
+ emit("error", null, describe(err), data, withSignals = true)
122
+
123
+ /** Something went wrong, described rather than thrown. */
124
+ @JvmStatic
125
+ @JvmOverloads
126
+ fun error(message: String, type: String = "Error", data: Map<String, Any?>? = null): String =
127
+ emit("error", null, mapOf("type" to type, "message" to message), data, withSignals = true)
128
+
129
+ /**
130
+ * The user struggled here. Not a crash — a place the product hurt,
131
+ * named by you.
132
+ */
133
+ @JvmStatic
134
+ @JvmOverloads
135
+ fun warn(name: String, data: Map<String, Any?>? = null): String =
136
+ emit("warn", name, null, data, withSignals = true)
137
+
138
+ /**
139
+ * This happened. Always lands in the signal ring; [quiet] keeps it
140
+ * out of the event stream, which is how a high-frequency
141
+ * breadcrumb stays affordable.
142
+ */
143
+ @JvmStatic
144
+ @JvmOverloads
145
+ fun trace(name: String, data: Map<String, Any?>? = null, quiet: Boolean = false): String {
146
+ val signal = mutableMapOf<String, Any?>("name" to name)
147
+ data?.let { signal.putAll(it) }
148
+ SentoriSignalRing.push("trace", signal)
149
+ if (quiet) return newEventId()
150
+ return emit("trace", name, null, data, withSignals = false)
151
+ }
152
+
153
+ /**
154
+ * This should hold. A passing assert never becomes an event — it
155
+ * increments a counter that rides the next batch, so a liveness
156
+ * check costs no request. Only failures are events.
157
+ *
158
+ * Unlike the language's own `assert`, this never stops the
159
+ * program. That difference is the point: a monitoring SDK that can
160
+ * halt the app it monitors has picked the wrong side of the
161
+ * bargain.
162
+ */
163
+ @JvmStatic
164
+ @JvmOverloads
165
+ fun assert(name: String, ok: Boolean, data: Map<String, Any?>? = null): String {
166
+ SentoriConfig.current?.let { SentoriTransport.countAssert(name, ok, it.release) }
167
+ if (ok) return newEventId()
168
+ return emit("assert", name, null, data, withSignals = true)
169
+ }
170
+
171
+ /**
172
+ * Is that bug back? A tripwire: reaching this call is the signal.
173
+ * It changes no control flow and returns no verdict.
174
+ */
175
+ @JvmStatic
176
+ @JvmOverloads
177
+ fun probe(ref: String, data: Map<String, Any?>? = null): String =
178
+ emit("probe", ref, null, data, withSignals = false)
179
+
180
+ // ── assembly ──────────────────────────────────────────────────
181
+
182
+ private fun emit(
183
+ kind: String,
184
+ name: String?,
185
+ error: Map<String, Any?>?,
186
+ data: Map<String, Any?>?,
187
+ withSignals: Boolean,
188
+ ): String {
189
+ val id = newEventId()
190
+ // Before init every verb is a no-op that still returns an id,
191
+ // so a call site does not change shape depending on whether
192
+ // the SDK came up.
193
+ val config = SentoriConfig.current ?: return id
194
+
195
+ val payload = mutableMapOf<String, Any?>()
196
+ error?.let { payload["error"] = it }
197
+ data?.let { payload["data"] = it }
198
+ SentoriScope.context?.let { payload["context"] = it }
199
+ if (withSignals) {
200
+ val signals = SentoriSignalRing.snapshot()
201
+ if (signals.isNotEmpty()) payload["signals"] = signals
202
+ }
203
+ payload["device"] = SentoriDevice.snapshot()
204
+
205
+ val event =
206
+ mutableMapOf<String, Any?>(
207
+ "id" to id,
208
+ "kind" to kind,
209
+ "occurredAt" to iso8601(Date()),
210
+ "platform" to "android",
211
+ "release" to config.release,
212
+ "environment" to config.environment,
213
+ "payload" to payload,
214
+ )
215
+ name?.let { event["name"] = it }
216
+ SentoriScope.userKey?.let { event["userKey"] = it }
217
+
218
+ SentoriTransport.enqueue(event)
219
+ return id
220
+ }
221
+
222
+ private fun describe(err: Throwable): Map<String, Any?> {
223
+ val frames =
224
+ err.stackTrace.take(50).map { f ->
225
+ mapOf(
226
+ "function" to "${f.className}.${f.methodName}",
227
+ "file" to (f.fileName ?: ""),
228
+ "line" to f.lineNumber,
229
+ )
230
+ }
231
+ val out =
232
+ mutableMapOf<String, Any?>(
233
+ "type" to (err.javaClass.name),
234
+ "message" to (err.message ?: ""),
235
+ "stack" to frames,
236
+ )
237
+ err.cause?.let { if (it !== err) out["cause"] = describe(it) }
238
+ return out
239
+ }
240
+
241
+ private val isoFormat =
242
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
243
+ timeZone = TimeZone.getTimeZone("UTC")
244
+ }
245
+
246
+ internal fun iso8601(date: Date): String = synchronized(isoFormat) { isoFormat.format(date) }
247
+
248
+ /**
249
+ * UUIDv7: 48 bits of milliseconds then random, so ids sort by
250
+ * creation time. The server keys events on them and the dashboard
251
+ * orders by them, so a v4 would scatter a session across the
252
+ * index.
253
+ */
254
+ internal fun newEventId(): String {
255
+ val bytes = ByteArray(16)
256
+ Random.nextBytes(bytes)
257
+ val ms = System.currentTimeMillis()
258
+ bytes[0] = ((ms shr 40) and 0xff).toByte()
259
+ bytes[1] = ((ms shr 32) and 0xff).toByte()
260
+ bytes[2] = ((ms shr 24) and 0xff).toByte()
261
+ bytes[3] = ((ms shr 16) and 0xff).toByte()
262
+ bytes[4] = ((ms shr 8) and 0xff).toByte()
263
+ bytes[5] = (ms and 0xff).toByte()
264
+ bytes[6] = ((bytes[6].toInt() and 0x0f) or 0x70).toByte()
265
+ bytes[8] = ((bytes[8].toInt() and 0x3f) or 0x80).toByte()
266
+
267
+ val hex = StringBuilder(32)
268
+ for (b in bytes) hex.append(String.format("%02x", b.toInt() and 0xff))
269
+ val h = hex.toString()
270
+ return "${h.substring(0, 8)}-${h.substring(8, 12)}-${h.substring(12, 16)}-" +
271
+ "${h.substring(16, 20)}-${h.substring(20, 32)}"
272
+ }
273
+ }
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriAnrWatchdog.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  package com.sentori
2
5
 
3
6
  import android.content.Context
@@ -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
+ }
@@ -0,0 +1,61 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriConfig.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import java.util.concurrent.atomic.AtomicReference
7
+
8
+ /**
9
+ * Set once by [Sentori.start]; everything else reads it and treats
10
+ * absence as "not initialised, so every verb is a no-op".
11
+ *
12
+ * That default is the failure-isolation half of the client zero-cost
13
+ * rule: an app that mis-wires its token gets a silent SDK, never an
14
+ * exception on a path it did not know it had.
15
+ */
16
+ class SentoriConfig
17
+ @JvmOverloads
18
+ constructor(
19
+ val token: String,
20
+ ingestUrl: String,
21
+ val release: String,
22
+ val environment: String,
23
+ /**
24
+ * The integrator's own health endpoint, carried on batches and
25
+ * probed server-side. The app never pings it — a monitoring SDK
26
+ * that adds traffic to the thing it monitors has picked the wrong
27
+ * side of the bargain.
28
+ */
29
+ val backendHealthUrl: String? = null,
30
+ ) {
31
+ /**
32
+ * A trailing slash here becomes `//v1/events:batch`, which some
33
+ * proxies answer and some 404.
34
+ */
35
+ val ingestUrl: String = ingestUrl.trimEnd('/')
36
+
37
+ companion object {
38
+ private val ref = AtomicReference<SentoriConfig?>(null)
39
+
40
+ @JvmStatic val current: SentoriConfig? get() = ref.get()
41
+
42
+ @JvmStatic val isInitialised: Boolean get() = ref.get() != null
43
+
44
+ internal fun set(config: SentoriConfig?) = ref.set(config)
45
+
46
+ internal fun resetForTests() = ref.set(null)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Reported in the `Sentori-Sdk` header so a server-side problem can be
52
+ * attributed to a client version.
53
+ *
54
+ * Mirrors the constant in `sdk/react-native/src/transport.ts` and the
55
+ * Swift one, and is written by `scripts/sync-sdk-version.mjs` — a
56
+ * version string nothing writes goes stale, and only ever in the
57
+ * direction of a lie.
58
+ */
59
+ object SentoriVersion {
60
+ const val CURRENT = "1.7.1"
61
+ }
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriCrashHandler.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  package com.sentori
2
5
 
3
6
  import android.content.Context
@@ -193,6 +196,25 @@ object SentoriCrashHandler {
193
196
  }
194
197
  }
195
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
+
196
218
  private fun attachPending(event: JSONObject) {
197
219
  val snap = try {
198
220
  SentoriScreenshotCapture.captureKeyWindow()
@@ -0,0 +1,61 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriDevice.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import android.content.Context
7
+ import android.os.Build
8
+
9
+ /**
10
+ * What the device is, as `payload.device` on every event.
11
+ *
12
+ * Everything here is read once and cached: this runs on the calling
13
+ * thread inside every verb, and a verb that asks the window manager
14
+ * for a fresh value each time is not the O(1) the contract promises.
15
+ */
16
+ object SentoriDevice {
17
+
18
+ private val lock = Any()
19
+ private var screen: Map<String, Any?>? = null
20
+
21
+ /**
22
+ * Optional. Without a [Context] the snapshot omits the screen and
23
+ * carries everything else — an event with a thinner device beats
24
+ * no event, and a host that starts the SDK before it has a context
25
+ * is making a reasonable choice.
26
+ */
27
+ @JvmStatic
28
+ fun bind(context: Context?) {
29
+ val ctx = context ?: return
30
+ synchronized(lock) {
31
+ val m = ctx.resources.displayMetrics
32
+ screen =
33
+ mapOf(
34
+ "width" to (m.widthPixels / m.density),
35
+ "height" to (m.heightPixels / m.density),
36
+ "scale" to m.density,
37
+ )
38
+ }
39
+ }
40
+
41
+ @JvmStatic
42
+ fun snapshot(): Map<String, Any?> {
43
+ val out =
44
+ mutableMapOf<String, Any?>(
45
+ "os" to "android",
46
+ "osVersion" to Build.VERSION.RELEASE,
47
+ // The hardware identifier, not a marketing name.
48
+ // Mapping it would need a table that goes stale with
49
+ // every launch; the dashboard does that lookup
50
+ // server-side where it can be updated without a client
51
+ // release.
52
+ "model" to "${Build.MANUFACTURER} ${Build.MODEL}",
53
+ )
54
+ synchronized(lock) { screen }?.let { out["screen"] = it }
55
+ return out
56
+ }
57
+
58
+ internal fun resetForTests() {
59
+ synchronized(lock) { screen = null }
60
+ }
61
+ }