@rejourneyco/react-native 1.0.8 → 1.0.10
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/README.md +77 -3
- package/android/src/main/AndroidManifest.xml +6 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +143 -8
- package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +21 -3
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +93 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +226 -146
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +7 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +39 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
- package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
- package/ios/Engine/DeviceRegistrar.swift +13 -3
- package/ios/Engine/RejourneyImpl.swift +204 -115
- package/ios/Recording/AnrSentinel.swift +58 -25
- package/ios/Recording/InteractionRecorder.swift +1 -0
- package/ios/Recording/RejourneyURLProtocol.swift +216 -0
- package/ios/Recording/ReplayOrchestrator.swift +207 -144
- package/ios/Recording/SegmentDispatcher.swift +8 -0
- package/ios/Recording/StabilityMonitor.swift +40 -32
- package/ios/Recording/TelemetryPipeline.swift +45 -2
- package/ios/Recording/ViewHierarchyScanner.swift +1 -0
- package/ios/Recording/VisualCapture.swift +79 -29
- package/ios/Rejourney.mm +27 -8
- package/ios/Utility/DataCompression.swift +2 -2
- package/ios/Utility/ImageBlur.swift +0 -1
- package/lib/commonjs/expoRouterTracking.js +137 -0
- package/lib/commonjs/index.js +204 -34
- package/lib/commonjs/sdk/autoTracking.js +262 -100
- package/lib/commonjs/sdk/networkInterceptor.js +84 -4
- package/lib/module/expoRouterTracking.js +135 -0
- package/lib/module/index.js +203 -28
- package/lib/module/sdk/autoTracking.js +260 -100
- package/lib/module/sdk/networkInterceptor.js +84 -4
- package/lib/typescript/NativeRejourney.d.ts +5 -2
- package/lib/typescript/expoRouterTracking.d.ts +14 -0
- package/lib/typescript/index.d.ts +2 -2
- package/lib/typescript/sdk/autoTracking.d.ts +14 -1
- package/lib/typescript/types/index.d.ts +56 -5
- package/package.json +23 -3
- package/src/NativeRejourney.ts +8 -5
- package/src/expoRouterTracking.ts +167 -0
- package/src/index.ts +221 -35
- package/src/sdk/autoTracking.ts +286 -114
- package/src/sdk/networkInterceptor.ts +110 -1
- package/src/types/index.ts +58 -6
|
@@ -19,6 +19,7 @@ package com.rejourney.recording
|
|
|
19
19
|
import com.rejourney.engine.DiagnosticLog
|
|
20
20
|
import kotlinx.coroutines.*
|
|
21
21
|
import okhttp3.*
|
|
22
|
+
import com.rejourney.recording.RejourneyNetworkInterceptor
|
|
22
23
|
import okhttp3.MediaType.Companion.toMediaType
|
|
23
24
|
import okhttp3.RequestBody.Companion.toRequestBody
|
|
24
25
|
import org.json.JSONObject
|
|
@@ -127,6 +128,8 @@ class SegmentDispatcher private constructor() {
|
|
|
127
128
|
.connectTimeout(5, TimeUnit.SECONDS) // Short timeout for debugging
|
|
128
129
|
.readTimeout(10, TimeUnit.SECONDS)
|
|
129
130
|
.writeTimeout(10, TimeUnit.SECONDS)
|
|
131
|
+
// Mirror iOS URLProtocol: ensure native upload/auth traffic is captured
|
|
132
|
+
.addInterceptor(RejourneyNetworkInterceptor())
|
|
130
133
|
.build()
|
|
131
134
|
|
|
132
135
|
private val retryQueue = mutableListOf<PendingUpload>()
|
|
@@ -259,6 +262,8 @@ class SegmentDispatcher private constructor() {
|
|
|
259
262
|
backgroundDurationMs: Long,
|
|
260
263
|
metrics: Map<String, Any>?,
|
|
261
264
|
currentQueueDepth: Int = 0,
|
|
265
|
+
endReason: String? = null,
|
|
266
|
+
lifecycleVersion: Int? = null,
|
|
262
267
|
completion: (Boolean) -> Unit
|
|
263
268
|
) {
|
|
264
269
|
val url = "$endpoint/api/ingest/session/end"
|
|
@@ -270,6 +275,8 @@ class SegmentDispatcher private constructor() {
|
|
|
270
275
|
if (backgroundDurationMs > 0) put("totalBackgroundTimeMs", backgroundDurationMs)
|
|
271
276
|
metrics?.let { put("metrics", JSONObject(it)) }
|
|
272
277
|
put("sdkTelemetry", buildSdkTelemetry(currentQueueDepth))
|
|
278
|
+
if (!endReason.isNullOrBlank()) put("endReason", endReason)
|
|
279
|
+
if ((lifecycleVersion ?: 0) > 0) put("lifecycleVersion", lifecycleVersion)
|
|
273
280
|
}
|
|
274
281
|
|
|
275
282
|
val request = buildRequest(url, body)
|
|
@@ -173,6 +173,9 @@ class StabilityMonitor private constructor(private val context: Context) {
|
|
|
173
173
|
ReplayOrchestrator.shared?.incrementFaultTally()
|
|
174
174
|
persistIncident(incident)
|
|
175
175
|
|
|
176
|
+
// Flush visual frames to disk for crash safety
|
|
177
|
+
try { VisualCapture.shared?.flushToDisk() } catch (_: Exception) { }
|
|
178
|
+
|
|
176
179
|
// Give time to write
|
|
177
180
|
Thread.sleep(150)
|
|
178
181
|
}
|
|
@@ -130,6 +130,32 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Pause the heartbeat timer when the app goes to background.
|
|
135
|
+
* This prevents the pipeline from uploading empty event batches
|
|
136
|
+
* while backgrounded, which would inflate session duration.
|
|
137
|
+
*/
|
|
138
|
+
fun pause() {
|
|
139
|
+
heartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
140
|
+
heartbeatRunnable = null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resume the heartbeat timer when the app returns to foreground.
|
|
145
|
+
*/
|
|
146
|
+
fun resume() {
|
|
147
|
+
if (heartbeatRunnable != null) return
|
|
148
|
+
mainHandler.post {
|
|
149
|
+
heartbeatRunnable = object : Runnable {
|
|
150
|
+
override fun run() {
|
|
151
|
+
dispatchNow()
|
|
152
|
+
mainHandler.postDelayed(this, 5000)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
mainHandler.postDelayed(heartbeatRunnable!!, 5000)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
133
159
|
fun shutdown() {
|
|
134
160
|
heartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
135
161
|
heartbeatRunnable = null
|
|
@@ -320,6 +346,15 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
320
346
|
))
|
|
321
347
|
}
|
|
322
348
|
|
|
349
|
+
fun recordConsoleLogEvent(level: String, message: String) {
|
|
350
|
+
enqueue(mapOf(
|
|
351
|
+
"type" to "log",
|
|
352
|
+
"timestamp" to ts(),
|
|
353
|
+
"level" to level,
|
|
354
|
+
"message" to message
|
|
355
|
+
))
|
|
356
|
+
}
|
|
357
|
+
|
|
323
358
|
fun recordJSErrorEvent(name: String, message: String, stack: String?) {
|
|
324
359
|
val event = mutableMapOf<String, Any>(
|
|
325
360
|
"type" to "error",
|
|
@@ -331,6 +366,8 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
331
366
|
event["stack"] = stack
|
|
332
367
|
}
|
|
333
368
|
enqueue(event)
|
|
369
|
+
// Prioritize JS error delivery to reduce loss on fatal terminations.
|
|
370
|
+
serialWorker.execute { shipPendingEvents() }
|
|
334
371
|
}
|
|
335
372
|
|
|
336
373
|
fun recordAnrEvent(durationMs: Long, stack: String?) {
|
|
@@ -344,6 +381,8 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
344
381
|
event["stack"] = stack
|
|
345
382
|
}
|
|
346
383
|
enqueue(event)
|
|
384
|
+
// Prioritize ANR delivery while the process is still alive.
|
|
385
|
+
serialWorker.execute { shipPendingEvents() }
|
|
347
386
|
}
|
|
348
387
|
|
|
349
388
|
fun recordUserAssociation(userId: String) {
|
|
@@ -203,6 +203,14 @@ class ViewHierarchyScanner private constructor() {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
private fun isSensitive(view: View): Boolean {
|
|
206
|
+
if (view.contentDescription?.toString() == "rejourney_occlude") return true
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
// Check for React Native accessibility hint tag
|
|
210
|
+
val hint = view.getTag(com.facebook.react.R.id.accessibility_hint) as? String
|
|
211
|
+
if (hint == "rejourney_occlude") return true
|
|
212
|
+
} catch (_: Exception) { }
|
|
213
|
+
|
|
206
214
|
if (view is EditText) {
|
|
207
215
|
val inputType = view.inputType
|
|
208
216
|
// Check for password input types
|
|
@@ -30,6 +30,7 @@ import android.view.TextureView
|
|
|
30
30
|
import android.view.View
|
|
31
31
|
import android.view.ViewGroup
|
|
32
32
|
import android.view.WindowManager
|
|
33
|
+
import android.widget.EditText
|
|
33
34
|
import com.rejourney.engine.DiagnosticLog
|
|
34
35
|
import com.rejourney.utility.gzipCompress
|
|
35
36
|
import java.io.ByteArrayOutputStream
|
|
@@ -170,6 +171,10 @@ class VisualCapture private constructor(private val context: Context) {
|
|
|
170
171
|
redactionMask.remove(view)
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
fun invalidateMaskCache() {
|
|
175
|
+
redactionMask.invalidateCache()
|
|
176
|
+
}
|
|
177
|
+
|
|
173
178
|
fun configure(snapshotInterval: Double, jpegQuality: Double) {
|
|
174
179
|
this.snapshotInterval = snapshotInterval
|
|
175
180
|
this.quality = jpegQuality.toFloat()
|
|
@@ -241,7 +246,7 @@ class VisualCapture private constructor(private val context: Context) {
|
|
|
241
246
|
|
|
242
247
|
if (bounds.width() <= 0 || bounds.height() <= 0) return
|
|
243
248
|
|
|
244
|
-
val redactRects = redactionMask.computeRects()
|
|
249
|
+
val redactRects = redactionMask.computeRects(decorView)
|
|
245
250
|
|
|
246
251
|
val screenScale = 1.25f
|
|
247
252
|
val scaledWidth = (bounds.width() / screenScale).toInt()
|
|
@@ -466,12 +471,18 @@ class VisualCapture private constructor(private val context: Context) {
|
|
|
466
471
|
sendScreenshots()
|
|
467
472
|
}
|
|
468
473
|
|
|
469
|
-
fun uploadPendingFrames(sessionId: String) {
|
|
474
|
+
fun uploadPendingFrames(sessionId: String, completion: ((Boolean) -> Unit)? = null) {
|
|
470
475
|
val framesPath = File(context.cacheDir, "rj_pending/$sessionId/frames")
|
|
471
476
|
|
|
472
|
-
if (!framesPath.exists())
|
|
477
|
+
if (!framesPath.exists()) {
|
|
478
|
+
completion?.invoke(true)
|
|
479
|
+
return
|
|
480
|
+
}
|
|
473
481
|
|
|
474
|
-
val frameFiles = framesPath.listFiles()?.sortedBy { it.name } ?:
|
|
482
|
+
val frameFiles = framesPath.listFiles()?.sortedBy { it.name } ?: run {
|
|
483
|
+
completion?.invoke(true)
|
|
484
|
+
return
|
|
485
|
+
}
|
|
475
486
|
|
|
476
487
|
val frames = mutableListOf<Pair<ByteArray, Long>>()
|
|
477
488
|
for (file in frameFiles) {
|
|
@@ -481,9 +492,15 @@ class VisualCapture private constructor(private val context: Context) {
|
|
|
481
492
|
frames.add(Pair(data, ts))
|
|
482
493
|
}
|
|
483
494
|
|
|
484
|
-
if (frames.isEmpty())
|
|
495
|
+
if (frames.isEmpty()) {
|
|
496
|
+
completion?.invoke(true)
|
|
497
|
+
return
|
|
498
|
+
}
|
|
485
499
|
|
|
486
|
-
val bundle = packageFrameBundle(frames, frames.first().second) ?:
|
|
500
|
+
val bundle = packageFrameBundle(frames, frames.first().second) ?: run {
|
|
501
|
+
completion?.invoke(false)
|
|
502
|
+
return
|
|
503
|
+
}
|
|
487
504
|
|
|
488
505
|
SegmentDispatcher.shared.transmitFrameBundle(
|
|
489
506
|
payload = bundle,
|
|
@@ -496,6 +513,7 @@ class VisualCapture private constructor(private val context: Context) {
|
|
|
496
513
|
frameFiles.forEach { it.delete() }
|
|
497
514
|
framesPath.delete()
|
|
498
515
|
}
|
|
516
|
+
completion?.invoke(ok)
|
|
499
517
|
}
|
|
500
518
|
}
|
|
501
519
|
}
|
|
@@ -530,6 +548,10 @@ private class CaptureStateMachine {
|
|
|
530
548
|
private class RedactionMask {
|
|
531
549
|
private val views = CopyOnWriteArrayList<WeakReference<View>>()
|
|
532
550
|
|
|
551
|
+
private val cachedAutoRects = mutableListOf<Rect>()
|
|
552
|
+
private var lastScanTime = 0L
|
|
553
|
+
private val scanCacheDurationMs = 500L
|
|
554
|
+
|
|
533
555
|
fun add(view: View) {
|
|
534
556
|
views.add(WeakReference(view))
|
|
535
557
|
}
|
|
@@ -538,29 +560,81 @@ private class RedactionMask {
|
|
|
538
560
|
views.removeIf { it.get() === view || it.get() == null }
|
|
539
561
|
}
|
|
540
562
|
|
|
541
|
-
fun
|
|
563
|
+
fun invalidateCache() {
|
|
564
|
+
lastScanTime = 0L
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
fun computeRects(decorView: View? = null): List<Rect> {
|
|
542
568
|
val rects = mutableListOf<Rect>()
|
|
543
569
|
views.removeIf { it.get() == null }
|
|
544
570
|
|
|
545
571
|
for (ref in views) {
|
|
546
572
|
val view = ref.get() ?: continue
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
val
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
)
|
|
558
|
-
|
|
559
|
-
if (rect.width() > 0 && rect.height() > 0) {
|
|
560
|
-
rects.add(rect)
|
|
573
|
+
val rect = getViewRect(view)
|
|
574
|
+
if (rect != null) rects.add(rect)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (decorView != null) {
|
|
578
|
+
val now = SystemClock.elapsedRealtime()
|
|
579
|
+
if (now - lastScanTime >= scanCacheDurationMs) {
|
|
580
|
+
cachedAutoRects.clear()
|
|
581
|
+
scanForSensitiveViews(decorView, cachedAutoRects)
|
|
582
|
+
lastScanTime = now
|
|
561
583
|
}
|
|
584
|
+
rects.addAll(cachedAutoRects)
|
|
562
585
|
}
|
|
563
586
|
|
|
564
587
|
return rects
|
|
565
588
|
}
|
|
589
|
+
|
|
590
|
+
private fun getViewRect(view: View): Rect? {
|
|
591
|
+
if (!view.isShown || view.width <= 0 || view.height <= 0) return null
|
|
592
|
+
val location = IntArray(2)
|
|
593
|
+
view.getLocationOnScreen(location)
|
|
594
|
+
val rect = Rect(
|
|
595
|
+
location[0],
|
|
596
|
+
location[1],
|
|
597
|
+
location[0] + view.width,
|
|
598
|
+
location[1] + view.height
|
|
599
|
+
)
|
|
600
|
+
if (rect.width() > 0 && rect.height() > 0) return rect
|
|
601
|
+
return null
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private fun scanForSensitiveViews(view: View, rects: MutableList<Rect>, depth: Int = 0) {
|
|
605
|
+
if (depth > 20) return
|
|
606
|
+
if (!view.isShown || view.alpha <= 0.01f || view.width <= 0 || view.height <= 0) return
|
|
607
|
+
|
|
608
|
+
if (shouldMask(view)) {
|
|
609
|
+
val rect = getViewRect(view)
|
|
610
|
+
if (rect != null) {
|
|
611
|
+
rects.add(rect)
|
|
612
|
+
return
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (view is ViewGroup) {
|
|
617
|
+
for (i in 0 until view.childCount) {
|
|
618
|
+
scanForSensitiveViews(view.getChildAt(i), rects, depth + 1)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private fun shouldMask(view: View): Boolean {
|
|
624
|
+
if (view.contentDescription?.toString() == "rejourney_occlude") return true
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
val hint = view.getTag(com.facebook.react.R.id.accessibility_hint) as? String
|
|
628
|
+
if (hint == "rejourney_occlude") return true
|
|
629
|
+
} catch (_: Exception) { }
|
|
630
|
+
|
|
631
|
+
if (view is EditText) return true
|
|
632
|
+
|
|
633
|
+
val className = view.javaClass.simpleName.lowercase(java.util.Locale.US)
|
|
634
|
+
if (className.contains("camera") || (className.contains("surfaceview") && className.contains("preview"))) {
|
|
635
|
+
return true
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return false
|
|
639
|
+
}
|
|
566
640
|
}
|
|
@@ -17,21 +17,33 @@
|
|
|
17
17
|
package com.rejourney.utility
|
|
18
18
|
|
|
19
19
|
import java.io.ByteArrayOutputStream
|
|
20
|
+
import java.io.OutputStream
|
|
21
|
+
import java.util.zip.Deflater
|
|
20
22
|
import java.util.zip.GZIPOutputStream
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Data compression utilities
|
|
24
26
|
* Android implementation aligned with iOS DataCompression.swift
|
|
27
|
+
* Uses level 9 (BEST_COMPRESSION) for smaller S3 payloads.
|
|
25
28
|
*/
|
|
26
29
|
object DataCompression {
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* GZIPOutputStream that uses Deflater.BEST_COMPRESSION for maximum ratio.
|
|
33
|
+
*/
|
|
34
|
+
private class GzipLevel9OutputStream(out: OutputStream) : GZIPOutputStream(out) {
|
|
35
|
+
init {
|
|
36
|
+
def.setLevel(Deflater.BEST_COMPRESSION)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
27
39
|
|
|
28
40
|
/**
|
|
29
|
-
* Compress data using gzip
|
|
41
|
+
* Compress data using gzip (level 9)
|
|
30
42
|
*/
|
|
31
43
|
fun gzipCompress(data: ByteArray): ByteArray? {
|
|
32
44
|
return try {
|
|
33
45
|
val bos = ByteArrayOutputStream()
|
|
34
|
-
|
|
46
|
+
GzipLevel9OutputStream(bos).use { gzip ->
|
|
35
47
|
gzip.write(data)
|
|
36
48
|
}
|
|
37
49
|
bos.toByteArray()
|
|
@@ -191,6 +191,20 @@ class RejourneyModule(reactContext: ReactApplicationContext) :
|
|
|
191
191
|
instance.getUserIdentity(promise)
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
@ReactMethod
|
|
195
|
+
@DoNotStrip
|
|
196
|
+
override fun setAnonymousId(anonymousId: String, promise: Promise) {
|
|
197
|
+
val instance = getImplOrReject(promise) ?: return
|
|
198
|
+
instance.setAnonymousId(anonymousId, promise)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@ReactMethod
|
|
202
|
+
@DoNotStrip
|
|
203
|
+
override fun getAnonymousId(promise: Promise) {
|
|
204
|
+
val instance = getImplOrReject(promise) ?: return
|
|
205
|
+
instance.getAnonymousId(promise)
|
|
206
|
+
}
|
|
207
|
+
|
|
194
208
|
@ReactMethod
|
|
195
209
|
@DoNotStrip
|
|
196
210
|
override fun setDebugMode(enabled: Boolean, promise: Promise) {
|
|
@@ -224,6 +224,24 @@ class RejourneyModule(reactContext: ReactApplicationContext) :
|
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
@ReactMethod
|
|
228
|
+
fun setAnonymousId(anonymousId: String, promise: Promise) {
|
|
229
|
+
try {
|
|
230
|
+
impl.setAnonymousId(anonymousId, promise)
|
|
231
|
+
} catch (e: Exception) {
|
|
232
|
+
promise.resolve(createErrorMap("Module initialization failed: ${e.message}"))
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
@ReactMethod
|
|
237
|
+
fun getAnonymousId(promise: Promise) {
|
|
238
|
+
try {
|
|
239
|
+
impl.getAnonymousId(promise)
|
|
240
|
+
} catch (e: Exception) {
|
|
241
|
+
promise.resolve(null)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
227
245
|
@ReactMethod
|
|
228
246
|
fun setSDKVersion(version: String) {
|
|
229
247
|
try {
|
|
@@ -37,6 +37,7 @@ public final class DeviceRegistrar: NSObject {
|
|
|
37
37
|
// MARK: Private State
|
|
38
38
|
|
|
39
39
|
private let _keychainId = "com.rejourney.device.fingerprint"
|
|
40
|
+
private let _fallbackIdKey = "com.rejourney.device.fallbackId"
|
|
40
41
|
|
|
41
42
|
private lazy var _httpSession: URLSession = {
|
|
42
43
|
let config = URLSessionConfiguration.default
|
|
@@ -124,13 +125,22 @@ public final class DeviceRegistrar: NSObject {
|
|
|
124
125
|
|
|
125
126
|
var composite = bundleId
|
|
126
127
|
composite += device.model
|
|
127
|
-
composite += device.
|
|
128
|
-
composite += device.systemVersion
|
|
129
|
-
composite += device.identifierForVendor?.uuidString ?? UUID().uuidString
|
|
128
|
+
composite += device.identifierForVendor?.uuidString ?? _stableDeviceFallback()
|
|
130
129
|
|
|
131
130
|
return _sha256(composite)
|
|
132
131
|
}
|
|
133
132
|
|
|
133
|
+
/// Returns a keychain-persisted UUID so the fingerprint stays stable even when
|
|
134
|
+
/// identifierForVendor is temporarily nil (early boot, App Clips, extensions).
|
|
135
|
+
private func _stableDeviceFallback() -> String {
|
|
136
|
+
if let existing = _keychainLoad(_fallbackIdKey) {
|
|
137
|
+
return existing
|
|
138
|
+
}
|
|
139
|
+
let fresh = UUID().uuidString
|
|
140
|
+
_keychainSave(_fallbackIdKey, value: fresh)
|
|
141
|
+
return fresh
|
|
142
|
+
}
|
|
143
|
+
|
|
134
144
|
// MARK: Server Communication
|
|
135
145
|
|
|
136
146
|
private func _fetchServerCredential(fingerprint: String, apiToken: String, completion: @escaping (Bool, String?) -> Void) {
|