@goliapkg/sentori-react-native 6.1.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.
- package/SentoriReactNative.podspec +11 -4
- package/android/build.gradle +15 -1
- package/android/src/main/java-core/com/sentori/Sentori.kt +244 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +3 -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/SentoriPush.kt +359 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +60 -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 +383 -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 +106 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +151 -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/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +16 -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/core/SentoriPush.swift +340 -0
- package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +42 -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 +7 -6
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +8 -0
- package/lib/push.js.map +1 -1
- package/lib/transport.js +1 -1
- package/package.json +1 -1
- package/src/push.ts +14 -6
- 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
|
@@ -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
|
-
|
|
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
|
package/android/build.gradle
CHANGED
|
@@ -18,7 +18,21 @@ android {
|
|
|
18
18
|
sourceSets {
|
|
19
19
|
main {
|
|
20
20
|
manifest.srcFile 'src/main/AndroidManifest.xml'
|
|
21
|
-
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
@@ -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
|