@goliapkg/sentori-react-native 6.2.0 → 6.2.1

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 (41) hide show
  1. package/SentoriReactNative.podspec +11 -4
  2. package/android/build.gradle +15 -1
  3. package/android/src/main/java-core/com/sentori/Sentori.kt +244 -0
  4. package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
  5. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
  6. package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +3 -0
  7. package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
  8. package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +3 -0
  9. package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
  10. package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
  11. package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
  12. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
  13. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
  14. package/android/src/main/java-core/com/sentori/SentoriPush.kt +359 -0
  15. package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +3 -0
  16. package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
  17. package/android/src/main/java-core/com/sentori/SentoriScope.kt +60 -0
  18. package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
  19. package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
  20. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +383 -0
  21. package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
  22. package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
  23. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +106 -0
  24. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +151 -0
  25. package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
  26. package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
  27. package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +16 -0
  28. package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
  29. package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
  30. package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
  31. package/ios/core/SentoriPush.swift +340 -0
  32. package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +42 -0
  33. package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
  34. package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
  35. package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
  36. package/lib/transport.js +1 -1
  37. package/package.json +1 -1
  38. package/src/transport.ts +1 -1
  39. package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
  40. package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
  41. 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
 
@@ -0,0 +1,244 @@
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
+
51
+ /**
52
+ * Identify the person using the app. Only a hash of [id] (or
53
+ * [email] when there is no id) travels; the raw values stay here.
54
+ *
55
+ * Call this before registering for push if you want the device to
56
+ * be reachable from an issue — a device with no user key receives
57
+ * broadcasts and nothing else.
58
+ */
59
+ @JvmStatic
60
+ fun user(id: String?, email: String?) = SentoriScope.setUser(id, email)
61
+
62
+ /** Merge keys into the ambient context that rides every event. */
63
+ @JvmStatic
64
+ fun context(patch: Map<String, Any?>) = SentoriScope.patchContext(patch)
65
+
66
+ /**
67
+ * Record a signal for the last-sixty-seconds ring that ships with
68
+ * an error. Any [kind] is accepted; the dashboard reads `http` as
69
+ * `{ method, url, status, ms }` and `trace` as a quiet breadcrumb.
70
+ *
71
+ * This SDK deliberately does not install an OkHttp interceptor:
72
+ * watching the host's traffic is the host's decision, not ours to
73
+ * make silently.
74
+ */
75
+ @JvmStatic
76
+ @JvmOverloads
77
+ fun pushSignal(kind: String, data: Map<String, Any?>? = null) =
78
+ SentoriSignalRing.push(kind, data)
79
+
80
+ /**
81
+ * `Sentori.push.register(...)`, matching `sentori.push.register()`
82
+ * in the React Native SDK and `Sentori.push` in Swift.
83
+ */
84
+ @JvmStatic val push: SentoriPush get() = SentoriPush
85
+
86
+ // ── the verbs ─────────────────────────────────────────────────
87
+
88
+ /** Something went wrong. */
89
+ @JvmStatic
90
+ @JvmOverloads
91
+ fun error(err: Throwable, data: Map<String, Any?>? = null): String =
92
+ emit("error", null, describe(err), data, withSignals = true)
93
+
94
+ /** Something went wrong, described rather than thrown. */
95
+ @JvmStatic
96
+ @JvmOverloads
97
+ fun error(message: String, type: String = "Error", data: Map<String, Any?>? = null): String =
98
+ emit("error", null, mapOf("type" to type, "message" to message), data, withSignals = true)
99
+
100
+ /**
101
+ * The user struggled here. Not a crash — a place the product hurt,
102
+ * named by you.
103
+ */
104
+ @JvmStatic
105
+ @JvmOverloads
106
+ fun warn(name: String, data: Map<String, Any?>? = null): String =
107
+ emit("warn", name, null, data, withSignals = true)
108
+
109
+ /**
110
+ * This happened. Always lands in the signal ring; [quiet] keeps it
111
+ * out of the event stream, which is how a high-frequency
112
+ * breadcrumb stays affordable.
113
+ */
114
+ @JvmStatic
115
+ @JvmOverloads
116
+ fun trace(name: String, data: Map<String, Any?>? = null, quiet: Boolean = false): String {
117
+ val signal = mutableMapOf<String, Any?>("name" to name)
118
+ data?.let { signal.putAll(it) }
119
+ SentoriSignalRing.push("trace", signal)
120
+ if (quiet) return newEventId()
121
+ return emit("trace", name, null, data, withSignals = false)
122
+ }
123
+
124
+ /**
125
+ * This should hold. A passing assert never becomes an event — it
126
+ * increments a counter that rides the next batch, so a liveness
127
+ * check costs no request. Only failures are events.
128
+ *
129
+ * Unlike the language's own `assert`, this never stops the
130
+ * program. That difference is the point: a monitoring SDK that can
131
+ * halt the app it monitors has picked the wrong side of the
132
+ * bargain.
133
+ */
134
+ @JvmStatic
135
+ @JvmOverloads
136
+ fun assert(name: String, ok: Boolean, data: Map<String, Any?>? = null): String {
137
+ SentoriConfig.current?.let { SentoriTransport.countAssert(name, ok, it.release) }
138
+ if (ok) return newEventId()
139
+ return emit("assert", name, null, data, withSignals = true)
140
+ }
141
+
142
+ /**
143
+ * Is that bug back? A tripwire: reaching this call is the signal.
144
+ * It changes no control flow and returns no verdict.
145
+ */
146
+ @JvmStatic
147
+ @JvmOverloads
148
+ fun probe(ref: String, data: Map<String, Any?>? = null): String =
149
+ emit("probe", ref, null, data, withSignals = false)
150
+
151
+ // ── assembly ──────────────────────────────────────────────────
152
+
153
+ private fun emit(
154
+ kind: String,
155
+ name: String?,
156
+ error: Map<String, Any?>?,
157
+ data: Map<String, Any?>?,
158
+ withSignals: Boolean,
159
+ ): String {
160
+ val id = newEventId()
161
+ // Before init every verb is a no-op that still returns an id,
162
+ // so a call site does not change shape depending on whether
163
+ // the SDK came up.
164
+ val config = SentoriConfig.current ?: return id
165
+
166
+ val payload = mutableMapOf<String, Any?>()
167
+ error?.let { payload["error"] = it }
168
+ data?.let { payload["data"] = it }
169
+ SentoriScope.context?.let { payload["context"] = it }
170
+ if (withSignals) {
171
+ val signals = SentoriSignalRing.snapshot()
172
+ if (signals.isNotEmpty()) payload["signals"] = signals
173
+ }
174
+ payload["device"] = SentoriDevice.snapshot()
175
+
176
+ val event =
177
+ mutableMapOf<String, Any?>(
178
+ "id" to id,
179
+ "kind" to kind,
180
+ "occurredAt" to iso8601(Date()),
181
+ "platform" to "android",
182
+ "release" to config.release,
183
+ "environment" to config.environment,
184
+ "payload" to payload,
185
+ )
186
+ name?.let { event["name"] = it }
187
+ SentoriScope.userKey?.let { event["userKey"] = it }
188
+
189
+ SentoriTransport.enqueue(event)
190
+ return id
191
+ }
192
+
193
+ private fun describe(err: Throwable): Map<String, Any?> {
194
+ val frames =
195
+ err.stackTrace.take(50).map { f ->
196
+ mapOf(
197
+ "function" to "${f.className}.${f.methodName}",
198
+ "file" to (f.fileName ?: ""),
199
+ "line" to f.lineNumber,
200
+ )
201
+ }
202
+ val out =
203
+ mutableMapOf<String, Any?>(
204
+ "type" to (err.javaClass.name),
205
+ "message" to (err.message ?: ""),
206
+ "stack" to frames,
207
+ )
208
+ err.cause?.let { if (it !== err) out["cause"] = describe(it) }
209
+ return out
210
+ }
211
+
212
+ private val isoFormat =
213
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
214
+ timeZone = TimeZone.getTimeZone("UTC")
215
+ }
216
+
217
+ internal fun iso8601(date: Date): String = synchronized(isoFormat) { isoFormat.format(date) }
218
+
219
+ /**
220
+ * UUIDv7: 48 bits of milliseconds then random, so ids sort by
221
+ * creation time. The server keys events on them and the dashboard
222
+ * orders by them, so a v4 would scatter a session across the
223
+ * index.
224
+ */
225
+ internal fun newEventId(): String {
226
+ val bytes = ByteArray(16)
227
+ Random.nextBytes(bytes)
228
+ val ms = System.currentTimeMillis()
229
+ bytes[0] = ((ms shr 40) and 0xff).toByte()
230
+ bytes[1] = ((ms shr 32) and 0xff).toByte()
231
+ bytes[2] = ((ms shr 24) and 0xff).toByte()
232
+ bytes[3] = ((ms shr 16) and 0xff).toByte()
233
+ bytes[4] = ((ms shr 8) and 0xff).toByte()
234
+ bytes[5] = (ms and 0xff).toByte()
235
+ bytes[6] = ((bytes[6].toInt() and 0x0f) or 0x70).toByte()
236
+ bytes[8] = ((bytes[8].toInt() and 0x3f) or 0x80).toByte()
237
+
238
+ val hex = StringBuilder(32)
239
+ for (b in bytes) hex.append(String.format("%02x", b.toInt() and 0xff))
240
+ val h = hex.toString()
241
+ return "${h.substring(0, 8)}-${h.substring(8, 12)}-${h.substring(12, 16)}-" +
242
+ "${h.substring(16, 20)}-${h.substring(20, 32)}"
243
+ }
244
+ }
@@ -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,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.0.0"
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
@@ -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
+ }
@@ -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
@@ -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
  /**