@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
|
@@ -91,6 +91,35 @@ object SentoriPush {
|
|
|
91
91
|
private const val PREFS = "com.sentori.push"
|
|
92
92
|
private const val HANDLE_KEY = "handle"
|
|
93
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
|
+
|
|
94
123
|
private val worker =
|
|
95
124
|
Executors.newSingleThreadScheduledExecutor { r ->
|
|
96
125
|
Thread(r, "sentori-push").apply { isDaemon = true }
|
|
@@ -125,7 +154,7 @@ object SentoriPush {
|
|
|
125
154
|
) {
|
|
126
155
|
val config = SentoriConfig.current
|
|
127
156
|
if (config == null) {
|
|
128
|
-
completion
|
|
157
|
+
deliver(completion, Result.Failure(Failure.NOT_INITIALISED, "Sentori.start has not run"))
|
|
129
158
|
return
|
|
130
159
|
}
|
|
131
160
|
|
|
@@ -138,20 +167,192 @@ object SentoriPush {
|
|
|
138
167
|
}
|
|
139
168
|
|
|
140
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)
|
|
141
176
|
val proceed = { status: String ->
|
|
142
177
|
worker.execute {
|
|
143
|
-
completion
|
|
178
|
+
deliver(completion, finishRegister(appContext, config, status, timeoutMs))
|
|
144
179
|
}
|
|
145
180
|
}
|
|
146
181
|
|
|
147
182
|
val current = SentoriPushNotifications.currentPermission(appContext)
|
|
148
183
|
if (current == "notDetermined" && activity != null) {
|
|
149
|
-
|
|
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) }
|
|
150
192
|
} else {
|
|
151
193
|
proceed(current)
|
|
152
194
|
}
|
|
153
195
|
}
|
|
154
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
|
+
|
|
155
356
|
private fun finishRegister(
|
|
156
357
|
context: Context,
|
|
157
358
|
config: SentoriConfig,
|
|
@@ -177,14 +378,9 @@ object SentoriPush {
|
|
|
177
378
|
}
|
|
178
379
|
}
|
|
179
380
|
|
|
180
|
-
return when (val r = registerWithServer(token, config)) {
|
|
381
|
+
return when (val r = registerWithServer(context, token, config)) {
|
|
181
382
|
is Result.Success -> {
|
|
182
|
-
|
|
183
|
-
context
|
|
184
|
-
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
185
|
-
.edit()
|
|
186
|
-
.putString(HANDLE_KEY, r.handle)
|
|
187
|
-
.apply()
|
|
383
|
+
rememberHandle(context, r.handle, token)
|
|
188
384
|
startDrain()
|
|
189
385
|
r
|
|
190
386
|
}
|
|
@@ -221,7 +417,16 @@ object SentoriPush {
|
|
|
221
417
|
drainTask?.cancel(false)
|
|
222
418
|
drainTask = null
|
|
223
419
|
}
|
|
224
|
-
appContext
|
|
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
|
|
225
430
|
SentoriPushNotifications.unregisterForRemoteNotifications(appContext)
|
|
226
431
|
|
|
227
432
|
val config = SentoriConfig.current
|
|
@@ -269,7 +474,11 @@ object SentoriPush {
|
|
|
269
474
|
return null
|
|
270
475
|
}
|
|
271
476
|
|
|
272
|
-
private fun registerWithServer(
|
|
477
|
+
private fun registerWithServer(
|
|
478
|
+
context: Context,
|
|
479
|
+
token: String,
|
|
480
|
+
config: SentoriConfig,
|
|
481
|
+
): Result {
|
|
273
482
|
val body =
|
|
274
483
|
mutableMapOf<String, Any?>(
|
|
275
484
|
// `kind`, not `provider`. The React Native SDK sent
|
|
@@ -277,6 +486,11 @@ object SentoriPush {
|
|
|
277
486
|
// registration it ever attempted.
|
|
278
487
|
"kind" to "fcm",
|
|
279
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),
|
|
280
494
|
// No `env`: FCM is one host, with no sandbox and
|
|
281
495
|
// production split for a token to be wrong about.
|
|
282
496
|
)
|
|
@@ -284,6 +498,11 @@ object SentoriPush {
|
|
|
284
498
|
// can address this device by the person who hit an issue.
|
|
285
499
|
// Absent until the host calls `Sentori.user`.
|
|
286
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 }
|
|
287
506
|
|
|
288
507
|
var conn: HttpURLConnection? = null
|
|
289
508
|
return try {
|
|
@@ -308,7 +527,15 @@ object SentoriPush {
|
|
|
308
527
|
// The handle is the `device_tokens` row id, a bare
|
|
309
528
|
// uuid. The RN SDK parsed it as an `ipt_*` string no
|
|
310
529
|
// server has ever returned.
|
|
311
|
-
|
|
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") }
|
|
312
539
|
if (handle.isNullOrEmpty()) {
|
|
313
540
|
Result.Failure(Failure.SERVER_REJECTED, "server returned no device token id")
|
|
314
541
|
} else {
|
|
@@ -348,6 +575,8 @@ object SentoriPush {
|
|
|
348
575
|
}
|
|
349
576
|
|
|
350
577
|
internal fun resetForTests() {
|
|
578
|
+
lastSentIdentity = null
|
|
579
|
+
SentoriScope.setIdentityListener(null)
|
|
351
580
|
synchronized(lock) {
|
|
352
581
|
cachedHandle = null
|
|
353
582
|
onMessage = null
|
|
@@ -50,6 +50,10 @@ object SentoriPushNotifications {
|
|
|
50
50
|
|
|
51
51
|
private var pendingPermissionCallback: ((String) -> Unit)? = null
|
|
52
52
|
|
|
53
|
+
private val watcher = java.util.concurrent.Executors.newSingleThreadExecutor { r ->
|
|
54
|
+
Thread(r, "sentori-push-permission").apply { isDaemon = true }
|
|
55
|
+
}
|
|
56
|
+
|
|
53
57
|
// ── status / permission ─────────────────────────────────────
|
|
54
58
|
|
|
55
59
|
/** Returns `granted` / `denied` / `notDetermined` without
|
|
@@ -82,7 +86,12 @@ object SentoriPushNotifications {
|
|
|
82
86
|
* Callbacks run on the main thread.
|
|
83
87
|
*/
|
|
84
88
|
@JvmStatic
|
|
85
|
-
|
|
89
|
+
@JvmOverloads
|
|
90
|
+
fun requestPermission(
|
|
91
|
+
activity: Activity?,
|
|
92
|
+
timeoutMs: Long = 60_000,
|
|
93
|
+
completion: (String) -> Unit,
|
|
94
|
+
) {
|
|
86
95
|
val ctx = activity ?: run {
|
|
87
96
|
completion("error:no-activity")
|
|
88
97
|
return
|
|
@@ -99,35 +108,89 @@ object SentoriPushNotifications {
|
|
|
99
108
|
completion("granted")
|
|
100
109
|
return
|
|
101
110
|
}
|
|
102
|
-
pendingPermissionCallback = completion
|
|
111
|
+
synchronized(lock) { pendingPermissionCallback = completion }
|
|
103
112
|
ActivityCompat.requestPermissions(
|
|
104
113
|
ctx,
|
|
105
114
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
|
106
115
|
PERMISSION_REQUEST_CODE,
|
|
107
116
|
)
|
|
117
|
+
watchForPermission(ctx.applicationContext, timeoutMs)
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
121
|
+
* Fast path for hosts that forward `onRequestPermissionsResult`.
|
|
122
|
+
*
|
|
123
|
+
* It used to be the *only* path, and the comment here said the
|
|
124
|
+
* callback might not fire because "the JS drain loop will still
|
|
125
|
+
* pick up the granted state next tick". That is true of React
|
|
126
|
+
* Native and of nothing else. A native host calls `register` and
|
|
127
|
+
* never hears of this method, so the callback was never invoked,
|
|
128
|
+
* `finishRegister` never ran, and a first launch registered
|
|
129
|
+
* nothing at all — silently, with the user having tapped Allow.
|
|
130
|
+
* The next launch worked, because by then the permission was
|
|
131
|
+
* already granted and the flow never suspended. New users simply
|
|
132
|
+
* did not get push until they happened to reopen the app.
|
|
116
133
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* accept that the callback may not fire on every device — the
|
|
121
|
-
* JS drain loop will still pick up the `granted` state next tick.
|
|
134
|
+
* Nothing needs to call this now. It stays because forwarding the
|
|
135
|
+
* result resolves in milliseconds instead of on the next poll,
|
|
136
|
+
* and because hosts that already call it should keep working.
|
|
122
137
|
*/
|
|
123
138
|
@JvmStatic
|
|
124
139
|
fun handlePermissionResult(requestCode: Int, grantResults: IntArray) {
|
|
125
140
|
if (requestCode != PERMISSION_REQUEST_CODE) return
|
|
126
|
-
val cb = pendingPermissionCallback ?: return
|
|
127
|
-
pendingPermissionCallback = null
|
|
128
141
|
val granted = grantResults.isNotEmpty() &&
|
|
129
142
|
grantResults[0] == PackageManager.PERMISSION_GRANTED
|
|
130
|
-
|
|
143
|
+
settlePermission(if (granted) "granted" else "denied")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Deliver a pending permission outcome exactly once. */
|
|
147
|
+
private fun settlePermission(status: String) {
|
|
148
|
+
val cb = synchronized(lock) {
|
|
149
|
+
pendingPermissionCallback.also { pendingPermissionCallback = null }
|
|
150
|
+
} ?: return
|
|
151
|
+
cb(status)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Watch the permission the framework actually holds, and settle
|
|
156
|
+
* when it changes.
|
|
157
|
+
*
|
|
158
|
+
* `checkSelfPermission` flips the moment the user answers the
|
|
159
|
+
* dialog, whether or not anyone forwarded the result — so this
|
|
160
|
+
* needs no cooperation from the host, works on any Activity
|
|
161
|
+
* rather than only a `ComponentActivity`, and cannot be defeated
|
|
162
|
+
* by a host that does not know the hook exists.
|
|
163
|
+
*
|
|
164
|
+
* On a background thread, because the caller's thread is never
|
|
165
|
+
* ours to block. If the user never answers, this settles as
|
|
166
|
+
* `notDetermined` at the deadline rather than hanging: a
|
|
167
|
+
* registration that reports nothing is the failure this whole
|
|
168
|
+
* change is about.
|
|
169
|
+
*/
|
|
170
|
+
private fun watchForPermission(ctx: Context, timeoutMs: Long) {
|
|
171
|
+
watcher.execute {
|
|
172
|
+
val deadline = System.currentTimeMillis() + timeoutMs
|
|
173
|
+
while (System.currentTimeMillis() < deadline) {
|
|
174
|
+
if (synchronized(lock) { pendingPermissionCallback } == null) return@execute
|
|
175
|
+
if (ContextCompat.checkSelfPermission(
|
|
176
|
+
ctx,
|
|
177
|
+
Manifest.permission.POST_NOTIFICATIONS,
|
|
178
|
+
) == PackageManager.PERMISSION_GRANTED
|
|
179
|
+
) {
|
|
180
|
+
settlePermission("granted")
|
|
181
|
+
return@execute
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
Thread.sleep(250)
|
|
185
|
+
} catch (_: InterruptedException) {
|
|
186
|
+
return@execute
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Denied and never-answered are the same observation from
|
|
190
|
+
// here: the framework only tells them apart through an
|
|
191
|
+
// Activity that may be gone by now. Say what was seen.
|
|
192
|
+
settlePermission("notDetermined")
|
|
193
|
+
}
|
|
131
194
|
}
|
|
132
195
|
|
|
133
196
|
// ── register / unregister ───────────────────────────────────
|
|
@@ -230,8 +293,17 @@ object SentoriPushNotifications {
|
|
|
230
293
|
}
|
|
231
294
|
}
|
|
232
295
|
|
|
233
|
-
/**
|
|
234
|
-
*
|
|
296
|
+
/**
|
|
297
|
+
* Record a tap.
|
|
298
|
+
*
|
|
299
|
+
* This used to be the only route to `register(onTap:)`, and its
|
|
300
|
+
* comment said the host wires it in `Activity.onCreate`. Nothing
|
|
301
|
+
* in the SDK called it and the docs never mentioned it, so a
|
|
302
|
+
* native host's `onTap` could not fire at all. `SentoriNotificationTap`
|
|
303
|
+
* now reaches this on its own, from a pending intent the SDK owns
|
|
304
|
+
* or from the intent an Activity was launched with; this stays
|
|
305
|
+
* public because a host that already forwards should keep working.
|
|
306
|
+
*/
|
|
235
307
|
@JvmStatic
|
|
236
308
|
fun handleNotificationTap(extras: Map<String, Any?>) {
|
|
237
309
|
synchronized(lock) {
|
|
@@ -283,6 +355,85 @@ object SentoriPushNotifications {
|
|
|
283
355
|
mgr.createNotificationChannel(channel)
|
|
284
356
|
}
|
|
285
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Put a data message in the tray, with a pending intent of ours.
|
|
360
|
+
*
|
|
361
|
+
* Nothing here posted a notification before, which left both
|
|
362
|
+
* kinds of message broken in a different way. A `data` message
|
|
363
|
+
* reached `onMessage` and the user saw nothing — insight's device
|
|
364
|
+
* said "No notifications" while the callback had fired. A
|
|
365
|
+
* `notification` message was drawn by the system, which never
|
|
366
|
+
* calls this service, so the tap had no way home. Posting it here
|
|
367
|
+
* is what makes one path work end to end.
|
|
368
|
+
*
|
|
369
|
+
* Only messages that carry something to display are posted. A
|
|
370
|
+
* data message without a title or a body is a silent instruction
|
|
371
|
+
* to the app, and an app that uses those would not thank us for
|
|
372
|
+
* turning each one into a notification the user has to dismiss.
|
|
373
|
+
*/
|
|
374
|
+
@JvmStatic
|
|
375
|
+
fun postNotification(ctx: Context, data: Map<String, String>) {
|
|
376
|
+
val title = data["title"]?.takeIf { it.isNotBlank() }
|
|
377
|
+
val body = data["body"]?.takeIf { it.isNotBlank() }
|
|
378
|
+
if (title == null && body == null) return
|
|
379
|
+
if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) return
|
|
380
|
+
|
|
381
|
+
ensureChannel(ctx)
|
|
382
|
+
val id = (data["google.message_id"] ?: data["id"] ?: title ?: "").hashCode()
|
|
383
|
+
val builder = androidx.core.app.NotificationCompat.Builder(
|
|
384
|
+
ctx,
|
|
385
|
+
data["channelId"]?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_ID,
|
|
386
|
+
)
|
|
387
|
+
.setSmallIcon(smallIcon(ctx))
|
|
388
|
+
.setAutoCancel(true)
|
|
389
|
+
.setPriority(androidx.core.app.NotificationCompat.PRIORITY_DEFAULT)
|
|
390
|
+
title?.let { builder.setContentTitle(it) }
|
|
391
|
+
body?.let { builder.setContentText(it) }
|
|
392
|
+
SentoriNotificationTap.pendingIntent(ctx, data, id)?.let { builder.setContentIntent(it) }
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
NotificationManagerCompat.from(ctx).notify(id, builder.build())
|
|
396
|
+
} catch (t: Throwable) {
|
|
397
|
+
// Losing the tray entry is not worth taking the host down
|
|
398
|
+
// for — but losing it in silence is what made this cost
|
|
399
|
+
// insight an afternoon. The channel got created, so they
|
|
400
|
+
// knew the code had reached `notify`; the tray was empty,
|
|
401
|
+
// `dumpsys notification` had nothing, and logcat had not
|
|
402
|
+
// one word. They worked backwards from the channel's
|
|
403
|
+
// existence to a throw nobody had reported.
|
|
404
|
+
//
|
|
405
|
+
// The argument for this line is the same one made when
|
|
406
|
+
// registration failures started logging, one release
|
|
407
|
+
// earlier — and then this catch was written silent in the
|
|
408
|
+
// same release. A failure the host cannot see is the
|
|
409
|
+
// hardest kind there is.
|
|
410
|
+
android.util.Log.w("sentori", "push notification not shown: $t")
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* A small icon that will not throw.
|
|
416
|
+
*
|
|
417
|
+
* The host's own, because a push SDK that ships its own artwork
|
|
418
|
+
* puts a stranger's mark in someone else's tray. But
|
|
419
|
+
* `applicationInfo.icon` is `0` for an app that declares no
|
|
420
|
+
* `android:icon` — insight's did not, it drew its logo from a
|
|
421
|
+
* launch theme — and `notify` throws on an icon of 0 rather than
|
|
422
|
+
* drawing something ugly. The result was a notification that
|
|
423
|
+
* silently never appeared.
|
|
424
|
+
*
|
|
425
|
+
* A system icon in that case: unbranded and plain, but visible,
|
|
426
|
+
* and visible is the whole point of the tray.
|
|
427
|
+
*/
|
|
428
|
+
private fun smallIcon(ctx: Context): Int {
|
|
429
|
+
val declared = try {
|
|
430
|
+
ctx.applicationInfo.icon
|
|
431
|
+
} catch (_: Throwable) {
|
|
432
|
+
0
|
|
433
|
+
}
|
|
434
|
+
return if (declared != 0) declared else android.R.drawable.ic_dialog_info
|
|
435
|
+
}
|
|
436
|
+
|
|
286
437
|
private fun isFirebaseAvailable(): Boolean {
|
|
287
438
|
return try {
|
|
288
439
|
Class.forName("com.google.firebase.messaging.FirebaseMessaging")
|
|
@@ -12,8 +12,29 @@ object SentoriScope {
|
|
|
12
12
|
|
|
13
13
|
private val lock = Any()
|
|
14
14
|
private var _userKey: String? = null
|
|
15
|
+
private var _traits: Map<String, Any?>? = null
|
|
15
16
|
private val _context = mutableMapOf<String, Any?>()
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* What to tell when the person changes.
|
|
20
|
+
*
|
|
21
|
+
* The push device row carries the identity, and nothing updated it
|
|
22
|
+
* after registration: an app that registers at launch and signs in
|
|
23
|
+
* ten seconds later — which is every app with a login screen —
|
|
24
|
+
* held a row with no user on it for the life of the install. A
|
|
25
|
+
* send aimed at that person reached nobody and said it had worked.
|
|
26
|
+
*
|
|
27
|
+
* A callback rather than a call into [SentoriPush], so this file
|
|
28
|
+
* keeps knowing nothing about push.
|
|
29
|
+
*/
|
|
30
|
+
@Volatile private var onIdentityChange: (() -> Unit)? = null
|
|
31
|
+
|
|
32
|
+
/** Register interest in identity changes. Only push does. */
|
|
33
|
+
@JvmStatic
|
|
34
|
+
fun setIdentityListener(listener: (() -> Unit)?) {
|
|
35
|
+
onIdentityChange = listener
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
/**
|
|
18
39
|
* Identify the person using the app. Only the hash goes on the
|
|
19
40
|
* wire; the id and email stay on the device.
|
|
@@ -27,9 +48,22 @@ object SentoriScope {
|
|
|
27
48
|
* Pass null for both to forget the user on sign-out.
|
|
28
49
|
*/
|
|
29
50
|
@JvmStatic
|
|
30
|
-
|
|
51
|
+
@JvmOverloads
|
|
52
|
+
fun setUser(id: String?, email: String?, traits: Map<String, Any?>? = null) {
|
|
31
53
|
val key = SentoriIdentity.userKey(id, email)
|
|
32
|
-
synchronized(lock) {
|
|
54
|
+
synchronized(lock) {
|
|
55
|
+
_userKey = key
|
|
56
|
+
// A call describes the person completely, so one made
|
|
57
|
+
// without traits means they have none rather than "leave
|
|
58
|
+
// the last ones". Absent and empty differ on the wire —
|
|
59
|
+
// absent keeps what the row has, and a signed-out device
|
|
60
|
+
// that kept them would still be selectable as whoever
|
|
61
|
+
// just left.
|
|
62
|
+
_traits = traits?.toMap() ?: emptyMap()
|
|
63
|
+
}
|
|
64
|
+
// Outside the lock: a listener that registers a device must
|
|
65
|
+
// not be holding this while it makes a request.
|
|
66
|
+
runCatching { onIdentityChange?.invoke() }
|
|
33
67
|
}
|
|
34
68
|
|
|
35
69
|
/** Merge keys into the ambient context. Later calls win per key. */
|
|
@@ -42,6 +76,17 @@ object SentoriScope {
|
|
|
42
76
|
val userKey: String?
|
|
43
77
|
get() = synchronized(lock) { _userKey }
|
|
44
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The person's attributes, for the push device row.
|
|
81
|
+
*
|
|
82
|
+
* Null until the host has called [setUser] at all, which is
|
|
83
|
+
* different from an empty map: null leaves the row's traits alone,
|
|
84
|
+
* empty clears them.
|
|
85
|
+
*/
|
|
86
|
+
@JvmStatic
|
|
87
|
+
val traits: Map<String, Any?>?
|
|
88
|
+
get() = synchronized(lock) { _traits }
|
|
89
|
+
|
|
45
90
|
/**
|
|
46
91
|
* Null rather than an empty map, so an event with no context omits
|
|
47
92
|
* the field instead of carrying `{}`.
|
|
@@ -54,7 +99,9 @@ object SentoriScope {
|
|
|
54
99
|
fun clear() {
|
|
55
100
|
synchronized(lock) {
|
|
56
101
|
_userKey = null
|
|
102
|
+
_traits = null
|
|
57
103
|
_context.clear()
|
|
58
104
|
}
|
|
105
|
+
onIdentityChange = null
|
|
59
106
|
}
|
|
60
107
|
}
|