@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.
Files changed (33) hide show
  1. package/android/build.gradle +6 -0
  2. package/android/src/main/java-core/com/sentori/Sentori.kt +33 -4
  3. package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
  4. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +1 -1
  5. package/android/src/main/java-core/com/sentori/SentoriCrashHandler.kt +19 -0
  6. package/android/src/main/java-core/com/sentori/SentoriFirebaseMessagingService.kt +15 -0
  7. package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
  8. package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
  9. package/android/src/main/java-core/com/sentori/SentoriPush.kt +242 -13
  10. package/android/src/main/java-core/com/sentori/SentoriPushNotifications.kt +168 -17
  11. package/android/src/main/java-core/com/sentori/SentoriScope.kt +49 -2
  12. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +95 -2
  13. package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
  14. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +43 -0
  15. package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
  16. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +556 -0
  17. package/ios/core/SentoriAttachment.swift +129 -0
  18. package/ios/core/SentoriCrashHandler.swift +15 -0
  19. package/ios/core/SentoriPushNotifications.swift +8 -0
  20. package/lib/push.d.ts.map +1 -1
  21. package/lib/push.js +155 -1
  22. package/lib/push.js.map +1 -1
  23. package/lib/scope.d.ts +6 -0
  24. package/lib/scope.d.ts.map +1 -1
  25. package/lib/scope.js +45 -1
  26. package/lib/scope.js.map +1 -1
  27. package/lib/transport.js +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/push.test.ts +203 -1
  30. package/src/push.ts +147 -1
  31. package/src/scope.ts +50 -1
  32. package/src/transport.ts +1 -1
  33. package/ios/core/SentoriPush.swift +0 -340
@@ -1,340 +0,0 @@
1
- // GENERATED MIRROR — do not edit.
2
- // Source of truth: sdk/native/ios/Sources/Sentori/SentoriPush.swift
3
- // Run `node scripts/sync-native-core.mjs` after editing it.
4
- import Foundation
5
-
6
- /// Push, as an app writes it.
7
- ///
8
- /// let r = await Sentori.push.register()
9
- /// if case .failure(let reason) = r { … }
10
- ///
11
- /// The pieces underneath already existed — permission, the
12
- /// `AppDelegate` swizzle, the token buffer, the tap and foreground
13
- /// callbacks — and were reachable only through the React Native
14
- /// bridge. What was missing everywhere but JavaScript is the part
15
- /// that puts a token on the server, which is the only reason a
16
- /// registered device is reachable at all.
17
- ///
18
- /// Asked for by insight (2026-08-11): two apps with no React Native,
19
- /// blocked, and unwilling to reimplement the HTTP contract because a
20
- /// second implementation drifts silently on the one path where nobody
21
- /// is watching.
22
- @objc(SentoriPush)
23
- public final class SentoriPush: NSObject {
24
-
25
- /// Why a registration did not produce a device handle. Each value
26
- /// asks the host for something different, which is the only
27
- /// reason to distinguish them — matching `PushRegisterFailure` in
28
- /// the React Native SDK exactly, so the same integration notes
29
- /// apply on both.
30
- @objc public enum Failure: Int {
31
- /// `Sentori.start` has not run. A wiring bug.
32
- case notInitialised
33
- /// The user said no. Not an error: do not retry on a timer,
34
- /// and offer it again from a settings screen.
35
- case permissionDenied
36
- /// No push entitlement in this build, or a simulator without
37
- /// one. Nothing to do at runtime.
38
- case noTransport
39
- /// The OS never handed back a token inside the window.
40
- /// Usually provisioning; retrying later is reasonable.
41
- case tokenTimeout
42
- /// Sentori answered non-2xx. Settings ▸ Push is where to look.
43
- case serverRejected
44
-
45
- public var name: String {
46
- switch self {
47
- case .notInitialised: return "not-initialised"
48
- case .permissionDenied: return "permission-denied"
49
- case .noTransport: return "no-transport"
50
- case .tokenTimeout: return "token-timeout"
51
- case .serverRejected: return "server-rejected"
52
- }
53
- }
54
- }
55
-
56
- /// Registration never throws. A denied permission is an ordinary
57
- /// answer, and an opt-in that throws inside someone's view model
58
- /// is the failure this SDK's contract with its host is written
59
- /// against.
60
- public enum Result {
61
- /// The `device_tokens` row id. Revoking takes it, and so does
62
- /// a targeted send.
63
- case success(handle: String)
64
- case failure(reason: Failure, message: String)
65
-
66
- public var handle: String? {
67
- if case .success(let h) = self { return h }
68
- return nil
69
- }
70
- }
71
-
72
- @objc public static let shared = SentoriPush()
73
-
74
- private let lock = NSLock()
75
- private var cachedHandle: String?
76
- private var onMessage: (([String: Any]) -> Void)?
77
- private var onTap: (([String: Any]) -> Void)?
78
- private var drainTimer: DispatchSourceTimer?
79
-
80
- private static let handleKey = "com.sentori.push.handle"
81
-
82
- /// Ask for permission, get a token, register it.
83
- ///
84
- /// Safe to call on every launch: the OS returns its cached
85
- /// decision without re-prompting, and the server upserts on
86
- /// `(project, provider, token)`.
87
- ///
88
- /// Call `Sentori.user` first if the device should be reachable
89
- /// from an issue. Without it the registration carries no user key
90
- /// and the device receives broadcasts only — the dashboard shows
91
- /// that as "N devices, 0 addressable", which is the one symptom
92
- /// with no other explanation.
93
- @discardableResult
94
- public func register(
95
- timeout: TimeInterval = 8,
96
- onMessage: (([String: Any]) -> Void)? = nil,
97
- onTap: (([String: Any]) -> Void)? = nil
98
- ) async -> Result {
99
- guard let config = SentoriConfig.current else {
100
- return .failure(reason: .notInitialised, message: "Sentori.start has not run")
101
- }
102
-
103
- // Bind before asking for anything: iOS replays a tap that
104
- // happened while the app was not running as soon as the
105
- // delegate attaches, and a callback set afterwards misses it.
106
- lock.lock()
107
- self.onMessage = onMessage
108
- self.onTap = onTap
109
- lock.unlock()
110
-
111
- let status = await currentOrRequestPermission()
112
- // "unavailable" is the native layer saying there is no app
113
- // bundle to ask on behalf of — a test host, a CLI, some
114
- // extensions. Not a decision the user made.
115
- guard let status, status != "unavailable" else {
116
- return .failure(reason: .noTransport, message: "no push support in this build")
117
- }
118
- guard ["granted", "provisional", "ephemeral"].contains(status) else {
119
- return .failure(reason: .permissionDenied, message: "push permission '\(status)'")
120
- }
121
-
122
- SentoriPushNotifications.shared.registerForRemoteNotifications()
123
- guard let token = await waitForToken(timeout: timeout) else {
124
- return .failure(
125
- reason: .tokenTimeout,
126
- message: "no device token within \(Int(timeout))s"
127
- )
128
- }
129
-
130
- switch await registerWithServer(token: token, config: config) {
131
- case .success(let handle):
132
- lock.lock()
133
- cachedHandle = handle
134
- lock.unlock()
135
- UserDefaults.standard.set(handle, forKey: Self.handleKey)
136
- startDrain()
137
- return .success(handle: handle)
138
- case .failure(let reason, let message):
139
- return .failure(reason: reason, message: message)
140
- }
141
- }
142
-
143
- /// The handle from an earlier `register`, without a round trip.
144
- @objc public func cachedDeviceHandle() -> String? {
145
- lock.lock()
146
- let h = cachedHandle
147
- lock.unlock()
148
- return h ?? UserDefaults.standard.string(forKey: Self.handleKey)
149
- }
150
-
151
- /// Current permission without prompting. `nil` when there is no
152
- /// push support in this build.
153
- public func permissionStatus() async -> String? {
154
- await withCheckedContinuation { cont in
155
- SentoriPushNotifications.shared.currentPermission { cont.resume(returning: $0) }
156
- }
157
- }
158
-
159
- /// Revoke the handle server-side and stop local delivery.
160
- /// Idempotent — repeat calls do nothing.
161
- @discardableResult
162
- public func unregister() async -> Bool {
163
- let handle = cachedDeviceHandle()
164
- lock.lock()
165
- cachedHandle = nil
166
- onMessage = nil
167
- onTap = nil
168
- drainTimer?.cancel()
169
- drainTimer = nil
170
- lock.unlock()
171
- UserDefaults.standard.removeObject(forKey: Self.handleKey)
172
- SentoriPushNotifications.shared.unregisterForRemoteNotifications()
173
-
174
- guard let handle, let config = SentoriConfig.current,
175
- let url = URL(string: "\(config.ingestUrl)/v1/push/tokens/\(handle)")
176
- else { return false }
177
-
178
- var request = URLRequest(url: url)
179
- request.httpMethod = "DELETE"
180
- request.setValue("Bearer \(config.token)", forHTTPHeaderField: "Authorization")
181
- request.timeoutInterval = 15
182
- let ok = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
183
- URLSession.shared.dataTask(with: request) { _, response, _ in
184
- let code = (response as? HTTPURLResponse)?.statusCode ?? 0
185
- cont.resume(returning: (200..<300).contains(code))
186
- }.resume()
187
- }
188
- return ok
189
- }
190
-
191
- // ── internals ─────────────────────────────────────────────────
192
-
193
- private func currentOrRequestPermission() async -> String? {
194
- let current = await permissionStatus()
195
- // `nil` means no native push at all; asking would not change
196
- // that. Anything already decided is returned as decided,
197
- // because a second prompt is not something iOS shows anyway.
198
- guard let current else { return nil }
199
- if current == "undetermined" || current == "notDetermined" {
200
- return await withCheckedContinuation { cont in
201
- SentoriPushNotifications.shared.requestPermission { cont.resume(returning: $0) }
202
- }
203
- }
204
- return current
205
- }
206
-
207
- /// The token arrives asynchronously through the AppDelegate
208
- /// swizzle and lands in the native buffer, so this polls it. Any
209
- /// notifications or taps that arrive alongside are delivered
210
- /// rather than dropped on the floor.
211
- private func waitForToken(timeout: TimeInterval) async -> String? {
212
- let deadline = Date().addingTimeInterval(timeout)
213
- while Date() < deadline {
214
- let state = SentoriPushNotifications.shared.drainState()
215
- flush(state)
216
- if let token = state["token"] as? String { return token }
217
- if state["error"] != nil { return nil }
218
- try? await Task.sleep(nanoseconds: 200_000_000)
219
- }
220
- return nil
221
- }
222
-
223
- private func registerWithServer(token: String, config: SentoriConfig) async -> Result {
224
- guard let url = URL(string: "\(config.ingestUrl)/v1/push/tokens") else {
225
- return .failure(reason: .serverRejected, message: "bad ingest url")
226
- }
227
- var body: [String: Any] = [
228
- // `kind`, not `provider`. The React Native SDK sent
229
- // `provider` for a year and earned a 422 for every
230
- // registration it ever attempted.
231
- "kind": "apns",
232
- "nativeToken": token,
233
- // Sandbox and production APNs are different hosts, and a
234
- // token minted against one is rejected by the other.
235
- "env": isDebugBuild ? "sandbox" : "production",
236
- ]
237
- // The same salted-nothing hash every event carries, so the
238
- // dashboard can address this device by the person who hit an
239
- // issue. Absent until the host calls `Sentori.user`.
240
- if let userKey = SentoriScope.userKey { body["userKey"] = userKey }
241
-
242
- guard let payload = try? JSONSerialization.data(withJSONObject: body) else {
243
- return .failure(reason: .serverRejected, message: "could not encode registration")
244
- }
245
-
246
- var request = URLRequest(url: url)
247
- request.httpMethod = "POST"
248
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
249
- request.setValue("Bearer \(config.token)", forHTTPHeaderField: "Authorization")
250
- request.setValue("swift/\(SentoriVersion.current)", forHTTPHeaderField: "Sentori-Sdk")
251
- request.httpBody = payload
252
- request.timeoutInterval = 15
253
-
254
- return await withCheckedContinuation { (cont: CheckedContinuation<Result, Never>) in
255
- URLSession.shared.dataTask(with: request) { data, response, error in
256
- let code = (response as? HTTPURLResponse)?.statusCode ?? 0
257
- guard (200..<300).contains(code) else {
258
- let why = error?.localizedDescription ?? "HTTP \(code)"
259
- cont.resume(
260
- returning: .failure(reason: .serverRejected, message: why))
261
- return
262
- }
263
- // The handle is the `device_tokens` row id, a bare
264
- // uuid. The RN SDK parsed it as an `ipt_*` string no
265
- // server has ever returned.
266
- guard let data,
267
- let j = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
268
- let handle = j["token_id"] as? String, !handle.isEmpty
269
- else {
270
- cont.resume(
271
- returning: .failure(
272
- reason: .serverRejected,
273
- message: "server returned no device token id"))
274
- return
275
- }
276
- cont.resume(returning: .success(handle: handle))
277
- }.resume()
278
- }
279
- }
280
-
281
- /// 1 Hz while registered. The native side buffers arrivals and
282
- /// taps; this hands them to the host.
283
- private func startDrain() {
284
- lock.lock()
285
- defer { lock.unlock() }
286
- guard drainTimer == nil else { return }
287
- let t = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
288
- t.schedule(deadline: .now() + 1, repeating: 1)
289
- t.setEventHandler { [weak self] in
290
- guard let self else { return }
291
- self.flush(SentoriPushNotifications.shared.drainState())
292
- }
293
- drainTimer = t
294
- t.resume()
295
- }
296
-
297
- private func flush(_ state: [String: Any]) {
298
- lock.lock()
299
- let message = onMessage
300
- let tap = onTap
301
- lock.unlock()
302
- guard message != nil || tap != nil else { return }
303
-
304
- let notifications = state["notifications"] as? [[String: Any]] ?? []
305
- let taps = state["taps"] as? [[String: Any]] ?? []
306
- guard !notifications.isEmpty || !taps.isEmpty else { return }
307
-
308
- // The host's UI lives on the main thread and a notification
309
- // callback almost always touches it.
310
- DispatchQueue.main.async {
311
- notifications.forEach { message?($0) }
312
- taps.forEach { tap?($0) }
313
- }
314
- }
315
-
316
- private var isDebugBuild: Bool {
317
- #if DEBUG
318
- return true
319
- #else
320
- return false
321
- #endif
322
- }
323
-
324
- func __resetForTests() {
325
- lock.lock()
326
- cachedHandle = nil
327
- onMessage = nil
328
- onTap = nil
329
- drainTimer?.cancel()
330
- drainTimer = nil
331
- lock.unlock()
332
- UserDefaults.standard.removeObject(forKey: Self.handleKey)
333
- }
334
- }
335
-
336
- extension Sentori {
337
- /// `Sentori.push.register()`, matching `sentori.push.register()`
338
- /// in the React Native SDK.
339
- public static var push: SentoriPush { SentoriPush.shared }
340
- }