@rejourneyco/react-native 1.0.7

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.
Files changed (105) hide show
  1. package/README.md +29 -0
  2. package/android/build.gradle.kts +135 -0
  3. package/android/consumer-rules.pro +10 -0
  4. package/android/proguard-rules.pro +1 -0
  5. package/android/src/main/AndroidManifest.xml +15 -0
  6. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
  7. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
  8. package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
  9. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
  10. package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
  11. package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
  12. package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
  13. package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
  14. package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
  15. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
  16. package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
  17. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
  18. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
  19. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
  20. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
  21. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
  22. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
  23. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
  24. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
  25. package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
  26. package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
  27. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
  28. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  29. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
  30. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  31. package/ios/Engine/DeviceRegistrar.swift +288 -0
  32. package/ios/Engine/DiagnosticLog.swift +387 -0
  33. package/ios/Engine/RejourneyImpl.swift +719 -0
  34. package/ios/Recording/AnrSentinel.swift +142 -0
  35. package/ios/Recording/EventBuffer.swift +326 -0
  36. package/ios/Recording/InteractionRecorder.swift +428 -0
  37. package/ios/Recording/ReplayOrchestrator.swift +624 -0
  38. package/ios/Recording/SegmentDispatcher.swift +492 -0
  39. package/ios/Recording/StabilityMonitor.swift +223 -0
  40. package/ios/Recording/TelemetryPipeline.swift +547 -0
  41. package/ios/Recording/ViewHierarchyScanner.swift +156 -0
  42. package/ios/Recording/VisualCapture.swift +675 -0
  43. package/ios/Rejourney.h +38 -0
  44. package/ios/Rejourney.mm +375 -0
  45. package/ios/Utility/DataCompression.swift +55 -0
  46. package/ios/Utility/ImageBlur.swift +89 -0
  47. package/ios/Utility/RuntimeMethodSwap.swift +41 -0
  48. package/ios/Utility/ViewIdentifier.swift +37 -0
  49. package/lib/commonjs/NativeRejourney.js +40 -0
  50. package/lib/commonjs/components/Mask.js +88 -0
  51. package/lib/commonjs/index.js +1443 -0
  52. package/lib/commonjs/sdk/autoTracking.js +1087 -0
  53. package/lib/commonjs/sdk/constants.js +166 -0
  54. package/lib/commonjs/sdk/errorTracking.js +187 -0
  55. package/lib/commonjs/sdk/index.js +50 -0
  56. package/lib/commonjs/sdk/metricsTracking.js +205 -0
  57. package/lib/commonjs/sdk/navigation.js +128 -0
  58. package/lib/commonjs/sdk/networkInterceptor.js +375 -0
  59. package/lib/commonjs/sdk/utils.js +433 -0
  60. package/lib/commonjs/sdk/version.js +13 -0
  61. package/lib/commonjs/types/expo-router.d.js +2 -0
  62. package/lib/commonjs/types/index.js +2 -0
  63. package/lib/module/NativeRejourney.js +38 -0
  64. package/lib/module/components/Mask.js +83 -0
  65. package/lib/module/index.js +1341 -0
  66. package/lib/module/sdk/autoTracking.js +1059 -0
  67. package/lib/module/sdk/constants.js +154 -0
  68. package/lib/module/sdk/errorTracking.js +177 -0
  69. package/lib/module/sdk/index.js +26 -0
  70. package/lib/module/sdk/metricsTracking.js +187 -0
  71. package/lib/module/sdk/navigation.js +120 -0
  72. package/lib/module/sdk/networkInterceptor.js +364 -0
  73. package/lib/module/sdk/utils.js +412 -0
  74. package/lib/module/sdk/version.js +7 -0
  75. package/lib/module/types/expo-router.d.js +2 -0
  76. package/lib/module/types/index.js +2 -0
  77. package/lib/typescript/NativeRejourney.d.ts +160 -0
  78. package/lib/typescript/components/Mask.d.ts +54 -0
  79. package/lib/typescript/index.d.ts +117 -0
  80. package/lib/typescript/sdk/autoTracking.d.ts +226 -0
  81. package/lib/typescript/sdk/constants.d.ts +138 -0
  82. package/lib/typescript/sdk/errorTracking.d.ts +47 -0
  83. package/lib/typescript/sdk/index.d.ts +24 -0
  84. package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
  85. package/lib/typescript/sdk/navigation.d.ts +48 -0
  86. package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
  87. package/lib/typescript/sdk/utils.d.ts +193 -0
  88. package/lib/typescript/sdk/version.d.ts +6 -0
  89. package/lib/typescript/types/index.d.ts +618 -0
  90. package/package.json +122 -0
  91. package/rejourney.podspec +23 -0
  92. package/src/NativeRejourney.ts +185 -0
  93. package/src/components/Mask.tsx +93 -0
  94. package/src/index.ts +1555 -0
  95. package/src/sdk/autoTracking.ts +1245 -0
  96. package/src/sdk/constants.ts +155 -0
  97. package/src/sdk/errorTracking.ts +231 -0
  98. package/src/sdk/index.ts +25 -0
  99. package/src/sdk/metricsTracking.ts +227 -0
  100. package/src/sdk/navigation.ts +152 -0
  101. package/src/sdk/networkInterceptor.ts +423 -0
  102. package/src/sdk/utils.ts +442 -0
  103. package/src/sdk/version.ts +6 -0
  104. package/src/types/expo-router.d.ts +7 -0
  105. package/src/types/index.ts +709 -0
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Copyright 2026 Rejourney
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * Performance timing utility for profiling SDK operations.
19
+ * All logging is runtime-gated by debug mode via DiagnosticLog.
20
+ *
21
+ * ANDROID-SPECIFIC: Uses Android Debug APIs for memory profiling
22
+ * that have no iOS equivalent (iOS uses different memory APIs).
23
+ */
24
+ package com.rejourney.platform
25
+
26
+ import android.os.Debug
27
+ import android.os.Looper
28
+ import android.os.SystemClock
29
+ import com.rejourney.engine.DiagnosticLog
30
+ import java.io.File
31
+ import java.util.concurrent.locks.ReentrantLock
32
+ import kotlin.concurrent.withLock
33
+ import kotlin.math.max
34
+ import kotlin.math.min
35
+
36
+ enum class PerfMetric {
37
+ FRAME,
38
+ SCREENSHOT,
39
+ RENDER,
40
+ PRIVACY_MASK,
41
+ VIEW_SCAN,
42
+ VIEW_SERIALIZE,
43
+ ENCODE,
44
+ PIXEL_BUFFER,
45
+ DOWNSCALE,
46
+ BUFFER_ALLOC,
47
+ ENCODE_APPEND,
48
+ UPLOAD
49
+ }
50
+
51
+ data class PerfFrameContext(
52
+ val reason: String = "unknown",
53
+ val shouldRender: Boolean = false,
54
+ val totalViewsScanned: Int = 0,
55
+ val sensitiveRects: Int = 0,
56
+ val didBailOut: Boolean = false,
57
+ val bailOutReason: String = "none",
58
+ val hasBlockedSurface: Boolean = false,
59
+ val performanceLevel: String = "normal",
60
+ val isWarmup: Boolean = false
61
+ )
62
+
63
+ object PerfTiming {
64
+ private const val PERF_ENABLED = true
65
+ private const val DUMP_INTERVAL_MS = 5000.0
66
+ private const val MIN_SAMPLES = 5L
67
+ private const val SAMPLE_CAPACITY = 256
68
+
69
+ private val names = arrayOf(
70
+ "frame_total",
71
+ "screenshot_ui",
72
+ "render_draw",
73
+ "privacy_mask",
74
+ "view_scan",
75
+ "view_serialize",
76
+ "encode_h264",
77
+ "pixel_buffer",
78
+ "downscale",
79
+ "buffer_alloc",
80
+ "encode_append",
81
+ "upload_net"
82
+ )
83
+
84
+ private val outlierThresholdMs = doubleArrayOf(
85
+ 80.0, // frame_total
86
+ 60.0, // screenshot_ui
87
+ 50.0, // render_draw
88
+ 8.0, // privacy_mask
89
+ 12.0, // view_scan
90
+ 8.0, // view_serialize
91
+ 20.0, // encode_h264
92
+ 8.0, // pixel_buffer
93
+ 16.0, // downscale
94
+ 6.0, // buffer_alloc
95
+ 12.0, // encode_append
96
+ 120.0 // upload_net
97
+ )
98
+
99
+ private val totals = DoubleArray(PerfMetric.values().size)
100
+ private val maxes = DoubleArray(PerfMetric.values().size)
101
+ private val counts = LongArray(PerfMetric.values().size)
102
+ private val warmupTotals = DoubleArray(PerfMetric.values().size)
103
+ private val warmupMaxes = DoubleArray(PerfMetric.values().size)
104
+ private val warmupCounts = LongArray(PerfMetric.values().size)
105
+ private val peakMemMbByMetric = DoubleArray(PerfMetric.values().size)
106
+ private val samples = Array(PerfMetric.values().size) { DoubleArray(SAMPLE_CAPACITY) }
107
+ private var processPeakRssMb = 0.0
108
+ private var processPeakHeapMb = 0.0
109
+ private var processPeakPssMb = 0.0
110
+ private var processPeakNativeHeapMb = 0.0
111
+ private var lastDumpTimeNs = 0L
112
+
113
+ @Volatile
114
+ private var frameContext = PerfFrameContext()
115
+
116
+ private val lock = ReentrantLock()
117
+
118
+ fun isEnabled(): Boolean = PERF_ENABLED && DiagnosticLog.detailedOutput
119
+
120
+ fun now(): Long = SystemClock.elapsedRealtimeNanos()
121
+
122
+ fun setFrameContext(context: PerfFrameContext) {
123
+ frameContext = context
124
+ }
125
+
126
+ fun record(metric: PerfMetric, startNs: Long, endNs: Long) {
127
+ if (!isEnabled()) return
128
+
129
+ val durationMs = (endNs - startNs).toDouble() / 1_000_000.0
130
+ val isMain = Looper.getMainLooper().thread == Thread.currentThread()
131
+ val threadName = if (isMain) "MAIN" else "BG"
132
+ val name = names[metric.ordinal]
133
+ val memory = memorySnapshotMb()
134
+
135
+ if (isMain && durationMs > 4.0) {
136
+ DiagnosticLog.caution(
137
+ "[RJ-PERF] ⚠️ [$threadName]${if (frameContext.isWarmup) "[WARMUP]" else ""} $name: ${"%.2f".format(durationMs)}ms"
138
+ )
139
+ } else {
140
+ DiagnosticLog.trace(
141
+ "[RJ-PERF] [$threadName]${if (frameContext.isWarmup) "[WARMUP]" else ""} $name: ${"%.2f".format(durationMs)}ms"
142
+ )
143
+ }
144
+
145
+ lock.withLock {
146
+ val idx = metric.ordinal
147
+ val isWarmupSample = frameContext.isWarmup
148
+ if (isWarmupSample) {
149
+ val warmCount = warmupCounts[idx]
150
+ warmupCounts[idx] = warmCount + 1
151
+ warmupTotals[idx] += durationMs
152
+ warmupMaxes[idx] = max(warmupMaxes[idx], durationMs)
153
+ } else {
154
+ val count = counts[idx]
155
+ counts[idx] = count + 1
156
+ totals[idx] += durationMs
157
+ maxes[idx] = max(maxes[idx], durationMs)
158
+
159
+ val sampleSlot = (count % SAMPLE_CAPACITY).toInt()
160
+ samples[idx][sampleSlot] = durationMs
161
+ }
162
+
163
+ peakMemMbByMetric[idx] = max(peakMemMbByMetric[idx], memory.rssMb)
164
+ processPeakRssMb = max(processPeakRssMb, memory.rssMb)
165
+ processPeakHeapMb = max(processPeakHeapMb, memory.heapMb)
166
+ processPeakPssMb = max(processPeakPssMb, memory.pssMb)
167
+ processPeakNativeHeapMb = max(processPeakNativeHeapMb, memory.nativeHeapMb)
168
+ }
169
+
170
+ if (durationMs >= outlierThresholdMs[metric.ordinal]) {
171
+ val ctx = frameContext
172
+ DiagnosticLog.caution(
173
+ "[RJ-PERF-OUTLIER]${if (ctx.isWarmup) "[WARMUP]" else ""} " +
174
+ "$name=${"%.2f".format(durationMs)}ms " +
175
+ "(reason=${ctx.reason}, render=${ctx.shouldRender}, views=${ctx.totalViewsScanned}, " +
176
+ "sensitive=${ctx.sensitiveRects}, bailout=${ctx.didBailOut}/${ctx.bailOutReason}, blocked=${ctx.hasBlockedSurface}, " +
177
+ "perf=${ctx.performanceLevel}, rss=${"%.1f".format(memory.rssMb)}MB, " +
178
+ "heap=${"%.1f".format(memory.heapMb)}MB, pss=${"%.1f".format(memory.pssMb)}MB, " +
179
+ "native=${"%.1f".format(memory.nativeHeapMb)}MB)"
180
+ )
181
+ }
182
+ }
183
+
184
+ fun dumpIfNeeded() {
185
+ if (!isEnabled()) return
186
+
187
+ val now = now()
188
+ if (lastDumpTimeNs != 0L && msBetween(lastDumpTimeNs, now) < DUMP_INTERVAL_MS) {
189
+ return
190
+ }
191
+
192
+ lock.withLock {
193
+ if (lastDumpTimeNs != 0L && msBetween(lastDumpTimeNs, now) < DUMP_INTERVAL_MS) {
194
+ return
195
+ }
196
+
197
+ var totalSamples = 0L
198
+ var totalWarmupSamples = 0L
199
+ counts.forEach { totalSamples += it }
200
+ warmupCounts.forEach { totalWarmupSamples += it }
201
+ if (totalSamples < MIN_SAMPLES && totalWarmupSamples < MIN_SAMPLES) {
202
+ return
203
+ }
204
+
205
+ lastDumpTimeNs = now
206
+
207
+ val log = StringBuilder("[Rejourney PERF SUMMARY]")
208
+ for (i in counts.indices) {
209
+ if (counts[i] > 0) {
210
+ val avg = totals[i] / counts[i].toDouble()
211
+ val p95 = percentileForMetricLocked(i, 95.0)
212
+ log.append(" ${names[i]}=${counts[i]}/${"%.1f".format(avg)}/${"%.1f".format(p95)}/${"%.1f".format(maxes[i])}ms")
213
+ }
214
+ }
215
+ if (totalWarmupSamples > 0) {
216
+ log.append(" warmup(")
217
+ var hasWarmupMetric = false
218
+ for (i in warmupCounts.indices) {
219
+ if (warmupCounts[i] == 0L) continue
220
+ if (hasWarmupMetric) log.append(" ")
221
+ hasWarmupMetric = true
222
+ val avg = warmupTotals[i] / warmupCounts[i].toDouble()
223
+ log.append("${names[i]}=${warmupCounts[i]}/${"%.1f".format(avg)}/${"%.1f".format(warmupMaxes[i])}ms")
224
+ }
225
+ log.append(")")
226
+ }
227
+ log.append(" mem_peak(rss/heap/pss/native)=${"%.1f/%.1f/%.1f/%.1fMB".format(processPeakRssMb, processPeakHeapMb, processPeakPssMb, processPeakNativeHeapMb)}")
228
+
229
+ val currentMemory = memorySnapshotMb()
230
+ val sys = systemSnapshot()
231
+ log.append(" mem_now(rss/heap/pss/native)=${"%.1f/%.1f/%.1f/%.1fMB".format(currentMemory.rssMb, currentMemory.heapMb, currentMemory.pssMb, currentMemory.nativeHeapMb)}")
232
+ log.append(" sys(threads/fds)=${sys.threadCount}/${sys.openFdCount}")
233
+
234
+ DiagnosticLog.trace(log.toString())
235
+ }
236
+ }
237
+
238
+ fun reset() {
239
+ if (!PERF_ENABLED) return
240
+
241
+ lock.withLock {
242
+ for (i in counts.indices) {
243
+ totals[i] = 0.0
244
+ maxes[i] = 0.0
245
+ counts[i] = 0
246
+ warmupTotals[i] = 0.0
247
+ warmupMaxes[i] = 0.0
248
+ warmupCounts[i] = 0
249
+ peakMemMbByMetric[i] = 0.0
250
+ for (j in 0 until SAMPLE_CAPACITY) {
251
+ samples[i][j] = 0.0
252
+ }
253
+ }
254
+ processPeakRssMb = 0.0
255
+ processPeakHeapMb = 0.0
256
+ processPeakPssMb = 0.0
257
+ processPeakNativeHeapMb = 0.0
258
+ lastDumpTimeNs = 0L
259
+ }
260
+
261
+ if (DiagnosticLog.detailedOutput) {
262
+ DiagnosticLog.trace("[Rejourney PERF] Metrics reset")
263
+ }
264
+ }
265
+
266
+ fun snapshot(): Map<String, Map<String, Number>> {
267
+ if (!PERF_ENABLED) return emptyMap()
268
+
269
+ return lock.withLock {
270
+ val result = mutableMapOf<String, Map<String, Number>>()
271
+ for (i in counts.indices) {
272
+ if (counts[i] > 0) {
273
+ val avg = totals[i] / counts[i].toDouble()
274
+ result[names[i]] = mapOf(
275
+ "count" to counts[i],
276
+ "avg_ms" to avg,
277
+ "p95_ms" to percentileForMetricLocked(i, 95.0),
278
+ "p99_ms" to percentileForMetricLocked(i, 99.0),
279
+ "max_ms" to maxes[i],
280
+ "peak_rss_mb" to peakMemMbByMetric[i],
281
+ "peak_native_heap_mb" to processPeakNativeHeapMb
282
+ )
283
+ }
284
+ }
285
+ result
286
+ }
287
+ }
288
+
289
+ fun nameForMetric(metric: PerfMetric): String = names.getOrElse(metric.ordinal) { "unknown" }
290
+
291
+ fun <T> time(metric: PerfMetric, block: () -> T): T {
292
+ if (!isEnabled()) return block()
293
+ val start = now()
294
+ return try {
295
+ block()
296
+ } finally {
297
+ record(metric, start, now())
298
+ }
299
+ }
300
+
301
+ fun <T> measure(label: String, block: () -> T): T {
302
+ if (!isEnabled()) return block()
303
+ val start = now()
304
+ return try {
305
+ block()
306
+ } finally {
307
+ val end = now()
308
+ val durationUs = (end - start) / 1000
309
+ val durationMs = durationUs / 1000.0
310
+ DiagnosticLog.trace("[PerfTiming] $label: ${durationUs}us (${"%.3f".format(durationMs)}ms)")
311
+ }
312
+ }
313
+
314
+ private fun percentileForMetricLocked(metricIndex: Int, percentile: Double): Double {
315
+ val count = counts[metricIndex]
316
+ if (count <= 0) return 0.0
317
+
318
+ val sampleCount = min(count.toInt(), SAMPLE_CAPACITY)
319
+ val bucket = samples[metricIndex]
320
+ val values = DoubleArray(sampleCount)
321
+
322
+ val latestCount = count
323
+ if (latestCount <= SAMPLE_CAPACITY) {
324
+ for (i in 0 until sampleCount) {
325
+ values[i] = bucket[i]
326
+ }
327
+ } else {
328
+ val start = (latestCount % SAMPLE_CAPACITY).toInt()
329
+ for (i in 0 until sampleCount) {
330
+ values[i] = bucket[(start + i) % SAMPLE_CAPACITY]
331
+ }
332
+ }
333
+
334
+ values.sort()
335
+ val rank = ((percentile / 100.0) * (sampleCount - 1)).toInt().coerceIn(0, sampleCount - 1)
336
+ return values[rank]
337
+ }
338
+
339
+ private fun msBetween(startNs: Long, endNs: Long): Double = (endNs - startNs).toDouble() / 1_000_000.0
340
+
341
+ private data class MemorySnapshot(
342
+ val rssMb: Double,
343
+ val heapMb: Double,
344
+ val pssMb: Double,
345
+ val nativeHeapMb: Double
346
+ )
347
+
348
+ private data class SystemSnapshot(
349
+ val threadCount: Int,
350
+ val openFdCount: Int
351
+ )
352
+
353
+ private fun memorySnapshotMb(): MemorySnapshot {
354
+ val runtime = Runtime.getRuntime()
355
+ val usedHeapMb = (runtime.totalMemory() - runtime.freeMemory()).toDouble() / (1024.0 * 1024.0)
356
+ val nativeHeapMb = Debug.getNativeHeapAllocatedSize().toDouble() / (1024.0 * 1024.0)
357
+
358
+ return try {
359
+ val memoryInfo = Debug.MemoryInfo()
360
+ Debug.getMemoryInfo(memoryInfo)
361
+ val pssMb = memoryInfo.totalPss.toDouble() / 1024.0
362
+ val rssMb = memoryInfo.totalPrivateDirty.toDouble() / 1024.0
363
+ MemorySnapshot(rssMb = rssMb, heapMb = usedHeapMb, pssMb = pssMb, nativeHeapMb = nativeHeapMb)
364
+ } catch (_: Exception) {
365
+ MemorySnapshot(rssMb = usedHeapMb, heapMb = usedHeapMb, pssMb = usedHeapMb, nativeHeapMb = nativeHeapMb)
366
+ }
367
+ }
368
+
369
+ private fun systemSnapshot(): SystemSnapshot {
370
+ val threads = try {
371
+ Thread.getAllStackTraces().size
372
+ } catch (_: Exception) {
373
+ -1
374
+ }
375
+
376
+ val openFdCount = try {
377
+ File("/proc/self/fd").list()?.size ?: -1
378
+ } catch (_: Exception) {
379
+ -1
380
+ }
381
+
382
+ return SystemSnapshot(threadCount = threads, openFdCount = openFdCount)
383
+ }
384
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Copyright 2026 Rejourney
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * Service to detect when the app is swiped away from recent apps or killed.
19
+ *
20
+ * This service uses onTaskRemoved() callback which is called when the user
21
+ * swipes the app away from the recent apps screen. By setting stopWithTask="false"
22
+ * in the manifest, this callback will fire even when the app is killed.
23
+ *
24
+ * IMPORTANT: This is not 100% reliable across all Android versions and OEMs,
25
+ * but it's the best available mechanism for detecting app termination.
26
+ *
27
+ * ANDROID-SPECIFIC: iOS handles app termination through applicationWillTerminate
28
+ * and doesn't need this service-based approach.
29
+ */
30
+ package com.rejourney.platform
31
+
32
+ import android.app.Service
33
+ import android.content.Intent
34
+ import android.os.IBinder
35
+ import android.os.SystemClock
36
+ import com.rejourney.engine.DiagnosticLog
37
+ import kotlinx.coroutines.*
38
+
39
+ /**
40
+ * Callback interface for notifying when app is being killed.
41
+ * Defined outside the class for better accessibility.
42
+ *
43
+ * CRITICAL: This callback is invoked SYNCHRONOUSLY on the main thread.
44
+ * The implementation should complete session end as fast as possible
45
+ * since the process may be killed immediately after this returns.
46
+ */
47
+ interface TaskRemovedListener {
48
+ /**
49
+ * Called when the app is being killed/swiped away.
50
+ * Implementation should be as fast as possible and use runBlocking
51
+ * to ensure critical operations complete before process death.
52
+ */
53
+ fun onTaskRemoved()
54
+ }
55
+
56
+ /**
57
+ * Service that detects app termination via onTaskRemoved() callback.
58
+ *
59
+ * This service must be registered in AndroidManifest.xml with:
60
+ * - android:stopWithTask="false" (critical - allows onTaskRemoved to fire)
61
+ * - android:exported="false" (security - don't allow external apps to start it)
62
+ */
63
+ class SessionLifecycleService : Service() {
64
+
65
+ companion object {
66
+ private const val TAG = "SessionLifecycleService"
67
+
68
+ @Volatile
69
+ var taskRemovedListener: TaskRemovedListener? = null
70
+
71
+ @Volatile
72
+ var isRunning = false
73
+ private set
74
+
75
+ private const val MIN_TIME_BEFORE_TASK_REMOVED_MS = 2000L
76
+ }
77
+
78
+ private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
79
+
80
+ private var serviceStartTime: Long = 0
81
+
82
+ override fun onCreate() {
83
+ super.onCreate()
84
+ isRunning = true
85
+ serviceStartTime = SystemClock.elapsedRealtime()
86
+ DiagnosticLog.trace("[$TAG] Service created (OEM: ${OEMDetector.getOEM()})")
87
+ DiagnosticLog.trace("[$TAG] ${OEMDetector.getRecommendations()}")
88
+ }
89
+
90
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
91
+ serviceStartTime = SystemClock.elapsedRealtime()
92
+ DiagnosticLog.trace("[$TAG] Service started (startId=$startId, OEM: ${OEMDetector.getOEM()})")
93
+ return START_STICKY
94
+ }
95
+
96
+ /**
97
+ * Called when the user removes a task from the recent apps list.
98
+ *
99
+ * This is the key callback for detecting app swipe-away/kill events.
100
+ * It fires when:
101
+ * - User swipes the app away from recent apps
102
+ * - User taps "Clear all" in recent apps (on some devices)
103
+ *
104
+ * NOTE: This may NOT fire in all cases:
105
+ * - Some OEMs suppress this callback
106
+ * - "Clear all" on some devices may not trigger it
107
+ * - System-initiated kills (low memory) may not trigger it
108
+ * - Samsung devices have a bug where this fires on app launch (we filter this)
109
+ *
110
+ * That's why we also use ApplicationExitInfo (Android 11+) and
111
+ * persistent state checking on next app launch as fallbacks.
112
+ */
113
+ override fun onTaskRemoved(rootIntent: Intent?) {
114
+ val timeSinceStart = SystemClock.elapsedRealtime() - serviceStartTime
115
+ val oem = OEMDetector.getOEM()
116
+
117
+ DiagnosticLog.notice("[$TAG] ⚠️ onTaskRemoved() called (OEM: $oem, timeSinceStart: ${timeSinceStart}ms)")
118
+
119
+ // Samsung bug: onTaskRemoved fires incorrectly on app launch
120
+ if (OEMDetector.isSamsung() && timeSinceStart < MIN_TIME_BEFORE_TASK_REMOVED_MS) {
121
+ DiagnosticLog.caution("[$TAG] ⚠️ Ignoring onTaskRemoved() - likely Samsung false positive (fired ${timeSinceStart}ms after start, expected > ${MIN_TIME_BEFORE_TASK_REMOVED_MS}ms)")
122
+ DiagnosticLog.caution("[$TAG] This is a known Samsung bug where onTaskRemoved fires on app launch")
123
+ super.onTaskRemoved(rootIntent)
124
+ return
125
+ }
126
+
127
+ if (OEMDetector.hasAggressiveTaskKilling()) {
128
+ DiagnosticLog.trace("[$TAG] OEM has aggressive task killing - onTaskRemoved may be unreliable")
129
+ }
130
+
131
+ DiagnosticLog.notice("[$TAG] ✅ Valid onTaskRemoved() - app is being killed/swiped away")
132
+
133
+ try {
134
+ DiagnosticLog.trace("[$TAG] Calling listener synchronously...")
135
+ taskRemovedListener?.onTaskRemoved()
136
+ DiagnosticLog.trace("[$TAG] Task removed listener completed")
137
+ } catch (e: Exception) {
138
+ DiagnosticLog.fault("[$TAG] Error notifying task removed listener: ${e.message}")
139
+ }
140
+
141
+ try {
142
+ stopSelf()
143
+ } catch (e: Exception) {
144
+ DiagnosticLog.caution("[$TAG] Error stopping service: ${e.message}")
145
+ }
146
+
147
+ super.onTaskRemoved(rootIntent)
148
+ }
149
+
150
+ override fun onBind(intent: Intent?): IBinder? {
151
+ return null
152
+ }
153
+
154
+ override fun onDestroy() {
155
+ isRunning = false
156
+ serviceScope.cancel()
157
+ DiagnosticLog.trace("[$TAG] Service destroyed")
158
+ super.onDestroy()
159
+ }
160
+ }