@goliapkg/sentori-react-native 6.2.0 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SentoriReactNative.podspec +11 -4
- package/android/build.gradle +21 -1
- package/android/src/main/java-core/com/sentori/Sentori.kt +273 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +22 -0
- package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +18 -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/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 +588 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +171 -17
- package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +107 -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 +476 -0
- package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -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 +149 -0
- package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +707 -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/core/SentoriAttachment.swift +129 -0
- package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +31 -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/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +50 -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.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/Tests/SentoriCrashHandlerTests.swift +0 -59
- package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
- package/ios/Tests/SentoriThreadSamplerTests.swift +0 -96
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
// GENERATED MIRROR — do not edit.
|
|
2
|
+
// Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriPush.kt
|
|
3
|
+
// Run `node scripts/sync-native-core.mjs` after editing it.
|
|
4
|
+
package com.sentori
|
|
5
|
+
|
|
6
|
+
import android.app.Activity
|
|
7
|
+
import android.content.Context
|
|
8
|
+
import java.net.HttpURLConnection
|
|
9
|
+
import java.net.URL
|
|
10
|
+
import java.util.concurrent.Executors
|
|
11
|
+
import java.util.concurrent.ScheduledFuture
|
|
12
|
+
import java.util.concurrent.TimeUnit
|
|
13
|
+
import org.json.JSONObject
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Push, as an app writes it.
|
|
17
|
+
*
|
|
18
|
+
* Sentori.push.register(activity) { result ->
|
|
19
|
+
* if (result is SentoriPush.Result.Failure) { … }
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* The pieces underneath already existed — the POST_NOTIFICATIONS
|
|
23
|
+
* prompt, the FCM service, the token and tap buffers — and were
|
|
24
|
+
* reachable only through the React Native bridge. What was missing
|
|
25
|
+
* everywhere but JavaScript is the part that puts a token on the
|
|
26
|
+
* server, which is the only reason a registered device is reachable
|
|
27
|
+
* at all.
|
|
28
|
+
*
|
|
29
|
+
* Asked for by insight (2026-08-11): two apps with no React Native,
|
|
30
|
+
* blocked, and unwilling to reimplement the HTTP contract because a
|
|
31
|
+
* second implementation drifts silently on the one path nobody
|
|
32
|
+
* watches.
|
|
33
|
+
*
|
|
34
|
+
* Callback-based rather than suspending, so this is usable from Java
|
|
35
|
+
* and from a codebase that has not adopted coroutines. The callback
|
|
36
|
+
* runs on a background thread; touch UI from it at your own risk.
|
|
37
|
+
*/
|
|
38
|
+
object SentoriPush {
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Why a registration did not produce a device handle. Same five
|
|
42
|
+
* names as `PushRegisterFailure` in the React Native SDK and the
|
|
43
|
+
* Swift one, so a single set of integration notes covers all
|
|
44
|
+
* three and an operator reading a support thread does not have to
|
|
45
|
+
* translate.
|
|
46
|
+
*/
|
|
47
|
+
enum class Failure(val reason: String) {
|
|
48
|
+
/** [Sentori.start] has not run. A wiring bug. */
|
|
49
|
+
NOT_INITIALISED("not-initialised"),
|
|
50
|
+
/**
|
|
51
|
+
* The user said no. Not an error: do not retry on a timer,
|
|
52
|
+
* and offer it again from a settings screen.
|
|
53
|
+
*/
|
|
54
|
+
PERMISSION_DENIED("permission-denied"),
|
|
55
|
+
/**
|
|
56
|
+
* No Firebase in this build, or no `google-services.json`.
|
|
57
|
+
* Nothing to do at runtime.
|
|
58
|
+
*/
|
|
59
|
+
NO_TRANSPORT("no-transport"),
|
|
60
|
+
/**
|
|
61
|
+
* FCM never handed back a token inside the window. Retrying
|
|
62
|
+
* later is reasonable.
|
|
63
|
+
*/
|
|
64
|
+
TOKEN_TIMEOUT("token-timeout"),
|
|
65
|
+
/** Sentori answered non-2xx. Settings ▸ Push is where to look. */
|
|
66
|
+
SERVER_REJECTED("server-rejected"),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Registration never throws. A denied permission is an ordinary
|
|
71
|
+
* answer, and an opt-in that throws inside someone's ViewModel is
|
|
72
|
+
* the failure this SDK's contract with its host is written
|
|
73
|
+
* against.
|
|
74
|
+
*/
|
|
75
|
+
sealed class Result {
|
|
76
|
+
/**
|
|
77
|
+
* The `device_tokens` row id. Revoking takes it, and so does a
|
|
78
|
+
* targeted send.
|
|
79
|
+
*/
|
|
80
|
+
data class Success(val handle: String) : Result()
|
|
81
|
+
|
|
82
|
+
data class Failure(val reason: SentoriPush.Failure, val message: String) : Result()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private val lock = Any()
|
|
86
|
+
private var cachedHandle: String? = null
|
|
87
|
+
private var onMessage: ((Map<String, Any?>) -> Unit)? = null
|
|
88
|
+
private var onTap: ((Map<String, Any?>) -> Unit)? = null
|
|
89
|
+
private var drainTask: ScheduledFuture<*>? = null
|
|
90
|
+
|
|
91
|
+
private const val PREFS = "com.sentori.push"
|
|
92
|
+
private const val HANDLE_KEY = "handle"
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Which installation this is, kept for as long as the app is
|
|
96
|
+
* installed.
|
|
97
|
+
*
|
|
98
|
+
* The server keys the device row on it, so a vendor rotating its
|
|
99
|
+
* token becomes an update of the row that already exists rather
|
|
100
|
+
* than a new row with a new address. Before this, a rotation
|
|
101
|
+
* silently retired whatever `spToken` a backend was holding.
|
|
102
|
+
*
|
|
103
|
+
* Generated here rather than issued by the server because it has
|
|
104
|
+
* to exist before the first registration, and it never leaves the
|
|
105
|
+
* device except in that registration — it is not the address, and
|
|
106
|
+
* an identifier that can claim a row must not be one that travels.
|
|
107
|
+
*/
|
|
108
|
+
private const val INSTALL_KEY = "install_id"
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The last token the vendor issued, so a sign-in can send the
|
|
112
|
+
* registration again without asking FCM for a token first.
|
|
113
|
+
*
|
|
114
|
+
* It is already in this app's private storage — FCM keeps its own
|
|
115
|
+
* copy there — so keeping it costs nothing new. The handle stays
|
|
116
|
+
* the capability; this is only an input to producing one.
|
|
117
|
+
*/
|
|
118
|
+
private const val TOKEN_KEY = "native_token"
|
|
119
|
+
|
|
120
|
+
/** What the server was last told, so a repeat is not sent. */
|
|
121
|
+
@Volatile private var lastSentIdentity: String? = null
|
|
122
|
+
|
|
123
|
+
private val worker =
|
|
124
|
+
Executors.newSingleThreadScheduledExecutor { r ->
|
|
125
|
+
Thread(r, "sentori-push").apply { isDaemon = true }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Ask for permission, get a token, register it.
|
|
130
|
+
*
|
|
131
|
+
* Safe to call on every launch: Android returns its cached
|
|
132
|
+
* decision without re-prompting, and the server upserts on
|
|
133
|
+
* `(project, provider, token)`.
|
|
134
|
+
*
|
|
135
|
+
* Call [Sentori.user] first if the device should be reachable from
|
|
136
|
+
* an issue. Without it the registration carries no user key and
|
|
137
|
+
* the device receives broadcasts only — the dashboard shows that
|
|
138
|
+
* as "N devices, 0 addressable", which is the one symptom with no
|
|
139
|
+
* other explanation.
|
|
140
|
+
*
|
|
141
|
+
* [activity] is needed only for the Android 13+ runtime prompt;
|
|
142
|
+
* pass null to skip prompting and use whatever was already
|
|
143
|
+
* granted.
|
|
144
|
+
*/
|
|
145
|
+
@JvmStatic
|
|
146
|
+
@JvmOverloads
|
|
147
|
+
fun register(
|
|
148
|
+
context: Context,
|
|
149
|
+
activity: Activity? = null,
|
|
150
|
+
timeoutMs: Long = 8_000,
|
|
151
|
+
onMessage: ((Map<String, Any?>) -> Unit)? = null,
|
|
152
|
+
onTap: ((Map<String, Any?>) -> Unit)? = null,
|
|
153
|
+
completion: (Result) -> Unit,
|
|
154
|
+
) {
|
|
155
|
+
val config = SentoriConfig.current
|
|
156
|
+
if (config == null) {
|
|
157
|
+
deliver(completion, Result.Failure(Failure.NOT_INITIALISED, "Sentori.start has not run"))
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Bind before asking for anything: a tap that arrived while
|
|
162
|
+
// the app was dead is replayed as soon as the service starts,
|
|
163
|
+
// and a callback set afterwards misses it.
|
|
164
|
+
synchronized(lock) {
|
|
165
|
+
this.onMessage = onMessage
|
|
166
|
+
this.onTap = onTap
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
val appContext = context.applicationContext
|
|
170
|
+
|
|
171
|
+
// Whatever launched this Activity, before anything else can
|
|
172
|
+
// replace it. A cold start from a notification arrives here
|
|
173
|
+
// and nowhere else, and `onTap` used to need the host to
|
|
174
|
+
// forward it by hand — which nothing told the host to do.
|
|
175
|
+
SentoriNotificationTap.consume(activity)
|
|
176
|
+
val proceed = { status: String ->
|
|
177
|
+
worker.execute {
|
|
178
|
+
deliver(completion, finishRegister(appContext, config, status, timeoutMs))
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
val current = SentoriPushNotifications.currentPermission(appContext)
|
|
183
|
+
if (current == "notDetermined" && activity != null) {
|
|
184
|
+
// `timeoutMs` is a network budget — how long to wait for a
|
|
185
|
+
// device token. Spending it on a person reading a dialog
|
|
186
|
+
// is a category error: the default is eight seconds, and
|
|
187
|
+
// nobody answers a permission prompt in eight seconds.
|
|
188
|
+
SentoriPushNotifications.requestPermission(
|
|
189
|
+
activity,
|
|
190
|
+
timeoutMs = permissionTimeoutMs,
|
|
191
|
+
) { proceed(it) }
|
|
192
|
+
} else {
|
|
193
|
+
proceed(current)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* How long to wait for someone to answer the permission dialog.
|
|
199
|
+
*
|
|
200
|
+
* Separate from `timeoutMs`, which is about the network. A person
|
|
201
|
+
* may be reading, or may have put the phone down; two minutes
|
|
202
|
+
* covers the first and gives up on the second rather than leaving
|
|
203
|
+
* a registration that never reports anything.
|
|
204
|
+
*/
|
|
205
|
+
@JvmStatic
|
|
206
|
+
var permissionTimeoutMs: Long = 120_000
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A vendor has issued this device a new token; tell the server
|
|
210
|
+
* now rather than at the next launch.
|
|
211
|
+
*
|
|
212
|
+
* `onNewToken` fired and the SDK wrote the value into a field.
|
|
213
|
+
* Nothing sent it. So from the moment a token rotated until the
|
|
214
|
+
* host next called `register`, the server held a dead token: the
|
|
215
|
+
* sends went out, the vendor answered UNREGISTERED, quarantine
|
|
216
|
+
* retired the device, and it came back at the next launch under a
|
|
217
|
+
* different address. For an app that stays resident, "the next
|
|
218
|
+
* launch" is not a bounded wait.
|
|
219
|
+
*
|
|
220
|
+
* Only re-registers a device that has registered before —
|
|
221
|
+
* `spToken` on disk is the evidence. A token arriving for a
|
|
222
|
+
* device the host never registered is not something to act on
|
|
223
|
+
* unasked.
|
|
224
|
+
*/
|
|
225
|
+
@JvmStatic
|
|
226
|
+
fun handleRotatedToken(context: Context, token: String) {
|
|
227
|
+
val appContext = context.applicationContext
|
|
228
|
+
val config = SentoriConfig.current ?: return
|
|
229
|
+
if (cachedDeviceHandle(appContext) == null) return
|
|
230
|
+
worker.execute {
|
|
231
|
+
when (val r = registerWithServer(appContext, token, config)) {
|
|
232
|
+
is Result.Success -> rememberHandle(appContext, r.handle, token)
|
|
233
|
+
is Result.Failure ->
|
|
234
|
+
android.util.Log.w(
|
|
235
|
+
"sentori",
|
|
236
|
+
"push re-register after token rotation failed " +
|
|
237
|
+
"(${r.reason.reason}): ${r.message}",
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Keep the address, in memory and on disk.
|
|
245
|
+
*
|
|
246
|
+
* Three callers now — the first registration, a rotation, and the
|
|
247
|
+
* live test — and three copies of four lines is how two of them
|
|
248
|
+
* end up writing different keys.
|
|
249
|
+
*/
|
|
250
|
+
private fun rememberHandle(context: Context, handle: String, token: String? = null) {
|
|
251
|
+
synchronized(lock) { cachedHandle = handle }
|
|
252
|
+
val appContext = context.applicationContext
|
|
253
|
+
appContext
|
|
254
|
+
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
255
|
+
.edit()
|
|
256
|
+
.putString(HANDLE_KEY, handle)
|
|
257
|
+
.apply {
|
|
258
|
+
// Kept for the sign-in path below, which has no other
|
|
259
|
+
// way back to a token.
|
|
260
|
+
if (token != null) putString(TOKEN_KEY, token)
|
|
261
|
+
}
|
|
262
|
+
.apply()
|
|
263
|
+
// From here on, signing in or out updates the row by itself.
|
|
264
|
+
SentoriScope.setIdentityListener { identityChanged(appContext) }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Send the registration again because the person changed.
|
|
269
|
+
*
|
|
270
|
+
* Only for a device that has already registered — a stored token
|
|
271
|
+
* is the evidence, the same rule [handleRotatedToken] uses. Runs
|
|
272
|
+
* on the worker: `Sentori.user` is synchronous and stays that way.
|
|
273
|
+
*/
|
|
274
|
+
private fun identityChanged(appContext: Context) {
|
|
275
|
+
val config = SentoriConfig.current ?: return
|
|
276
|
+
val prefs = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
277
|
+
val token = prefs.getString(TOKEN_KEY, null) ?: return
|
|
278
|
+
if (prefs.getString(HANDLE_KEY, null) == null) return
|
|
279
|
+
|
|
280
|
+
// `Sentori.user` is a verb an app may call on every screen,
|
|
281
|
+
// and one request per call is not free to a host.
|
|
282
|
+
val identity = "${SentoriScope.userKey}\u0000${SentoriScope.traits}"
|
|
283
|
+
if (identity == lastSentIdentity) return
|
|
284
|
+
lastSentIdentity = identity
|
|
285
|
+
|
|
286
|
+
worker.execute {
|
|
287
|
+
when (val r = registerWithServer(appContext, token, config)) {
|
|
288
|
+
is Result.Success -> rememberHandle(appContext, r.handle, token)
|
|
289
|
+
is Result.Failure -> {
|
|
290
|
+
// The row still names the previous person, so the
|
|
291
|
+
// next change has to be allowed to try again.
|
|
292
|
+
lastSentIdentity = null
|
|
293
|
+
android.util.Log.w(
|
|
294
|
+
"sentori",
|
|
295
|
+
"updating the device after a sign-in failed " +
|
|
296
|
+
"(${r.reason.reason}): ${r.message}",
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Register a vendor token directly, for the live test.
|
|
305
|
+
*
|
|
306
|
+
* The rotation path can only be exercised against a device that
|
|
307
|
+
* has registered, and there is no FCM in a test host to get a
|
|
308
|
+
* first token from. This is the same call `finishRegister` makes
|
|
309
|
+
* once it has one.
|
|
310
|
+
*/
|
|
311
|
+
internal fun registerNativeTokenForTests(context: Context, token: String): String? {
|
|
312
|
+
val config = SentoriConfig.current ?: return null
|
|
313
|
+
return when (val r = registerWithServer(context.applicationContext, token, config)) {
|
|
314
|
+
is Result.Success -> {
|
|
315
|
+
rememberHandle(context, r.handle, token)
|
|
316
|
+
r.handle
|
|
317
|
+
}
|
|
318
|
+
else -> null
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** This installation's id, minted on first use and kept after. */
|
|
323
|
+
internal fun installId(ctx: Context): String {
|
|
324
|
+
val prefs = ctx.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
325
|
+
prefs.getString(INSTALL_KEY, null)?.let { return it }
|
|
326
|
+
val fresh = java.util.UUID.randomUUID().toString()
|
|
327
|
+
prefs.edit().putString(INSTALL_KEY, fresh).apply()
|
|
328
|
+
return fresh
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Say it out loud, once, where the person wiring this up is
|
|
333
|
+
* looking.
|
|
334
|
+
*
|
|
335
|
+
* A failed `register` reported only to the server is invisible on
|
|
336
|
+
* the machine where the mistake was made: the integrator has to
|
|
337
|
+
* finish connecting the dashboard before it can tell them they
|
|
338
|
+
* have not finished connecting the dashboard. insight found their
|
|
339
|
+
* first-launch failure by adding a `Log.w` of their own and
|
|
340
|
+
* taking it out again.
|
|
341
|
+
*
|
|
342
|
+
* Warning, never error. A red line in someone else's logcat reads
|
|
343
|
+
* as "your app is broken", and a host team that believes that
|
|
344
|
+
* pulls the SDK out.
|
|
345
|
+
*/
|
|
346
|
+
private fun deliver(completion: (Result) -> Unit, result: Result) {
|
|
347
|
+
if (result is Result.Failure) {
|
|
348
|
+
android.util.Log.w(
|
|
349
|
+
"sentori",
|
|
350
|
+
"push register failed (${result.reason.reason}): ${result.message}",
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
completion(result)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private fun finishRegister(
|
|
357
|
+
context: Context,
|
|
358
|
+
config: SentoriConfig,
|
|
359
|
+
status: String,
|
|
360
|
+
timeoutMs: Long,
|
|
361
|
+
): Result {
|
|
362
|
+
if (status != "granted") {
|
|
363
|
+
return Result.Failure(Failure.PERMISSION_DENIED, "push permission '$status'")
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
SentoriPushNotifications.registerForRemoteNotifications(context)
|
|
367
|
+
|
|
368
|
+
val token = waitForToken(timeoutMs)
|
|
369
|
+
if (token == null) {
|
|
370
|
+
// An FCM that is not on the classpath reports an error
|
|
371
|
+
// rather than a token, and that is a build fact rather
|
|
372
|
+
// than something the user chose.
|
|
373
|
+
val err = SentoriPushNotifications.drainState()["error"] as? String
|
|
374
|
+
return if (err != null) {
|
|
375
|
+
Result.Failure(Failure.NO_TRANSPORT, err)
|
|
376
|
+
} else {
|
|
377
|
+
Result.Failure(Failure.TOKEN_TIMEOUT, "no device token within ${timeoutMs}ms")
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return when (val r = registerWithServer(context, token, config)) {
|
|
382
|
+
is Result.Success -> {
|
|
383
|
+
rememberHandle(context, r.handle, token)
|
|
384
|
+
startDrain()
|
|
385
|
+
r
|
|
386
|
+
}
|
|
387
|
+
else -> r
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The handle from an earlier [register], without a round trip. */
|
|
392
|
+
@JvmStatic
|
|
393
|
+
fun cachedDeviceHandle(context: Context): String? =
|
|
394
|
+
synchronized(lock) { cachedHandle }
|
|
395
|
+
?: context
|
|
396
|
+
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
397
|
+
.getString(HANDLE_KEY, null)
|
|
398
|
+
|
|
399
|
+
/** Current permission without prompting. */
|
|
400
|
+
@JvmStatic
|
|
401
|
+
fun permissionStatus(context: Context): String =
|
|
402
|
+
SentoriPushNotifications.currentPermission(context.applicationContext)
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Revoke the handle server-side and stop local delivery.
|
|
406
|
+
* Idempotent — repeat calls do nothing.
|
|
407
|
+
*/
|
|
408
|
+
@JvmStatic
|
|
409
|
+
@JvmOverloads
|
|
410
|
+
fun unregister(context: Context, completion: ((Boolean) -> Unit)? = null) {
|
|
411
|
+
val appContext = context.applicationContext
|
|
412
|
+
val handle = cachedDeviceHandle(appContext)
|
|
413
|
+
synchronized(lock) {
|
|
414
|
+
cachedHandle = null
|
|
415
|
+
onMessage = null
|
|
416
|
+
onTap = null
|
|
417
|
+
drainTask?.cancel(false)
|
|
418
|
+
drainTask = null
|
|
419
|
+
}
|
|
420
|
+
appContext
|
|
421
|
+
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
422
|
+
.edit()
|
|
423
|
+
.remove(HANDLE_KEY)
|
|
424
|
+
.remove(TOKEN_KEY)
|
|
425
|
+
.apply()
|
|
426
|
+
// Nothing to follow the person to any more; a later sign-in
|
|
427
|
+
// must not resurrect a device the host just revoked.
|
|
428
|
+
SentoriScope.setIdentityListener(null)
|
|
429
|
+
lastSentIdentity = null
|
|
430
|
+
SentoriPushNotifications.unregisterForRemoteNotifications(appContext)
|
|
431
|
+
|
|
432
|
+
val config = SentoriConfig.current
|
|
433
|
+
if (handle == null || config == null) {
|
|
434
|
+
completion?.invoke(false)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
worker.execute {
|
|
438
|
+
var conn: HttpURLConnection? = null
|
|
439
|
+
val ok =
|
|
440
|
+
try {
|
|
441
|
+
conn =
|
|
442
|
+
URL("${config.ingestUrl}/v1/push/tokens/$handle").openConnection()
|
|
443
|
+
as HttpURLConnection
|
|
444
|
+
conn.requestMethod = "DELETE"
|
|
445
|
+
conn.setRequestProperty("Authorization", "Bearer ${config.token}")
|
|
446
|
+
conn.connectTimeout = 15_000
|
|
447
|
+
conn.readTimeout = 15_000
|
|
448
|
+
conn.responseCode in 200..299
|
|
449
|
+
} catch (_: Throwable) {
|
|
450
|
+
false
|
|
451
|
+
} finally {
|
|
452
|
+
conn?.disconnect()
|
|
453
|
+
}
|
|
454
|
+
completion?.invoke(ok)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── internals ─────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
private fun waitForToken(timeoutMs: Long): String? {
|
|
461
|
+
val deadline = System.currentTimeMillis() + timeoutMs
|
|
462
|
+
while (System.currentTimeMillis() < deadline) {
|
|
463
|
+
val state = SentoriPushNotifications.drainState()
|
|
464
|
+
flush(state)
|
|
465
|
+
(state["token"] as? String)?.let { return it }
|
|
466
|
+
if (state["error"] != null) return null
|
|
467
|
+
try {
|
|
468
|
+
Thread.sleep(200)
|
|
469
|
+
} catch (_: InterruptedException) {
|
|
470
|
+
Thread.currentThread().interrupt()
|
|
471
|
+
return null
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return null
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private fun registerWithServer(
|
|
478
|
+
context: Context,
|
|
479
|
+
token: String,
|
|
480
|
+
config: SentoriConfig,
|
|
481
|
+
): Result {
|
|
482
|
+
val body =
|
|
483
|
+
mutableMapOf<String, Any?>(
|
|
484
|
+
// `kind`, not `provider`. The React Native SDK sent
|
|
485
|
+
// `provider` for a year and earned a 422 for every
|
|
486
|
+
// registration it ever attempted.
|
|
487
|
+
"kind" to "fcm",
|
|
488
|
+
"nativeToken" to token,
|
|
489
|
+
// Which installation this is. The server keys the row
|
|
490
|
+
// on it, so a rotated token updates this device
|
|
491
|
+
// rather than creating a second one under a new
|
|
492
|
+
// address.
|
|
493
|
+
"installId" to installId(context),
|
|
494
|
+
// No `env`: FCM is one host, with no sandbox and
|
|
495
|
+
// production split for a token to be wrong about.
|
|
496
|
+
)
|
|
497
|
+
// The same identity hash every event carries, so the dashboard
|
|
498
|
+
// can address this device by the person who hit an issue.
|
|
499
|
+
// Absent until the host calls `Sentori.user`.
|
|
500
|
+
SentoriScope.userKey?.let { body["userKey"] = it }
|
|
501
|
+
// Attributes of the person rather than of the device, kept
|
|
502
|
+
// apart so a build channel called "pro" cannot answer a send
|
|
503
|
+
// aimed at the pro plan. Null leaves the row's traits alone;
|
|
504
|
+
// an empty map clears them, which is what signing out sends.
|
|
505
|
+
SentoriScope.traits?.let { body["traits"] = it }
|
|
506
|
+
|
|
507
|
+
var conn: HttpURLConnection? = null
|
|
508
|
+
return try {
|
|
509
|
+
conn =
|
|
510
|
+
URL("${config.ingestUrl}/v1/push/tokens").openConnection() as HttpURLConnection
|
|
511
|
+
conn.requestMethod = "POST"
|
|
512
|
+
conn.setRequestProperty("Content-Type", "application/json")
|
|
513
|
+
conn.setRequestProperty("Authorization", "Bearer ${config.token}")
|
|
514
|
+
conn.setRequestProperty("Sentori-Sdk", "kotlin/${SentoriVersion.CURRENT}")
|
|
515
|
+
conn.connectTimeout = 15_000
|
|
516
|
+
conn.readTimeout = 15_000
|
|
517
|
+
conn.doOutput = true
|
|
518
|
+
conn.outputStream.use {
|
|
519
|
+
it.write(SentoriTransport.toJson(body).toString().toByteArray(Charsets.UTF_8))
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
val code = conn.responseCode
|
|
523
|
+
if (code !in 200..299) {
|
|
524
|
+
Result.Failure(Failure.SERVER_REJECTED, "HTTP $code")
|
|
525
|
+
} else {
|
|
526
|
+
val text = conn.inputStream.bufferedReader().readText()
|
|
527
|
+
// The handle is the `device_tokens` row id, a bare
|
|
528
|
+
// uuid. The RN SDK parsed it as an `ipt_*` string no
|
|
529
|
+
// server has ever returned.
|
|
530
|
+
// `spToken` is the name; `token_id` is the name the
|
|
531
|
+
// server shipped under. Reading the new one first and
|
|
532
|
+
// falling back matters in one direction that is easy
|
|
533
|
+
// to miss: a self-hosted deployment upgrades on its
|
|
534
|
+
// own schedule, so an SDK newer than its server is an
|
|
535
|
+
// ordinary state, and an SDK that only knew the new
|
|
536
|
+
// name would fail every registration against it.
|
|
537
|
+
val json = JSONObject(text)
|
|
538
|
+
val handle = json.optString("spToken").ifEmpty { json.optString("token_id") }
|
|
539
|
+
if (handle.isNullOrEmpty()) {
|
|
540
|
+
Result.Failure(Failure.SERVER_REJECTED, "server returned no device token id")
|
|
541
|
+
} else {
|
|
542
|
+
Result.Success(handle)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
} catch (e: Throwable) {
|
|
546
|
+
Result.Failure(Failure.SERVER_REJECTED, e.message ?: e.javaClass.name)
|
|
547
|
+
} finally {
|
|
548
|
+
conn?.disconnect()
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* 1 Hz while registered. The FCM service buffers arrivals and
|
|
554
|
+
* taps; this hands them to the host.
|
|
555
|
+
*/
|
|
556
|
+
private fun startDrain() {
|
|
557
|
+
synchronized(lock) {
|
|
558
|
+
if (drainTask != null) return
|
|
559
|
+
drainTask =
|
|
560
|
+
worker.scheduleWithFixedDelay(
|
|
561
|
+
{ flush(SentoriPushNotifications.drainState()) },
|
|
562
|
+
1,
|
|
563
|
+
1,
|
|
564
|
+
TimeUnit.SECONDS,
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
@Suppress("UNCHECKED_CAST")
|
|
570
|
+
private fun flush(state: Map<String, Any?>) {
|
|
571
|
+
val (message, tap) = synchronized(lock) { onMessage to onTap }
|
|
572
|
+
if (message == null && tap == null) return
|
|
573
|
+
(state["notifications"] as? List<Map<String, Any?>>)?.forEach { message?.invoke(it) }
|
|
574
|
+
(state["taps"] as? List<Map<String, Any?>>)?.forEach { tap?.invoke(it) }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
internal fun resetForTests() {
|
|
578
|
+
lastSentIdentity = null
|
|
579
|
+
SentoriScope.setIdentityListener(null)
|
|
580
|
+
synchronized(lock) {
|
|
581
|
+
cachedHandle = null
|
|
582
|
+
onMessage = null
|
|
583
|
+
onTap = null
|
|
584
|
+
drainTask?.cancel(false)
|
|
585
|
+
drainTask = null
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|