@goliapkg/sentori-react-native 6.2.1 → 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.
- package/android/build.gradle +6 -0
- package/android/src/main/java-core/com/sentori/Sentori.kt +33 -4
- package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +1 -1
- package/android/src/main/java-core/com/sentori/SentoriCrashHandler.kt +19 -0
- package/android/src/main/java-core/com/sentori/SentoriFirebaseMessagingService.kt +15 -0
- package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
- package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
- package/android/src/main/java-core/com/sentori/SentoriPush.kt +242 -13
- package/android/src/main/java-core/com/sentori/SentoriPushNotifications.kt +168 -17
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +49 -2
- package/android/src/main/java-core/com/sentori/SentoriTransport.kt +95 -2
- package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
- package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +43 -0
- package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +556 -0
- package/ios/core/SentoriAttachment.swift +129 -0
- package/ios/core/SentoriCrashHandler.swift +15 -0
- package/ios/core/SentoriPushNotifications.swift +8 -0
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +51 -1
- package/lib/push.js.map +1 -1
- package/lib/scope.d.ts +6 -0
- package/lib/scope.d.ts.map +1 -1
- package/lib/scope.js +45 -1
- package/lib/scope.js.map +1 -1
- package/lib/transport.js +1 -1
- package/package.json +2 -2
- package/src/__tests__/push.test.ts +99 -1
- package/src/push.ts +49 -1
- package/src/scope.ts +50 -1
- package/src/transport.ts +1 -1
- package/ios/core/SentoriPush.swift +0 -340
package/src/push.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { logger, pushSignal } from '@goliapkg/sentori-core'
|
|
25
25
|
|
|
26
|
-
import { currentUserKey } from './scope.js'
|
|
26
|
+
import { currentUserKey, currentUserTraits, onIdentityChange } from './scope.js'
|
|
27
27
|
|
|
28
28
|
// AppState is RN-only; we treat it dynamically so the SDK keeps
|
|
29
29
|
// importing cleanly under Bun / web.
|
|
@@ -46,6 +46,14 @@ import {
|
|
|
46
46
|
const STORAGE_KEY = 'sentori.push.ipt'
|
|
47
47
|
|
|
48
48
|
let _cachedIpt: null | string = null
|
|
49
|
+
// The last token the vendor handed us, kept so a sign-in can re-send
|
|
50
|
+
// the registration without asking the OS for a token again.
|
|
51
|
+
let _lastNativeToken: null | string = null
|
|
52
|
+
let _lastOptions: PushRegisterOptions = {}
|
|
53
|
+
// What the server was last told. A re-registration that would send
|
|
54
|
+
// the same thing is not sent: `user()` is a verb an app may call on
|
|
55
|
+
// every screen, and one HTTP request per call is not free to a host.
|
|
56
|
+
let _lastSentIdentity: string | null = null
|
|
49
57
|
let _drainInterval: ReturnType<typeof setInterval> | null = null
|
|
50
58
|
let _appStateSubscription: { remove: () => void } | null = null
|
|
51
59
|
let _backgrounded = false
|
|
@@ -176,9 +184,13 @@ export async function register(opts: PushRegisterOptions = {}): Promise<PushRegi
|
|
|
176
184
|
const token = await waitForToken(opts.tokenTimeoutMs ?? 8000)
|
|
177
185
|
const ipt = await registerWithServer(cfg, token, opts)
|
|
178
186
|
_cachedIpt = ipt
|
|
187
|
+
_lastNativeToken = token
|
|
188
|
+
_lastOptions = opts
|
|
179
189
|
void persistIpt(ipt)
|
|
180
190
|
opts.onToken?.(ipt)
|
|
181
191
|
bindBufferDrain(opts.onMessage, opts.onTap)
|
|
192
|
+
// From here on, signing in or out updates the row by itself.
|
|
193
|
+
onIdentityChange(reRegisterAfterIdentityChange)
|
|
182
194
|
return { ok: true, ipt }
|
|
183
195
|
} catch (e) {
|
|
184
196
|
const err = e instanceof Error ? e : new Error(String(e))
|
|
@@ -199,6 +211,29 @@ function fail(
|
|
|
199
211
|
return { ok: false, message, reason }
|
|
200
212
|
}
|
|
201
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Send the registration again because the person changed.
|
|
216
|
+
*
|
|
217
|
+
* Only for a device that has already registered — the vendor token is
|
|
218
|
+
* the evidence, the same rule the native SDKs use for a rotated
|
|
219
|
+
* token. Nothing here throws and nothing here is awaited by the host:
|
|
220
|
+
* `user()` is synchronous and stays that way.
|
|
221
|
+
*/
|
|
222
|
+
function reRegisterAfterIdentityChange(): void {
|
|
223
|
+
const token = _lastNativeToken
|
|
224
|
+
const cfg = tryGetRuntimeConfig()
|
|
225
|
+
if (token == null || cfg == null) return
|
|
226
|
+
const identity = JSON.stringify([currentUserKey() ?? null, currentUserTraits() ?? null])
|
|
227
|
+
if (identity === _lastSentIdentity) return
|
|
228
|
+
_lastSentIdentity = identity
|
|
229
|
+
void registerWithServer(cfg, token, _lastOptions).catch((e: unknown) => {
|
|
230
|
+
// A failed update leaves the row pointing at the previous person,
|
|
231
|
+
// so the next change must be allowed to try again.
|
|
232
|
+
_lastSentIdentity = null
|
|
233
|
+
logger.warn('push', 'updating the device after a sign-in failed', e)
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
202
237
|
/** Internal carrier so the helpers below can name which failure they
|
|
203
238
|
* are without every one of them returning a result object. */
|
|
204
239
|
class PushRegisterError extends Error {
|
|
@@ -228,6 +263,9 @@ export async function unregister(): Promise<void> {
|
|
|
228
263
|
}
|
|
229
264
|
}
|
|
230
265
|
nativePushUnregister()
|
|
266
|
+
onIdentityChange(undefined)
|
|
267
|
+
_lastNativeToken = null
|
|
268
|
+
_lastSentIdentity = null
|
|
231
269
|
_cachedIpt = null
|
|
232
270
|
void clearPersistedIpt()
|
|
233
271
|
teardownBufferDrain()
|
|
@@ -310,6 +348,12 @@ async function registerWithServer(
|
|
|
310
348
|
// out it went nowhere. (Insight asked whether it was stored; it was
|
|
311
349
|
// not — 2026-08-11.)
|
|
312
350
|
if (opts.metadata != null) body.metadata = opts.metadata
|
|
351
|
+
// Attributes of the person rather than of the device, kept apart so
|
|
352
|
+
// a build channel called "pro" cannot answer a send aimed at the pro
|
|
353
|
+
// plan. Absent leaves whatever the row already had; `{}` clears it,
|
|
354
|
+
// which is what signing out sends.
|
|
355
|
+
const traits = currentUserTraits()
|
|
356
|
+
if (traits != null) body.traits = traits
|
|
313
357
|
const res = await fetch(joinUrl(cfg.ingestUrl, '/v1/push/tokens'), {
|
|
314
358
|
method: 'POST',
|
|
315
359
|
headers: {
|
|
@@ -545,6 +589,10 @@ export function __resetForTests(): void {
|
|
|
545
589
|
_onMessage = undefined
|
|
546
590
|
_onTap = undefined
|
|
547
591
|
_ackQueue = []
|
|
592
|
+
_lastNativeToken = null
|
|
593
|
+
_lastOptions = {}
|
|
594
|
+
_lastSentIdentity = null
|
|
595
|
+
onIdentityChange(undefined)
|
|
548
596
|
teardownBufferDrain()
|
|
549
597
|
}
|
|
550
598
|
|
package/src/scope.ts
CHANGED
|
@@ -9,12 +9,50 @@ let _user: User | null = null;
|
|
|
9
9
|
let _userKey: string | undefined;
|
|
10
10
|
let _context: Record<string, unknown> = {};
|
|
11
11
|
let _hashGeneration = 0;
|
|
12
|
+
let _traits: Record<string, unknown> | undefined;
|
|
13
|
+
|
|
14
|
+
// What to tell when the person changes.
|
|
15
|
+
//
|
|
16
|
+
// The device row carries the identity, and until now nothing updated
|
|
17
|
+
// it after registration: an app that registers for push at launch and
|
|
18
|
+
// signs in ten seconds later — which is every app with a login screen
|
|
19
|
+
// — held a row with no user on it for the life of the install. A send
|
|
20
|
+
// aimed at that user reached nobody and reported success.
|
|
21
|
+
//
|
|
22
|
+
// A callback rather than a direct call because push.ts already imports
|
|
23
|
+
// this file; the arrow has to point one way.
|
|
24
|
+
let _onIdentityChange: (() => void) | undefined;
|
|
25
|
+
|
|
26
|
+
/** Register interest in identity changes. Only push does. */
|
|
27
|
+
export const onIdentityChange = (fn: (() => void) | undefined): void => {
|
|
28
|
+
_onIdentityChange = fn;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const announce = (): void => {
|
|
32
|
+
try {
|
|
33
|
+
_onIdentityChange?.();
|
|
34
|
+
} catch {
|
|
35
|
+
// NEVER rule: whatever the listener does, user() returns.
|
|
36
|
+
}
|
|
37
|
+
};
|
|
12
38
|
|
|
13
39
|
export const setUser = safeFn('user', function setUser(u: User | null): void {
|
|
14
40
|
_user = u;
|
|
15
41
|
_userKey = undefined;
|
|
42
|
+
// A call to `user()` describes the person completely, so one made
|
|
43
|
+
// without traits means they have none — not "leave the last ones".
|
|
44
|
+
// Absent and empty are different on the wire: absent keeps what the
|
|
45
|
+
// row has, and a signed-out device that kept them would still be
|
|
46
|
+
// selectable as whoever just left.
|
|
47
|
+
_traits = u?.traits == null ? {} : { ...u.traits };
|
|
16
48
|
const generation = ++_hashGeneration;
|
|
17
|
-
if (u === null)
|
|
49
|
+
if (u === null) {
|
|
50
|
+
// Signing out is an identity change too, and the one where a
|
|
51
|
+
// stale row matters most: the device would otherwise keep
|
|
52
|
+
// answering to the person who just left.
|
|
53
|
+
announce();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
18
56
|
// The wire carries only a salted hash — raw identity never leaves
|
|
19
57
|
// the device (breadth × depth needs distinctness, not identity).
|
|
20
58
|
// Hashing is async (WebCrypto); the verb stays synchronous and the
|
|
@@ -24,6 +62,9 @@ export const setUser = safeFn('user', function setUser(u: User | null): void {
|
|
|
24
62
|
.then((hashes) => {
|
|
25
63
|
if (generation === _hashGeneration) {
|
|
26
64
|
_userKey = hashes.id ?? hashes.email;
|
|
65
|
+
// Announced after the hash lands, not before: the whole point
|
|
66
|
+
// is to send the new key, and the key is what arrives late.
|
|
67
|
+
announce();
|
|
27
68
|
}
|
|
28
69
|
})
|
|
29
70
|
.catch(() => {
|
|
@@ -39,12 +80,20 @@ export const patchContext = safeFn('context', function patchContext(
|
|
|
39
80
|
|
|
40
81
|
export const currentUserKey = (): string | undefined => _userKey;
|
|
41
82
|
|
|
83
|
+
/** The person's attributes, for the device row. Undefined when the
|
|
84
|
+
* host has not set any — which is different from `{}`, the value that
|
|
85
|
+
* clears them. */
|
|
86
|
+
export const currentUserTraits = (): Record<string, unknown> | undefined =>
|
|
87
|
+
_traits === undefined ? undefined : { ..._traits };
|
|
88
|
+
|
|
42
89
|
export const currentContext = (): Record<string, unknown> | undefined =>
|
|
43
90
|
Object.keys(_context).length > 0 ? { ..._context } : undefined;
|
|
44
91
|
|
|
45
92
|
export const __resetForTests = (): void => {
|
|
46
93
|
_user = null;
|
|
47
94
|
_userKey = undefined;
|
|
95
|
+
_traits = undefined;
|
|
48
96
|
_context = {};
|
|
97
|
+
_onIdentityChange = undefined;
|
|
49
98
|
_hashGeneration++;
|
|
50
99
|
};
|
package/src/transport.ts
CHANGED
|
@@ -20,7 +20,7 @@ const STORAGE_KEY = '@sentori/pending';
|
|
|
20
20
|
const MAX_PERSISTED = 1000;
|
|
21
21
|
|
|
22
22
|
// Pinned to package.json by a test — bump both together.
|
|
23
|
-
const SDK_VERSION = '6.
|
|
23
|
+
const SDK_VERSION = '6.3.0';
|
|
24
24
|
|
|
25
25
|
let _queue: WireEvent[] = [];
|
|
26
26
|
let _assertStats = new Map<string, AssertStat>();
|
|
@@ -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
|
-
}
|