@scalebun/react-native 1.0.3 → 1.1.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.
Files changed (244) hide show
  1. package/android/src/main/java/com/scalebun/core/crash/CrashStorage.kt +115 -1
  2. package/android/src/main/java/com/scalebun/core/crash/NativeCrashHandler.kt +14 -0
  3. package/android/src/main/java/com/scalebun/replaysdk/ReplaySdkModule.kt +25 -9
  4. package/android/src/main/java/com/scalebun/replaysdk/voice/VoiceTranscriber.kt +68 -2
  5. package/android/src/main/java/com/scalebun/rn/crash/ScaleBunCrashModule.kt +66 -2
  6. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +70 -4
  7. package/ios/Crash/ScaleBunCrashBridge.mm +6 -0
  8. package/ios/Crash/ScaleBunCrashHandler.swift +14 -1
  9. package/ios/Crash/ScaleBunCrashModule.swift +14 -0
  10. package/ios/Crash/ScaleBunCrashSignalBridge.h +32 -1
  11. package/ios/Crash/ScaleBunCrashSignalBridge.m +172 -7
  12. package/ios/Crash/ScaleBunCrashSignalStress.c +137 -0
  13. package/ios/Crash/ScaleBunCrashStorage.swift +75 -0
  14. package/lib/commonjs/analytics/EventTracker.js +30 -2
  15. package/lib/commonjs/analytics/EventTracker.js.map +1 -1
  16. package/lib/commonjs/bootstrap/SDKBootstrapper.js +31 -23
  17. package/lib/commonjs/bootstrap/SDKBootstrapper.js.map +1 -1
  18. package/lib/commonjs/compat/codepush.js +78 -15
  19. package/lib/commonjs/compat/codepush.js.map +1 -1
  20. package/lib/commonjs/core/config/schema.js +26 -5
  21. package/lib/commonjs/core/config/schema.js.map +1 -1
  22. package/lib/commonjs/core/constants/endpoints.js +9 -6
  23. package/lib/commonjs/core/constants/endpoints.js.map +1 -1
  24. package/lib/commonjs/core/context/device.js +130 -0
  25. package/lib/commonjs/core/context/device.js.map +1 -1
  26. package/lib/commonjs/debug/replayWiring.js +2 -1
  27. package/lib/commonjs/debug/replayWiring.js.map +1 -1
  28. package/lib/commonjs/features/bugreport/BugReportFeature.js +112 -0
  29. package/lib/commonjs/features/bugreport/BugReportFeature.js.map +1 -0
  30. package/lib/commonjs/features/bugreport/buildPayload.js +97 -0
  31. package/lib/commonjs/features/bugreport/buildPayload.js.map +1 -0
  32. package/lib/commonjs/features/crash/CrashReporter.js +25 -1
  33. package/lib/commonjs/features/crash/CrashReporter.js.map +1 -1
  34. package/lib/commonjs/features/crash/capture.js +27 -9
  35. package/lib/commonjs/features/crash/capture.js.map +1 -1
  36. package/lib/commonjs/features/crash/nativeCrashBridge.js +163 -28
  37. package/lib/commonjs/features/crash/nativeCrashBridge.js.map +1 -1
  38. package/lib/commonjs/features/engage/EngagePromptProvider.js +36 -2
  39. package/lib/commonjs/features/engage/EngagePromptProvider.js.map +1 -1
  40. package/lib/commonjs/features/engage/EngagePromptView.js +47 -14
  41. package/lib/commonjs/features/engage/EngagePromptView.js.map +1 -1
  42. package/lib/commonjs/features/engage/engageTriggerEngine.js +10 -0
  43. package/lib/commonjs/features/engage/engageTriggerEngine.js.map +1 -1
  44. package/lib/commonjs/features/engage/engageTypes.js.map +1 -1
  45. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +2 -1
  46. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js.map +1 -1
  47. package/lib/commonjs/features/ota/OtaOrchestrator.js +72 -5
  48. package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
  49. package/lib/commonjs/features/ota/deviceAttributes.js +48 -0
  50. package/lib/commonjs/features/ota/deviceAttributes.js.map +1 -0
  51. package/lib/commonjs/features/ota/geoCountry.js +93 -0
  52. package/lib/commonjs/features/ota/geoCountry.js.map +1 -0
  53. package/lib/commonjs/features/ota/useOtaUpdate.js +4 -1
  54. package/lib/commonjs/features/ota/useOtaUpdate.js.map +1 -1
  55. package/lib/commonjs/features/performance/PerformanceFeature.js +23 -9
  56. package/lib/commonjs/features/performance/PerformanceFeature.js.map +1 -1
  57. package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js +69 -9
  58. package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
  59. package/lib/commonjs/features/performance/transport/PerformanceTransport.js +25 -0
  60. package/lib/commonjs/features/performance/transport/PerformanceTransport.js.map +1 -1
  61. package/lib/commonjs/features/session/BackendSessionAdapter.js +128 -11
  62. package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
  63. package/lib/commonjs/features/session/JourneyEventPipeline.js +1 -0
  64. package/lib/commonjs/features/session/JourneyEventPipeline.js.map +1 -1
  65. package/lib/commonjs/features/session/ReplayCaptureManager.js +46 -0
  66. package/lib/commonjs/features/session/ReplayCaptureManager.js.map +1 -1
  67. package/lib/commonjs/features/session/SessionManager.js +42 -12
  68. package/lib/commonjs/features/session/SessionManager.js.map +1 -1
  69. package/lib/commonjs/features/session/nativeCapture.js +4 -4
  70. package/lib/commonjs/features/session/nativeCapture.js.map +1 -1
  71. package/lib/commonjs/features/session/outboxFrameCapture.js +3 -2
  72. package/lib/commonjs/features/session/outboxFrameCapture.js.map +1 -1
  73. package/lib/commonjs/features/session/sessionTypes.js.map +1 -1
  74. package/lib/commonjs/index.js +13 -0
  75. package/lib/commonjs/index.js.map +1 -1
  76. package/lib/commonjs/public/ScaleBunFacade.js +199 -2
  77. package/lib/commonjs/public/ScaleBunFacade.js.map +1 -1
  78. package/lib/commonjs/specs/NativeScaleBunCrash.js.map +1 -1
  79. package/lib/module/analytics/EventTracker.js +29 -1
  80. package/lib/module/analytics/EventTracker.js.map +1 -1
  81. package/lib/module/bootstrap/SDKBootstrapper.js +29 -21
  82. package/lib/module/bootstrap/SDKBootstrapper.js.map +1 -1
  83. package/lib/module/compat/codepush.js +77 -14
  84. package/lib/module/compat/codepush.js.map +1 -1
  85. package/lib/module/core/config/schema.js +26 -5
  86. package/lib/module/core/config/schema.js.map +1 -1
  87. package/lib/module/core/constants/endpoints.js +9 -6
  88. package/lib/module/core/constants/endpoints.js.map +1 -1
  89. package/lib/module/core/context/device.js +119 -0
  90. package/lib/module/core/context/device.js.map +1 -1
  91. package/lib/module/debug/replayWiring.js +2 -1
  92. package/lib/module/debug/replayWiring.js.map +1 -1
  93. package/lib/module/features/bugreport/BugReportFeature.js +105 -0
  94. package/lib/module/features/bugreport/BugReportFeature.js.map +1 -0
  95. package/lib/module/features/bugreport/buildPayload.js +91 -0
  96. package/lib/module/features/bugreport/buildPayload.js.map +1 -0
  97. package/lib/module/features/crash/CrashReporter.js +25 -1
  98. package/lib/module/features/crash/CrashReporter.js.map +1 -1
  99. package/lib/module/features/crash/capture.js +28 -9
  100. package/lib/module/features/crash/capture.js.map +1 -1
  101. package/lib/module/features/crash/nativeCrashBridge.js +161 -28
  102. package/lib/module/features/crash/nativeCrashBridge.js.map +1 -1
  103. package/lib/module/features/engage/EngagePromptProvider.js +36 -2
  104. package/lib/module/features/engage/EngagePromptProvider.js.map +1 -1
  105. package/lib/module/features/engage/EngagePromptView.js +47 -14
  106. package/lib/module/features/engage/EngagePromptView.js.map +1 -1
  107. package/lib/module/features/engage/engageTriggerEngine.js +10 -0
  108. package/lib/module/features/engage/engageTriggerEngine.js.map +1 -1
  109. package/lib/module/features/engage/engageTypes.js.map +1 -1
  110. package/lib/module/features/journey/ScaleBunDebugRoot.js +2 -1
  111. package/lib/module/features/journey/ScaleBunDebugRoot.js.map +1 -1
  112. package/lib/module/features/ota/OtaOrchestrator.js +72 -5
  113. package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
  114. package/lib/module/features/ota/deviceAttributes.js +43 -0
  115. package/lib/module/features/ota/deviceAttributes.js.map +1 -0
  116. package/lib/module/features/ota/geoCountry.js +86 -0
  117. package/lib/module/features/ota/geoCountry.js.map +1 -0
  118. package/lib/module/features/ota/useOtaUpdate.js +4 -1
  119. package/lib/module/features/ota/useOtaUpdate.js.map +1 -1
  120. package/lib/module/features/performance/PerformanceFeature.js +23 -9
  121. package/lib/module/features/performance/PerformanceFeature.js.map +1 -1
  122. package/lib/module/features/performance/collectors/FrameMetricsCollector.js +70 -10
  123. package/lib/module/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
  124. package/lib/module/features/performance/transport/PerformanceTransport.js +25 -0
  125. package/lib/module/features/performance/transport/PerformanceTransport.js.map +1 -1
  126. package/lib/module/features/session/BackendSessionAdapter.js +128 -11
  127. package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
  128. package/lib/module/features/session/JourneyEventPipeline.js +1 -0
  129. package/lib/module/features/session/JourneyEventPipeline.js.map +1 -1
  130. package/lib/module/features/session/ReplayCaptureManager.js +46 -0
  131. package/lib/module/features/session/ReplayCaptureManager.js.map +1 -1
  132. package/lib/module/features/session/SessionManager.js +42 -12
  133. package/lib/module/features/session/SessionManager.js.map +1 -1
  134. package/lib/module/features/session/nativeCapture.js +4 -5
  135. package/lib/module/features/session/nativeCapture.js.map +1 -1
  136. package/lib/module/features/session/outboxFrameCapture.js +3 -2
  137. package/lib/module/features/session/outboxFrameCapture.js.map +1 -1
  138. package/lib/module/features/session/sessionTypes.js.map +1 -1
  139. package/lib/module/index.js +4 -1
  140. package/lib/module/index.js.map +1 -1
  141. package/lib/module/public/ScaleBunFacade.js +199 -2
  142. package/lib/module/public/ScaleBunFacade.js.map +1 -1
  143. package/lib/module/specs/NativeScaleBunCrash.js.map +1 -1
  144. package/lib/typescript/analytics/EventTracker.d.ts +20 -0
  145. package/lib/typescript/analytics/EventTracker.d.ts.map +1 -1
  146. package/lib/typescript/bootstrap/SDKBootstrapper.d.ts.map +1 -1
  147. package/lib/typescript/compat/codepush.d.ts +27 -4
  148. package/lib/typescript/compat/codepush.d.ts.map +1 -1
  149. package/lib/typescript/core/config/schema.d.ts +4 -1
  150. package/lib/typescript/core/config/schema.d.ts.map +1 -1
  151. package/lib/typescript/core/constants/endpoints.d.ts +9 -6
  152. package/lib/typescript/core/constants/endpoints.d.ts.map +1 -1
  153. package/lib/typescript/core/context/device.d.ts +46 -0
  154. package/lib/typescript/core/context/device.d.ts.map +1 -1
  155. package/lib/typescript/debug/replayWiring.d.ts.map +1 -1
  156. package/lib/typescript/features/bugreport/BugReportFeature.d.ts +59 -0
  157. package/lib/typescript/features/bugreport/BugReportFeature.d.ts.map +1 -0
  158. package/lib/typescript/features/bugreport/buildPayload.d.ts +51 -0
  159. package/lib/typescript/features/bugreport/buildPayload.d.ts.map +1 -0
  160. package/lib/typescript/features/crash/CrashReporter.d.ts +20 -1
  161. package/lib/typescript/features/crash/CrashReporter.d.ts.map +1 -1
  162. package/lib/typescript/features/crash/capture.d.ts +21 -2
  163. package/lib/typescript/features/crash/capture.d.ts.map +1 -1
  164. package/lib/typescript/features/crash/nativeCrashBridge.d.ts +16 -3
  165. package/lib/typescript/features/crash/nativeCrashBridge.d.ts.map +1 -1
  166. package/lib/typescript/features/engage/EngagePromptProvider.d.ts.map +1 -1
  167. package/lib/typescript/features/engage/EngagePromptView.d.ts.map +1 -1
  168. package/lib/typescript/features/engage/EngageTransport.d.ts +1 -1
  169. package/lib/typescript/features/engage/EngageTransport.d.ts.map +1 -1
  170. package/lib/typescript/features/engage/engageTriggerEngine.d.ts.map +1 -1
  171. package/lib/typescript/features/engage/engageTypes.d.ts +15 -0
  172. package/lib/typescript/features/engage/engageTypes.d.ts.map +1 -1
  173. package/lib/typescript/features/journey/ScaleBunDebugRoot.d.ts.map +1 -1
  174. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +14 -2
  175. package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
  176. package/lib/typescript/features/ota/deviceAttributes.d.ts +18 -0
  177. package/lib/typescript/features/ota/deviceAttributes.d.ts.map +1 -0
  178. package/lib/typescript/features/ota/geoCountry.d.ts +14 -0
  179. package/lib/typescript/features/ota/geoCountry.d.ts.map +1 -0
  180. package/lib/typescript/features/ota/useOtaUpdate.d.ts +20 -2
  181. package/lib/typescript/features/ota/useOtaUpdate.d.ts.map +1 -1
  182. package/lib/typescript/features/performance/PerformanceFeature.d.ts +0 -1
  183. package/lib/typescript/features/performance/PerformanceFeature.d.ts.map +1 -1
  184. package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts +33 -0
  185. package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts.map +1 -1
  186. package/lib/typescript/features/performance/transport/PerformanceTransport.d.ts.map +1 -1
  187. package/lib/typescript/features/session/BackendSessionAdapter.d.ts +42 -2
  188. package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
  189. package/lib/typescript/features/session/JourneyEventPipeline.d.ts +2 -0
  190. package/lib/typescript/features/session/JourneyEventPipeline.d.ts.map +1 -1
  191. package/lib/typescript/features/session/ReplayCaptureManager.d.ts +38 -1
  192. package/lib/typescript/features/session/ReplayCaptureManager.d.ts.map +1 -1
  193. package/lib/typescript/features/session/SessionManager.d.ts.map +1 -1
  194. package/lib/typescript/features/session/nativeCapture.d.ts +2 -1
  195. package/lib/typescript/features/session/nativeCapture.d.ts.map +1 -1
  196. package/lib/typescript/features/session/outboxFrameCapture.d.ts.map +1 -1
  197. package/lib/typescript/features/session/sessionTypes.d.ts +20 -3
  198. package/lib/typescript/features/session/sessionTypes.d.ts.map +1 -1
  199. package/lib/typescript/index.d.ts +1 -0
  200. package/lib/typescript/index.d.ts.map +1 -1
  201. package/lib/typescript/public/ScaleBunFacade.d.ts +69 -0
  202. package/lib/typescript/public/ScaleBunFacade.d.ts.map +1 -1
  203. package/lib/typescript/public/types.d.ts +34 -8
  204. package/lib/typescript/public/types.d.ts.map +1 -1
  205. package/lib/typescript/specs/NativeScaleBunCrash.d.ts +14 -0
  206. package/lib/typescript/specs/NativeScaleBunCrash.d.ts.map +1 -1
  207. package/lib/typescript/vendor/protocol/index.d.ts +17 -17
  208. package/package.json +1 -1
  209. package/src/analytics/EventTracker.ts +28 -1
  210. package/src/bootstrap/SDKBootstrapper.ts +27 -23
  211. package/src/compat/codepush.ts +87 -16
  212. package/src/core/config/schema.ts +26 -5
  213. package/src/core/constants/endpoints.ts +9 -6
  214. package/src/core/context/device.ts +146 -0
  215. package/src/debug/replayWiring.ts +2 -1
  216. package/src/features/bugreport/BugReportFeature.ts +128 -0
  217. package/src/features/bugreport/buildPayload.ts +116 -0
  218. package/src/features/crash/CrashReporter.ts +40 -1
  219. package/src/features/crash/capture.ts +36 -9
  220. package/src/features/crash/nativeCrashBridge.ts +156 -22
  221. package/src/features/engage/EngagePromptProvider.tsx +48 -2
  222. package/src/features/engage/EngagePromptView.tsx +51 -15
  223. package/src/features/engage/EngageTransport.ts +1 -1
  224. package/src/features/engage/engageTriggerEngine.ts +15 -0
  225. package/src/features/engage/engageTypes.ts +15 -0
  226. package/src/features/journey/ScaleBunDebugRoot.tsx +2 -1
  227. package/src/features/ota/OtaOrchestrator.ts +84 -6
  228. package/src/features/ota/deviceAttributes.ts +46 -0
  229. package/src/features/ota/geoCountry.ts +85 -0
  230. package/src/features/ota/useOtaUpdate.ts +20 -2
  231. package/src/features/performance/PerformanceFeature.ts +22 -9
  232. package/src/features/performance/collectors/FrameMetricsCollector.ts +72 -10
  233. package/src/features/performance/transport/PerformanceTransport.ts +18 -0
  234. package/src/features/session/BackendSessionAdapter.ts +137 -10
  235. package/src/features/session/JourneyEventPipeline.ts +3 -0
  236. package/src/features/session/ReplayCaptureManager.ts +53 -1
  237. package/src/features/session/SessionManager.ts +46 -7
  238. package/src/features/session/nativeCapture.ts +3 -2
  239. package/src/features/session/outboxFrameCapture.ts +3 -2
  240. package/src/features/session/sessionTypes.ts +26 -3
  241. package/src/index.ts +4 -1
  242. package/src/public/ScaleBunFacade.ts +198 -2
  243. package/src/public/types.ts +35 -8
  244. package/src/specs/NativeScaleBunCrash.ts +16 -0
@@ -13,14 +13,128 @@ internal object CrashStorage {
13
13
 
14
14
  private const val FILE_NAME = "scalebun_native_crash.json"
15
15
 
16
+ /**
17
+ * The session that is live RIGHT NOW, written once per session and read by the crash handler.
18
+ *
19
+ * WHY A SEPARATE FILE, AND WHY WRITTEN AHEAD OF TIME.
20
+ *
21
+ * A crash record survives the process; the JS session id did not, because it only ever existed
22
+ * in JS memory. So on the next launch there was nothing to attribute the crash to except
23
+ * whichever session had just been created — the crash from session A landed on session B.
24
+ *
25
+ * The fix is not to hand the id to the crash handler (a fatal handler cannot call into JS, and
26
+ * must not allocate or block), but to make sure it is ALREADY ON DISK before any crash can
27
+ * happen. This file is that guarantee: written once when a session starts, read synchronously
28
+ * inside `uncaughtException`.
29
+ *
30
+ * Kept separate from the crash record so a stale id can never resurrect a cleared crash, and so
31
+ * clearing a drained crash does not disturb the live session's identity.
32
+ */
33
+ private const val SESSION_FILE_NAME = "scalebun_active_session.txt"
34
+
35
+ /**
36
+ * Record the session that is now live. Called once per session from JS, never per event.
37
+ *
38
+ * Synchronous by design: it must be durable before the next line of app code runs, because the
39
+ * next line of app code is exactly what might crash. One small write per session is affordable;
40
+ * the same write per event would not be.
41
+ */
42
+ fun setActiveSessionId(context: Context, sessionId: String?) {
43
+ try {
44
+ val file = File(context.filesDir, SESSION_FILE_NAME)
45
+ if (sessionId.isNullOrEmpty()) file.delete() else file.writeText(sessionId)
46
+ } catch (_: Exception) {
47
+ // A missing session id degrades attribution; it must never break the app.
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The live session id, or null.
53
+ *
54
+ * Called from the uncaught-exception handler, so it does exactly one thing: read a small file.
55
+ * No allocation beyond the string, no locks, no JS, no network.
56
+ */
57
+ fun activeSessionId(context: Context): String? {
58
+ return try {
59
+ val file = File(context.filesDir, SESSION_FILE_NAME)
60
+ if (!file.exists()) null else file.readText().trim().ifEmpty { null }
61
+ } catch (_: Exception) {
62
+ null
63
+ }
64
+ }
65
+
66
+ /**
67
+ * How many undelivered crashes we keep.
68
+ *
69
+ * One process produces one fatal crash, so this is not about a burst — it is about a device
70
+ * that crashes, restarts offline, and crashes again before the first crash could be uploaded.
71
+ * With a single fixed filename the second crash silently overwrote the first.
72
+ *
73
+ * Three is deliberately small: crashes are only pending while delivery is failing, and an
74
+ * unbounded spool on a device that crashes in a loop is its own bug. When full, the OLDEST is
75
+ * dropped — a fresh crash is better evidence than a stale one, and the alternative (dropping the
76
+ * new one) would hide an ongoing crash loop behind the first crash of the day.
77
+ */
78
+ private const val MAX_PENDING = 3
79
+ private const val PREFIX = "scalebun_native_crash"
80
+
81
+ /** Every pending crash file, oldest first. */
82
+ private fun pendingFiles(context: Context): List<File> =
83
+ (context.filesDir.listFiles { f -> f.name.startsWith(PREFIX) && f.name.endsWith(".json") }
84
+ ?: emptyArray())
85
+ .sortedBy { it.lastModified() }
86
+
87
+ /**
88
+ * Persist a crash. Each gets its own file and its own STABLE id.
89
+ *
90
+ * The id is generated HERE, once, when the crash is first written — never at upload time. It is
91
+ * what makes retry safe: the backend dedupes on it, so the same crash delivered any number of
92
+ * times produces one record. An id minted per attempt would defeat that completely.
93
+ */
16
94
  fun write(context: Context, crashData: JSONObject) {
17
95
  try {
18
- File(context.filesDir, FILE_NAME).writeText(crashData.toString())
96
+ val crashId = crashData.optString("crashId", "").ifEmpty {
97
+ // Time + identity hash: unique in practice, and derivable without allocation-heavy
98
+ // APIs so the same scheme can be mirrored on the signal path.
99
+ "crash-${System.currentTimeMillis()}-${android.os.Process.myPid()}"
100
+ }
101
+ crashData.put("crashId", crashId)
102
+
103
+ val existing = pendingFiles(context)
104
+ if (existing.size >= MAX_PENDING) {
105
+ existing.take(existing.size - MAX_PENDING + 1).forEach { runCatching { it.delete() } }
106
+ }
107
+ File(context.filesDir, "$PREFIX-$crashId.json").writeText(crashData.toString())
19
108
  } catch (_: Exception) {
20
109
  // Must not throw — called from uncaught exception handler
21
110
  }
22
111
  }
23
112
 
113
+ /**
114
+ * Every pending crash, WITHOUT deleting anything.
115
+ *
116
+ * Read used to clear as a side effect, so a failed upload lost the crash permanently: the only
117
+ * durable copy was destroyed before anyone knew the network had worked. Deletion is now a
118
+ * separate, explicit act performed after the backend accepts — see [clearCrash].
119
+ */
120
+ fun readAll(context: Context): List<JSONObject> =
121
+ pendingFiles(context).mapNotNull { f ->
122
+ runCatching { JSONObject(f.readText()) }.getOrNull()
123
+ }
124
+
125
+ /** Drop one delivered crash by its stable id. Safe to call for an id that is already gone. */
126
+ fun clearCrash(context: Context, crashId: String) {
127
+ try {
128
+ File(context.filesDir, "$PREFIX-$crashId.json").delete()
129
+ // Legacy single-file record predating per-crash ids.
130
+ if (crashId.isEmpty()) File(context.filesDir, FILE_NAME).delete()
131
+ } catch (_: Exception) {}
132
+ }
133
+
134
+ /**
135
+ * The LEGACY single-file record, kept only so a crash written by a previous SDK version is not
136
+ * stranded on disk after an app update. Read non-destructively like everything else now.
137
+ */
24
138
  fun read(context: Context): JSONObject? {
25
139
  return try {
26
140
  val file = File(context.filesDir, FILE_NAME)
@@ -49,6 +49,20 @@ class NativeCrashHandler private constructor(
49
49
  put("timestamp", System.currentTimeMillis().toDouble())
50
50
  put("platform", "android")
51
51
  if (causeInfo != null) put("cause", causeInfo)
52
+
53
+ /**
54
+ * The session this crash actually belongs to.
55
+ *
56
+ * Read from disk, where it was written when the session started — NOT asked for
57
+ * now. A fatal handler cannot call into JS, and the JS thread may already be gone.
58
+ * Without this the crash arrives on the next launch with no identity, and gets
59
+ * attributed to whichever session exists by then: the crash from session A lands
60
+ * on session B, marking a healthy session crashed and a crashed one clean.
61
+ *
62
+ * Omitted rather than written empty when unknown (a crash before any session
63
+ * started), so the reader can tell "no session" from "session ''".
64
+ */
65
+ CrashStorage.activeSessionId(context)?.let { put("sessionId", it) }
52
66
  }
53
67
 
54
68
  CrashStorage.write(context, data)
@@ -913,25 +913,41 @@ class ReplaySdkModule(reactContext: ReactApplicationContext) : ReplaySdkSpec(rea
913
913
  @ReactMethod
914
914
  override fun stopVoiceRecording(promise: Promise) {
915
915
  try {
916
- val transcript = voiceTranscriber?.stop()
916
+ // Order matters twice here:
917
+ // 1. The recorder stops FIRST — it owns the mic while running, and on
918
+ // most devices a concurrent capture silences the recognition engine
919
+ // (the transcript arrives empty). Releasing the mic before asking
920
+ // the engine to finalize gives its last buffer a chance to flush.
921
+ // 2. The transcript is read via the ASYNC stop: stopListening() →
922
+ // onResults is not synchronous, and the old sync read raced it —
923
+ // returning partials or nothing even when the engine had a result.
917
924
  val clip = voiceRecorder?.stop()
925
+ val transcriber = voiceTranscriber
918
926
  voiceRecorder = null
919
927
  voiceTranscriber = null
920
928
  if (clip == null) {
929
+ transcriber?.stop()
921
930
  promise.resolve(null)
922
931
  return
923
932
  }
924
933
  // Bytes are read into base64 by the recorder; drop the temp file now.
925
934
  java.io.File(clip.filePath).delete()
926
- val result = Arguments.createMap().apply {
927
- putString("base64", clip.base64)
928
- putString("mime", "audio/mp4")
929
- putInt("durationMs", clip.durationMs)
930
- putInt("byteSize", clip.byteSize)
931
- putString("transcript", transcript?.text ?: "")
932
- putBoolean("transcriptAvailable", transcript?.available ?: false)
935
+ val resolveWith = { transcript: com.scalebun.replaysdk.voice.VoiceTranscriber.TranscriptResult? ->
936
+ val result = Arguments.createMap().apply {
937
+ putString("base64", clip.base64)
938
+ putString("mime", "audio/mp4")
939
+ putInt("durationMs", clip.durationMs)
940
+ putInt("byteSize", clip.byteSize)
941
+ putString("transcript", transcript?.text ?: "")
942
+ putBoolean("transcriptAvailable", transcript?.available ?: false)
943
+ }
944
+ promise.resolve(result)
945
+ }
946
+ if (transcriber == null) {
947
+ resolveWith(null)
948
+ } else {
949
+ transcriber.stop { transcript -> resolveWith(transcript) }
933
950
  }
934
- promise.resolve(result)
935
951
  } catch (e: Exception) {
936
952
  voiceRecorder = null
937
953
  voiceTranscriber = null
@@ -31,6 +31,11 @@ class VoiceTranscriber(private val context: Context) {
31
31
  @Volatile private var transcript: String = ""
32
32
  @Volatile private var available: Boolean = false
33
33
 
34
+ companion object {
35
+ /** How long stop() waits for the engine's final onResults before giving up. */
36
+ private const val FINAL_RESULT_GRACE_MS = 700L
37
+ }
38
+
34
39
  /** Start a live on-device recognition session. Best-effort — never throws. */
35
40
  fun start() {
36
41
  main.post {
@@ -63,7 +68,13 @@ class VoiceTranscriber(private val context: Context) {
63
68
  }
64
69
  }
65
70
 
66
- /** Stop the session and return the accumulated transcript (may be empty). */
71
+ /**
72
+ * Stop the session and return the accumulated transcript (may be empty).
73
+ *
74
+ * Kept for binary compatibility; prefer [stop] with a callback — this sync
75
+ * form returns BEFORE the recognizer's final onResults can arrive, so it only
76
+ * ever sees partials.
77
+ */
67
78
  fun stop(): TranscriptResult {
68
79
  main.post {
69
80
  try {
@@ -76,12 +87,53 @@ class VoiceTranscriber(private val context: Context) {
76
87
  return TranscriptResult(transcript.trim(), available)
77
88
  }
78
89
 
90
+ /**
91
+ * Stop the session and deliver the transcript AFTER the engine has had a
92
+ * chance to emit its final result. stopListening() → onResults is
93
+ * asynchronous; the sync stop() raced it and returned partials-only (or
94
+ * nothing). We wait up to [FINAL_RESULT_GRACE_MS], completing early when
95
+ * onResults lands.
96
+ */
97
+ fun stop(onDone: (TranscriptResult) -> Unit) {
98
+ main.post {
99
+ val rec = recognizer
100
+ recognizer = null
101
+ if (rec == null || !available) {
102
+ onDone(TranscriptResult(transcript.trim(), available))
103
+ return@post
104
+ }
105
+ var completed = false
106
+ val finish = {
107
+ if (!completed) {
108
+ completed = true
109
+ try {
110
+ rec.destroy()
111
+ } catch (_: Exception) {
112
+ }
113
+ onDone(TranscriptResult(transcript.trim(), available))
114
+ }
115
+ }
116
+ pendingFinish = finish
117
+ try {
118
+ rec.stopListening()
119
+ } catch (_: Exception) {
120
+ finish()
121
+ return@post
122
+ }
123
+ main.postDelayed({ finish() }, FINAL_RESULT_GRACE_MS)
124
+ }
125
+ }
126
+
127
+ @Volatile private var pendingFinish: (() -> Unit)? = null
128
+
79
129
  private val listener = object : RecognitionListener {
80
130
  override fun onResults(results: Bundle?) {
81
131
  results
82
132
  ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
83
133
  ?.firstOrNull()
84
134
  ?.let { if (it.isNotBlank()) transcript = it }
135
+ // Final result is in — no reason to sit out the rest of the grace window.
136
+ main.post { pendingFinish?.invoke() }
85
137
  }
86
138
 
87
139
  override fun onPartialResults(partial: Bundle?) {
@@ -91,7 +143,21 @@ class VoiceTranscriber(private val context: Context) {
91
143
  ?.let { if (it.isNotBlank()) transcript = it }
92
144
  }
93
145
 
94
- override fun onError(error: Int) {}
146
+ override fun onError(error: Int) {
147
+ // The errors this swallowed are exactly the ones that explain an empty
148
+ // transcript in the field: ERROR_AUDIO(3)/ERROR_RECOGNIZER_BUSY(8) mean
149
+ // the mic was contended (a concurrent MediaRecorder usually wins and the
150
+ // engine hears silence); ERROR_NO_MATCH(7) means it heard but understood
151
+ // nothing. Keep them visible.
152
+ android.util.Log.i("ScaleBunVoice", "SpeechRecognizer error=$error (transcript so far: ${transcript.length} chars)")
153
+ if (error == SpeechRecognizer.ERROR_AUDIO ||
154
+ error == SpeechRecognizer.ERROR_RECOGNIZER_BUSY ||
155
+ error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS
156
+ ) {
157
+ available = false
158
+ }
159
+ main.post { pendingFinish?.invoke() }
160
+ }
95
161
  override fun onReadyForSpeech(params: Bundle?) {}
96
162
  override fun onBeginningOfSpeech() {}
97
163
  override fun onRmsChanged(rmsdB: Float) {}
@@ -48,16 +48,62 @@ class ScaleBunCrashModule(reactContext: ReactApplicationContext) :
48
48
  }
49
49
  }
50
50
 
51
+ /**
52
+ * Record which session is live, so a crash can name it.
53
+ *
54
+ * Called once when a session starts — never per event. The write is small and synchronous
55
+ * because it has to be durable before the next line of app code runs, and the next line of app
56
+ * code is exactly what might crash.
57
+ */
58
+ @ReactMethod
59
+ override fun setActiveSessionId(sessionId: String?, promise: Promise) {
60
+ try {
61
+ CrashStorage.setActiveSessionId(reactApplicationContext.applicationContext, sessionId)
62
+ promise.resolve(true)
63
+ } catch (e: Exception) {
64
+ // Attribution is best-effort; never fail the caller over it.
65
+ promise.resolve(false)
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Delete one crash AFTER the backend has accepted it.
71
+ *
72
+ * Separating deletion from the read is the whole of at-least-once: the durable copy outlives
73
+ * every failed attempt and disappears only once the crash is known to be recorded.
74
+ */
75
+ @ReactMethod
76
+ override fun clearPendingCrash(crashId: String?, promise: Promise) {
77
+ try {
78
+ CrashStorage.clearCrash(reactApplicationContext.applicationContext, crashId ?: "")
79
+ promise.resolve(true)
80
+ } catch (e: Exception) {
81
+ // Failing to delete is harmless — the crash is already recorded, and the backend dedupes
82
+ // on crashId, so a redelivery produces no second row.
83
+ promise.resolve(false)
84
+ }
85
+ }
86
+
51
87
  @ReactMethod
52
88
  override fun drainPendingCrash(promise: Promise) {
53
89
  try {
54
90
  val context = reactApplicationContext.applicationContext
55
- val crash = CrashStorage.read(context)
91
+ /**
92
+ * NON-DESTRUCTIVE.
93
+ *
94
+ * This used to clear the record the moment it was read, so a failed upload destroyed the
95
+ * only durable copy and the crash was gone forever — the app had proof of a crash,
96
+ * deleted it, and then failed to deliver it. Deletion is now `clearPendingCrash`, called
97
+ * by JS only after the backend has accepted.
98
+ *
99
+ * Oldest first: if several are pending, the earliest crash is the one that has been
100
+ * waiting longest and is most at risk of being aged out.
101
+ */
102
+ val crash = CrashStorage.readAll(context).firstOrNull() ?: CrashStorage.read(context)
56
103
  if (crash == null) {
57
104
  promise.resolve(null)
58
105
  return
59
106
  }
60
- CrashStorage.clear(context)
61
107
 
62
108
  val result = Arguments.createMap()
63
109
  result.putString("name", crash.optString("name", "crash.native"))
@@ -67,6 +113,24 @@ class ScaleBunCrashModule(reactContext: ReactApplicationContext) :
67
113
  result.putDouble("timestamp", crash.optDouble("timestamp"))
68
114
  result.putString("platform", "android")
69
115
 
116
+ /**
117
+ * The session the crash belonged to — the whole point of the fix, and this mapper
118
+ * ENUMERATES its fields, so a value not named here is silently dropped no matter how
119
+ * carefully it was written on the crash path.
120
+ *
121
+ * Absent on records written before this shipped, and on a crash that happened before
122
+ * any session started. Left unset in both cases rather than defaulted, so the JS side
123
+ * can tell "unknown" from a real id and fall back deliberately.
124
+ */
125
+ val sid = crash.optString("sessionId", "")
126
+ if (sid.isNotEmpty()) result.putString("sessionId", sid)
127
+
128
+ // The STABLE id. It is what the backend dedupes on, and what JS hands back to
129
+ // clearPendingCrash once delivery succeeds — so it has to survive this enumerating
130
+ // mapper as well.
131
+ val cid = crash.optString("crashId", "")
132
+ if (cid.isNotEmpty()) result.putString("crashId", cid)
133
+
70
134
  val stackJson = crash.optJSONArray("stackTrace")
71
135
  if (stackJson != null) {
72
136
  val frames = Arguments.createArray()
@@ -30,6 +30,15 @@ class SlotManager(private val filesDir: File) {
30
30
  private const val BUNDLE_FILENAME = "bundle.jsbundle"
31
31
  private const val META_FILENAME = "meta.json"
32
32
  private const val BOOT_MARKER_FILENAME = "boot_marker"
33
+
34
+ /**
35
+ * Launches a freshly-applied bundle gets to call markHealthy() before the
36
+ * guard treats it as crash-looping. 2 = one retry: a single unlucky boot
37
+ * (OOM kill, user swipe-away during startup) does not cost the update,
38
+ * but a bundle that genuinely cannot start is gone by the second launch.
39
+ */
40
+ private const val MAX_BOOT_ATTEMPTS = 2
41
+ private const val REVERT_RECORD_FILENAME = "last_revert.json"
33
42
  }
34
43
 
35
44
  private val otaRoot: File get() = File(filesDir, OTA_DIR)
@@ -37,6 +46,7 @@ class SlotManager(private val filesDir: File) {
37
46
  private val nextSlot: File get() = File(otaRoot, "next")
38
47
  private val previousSlot: File get() = File(otaRoot, "previous")
39
48
  private val bootMarker: File get() = File(otaRoot, BOOT_MARKER_FILENAME)
49
+ private val revertRecord: File get() = File(otaRoot, REVERT_RECORD_FILENAME)
40
50
 
41
51
  /**
42
52
  * Returns the absolute path to the active JS bundle, or null if no OTA
@@ -157,9 +167,26 @@ class SlotManager(private val filesDir: File) {
157
167
 
158
168
  /**
159
169
  * Revert to previous slot (boot-guard triggered or manual rollback).
170
+ *
171
+ * `reason` is persisted so JS can report WHY the device is no longer on the
172
+ * bundle it installed. Without it an auto-revert was invisible to the
173
+ * dashboard: `bootGuardReverted` was read by the orchestrator and written by
174
+ * nobody, so every crash-guard rollback recorded zero rollbacks.
160
175
  */
161
- fun revert(): Boolean {
176
+ @JvmOverloads
177
+ fun revert(reason: String? = null): Boolean {
162
178
  try {
179
+ if (reason != null) {
180
+ runCatching {
181
+ otaRoot.mkdirs()
182
+ revertRecord.writeText(
183
+ JSONObject()
184
+ .put("reason", reason)
185
+ .put("at", System.currentTimeMillis())
186
+ .toString(),
187
+ )
188
+ }
189
+ }
163
190
  if (!previousSlot.exists()) {
164
191
  Log.w(TAG, "No previous/ to revert to — clearing current/")
165
192
  currentSlot.deleteRecursively()
@@ -228,12 +255,39 @@ class SlotManager(private val filesDir: File) {
228
255
  "Boot guard cannot verify this bundle — marker or meta has no sha256. " +
229
256
  "Leaving it in place; restage with a current SDK to re-enable the check.",
230
257
  )
231
- } else if (currentId != markerId) {
258
+ return
259
+ }
260
+ if (currentId != markerId) {
232
261
  Log.w(TAG, "Boot marker sha256 mismatch — auto-reverting")
233
262
  revert()
263
+ return
234
264
  }
235
- // If they match: this is the first launch of a new bundle.
236
- // The marker stays until markHealthy() is called.
265
+
266
+ // ── Crash-loop revert ────────────────────────────────────────────
267
+ // Matching hashes mean the bundle is INTACT, which says nothing
268
+ // about whether it RUNS. A bundle that throws at JS init installs
269
+ // cleanly, matches every boot, and never reaches markHealthy() — so
270
+ // a hash-only guard let it crash-loop forever, bricking the app
271
+ // until reinstall. That is the exact failure an OTA boot guard
272
+ // exists to prevent.
273
+ //
274
+ // The marker is therefore also a boot-attempt counter: every launch
275
+ // that starts with the marker still present increments it. Reaching
276
+ // the limit means the bundle has been given MAX_BOOT_ATTEMPTS
277
+ // chances to call markHealthy() and never did → revert.
278
+ val attempts = markerJson.optInt("bootAttempts", 0) + 1
279
+ if (attempts >= MAX_BOOT_ATTEMPTS) {
280
+ Log.w(
281
+ TAG,
282
+ "Bundle failed to become healthy in $attempts launch(es) — auto-reverting",
283
+ )
284
+ revert(reason = "boot_crash_guard")
285
+ return
286
+ }
287
+ markerJson.put("bootAttempts", attempts)
288
+ bootMarker.writeText(markerJson.toString())
289
+ FileOutputStream(bootMarker, true).use { it.fd.sync() }
290
+ Log.i(TAG, "Boot guard: attempt $attempts/$MAX_BOOT_ATTEMPTS for this bundle")
237
291
  } catch (e: Exception) {
238
292
  Log.e(TAG, "checkBootGuard() failed — reverting to be safe", e)
239
293
  revert()
@@ -249,6 +303,18 @@ class SlotManager(private val filesDir: File) {
249
303
  state.put("next", readMeta(nextSlot))
250
304
  state.put("previous", readMeta(previousSlot))
251
305
  state.put("bootMarkerPresent", bootMarker.exists())
306
+ // Consume-on-read: the orchestrator turns this into an AUTO_ROLLBACK
307
+ // event exactly once, then the record is gone. Leaving it in place would
308
+ // re-report the same rollback on every subsequent launch.
309
+ if (revertRecord.exists()) {
310
+ runCatching {
311
+ val rec = JSONObject(revertRecord.readText())
312
+ state.put("bootGuardReverted", true)
313
+ state.put("bootGuardRevertReason", rec.optString("reason", "boot_crash_guard"))
314
+ state.put("bootGuardRevertedAt", rec.optLong("at", 0L))
315
+ }
316
+ revertRecord.delete()
317
+ }
252
318
  return state
253
319
  }
254
320
 
@@ -25,6 +25,12 @@ RCT_EXTERN_METHOD(drainPendingCrash:(RCTPromiseResolveBlock)resolve
25
25
  RCT_EXTERN_METHOD(crashNow:(RCTPromiseResolveBlock)resolve
26
26
  reject:(RCTPromiseRejectBlock)reject)
27
27
 
28
+ // Selector must match the Swift @objc(setActiveSessionId:resolve:reject:) exactly — a mismatch here
29
+ // fails at call time, not build time, and the symptom is a crash with no session rather than an error.
30
+ RCT_EXTERN_METHOD(setActiveSessionId:(NSString *)sessionId
31
+ resolve:(RCTPromiseResolveBlock)resolve
32
+ reject:(RCTPromiseRejectBlock)reject)
33
+
28
34
  @end
29
35
 
30
36
  #ifdef RCT_NEW_ARCH_ENABLED
@@ -39,6 +39,15 @@ import Foundation
39
39
  "timestamp": Date().timeIntervalSince1970 * 1000,
40
40
  "platform": "ios",
41
41
  ]
42
+ /**
43
+ * The session this crash belongs to, read from state written when the session began —
44
+ * never asked for now. The JS thread may already be gone, and a fatal handler must not
45
+ * call into it. Absent when no session had started yet, which the reader treats as
46
+ * unknown rather than guessing.
47
+ */
48
+ if let sid = ScaleBunCrashStorage.activeSessionId() {
49
+ data["sessionId"] = sid
50
+ }
42
51
  if let userInfo = exception.userInfo, !userInfo.isEmpty {
43
52
  // Flatten userInfo to string-keyed strings to stay JSON-safe
44
53
  var info: [String: String] = [:]
@@ -57,7 +66,11 @@ import Foundation
57
66
  // Signal handlers — installs C-level handlers via ScaleBunCrashSignalBridge.
58
67
  // Handlers write a minimal record (signal number + timestamp) using only
59
68
  // async-signal-safe syscalls, then restore the default handler and re-raise.
60
- ScaleBunCrashSignalBridgeInstall(ScaleBunCrashStorage.crashFilePathCString)
69
+ ScaleBunCrashSignalBridgeInstall(
70
+ ScaleBunCrashStorage.crashFilePathCString,
71
+ ScaleBunCrashStorage.sessionMarkerPathCString,
72
+ ScaleBunCrashStorage.sessionTmpPathCString
73
+ )
61
74
  }
62
75
 
63
76
  /** For testing only — resets the installed flag. */
@@ -50,6 +50,20 @@ class ScaleBunCrashModule: NSObject, RCTBridgeModule {
50
50
  }
51
51
  }
52
52
 
53
+ /**
54
+ * Record which session is live, so a crash can name it.
55
+ *
56
+ * Once per session, never per event. It must be durable before the next line of app code runs,
57
+ * because the next line of app code is what might crash.
58
+ */
59
+ @objc(setActiveSessionId:resolve:reject:)
60
+ func setActiveSessionId(_ sessionId: NSString?,
61
+ resolve: @escaping RCTPromiseResolveBlock,
62
+ reject: @escaping RCTPromiseRejectBlock) {
63
+ ScaleBunCrashStorage.setActiveSessionId(sessionId as String?)
64
+ resolve(true)
65
+ }
66
+
53
67
  @objc(drainPendingCrash:reject:)
54
68
  func drainPendingCrash(_ resolve: @escaping RCTPromiseResolveBlock,
55
69
  reject: @escaping RCTPromiseRejectBlock) {
@@ -15,4 +15,35 @@
15
15
  * @param crashFilePath Null-terminated C string. Must remain valid for the
16
16
  * lifetime of the process (use a static buffer).
17
17
  */
18
- void ScaleBunCrashSignalBridgeInstall(const char *crashFilePath);
18
+ /**
19
+ * @param crashFilePath Where the crash record is written.
20
+ * @param sessionMarkerPath Where the active session id is published (read by the handler).
21
+ * @param sessionTmpPath Staging path for the atomic rename. MUST be on the same filesystem as
22
+ * sessionMarkerPath — rename is only atomic within one filesystem, and a
23
+ * cross-device rename fails outright, which would leave the marker stale.
24
+ *
25
+ * All three must remain valid for the process lifetime (use static buffers).
26
+ */
27
+ void ScaleBunCrashSignalBridgeInstall(const char *crashFilePath,
28
+ const char *sessionMarkerPath,
29
+ const char *sessionTmpPath);
30
+
31
+ /**
32
+ * Publish the active session id so a signal crash can name the session it happened in.
33
+ *
34
+ * Call from NORMAL CODE only (once per session) — never from a signal handler. It writes a temp file
35
+ * and `rename(2)`s it over the marker; POSIX requires that rename be atomic, so a handler reading
36
+ * the marker sees either the previous complete id or the new complete id, never a mix.
37
+ *
38
+ * This replaced a shared static buffer published under a `volatile sig_atomic_t` length. That design
39
+ * could tear: overwriting an id IN PLACE with one of the SAME length left the length valid while the
40
+ * bytes were half-replaced, so the handler could read "sess-BBBAAAA…". Nothing about the atomicity
41
+ * of the length fixed that, because the length never changed.
42
+ *
43
+ * Pass NULL to clear.
44
+ *
45
+ * @param sessionId Null-terminated C string. Copied, so the caller's storage need not outlive the
46
+ * call. Characters outside [A-Za-z0-9_.:-] are dropped so the bytes are safe to
47
+ * emit inside a JSON string without escaping.
48
+ */
49
+ void ScaleBunCrashSignalBridgeSetSessionId(const char *sessionId);