@rejourneyco/react-native 1.0.2 → 1.0.3
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/src/main/java/com/rejourney/RejourneyModuleImpl.kt +38 -363
- package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +11 -113
- package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +1 -15
- package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +1 -61
- package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +3 -1
- package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +1 -22
- package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +3 -26
- package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +0 -2
- package/android/src/main/java/com/rejourney/network/UploadManager.kt +7 -93
- package/android/src/main/java/com/rejourney/network/UploadWorker.kt +5 -41
- package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +2 -58
- package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +4 -4
- package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +36 -7
- package/ios/Capture/RJViewHierarchyScanner.m +68 -51
- package/ios/Core/RJLifecycleManager.m +0 -14
- package/ios/Core/Rejourney.mm +24 -37
- package/ios/Network/RJDeviceAuthManager.m +0 -2
- package/ios/Network/RJUploadManager.h +8 -0
- package/ios/Network/RJUploadManager.m +45 -0
- package/ios/Privacy/RJPrivacyMask.m +5 -31
- package/ios/Rejourney.h +0 -14
- package/ios/Touch/RJTouchInterceptor.m +21 -15
- package/ios/Utils/RJEventBuffer.m +57 -69
- package/ios/Utils/RJWindowUtils.m +87 -86
- package/lib/commonjs/index.js +42 -30
- package/lib/commonjs/sdk/autoTracking.js +0 -3
- package/lib/commonjs/sdk/networkInterceptor.js +0 -11
- package/lib/commonjs/sdk/utils.js +73 -14
- package/lib/module/index.js +42 -30
- package/lib/module/sdk/autoTracking.js +0 -3
- package/lib/module/sdk/networkInterceptor.js +0 -11
- package/lib/module/sdk/utils.js +73 -14
- package/lib/typescript/sdk/utils.d.ts +31 -1
- package/package.json +16 -4
- package/src/index.ts +40 -19
- package/src/sdk/autoTracking.ts +0 -2
- package/src/sdk/constants.ts +13 -13
- package/src/sdk/networkInterceptor.ts +0 -9
- package/src/sdk/utils.ts +76 -14
|
@@ -59,7 +59,6 @@ interface VideoEncoderDelegate {
|
|
|
59
59
|
|
|
60
60
|
class VideoEncoder(private val segmentDir: File) {
|
|
61
61
|
|
|
62
|
-
// Configuration - matching iOS RJVideoEncoder defaults for quality and efficiency
|
|
63
62
|
/** Target video bitrate in bits per second. Default: 1.5 Mbps - matches iOS */
|
|
64
63
|
var targetBitrate: Int = 1_500_000
|
|
65
64
|
|
|
@@ -84,7 +83,6 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
84
83
|
/** Delegate for receiving segment completion notifications */
|
|
85
84
|
var delegate: VideoEncoderDelegate? = null
|
|
86
85
|
|
|
87
|
-
// State
|
|
88
86
|
private var encoder: MediaCodec? = null
|
|
89
87
|
private var muxer: MediaMuxer? = null
|
|
90
88
|
private var inputSurface: Surface? = null
|
|
@@ -101,37 +99,24 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
101
99
|
private val presentationTimesUs = ArrayDeque<Long>()
|
|
102
100
|
private var lastPresentationTimeUs = 0L
|
|
103
101
|
|
|
104
|
-
// CRITICAL FIX: Track original unscaled dimensions for segment rotation
|
|
105
|
-
// Without this, finishSegmentAndContinue() would pass already-scaled dimensions
|
|
106
|
-
// to startSegment(), causing double-scaling and degrading video quality
|
|
107
102
|
private var originalRequestedWidth = 0
|
|
108
103
|
private var originalRequestedHeight = 0
|
|
109
104
|
|
|
110
105
|
private var sessionId: String? = null
|
|
111
106
|
|
|
112
|
-
// Encoding thread
|
|
113
107
|
private var encodingThread: HandlerThread? = null
|
|
114
108
|
private var encodingHandler: Handler? = null
|
|
115
109
|
|
|
116
|
-
// Buffer info for draining encoder
|
|
117
110
|
private val bufferInfo = MediaCodec.BufferInfo()
|
|
118
111
|
|
|
119
|
-
// Lock for thread-safe encoder access
|
|
120
|
-
// Prevents concurrent calls to drainEncoder from appendFrame and finishSegment
|
|
121
112
|
private val encoderLock = ReentrantLock()
|
|
122
113
|
|
|
123
|
-
// isRecording: Allow frames once encoder and surface are ready
|
|
124
|
-
// NOTE: The muxer starts asynchronously when the first frame is encoded and
|
|
125
|
-
// drainEncoder receives INFO_OUTPUT_FORMAT_CHANGED. We cannot require muxerStarted
|
|
126
|
-
// here because that would create a chicken-and-egg problem where no frames can be
|
|
127
|
-
// appended to trigger the muxer to start.
|
|
128
114
|
val isRecording: Boolean
|
|
129
115
|
get() = encoder != null && inputSurface != null
|
|
130
116
|
|
|
131
117
|
val currentFrameCount: Int
|
|
132
118
|
get() = frameCount.get()
|
|
133
119
|
|
|
134
|
-
// Prewarm state
|
|
135
120
|
private var isPrewarmed = false
|
|
136
121
|
|
|
137
122
|
init {
|
|
@@ -147,7 +132,6 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
147
132
|
if (isPrewarmed) return
|
|
148
133
|
|
|
149
134
|
try {
|
|
150
|
-
// Pre-query available H.264 encoders to cache codec info
|
|
151
135
|
val codecInfo = findEncoder(MediaFormat.MIMETYPE_VIDEO_AVC)
|
|
152
136
|
if (codecInfo != null) {
|
|
153
137
|
Logger.debug("[VideoEncoder] Prewarm: found encoder ${codecInfo.name}")
|
|
@@ -178,22 +162,17 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
178
162
|
fun startSegment(width: Int, height: Int): Boolean {
|
|
179
163
|
Logger.debug("[VideoEncoder] startSegment: ${width}x${height}")
|
|
180
164
|
|
|
181
|
-
// If already recording, finish current segment first
|
|
182
165
|
if (isRecording) {
|
|
183
166
|
Logger.debug("[VideoEncoder] Already recording, finishing previous segment")
|
|
184
167
|
finishSegment()
|
|
185
168
|
}
|
|
186
169
|
|
|
187
|
-
// CRITICAL FIX: Store original unscaled dimensions BEFORE scaling
|
|
188
|
-
// Used by finishSegmentInternal() during segment rotation to prevent double-scaling
|
|
189
170
|
originalRequestedWidth = width
|
|
190
171
|
originalRequestedHeight = height
|
|
191
172
|
|
|
192
|
-
// Apply scale factor using native pixels (matching iOS)
|
|
193
173
|
var scaledWidth = (width * captureScale).toInt()
|
|
194
174
|
var scaledHeight = (height * captureScale).toInt()
|
|
195
175
|
|
|
196
|
-
// Apply max dimension cap (like iOS)
|
|
197
176
|
val maxDim = maxOf(scaledWidth, scaledHeight)
|
|
198
177
|
if (maxDim > maxDimension) {
|
|
199
178
|
val scale = maxDimension.toFloat() / maxDim.toFloat()
|
|
@@ -202,11 +181,9 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
202
181
|
Logger.debug("[VideoEncoder] Applied max dimension cap: ${scaledWidth}x${scaledHeight}")
|
|
203
182
|
}
|
|
204
183
|
|
|
205
|
-
// Ensure dimensions are even (required for H.264)
|
|
206
184
|
scaledWidth = (scaledWidth / 2) * 2
|
|
207
185
|
scaledHeight = (scaledHeight / 2) * 2
|
|
208
186
|
|
|
209
|
-
// Minimum size check
|
|
210
187
|
if (scaledWidth < 100 || scaledHeight < 100) {
|
|
211
188
|
Logger.warning("[VideoEncoder] Frame size too small, using minimum 100x100")
|
|
212
189
|
scaledWidth = 100
|
|
@@ -216,7 +193,6 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
216
193
|
currentFrameWidth = scaledWidth
|
|
217
194
|
currentFrameHeight = scaledHeight
|
|
218
195
|
|
|
219
|
-
// Reset counters
|
|
220
196
|
frameCount.set(0)
|
|
221
197
|
lastFrameTimestamp.set(0)
|
|
222
198
|
segmentFirstFrameTimestamp.set(0)
|
|
@@ -224,21 +200,17 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
224
200
|
lastPresentationTimeUs = 0L
|
|
225
201
|
segmentStartTime.set(System.currentTimeMillis())
|
|
226
202
|
|
|
227
|
-
// Generate unique filename
|
|
228
203
|
val sessionPrefix = sessionId ?: "unknown"
|
|
229
204
|
val filename = "seg_${sessionPrefix}_${segmentStartTime.get()}.mp4"
|
|
230
205
|
currentSegmentFile = File(segmentDir, filename)
|
|
231
206
|
|
|
232
|
-
// Delete existing file if any
|
|
233
207
|
currentSegmentFile?.delete()
|
|
234
208
|
|
|
235
209
|
Logger.debug("[VideoEncoder] Creating segment: $filename (${scaledWidth}x${scaledHeight})")
|
|
236
210
|
|
|
237
211
|
return try {
|
|
238
|
-
// Start encoding thread
|
|
239
212
|
startEncodingThread()
|
|
240
213
|
|
|
241
|
-
// Configure encoder
|
|
242
214
|
configureEncoder(scaledWidth, scaledHeight)
|
|
243
215
|
|
|
244
216
|
true
|
|
@@ -274,20 +246,17 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
274
246
|
}
|
|
275
247
|
|
|
276
248
|
try {
|
|
277
|
-
// Scale bitmap if needed
|
|
278
249
|
val scaledBitmap = if (bitmap.width != currentFrameWidth || bitmap.height != currentFrameHeight) {
|
|
279
250
|
Bitmap.createScaledBitmap(bitmap, currentFrameWidth, currentFrameHeight, true)
|
|
280
251
|
} else {
|
|
281
252
|
bitmap
|
|
282
253
|
}
|
|
283
254
|
|
|
284
|
-
// Track first frame timestamp for presentation time calculation
|
|
285
255
|
if (frameCount.get() == 0) {
|
|
286
256
|
segmentFirstFrameTimestamp.set(timestamp)
|
|
287
257
|
}
|
|
288
258
|
|
|
289
259
|
|
|
290
|
-
// Draw bitmap to encoder input surface
|
|
291
260
|
val canvas = surface.lockHardwareCanvas()
|
|
292
261
|
try {
|
|
293
262
|
canvas.drawBitmap(scaledBitmap, 0f, 0f, null)
|
|
@@ -295,12 +264,10 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
295
264
|
surface.unlockCanvasAndPost(canvas)
|
|
296
265
|
}
|
|
297
266
|
|
|
298
|
-
// Recycle scaled bitmap if we created a new one
|
|
299
267
|
if (scaledBitmap != bitmap) {
|
|
300
268
|
scaledBitmap.recycle()
|
|
301
269
|
}
|
|
302
270
|
|
|
303
|
-
// Drain encoder output (thread-safe)
|
|
304
271
|
encoderLock.withLock {
|
|
305
272
|
val presentationTimeUs = computePresentationTimeUs(timestamp)
|
|
306
273
|
presentationTimesUs.add(presentationTimeUs)
|
|
@@ -314,7 +281,6 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
314
281
|
Logger.debug("[VideoEncoder] Frame appended: ${frameCount.get()}/$framesPerSegment")
|
|
315
282
|
}
|
|
316
283
|
|
|
317
|
-
// Auto-rotate segment if we've reached the limit
|
|
318
284
|
if (frameCount.get() >= framesPerSegment) {
|
|
319
285
|
Logger.info("[VideoEncoder] Segment full (${frameCount.get()} frames), rotating")
|
|
320
286
|
finishSegmentAndContinue()
|
|
@@ -364,7 +330,6 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
364
330
|
val width = currentFrameWidth
|
|
365
331
|
val height = currentFrameHeight
|
|
366
332
|
|
|
367
|
-
// Skip if no frames were written
|
|
368
333
|
if (count == 0) {
|
|
369
334
|
Logger.debug("[VideoEncoder] No frames in segment, canceling")
|
|
370
335
|
cancelSegment()
|
|
@@ -372,25 +337,19 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
372
337
|
}
|
|
373
338
|
|
|
374
339
|
try {
|
|
375
|
-
// Thread-safe: Lock to prevent race with appendFrame's drainEncoder
|
|
376
340
|
encoderLock.withLock {
|
|
377
|
-
// Signal end of stream
|
|
378
341
|
enc.signalEndOfInputStream()
|
|
379
342
|
|
|
380
|
-
// Drain remaining output
|
|
381
343
|
drainEncoder(true)
|
|
382
344
|
}
|
|
383
345
|
|
|
384
|
-
// Stop and release muxer
|
|
385
346
|
if (muxerStarted.get()) {
|
|
386
347
|
muxer?.stop()
|
|
387
348
|
}
|
|
388
349
|
|
|
389
|
-
// Get file size for logging
|
|
390
350
|
val fileSize = segmentFile?.length() ?: 0
|
|
391
351
|
Logger.info("[VideoEncoder] Segment complete - $count frames, ${fileSize / 1024.0} KB, ${(endTime - startTime) / 1000.0}s")
|
|
392
352
|
|
|
393
|
-
// Notify delegate
|
|
394
353
|
segmentFile?.let {
|
|
395
354
|
delegate?.onSegmentFinished(it, startTime, endTime, count)
|
|
396
355
|
}
|
|
@@ -401,13 +360,8 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
401
360
|
} finally {
|
|
402
361
|
cleanup()
|
|
403
362
|
|
|
404
|
-
// Start new segment if requested
|
|
405
363
|
if (shouldContinue && sessionId != null) {
|
|
406
364
|
Logger.debug("[VideoEncoder] Starting new segment after rotation")
|
|
407
|
-
// CRITICAL FIX: Use original unscaled dimensions, NOT width/height
|
|
408
|
-
// which are the already-scaled currentFrameWidth/currentFrameHeight.
|
|
409
|
-
// Using scaled dimensions would cause double-scaling:
|
|
410
|
-
// 1080x2424 -> 356x800 (first segment) -> 142x320 (second segment)
|
|
411
365
|
startSegment(originalRequestedWidth, originalRequestedHeight)
|
|
412
366
|
}
|
|
413
367
|
}
|
|
@@ -482,12 +436,9 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
482
436
|
Logger.warning("[VideoEncoder] Emergency flush - attempting to save ${frameCount.get()} frames")
|
|
483
437
|
|
|
484
438
|
return try {
|
|
485
|
-
// Save segment metadata to disk for recovery
|
|
486
439
|
saveCrashSegmentMetadata()
|
|
487
440
|
|
|
488
|
-
// Thread-safe: Lock to prevent race with appendFrame
|
|
489
441
|
encoderLock.withLock {
|
|
490
|
-
// Try to finalize
|
|
491
442
|
encoder?.signalEndOfInputStream()
|
|
492
443
|
drainEncoder(true)
|
|
493
444
|
}
|
|
@@ -525,32 +476,27 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
525
476
|
}
|
|
526
477
|
|
|
527
478
|
private fun configureEncoder(width: Int, height: Int) {
|
|
528
|
-
// Find H.264 encoder
|
|
529
479
|
val codecInfo = findEncoder(MediaFormat.MIMETYPE_VIDEO_AVC)
|
|
530
480
|
?: throw IllegalStateException("No H.264 encoder available")
|
|
531
481
|
|
|
532
|
-
// Configure format - matching iOS RJVideoEncoder settings
|
|
533
482
|
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
|
|
534
483
|
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
|
|
535
484
|
setInteger(MediaFormat.KEY_BIT_RATE, targetBitrate)
|
|
536
485
|
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
|
|
537
|
-
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, keyframeInterval)
|
|
486
|
+
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, keyframeInterval)
|
|
538
487
|
|
|
539
|
-
// Baseline profile to reduce encoder CPU cost (matches iOS Baseline+CAVLC change)
|
|
540
488
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
|
|
541
489
|
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
|
|
542
490
|
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
|
|
543
491
|
}
|
|
544
492
|
}
|
|
545
493
|
|
|
546
|
-
// Create encoder
|
|
547
494
|
encoder = MediaCodec.createByCodecName(codecInfo.name).apply {
|
|
548
495
|
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
|
549
496
|
inputSurface = createInputSurface()
|
|
550
497
|
start()
|
|
551
498
|
}
|
|
552
499
|
|
|
553
|
-
// Create muxer
|
|
554
500
|
muxer = MediaMuxer(currentSegmentFile!!.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
|
555
501
|
|
|
556
502
|
Logger.debug("[VideoEncoder] Encoder configured: ${codecInfo.name} ${width}x${height} @ ${targetBitrate}bps")
|
|
@@ -589,7 +535,6 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
589
535
|
when {
|
|
590
536
|
outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
|
|
591
537
|
if (!endOfStream) break
|
|
592
|
-
// Continue draining if end of stream
|
|
593
538
|
}
|
|
594
539
|
|
|
595
540
|
outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
|
@@ -608,7 +553,6 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
608
553
|
?: throw RuntimeException("Encoder output buffer $outputBufferIndex was null")
|
|
609
554
|
|
|
610
555
|
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
|
|
611
|
-
// Codec config data - skip
|
|
612
556
|
bufferInfo.size = 0
|
|
613
557
|
}
|
|
614
558
|
|
|
@@ -662,7 +606,6 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
662
606
|
}
|
|
663
607
|
|
|
664
608
|
companion object {
|
|
665
|
-
// Class-level prewarm state (matching iOS +prewarmEncoderAsync)
|
|
666
609
|
@Volatile
|
|
667
610
|
private var staticPrewarmed = false
|
|
668
611
|
|
|
@@ -687,7 +630,6 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
687
630
|
try {
|
|
688
631
|
val startTime = System.nanoTime()
|
|
689
632
|
|
|
690
|
-
// Query available H.264 encoders to cache codec info in system
|
|
691
633
|
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
|
692
634
|
val codecInfos = codecList.codecInfos
|
|
693
635
|
|
|
@@ -698,7 +640,6 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
698
640
|
val types = info.supportedTypes
|
|
699
641
|
if (types.any { it.equals(MediaFormat.MIMETYPE_VIDEO_AVC, ignoreCase = true) }) {
|
|
700
642
|
encoderName = info.name
|
|
701
|
-
// Touch capabilities to trigger JIT/caching
|
|
702
643
|
val caps = info.getCapabilitiesForType(MediaFormat.MIMETYPE_VIDEO_AVC)
|
|
703
644
|
caps.videoCapabilities
|
|
704
645
|
caps.encoderCapabilities
|
|
@@ -731,7 +672,6 @@ class VideoEncoder(private val segmentDir: File) {
|
|
|
731
672
|
return try {
|
|
732
673
|
val json = metaFile.readText()
|
|
733
674
|
val result = mutableMapOf<String, Any>()
|
|
734
|
-
// Simple JSON parsing (avoiding JSONObject for minimal dependencies)
|
|
735
675
|
json.lines().forEach { line ->
|
|
736
676
|
val trimmed = line.trim().removeSuffix(",")
|
|
737
677
|
when {
|
|
@@ -47,24 +47,18 @@ class SessionLifecycleService : Service() {
|
|
|
47
47
|
companion object {
|
|
48
48
|
private const val TAG = "SessionLifecycleService"
|
|
49
49
|
|
|
50
|
-
// Static listener reference - set by RejourneyModuleImpl
|
|
51
50
|
@Volatile
|
|
52
51
|
var taskRemovedListener: TaskRemovedListener? = null
|
|
53
52
|
|
|
54
|
-
// Track if service is running
|
|
55
53
|
@Volatile
|
|
56
54
|
var isRunning = false
|
|
57
55
|
private set
|
|
58
56
|
|
|
59
|
-
|
|
60
|
-
// Samsung devices sometimes fire onTaskRemoved immediately on app launch
|
|
61
|
-
private const val MIN_TIME_BEFORE_TASK_REMOVED_MS = 2000L // 2 seconds
|
|
57
|
+
private const val MIN_TIME_BEFORE_TASK_REMOVED_MS = 2000L
|
|
62
58
|
}
|
|
63
59
|
|
|
64
60
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
65
61
|
|
|
66
|
-
// Track service start time to detect false positives (Samsung bug)
|
|
67
|
-
// Instance variable, not static, since each service instance has its own start time
|
|
68
62
|
private var serviceStartTime: Long = 0
|
|
69
63
|
|
|
70
64
|
override fun onCreate() {
|
|
@@ -78,8 +72,6 @@ class SessionLifecycleService : Service() {
|
|
|
78
72
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
79
73
|
serviceStartTime = SystemClock.elapsedRealtime()
|
|
80
74
|
Logger.debug("[$TAG] Service started (startId=$startId, OEM: ${OEMDetector.getOEM()})")
|
|
81
|
-
// Return START_STICKY to allow system to restart service if killed
|
|
82
|
-
// This helps ensure the service stays alive to catch termination events
|
|
83
75
|
return START_STICKY
|
|
84
76
|
}
|
|
85
77
|
|
|
@@ -106,9 +98,6 @@ class SessionLifecycleService : Service() {
|
|
|
106
98
|
|
|
107
99
|
Logger.debug("[$TAG] ⚠️ onTaskRemoved() called (OEM: $oem, timeSinceStart: ${timeSinceStart}ms)")
|
|
108
100
|
|
|
109
|
-
// SAMSUNG BUG FIX: Samsung devices sometimes fire onTaskRemoved() immediately
|
|
110
|
-
// after app launch (within 1-2 seconds). This is a known bug on Samsung devices.
|
|
111
|
-
// We filter out these false positives by checking the time since service start.
|
|
112
101
|
if (OEMDetector.isSamsung() && timeSinceStart < Companion.MIN_TIME_BEFORE_TASK_REMOVED_MS) {
|
|
113
102
|
Logger.warning("[$TAG] ⚠️ Ignoring onTaskRemoved() - likely Samsung false positive (fired ${timeSinceStart}ms after start, expected > ${MIN_TIME_BEFORE_TASK_REMOVED_MS}ms)")
|
|
114
103
|
Logger.warning("[$TAG] This is a known Samsung bug where onTaskRemoved fires on app launch")
|
|
@@ -116,20 +105,12 @@ class SessionLifecycleService : Service() {
|
|
|
116
105
|
return
|
|
117
106
|
}
|
|
118
107
|
|
|
119
|
-
// For other OEMs with aggressive task killing, log but still process
|
|
120
108
|
if (OEMDetector.hasAggressiveTaskKilling()) {
|
|
121
109
|
Logger.debug("[$TAG] OEM has aggressive task killing - onTaskRemoved may be unreliable")
|
|
122
110
|
}
|
|
123
111
|
|
|
124
112
|
Logger.debug("[$TAG] ✅ Valid onTaskRemoved() - app is being killed/swiped away")
|
|
125
113
|
|
|
126
|
-
// CRITICAL: Notify the listener IMMEDIATELY and SYNCHRONOUSLY
|
|
127
|
-
// The previous implementation used a coroutine with 100ms delay which caused
|
|
128
|
-
// the process to be killed before session end could complete.
|
|
129
|
-
//
|
|
130
|
-
// We now call the listener directly on the main thread. The listener
|
|
131
|
-
// (RejourneyModuleImpl) is responsible for doing synchronous work as fast
|
|
132
|
-
// as possible since the process may be killed immediately after this returns.
|
|
133
114
|
try {
|
|
134
115
|
Logger.debug("[$TAG] Calling listener synchronously...")
|
|
135
116
|
taskRemovedListener?.onTaskRemoved()
|
|
@@ -138,7 +119,6 @@ class SessionLifecycleService : Service() {
|
|
|
138
119
|
Logger.error("[$TAG] Error notifying task removed listener", e)
|
|
139
120
|
}
|
|
140
121
|
|
|
141
|
-
// Stop the service - no need for async since listener is already done
|
|
142
122
|
try {
|
|
143
123
|
stopSelf()
|
|
144
124
|
} catch (e: Exception) {
|
|
@@ -149,7 +129,6 @@ class SessionLifecycleService : Service() {
|
|
|
149
129
|
}
|
|
150
130
|
|
|
151
131
|
override fun onBind(intent: Intent?): IBinder? {
|
|
152
|
-
// This is a started service, not a bound service
|
|
153
132
|
return null
|
|
154
133
|
}
|
|
155
134
|
|
|
@@ -57,9 +57,8 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
57
57
|
private const val KEYSTORE_ALIAS = "com.rejourney.devicekey"
|
|
58
58
|
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
private const val
|
|
62
|
-
private const val AUTH_COOLDOWN_MAX_MS = 300000L // 5 minute max cooldown
|
|
60
|
+
private const val AUTH_COOLDOWN_BASE_MS = 5000L
|
|
61
|
+
private const val AUTH_COOLDOWN_MAX_MS = 300000L
|
|
63
62
|
private const val AUTH_MAX_CONSECUTIVE_FAILURES = 10
|
|
64
63
|
|
|
65
64
|
fun getInstance(context: Context): DeviceAuthManager {
|
|
@@ -69,10 +68,8 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
69
68
|
}
|
|
70
69
|
}
|
|
71
70
|
|
|
72
|
-
// Listener for authentication failures (set by RejourneyModuleImpl)
|
|
73
71
|
var authFailureListener: AuthFailureListener? = null
|
|
74
72
|
|
|
75
|
-
// Use shared client (SSL pinning removed)
|
|
76
73
|
private val client = HttpClientProvider.shared
|
|
77
74
|
|
|
78
75
|
private val prefs: SharedPreferences by lazy {
|
|
@@ -143,7 +140,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
143
140
|
.putString(KEY_SDK_VERSION, sdkVersion)
|
|
144
141
|
.apply()
|
|
145
142
|
|
|
146
|
-
// Check if already registered
|
|
147
143
|
val existingCredentialId = prefs.getString(KEY_CREDENTIAL_ID, null)
|
|
148
144
|
if (!existingCredentialId.isNullOrEmpty()) {
|
|
149
145
|
Logger.debug("Device already registered with credential: $existingCredentialId")
|
|
@@ -151,7 +147,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
151
147
|
return
|
|
152
148
|
}
|
|
153
149
|
|
|
154
|
-
// Generate or load ECDSA keypair
|
|
155
150
|
val publicKeyPEM = try {
|
|
156
151
|
getOrCreatePublicKeyPEM()
|
|
157
152
|
} catch (e: Exception) {
|
|
@@ -165,7 +160,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
165
160
|
return
|
|
166
161
|
}
|
|
167
162
|
|
|
168
|
-
// Register with backend
|
|
169
163
|
val requestBody = JSONObject().apply {
|
|
170
164
|
put("projectPublicKey", projectKey)
|
|
171
165
|
put("bundleId", bundleId)
|
|
@@ -241,7 +235,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
241
235
|
fun getUploadToken(
|
|
242
236
|
callback: (success: Boolean, token: String?, expiresIn: Int, error: String?) -> Unit
|
|
243
237
|
) {
|
|
244
|
-
// Check cached token
|
|
245
238
|
val cachedToken = prefs.getString(KEY_UPLOAD_TOKEN, null)
|
|
246
239
|
val tokenExpiry = prefs.getLong(KEY_TOKEN_EXPIRY, 0)
|
|
247
240
|
|
|
@@ -261,14 +254,12 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
261
254
|
|
|
262
255
|
val savedApiUrl = prefs.getString(KEY_API_URL, null)?.takeIf { it.startsWith("http") } ?: "https://api.rejourney.co"
|
|
263
256
|
|
|
264
|
-
// Step 1: Request challenge
|
|
265
257
|
requestChallenge(credentialId) { challengeSuccess, challenge, nonce, challengeError ->
|
|
266
258
|
if (!challengeSuccess || challenge == null || nonce == null) {
|
|
267
259
|
callback(false, null, 0, challengeError ?: "Failed to get challenge")
|
|
268
260
|
return@requestChallenge
|
|
269
261
|
}
|
|
270
262
|
|
|
271
|
-
// Step 2: Sign challenge
|
|
272
263
|
val signature = try {
|
|
273
264
|
signChallenge(challenge)
|
|
274
265
|
} catch (e: Exception) {
|
|
@@ -282,7 +273,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
282
273
|
return@requestChallenge
|
|
283
274
|
}
|
|
284
275
|
|
|
285
|
-
// Step 3: Start session with signed challenge
|
|
286
276
|
startSession(credentialId, challenge, nonce, signature, callback)
|
|
287
277
|
}
|
|
288
278
|
}
|
|
@@ -434,7 +424,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
434
424
|
suspend fun ensureValidToken(): Boolean {
|
|
435
425
|
Logger.debug("[DeviceAuthManager] ensureValidToken() called")
|
|
436
426
|
|
|
437
|
-
// Always refresh from SharedPreferences before network operations
|
|
438
427
|
loadStoredRegistrationInfo()
|
|
439
428
|
|
|
440
429
|
return kotlinx.coroutines.suspendCancellableCoroutine { continuation ->
|
|
@@ -478,7 +467,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
478
467
|
.remove(KEY_TOKEN_EXPIRY)
|
|
479
468
|
.apply()
|
|
480
469
|
|
|
481
|
-
// Also delete the private key from Keystore
|
|
482
470
|
try {
|
|
483
471
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
|
|
484
472
|
keyStore.load(null)
|
|
@@ -506,7 +494,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
506
494
|
}
|
|
507
495
|
}
|
|
508
496
|
|
|
509
|
-
// ==================== ECDSA Keypair Management ====================
|
|
510
497
|
|
|
511
498
|
/**
|
|
512
499
|
* Get or create ECDSA P-256 keypair and return public key in PEM format.
|
|
@@ -515,14 +502,12 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
515
502
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
|
|
516
503
|
keyStore.load(null)
|
|
517
504
|
|
|
518
|
-
// Try to load existing key
|
|
519
505
|
if (keyStore.containsAlias(KEYSTORE_ALIAS)) {
|
|
520
506
|
Logger.debug("Loaded existing ECDSA private key")
|
|
521
507
|
val certificate = keyStore.getCertificate(KEYSTORE_ALIAS)
|
|
522
508
|
return exportPublicKeyToPEM(certificate.publicKey)
|
|
523
509
|
}
|
|
524
510
|
|
|
525
|
-
// Generate new keypair
|
|
526
511
|
Logger.debug("Generating new ECDSA P-256 keypair")
|
|
527
512
|
|
|
528
513
|
val keyPairGenerator = KeyPairGenerator.getInstance(
|
|
@@ -536,7 +521,7 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
536
521
|
)
|
|
537
522
|
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) // P-256
|
|
538
523
|
.setDigests(KeyProperties.DIGEST_SHA256)
|
|
539
|
-
.setUserAuthenticationRequired(false)
|
|
524
|
+
.setUserAuthenticationRequired(false)
|
|
540
525
|
.build()
|
|
541
526
|
|
|
542
527
|
keyPairGenerator.initialize(parameterSpec)
|
|
@@ -551,7 +536,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
551
536
|
*/
|
|
552
537
|
private fun exportPublicKeyToPEM(publicKey: PublicKey): String? {
|
|
553
538
|
return try {
|
|
554
|
-
// Get the raw public key bytes (X.509 SubjectPublicKeyInfo format)
|
|
555
539
|
val encoded = publicKey.encoded
|
|
556
540
|
val base64 = Base64.encodeToString(encoded, Base64.NO_WRAP)
|
|
557
541
|
|
|
@@ -574,10 +558,8 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
574
558
|
val privateKey = keyStore.getKey(KEYSTORE_ALIAS, null) as? PrivateKey
|
|
575
559
|
?: throw Exception("Private key not found")
|
|
576
560
|
|
|
577
|
-
// Decode base64 challenge
|
|
578
561
|
val challengeBytes = Base64.decode(challenge, Base64.DEFAULT)
|
|
579
562
|
|
|
580
|
-
// Sign with ECDSA-SHA256
|
|
581
563
|
val signature = Signature.getInstance("SHA256withECDSA")
|
|
582
564
|
signature.initSign(privateKey)
|
|
583
565
|
signature.update(challengeBytes)
|
|
@@ -591,7 +573,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
591
573
|
}
|
|
592
574
|
}
|
|
593
575
|
|
|
594
|
-
// ==================== Backend Communication ====================
|
|
595
576
|
|
|
596
577
|
/**
|
|
597
578
|
* Request challenge from backend.
|
|
@@ -638,7 +619,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
638
619
|
|
|
639
620
|
when (errorCode) {
|
|
640
621
|
403 -> {
|
|
641
|
-
// Access forbidden - security error
|
|
642
622
|
Logger.error("SECURITY: Challenge request forbidden")
|
|
643
623
|
val errorMessage = parseErrorMessage(errorBody) ?: "Access forbidden"
|
|
644
624
|
clearCredentials()
|
|
@@ -646,7 +626,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
646
626
|
callback(false, null, null, errorMessage)
|
|
647
627
|
}
|
|
648
628
|
404 -> {
|
|
649
|
-
// Device credential rejected by backend (likely DB reset or invalid)
|
|
650
629
|
Logger.error("SECURITY: Device credential not found")
|
|
651
630
|
val errorMessage = parseErrorMessage(errorBody) ?: "Credential not found"
|
|
652
631
|
clearCredentials()
|
|
@@ -720,7 +699,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
720
699
|
|
|
721
700
|
when (errorCode) {
|
|
722
701
|
403 -> {
|
|
723
|
-
// Access forbidden - security error
|
|
724
702
|
Logger.error("SECURITY: Start-session forbidden")
|
|
725
703
|
val errorMessage = parseErrorMessage(errorBody) ?: "Access forbidden"
|
|
726
704
|
clearCredentials()
|
|
@@ -728,7 +706,6 @@ class DeviceAuthManager private constructor(private val context: Context) {
|
|
|
728
706
|
callback(false, null, 0, errorMessage)
|
|
729
707
|
}
|
|
730
708
|
404 -> {
|
|
731
|
-
// Credential or project not found
|
|
732
709
|
Logger.error("SECURITY: Start-session resource not found")
|
|
733
710
|
val errorMessage = parseErrorMessage(errorBody) ?: "Resource not found"
|
|
734
711
|
clearCredentials()
|
|
@@ -108,11 +108,9 @@ class NetworkMonitor private constructor(private val context: Context) {
|
|
|
108
108
|
connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
109
109
|
telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
|
|
110
110
|
|
|
111
|
-
// Check initial state
|
|
112
111
|
updateCurrentNetworkState()
|
|
113
112
|
listener?.onNetworkChanged(currentQuality)
|
|
114
113
|
|
|
115
|
-
// Register for updates
|
|
116
114
|
networkCallback = object : ConnectivityManager.NetworkCallback() {
|
|
117
115
|
override fun onAvailable(network: Network) {
|
|
118
116
|
updateCurrentNetworkState()
|