@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
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Builds the body for POST /ingestion/sessions/:sessionId/bug-reports.
3
+ *
4
+ * Kept as a PURE function with no transport, no bridge and no clock of its own, because the wire
5
+ * shape is a cross-repo contract and the only way to keep it honest is to be able to assert it
6
+ * directly. Every bound below is copied from the server DTO
7
+ * (wzb-dashboard-backend/src/features/bug-reports/dto/submit-bug-report.dto.ts) rather than guessed:
8
+ *
9
+ * clientReportId? string idempotency key -> BugReport.clientReportId (@unique)
10
+ * title? string <= 300
11
+ * message string <= 20000 -> BugReport.description (REQUIRED)
12
+ * email? string <= 320
13
+ * url? string <= 2000
14
+ * console? unknown[] recent breadcrumbs
15
+ * screenshotKey? string <= 512, [A-Za-z0-9._/-] and no ".."
16
+ * timestamp? number 2000-01-01 .. 2100-01-01 in epoch MILLIS
17
+ * replaySeq? int 0 .. 2147483647
18
+ * replaySegmentId? string <= 256
19
+ *
20
+ * Truncating here rather than letting the server 400 is deliberate: a 20,001-character description
21
+ * is a real report from a real annoyed user, and losing it to a validation error helps nobody. The
22
+ * ceiling is theirs, so we respect it at the edge. Nothing is silently REWRITTEN — only cut, and
23
+ * only at bounds the server would otherwise reject outright.
24
+ */
25
+
26
+ /** Mirrors SubmitBugReportDto. Optional keys are omitted entirely rather than sent as undefined. */
27
+ export interface BugReportPayload {
28
+ clientReportId?: string;
29
+ title?: string;
30
+ message: string;
31
+ email?: string;
32
+ url?: string;
33
+ console?: unknown[];
34
+ screenshotKey?: string;
35
+ timestamp?: number;
36
+ replaySeq?: number;
37
+ replaySegmentId?: string;
38
+ }
39
+
40
+ export interface ReplayCoord {
41
+ seq?: number | null;
42
+ segmentId?: string | null;
43
+ }
44
+
45
+ const MAX_TITLE = 300;
46
+ const MAX_MESSAGE = 20_000;
47
+ const MAX_EMAIL = 320;
48
+ const MAX_SEGMENT_ID = 256;
49
+ /** The server rejects outside these; a drifted device clock must not cost us the whole report. */
50
+ const TS_MIN = 946684800000; // 2000-01-01
51
+ const TS_MAX = 4102444800000; // 2100-01-01
52
+ const MAX_BREADCRUMBS = 50;
53
+
54
+ function clip(v: string | undefined | null, max: number): string | undefined {
55
+ if (typeof v !== 'string') return undefined;
56
+ const t = v.trim();
57
+ return t.length === 0 ? undefined : t.slice(0, max);
58
+ }
59
+
60
+ export function buildBugReportPayload(
61
+ input: { message: string; title?: string; email?: string; clientReportId?: string },
62
+ breadcrumbs: readonly unknown[] = [],
63
+ ctx: { replayCoord?: ReplayCoord | null; timestamp?: number } = {},
64
+ ): BugReportPayload {
65
+ const message = (input.message ?? '').trim().slice(0, MAX_MESSAGE);
66
+
67
+ const payload: BugReportPayload = { message };
68
+
69
+ const title = clip(input.title, MAX_TITLE);
70
+ if (title) payload.title = title;
71
+
72
+ const email = clip(input.email, MAX_EMAIL);
73
+ if (email) payload.email = email;
74
+
75
+ const clientReportId = clip(input.clientReportId, 200);
76
+ if (clientReportId) payload.clientReportId = clientReportId;
77
+
78
+ if (breadcrumbs.length > 0) payload.console = breadcrumbs.slice(-MAX_BREADCRUMBS);
79
+
80
+ /**
81
+ * An out-of-range clock is DROPPED, not clamped.
82
+ *
83
+ * Clamping would invent a `reportedAt` the device never observed, and the server keeps that column
84
+ * separate from `createdAt` precisely so the two can be compared. A missing client time is honest;
85
+ * a fabricated one silently corrupts that comparison.
86
+ */
87
+ const ts = ctx.timestamp;
88
+ if (typeof ts === 'number' && Number.isFinite(ts) && ts >= TS_MIN && ts <= TS_MAX) {
89
+ payload.timestamp = ts;
90
+ }
91
+
92
+ /**
93
+ * The replay anchor is included ONLY when a frame actually existed at submit time.
94
+ *
95
+ * Defaulting `replaySeq` to 0 would point every anchor-less report at the start of the session,
96
+ * which reads in the dashboard exactly like a real anchor and is wrong. Replay may be disabled,
97
+ * paused, or yet to produce a frame; absent must stay absent.
98
+ */
99
+ const seq = ctx.replayCoord?.seq;
100
+ if (typeof seq === 'number' && Number.isInteger(seq) && seq >= 0 && seq <= 2147483647) {
101
+ payload.replaySeq = seq;
102
+ const segmentId = clip(ctx.replayCoord?.segmentId ?? undefined, MAX_SEGMENT_ID);
103
+ if (segmentId) payload.replaySegmentId = segmentId;
104
+ }
105
+
106
+ /**
107
+ * `url` is intentionally NEVER set on React Native.
108
+ *
109
+ * The field means a page URL. The nearest mobile concept is the current screen name, and putting
110
+ * a screen name in a column called `url` is exactly the field-misuse that makes a dashboard show
111
+ * one thing and mean another. Screen context already reaches the report through the session it is
112
+ * attached to. If mobile needs its own field, that is a schema change, not a reused column.
113
+ */
114
+
115
+ return payload;
116
+ }
@@ -18,7 +18,7 @@ import { noThrow } from '../../core/lifecycle/crashSafe';
18
18
  import { SessionManager } from '../session/SessionManager';
19
19
 
20
20
  /** Where the crash originated. */
21
- export type CrashSource = 'js' | 'handled' | 'native';
21
+ export type CrashSource = 'js' | 'handled' | 'native' | 'manual';
22
22
 
23
23
  /** Normalized, transport-agnostic crash report. */
24
24
  export interface CrashReport {
@@ -40,6 +40,25 @@ export interface CrashReport {
40
40
  timestamp?: string;
41
41
  /** Free-form extra context (thread name, signal, cause, ...). */
42
42
  metadata?: Record<string, unknown>;
43
+ /**
44
+ * The session this crash BELONGS to, when that is not the current one.
45
+ *
46
+ * Set only by a recovered native crash: it happened in a process that is already gone, so the
47
+ * session it belongs to is not the session running now. Without it the report is routed through
48
+ * whichever SessionManager exists at drain time and lands on the NEW session — marking a healthy
49
+ * session crashed and the crashed one clean.
50
+ *
51
+ * Absent means "the current session", which is correct for every live report.
52
+ */
53
+ sessionId?: string;
54
+ /**
55
+ * Stable id for a recovered native crash — the idempotency key.
56
+ *
57
+ * Generated ONCE when the crash was first written to disk, never per attempt, so any number of
58
+ * retries collapse to one backend record. An id minted at upload time would defeat dedupe
59
+ * entirely and turn every retry into a fresh duplicate crash.
60
+ */
61
+ crashId?: string;
43
62
  }
44
63
 
45
64
  /**
@@ -111,11 +130,31 @@ export class BackendErrorsSink implements CrashSink {
111
130
  deliver(report: CrashReport): void {
112
131
  const transport = SessionManager.getExistingInstance()?.getBackendTransport();
113
132
  if (!transport) return;
133
+ // D12: this used to forward four fields and drop the rest — so a native
134
+ // crash's signal/thread/exception-type were captured on device and
135
+ // discarded one call before the wire, every RN error row had a NULL
136
+ // platform (making Issue.primaryPlatform mean "web-only"), and fatality
137
+ // was indistinguishable from a handled error. The receiver has always
138
+ // accepted all of these.
114
139
  transport.queueError({
115
140
  type: report.type,
116
141
  message: report.message,
117
142
  stack: report.stack,
118
143
  screenName: report.screenName,
144
+ platform: report.platform,
145
+ isFatal: report.isFatal,
146
+ metadata: report.metadata,
147
+ /**
148
+ * Carried through so the transport can address the ORIGINAL session.
149
+ *
150
+ * Set only on a recovered native crash, whose session died with its process. Omitted
151
+ * for every live report, where the current session is the right answer — so this
152
+ * changes nothing about normal error delivery.
153
+ */
154
+ ...(report.sessionId ? { sessionId: report.sessionId } : {}),
155
+ // Becomes SessionError.clientId, which is @unique — that constraint is what turns a
156
+ // retried crash into one record instead of a duplicate.
157
+ ...(report.crashId ? { clientId: report.crashId } : {}),
119
158
  });
120
159
  // Best-effort immediate delivery so the crash surfaces quickly.
121
160
  transport.flushErrors().catch(() => {});
@@ -1,21 +1,48 @@
1
- import { createEnvelope } from '../../pipeline/envelope/envelope';
2
- import { memoryQueue } from '../../pipeline/queue/memoryQueue';
3
1
  import { noThrow } from '../../core/lifecycle/crashSafe';
2
+ import { getCrashReporter } from './CrashReporter';
4
3
 
5
- export function captureException(error: Error, extra?: any) {
4
+ /**
5
+ * Manual error/message capture — the `Sentry.captureException` equivalent.
6
+ *
7
+ * D3: these used to write envelopes into `memoryQueue`, a queue whose only
8
+ * drain (`Dispatcher.flush`) was registered in the DI container and never
9
+ * resolved by anything — every capture was accumulated and discarded. They
10
+ * were also never exported, so the two entry points were reachable by nobody
11
+ * and delivered to nowhere.
12
+ *
13
+ * Now they ride the SAME pipeline as automatic crash capture (CrashReporter →
14
+ * BackendErrorsSink → `/ingestion/sessions/:id/errors`) — the one path in this
15
+ * SDK that is proven to land rows.
16
+ */
17
+ export function captureException(
18
+ error: Error,
19
+ extra?: { screenName?: string; metadata?: Record<string, unknown> },
20
+ ): void {
6
21
  noThrow(() => {
7
- const envelope = createEnvelope('crash', {
22
+ getCrashReporter().report({
23
+ type: 'handled',
8
24
  message: error.message,
9
25
  stack: error.stack,
10
- ...extra,
26
+ isFatal: false,
27
+ source: 'manual',
28
+ screenName: extra?.screenName,
29
+ metadata: extra?.metadata,
11
30
  });
12
- memoryQueue.add(envelope);
13
31
  });
14
32
  }
15
33
 
16
- export function captureMessage(message: string) {
34
+ export function captureMessage(
35
+ message: string,
36
+ extra?: { screenName?: string; metadata?: Record<string, unknown> },
37
+ ): void {
17
38
  noThrow(() => {
18
- const envelope = createEnvelope('log', { message, level: 'info' });
19
- memoryQueue.add(envelope);
39
+ getCrashReporter().report({
40
+ type: 'handled',
41
+ message,
42
+ isFatal: false,
43
+ source: 'manual',
44
+ screenName: extra?.screenName,
45
+ metadata: extra?.metadata,
46
+ });
20
47
  });
21
48
  }
@@ -1,7 +1,5 @@
1
1
  import { NativeModules } from 'react-native';
2
2
  import { nativeModuleVia } from '../../core/architecture';
3
- import { createEnvelope } from '../../pipeline/envelope/envelope';
4
- import { memoryQueue } from '../../pipeline/queue/memoryQueue';
5
3
  import { logger } from '../../core/logger/internalLogger';
6
4
  import { getCrashReporter } from './CrashReporter';
7
5
 
@@ -45,6 +43,27 @@ function getCrashModule(): any {
45
43
  * NSSetUncaughtExceptionHandler + signal handlers on iOS).
46
44
  * Safe to call multiple times — native side is idempotent.
47
45
  */
46
+ /**
47
+ * Tell native which session is live, so a crash that happens next can name it.
48
+ *
49
+ * Called once per session — never per event. Native writes it to crash-safe storage immediately,
50
+ * because a fatal handler cannot call back into JS to ask, and by the time it runs the JS thread may
51
+ * already be gone.
52
+ *
53
+ * Fire-and-forget and no-throw: losing this degrades a recovered crash to unattributed, which is the
54
+ * old behaviour. It must never be able to fail a session start.
55
+ */
56
+ export async function setNativeActiveSession(sessionId: string): Promise<void> {
57
+ try {
58
+ const mod = getCrashModule();
59
+ if (!mod?.setActiveSessionId || !sessionId) return;
60
+ await mod.setActiveSessionId(sessionId);
61
+ logger.debug(`[NativeCrash] Active session → ${sessionId}`);
62
+ } catch {
63
+ // No-throw
64
+ }
65
+ }
66
+
48
67
  export async function installNativeCrashHandler(): Promise<void> {
49
68
  try {
50
69
  const mod = getCrashModule();
@@ -60,34 +79,68 @@ export async function installNativeCrashHandler(): Promise<void> {
60
79
  }
61
80
 
62
81
  /**
63
- * Drain a crash record left from the previous process run.
64
- * If found, enqueues a crash.native envelope and returns true.
65
- * Returns false when no pending crash is found.
82
+ * How many pending crashes one launch will deliver.
83
+ *
84
+ * The native spool holds three (a device that crashes, restarts offline, and crashes again), but
85
+ * `drainPendingCrash` hands back the OLDEST ONE ONLY. Draining once per launch therefore needed three
86
+ * launches to clear three crashes — and a fourth crash evicts the oldest, so on a device crashing in a
87
+ * loop the spool loses faster than a one-per-launch drain empties it.
88
+ *
89
+ * Bounded, not `while (true)`: this loop is driven by native state and stops the moment delivery
90
+ * fails, but the bound is what makes it impossible for a native bug — a clear that silently does
91
+ * nothing — to spin the drain forever at startup.
92
+ */
93
+ const MAX_DRAIN_PER_LAUNCH = 4;
94
+
95
+ /**
96
+ * Drain the crash records left from previous process runs.
97
+ * Returns true if at least one pending crash was found.
98
+ *
99
+ * Stops early the first time delivery fails: if the network is down for one crash it is down for the
100
+ * next, and every extra attempt is a request that cannot succeed made during app startup.
66
101
  */
67
102
  export async function drainNativeCrash(): Promise<boolean> {
103
+ let found = false;
104
+ const seen = new Set<string>();
105
+ for (let i = 0; i < MAX_DRAIN_PER_LAUNCH; i++) {
106
+ const outcome = await drainOne(seen);
107
+ if (outcome === 'none') break;
108
+ found = true;
109
+ // 'kept' means the backend did not take it. The rest of the spool is behind the same
110
+ // network, so stop rather than fail three more times.
111
+ if (outcome === 'kept') break;
112
+ }
113
+ return found;
114
+ }
115
+
116
+ /** One record: read → report → clear if the backend took it. */
117
+ async function drainOne(seen: Set<string>): Promise<'none' | 'cleared' | 'kept'> {
68
118
  try {
69
119
  const mod = getCrashModule();
70
- if (!mod?.drainPendingCrash) return false;
120
+ if (!mod?.drainPendingCrash) return 'none';
71
121
 
72
122
  const crash = await mod.drainPendingCrash();
73
- if (!crash) return false;
123
+ if (!crash) return 'none';
124
+
125
+ /*
126
+ * The same record twice means the previous clear did not take effect — a native failure we
127
+ * cannot fix from here. Reporting it again would deliver a duplicate on every iteration, so
128
+ * treat it as the end of the spool.
129
+ */
130
+ const seenKey = typeof crash.crashId === 'string' ? crash.crashId : '';
131
+ // `''` is a deliberate sentinel for the single legacy record, which has no id. Tracking it
132
+ // too means a legacy clear that silently fails stops the loop instead of redelivering the
133
+ // same crash on every one of the remaining iterations.
134
+ if (seen.has(seenKey)) return 'none';
135
+ seen.add(seenKey);
74
136
 
75
137
  const stackArr: string[] = Array.isArray(crash.stackTrace) ? crash.stackTrace : [];
76
138
 
77
139
  // 1) Desktop debug stream — keep the rich native-crash envelope.
78
- const envelope = createEnvelope('crash', {
79
- name: crash.name ?? 'crash.native',
80
- exceptionType: crash.exceptionType,
81
- message: crash.message,
82
- stackTrace: stackArr,
83
- threadName: crash.threadName,
84
- timestamp: crash.timestamp,
85
- platform: crash.platform,
86
- ...(crash.signal !== undefined ? { signal: crash.signal } : {}),
87
- ...(crash.cause !== undefined ? { cause: crash.cause } : {}),
88
- ...(crash.userInfo !== undefined ? { userInfo: crash.userInfo } : {}),
89
- });
90
- memoryQueue.add(envelope);
140
+ // D2: a rich envelope used to be written here into memoryQueue — a queue
141
+ // whose drain was never wired, so the native crash's forensic detail was
142
+ // captured and then discarded. The flattened report below now carries the
143
+ // same fields in `metadata` (D12), through the pipeline that actually lands.
91
144
 
92
145
  // 2) SaaS backend errors endpoint (dashboard Crashes view) — route the
93
146
  // same normalized report through the central reporter. This is what
@@ -99,6 +152,26 @@ export async function drainNativeCrash(): Promise<boolean> {
99
152
  message: crash.message ?? crash.exceptionType ?? 'Native crash',
100
153
  stack: stackArr.length ? stackArr.join('\n') : undefined,
101
154
  platform: crash.platform,
155
+ /**
156
+ * THE ORIGINAL SESSION — the entire point of the recovery path.
157
+ *
158
+ * This crash happened in a process that no longer exists, so it does NOT belong to the
159
+ * session running now. Passing it explicitly is what stops the reporter routing it
160
+ * through the current SessionManager and attributing it to the session created after
161
+ * the restart.
162
+ *
163
+ * Undefined on records written before this shipped, and on a crash that beat the first
164
+ * session. The sink then keeps the legacy behaviour rather than guessing an owner.
165
+ */
166
+ ...(crash.sessionId ? { sessionId: String(crash.sessionId) } : {}),
167
+ /**
168
+ * The stable id, carried through so it becomes the backend's dedupe key.
169
+ *
170
+ * Written once when the crash was first persisted — never regenerated here. If this were
171
+ * minted per attempt, every retry would arrive as a brand-new crash and at-least-once
172
+ * delivery would turn into duplicate-every-time.
173
+ */
174
+ ...(crash.crashId ? { crashId: String(crash.crashId) } : {}),
102
175
  metadata: {
103
176
  exceptionType: crash.exceptionType,
104
177
  threadName: crash.threadName,
@@ -106,8 +179,69 @@ export async function drainNativeCrash(): Promise<boolean> {
106
179
  },
107
180
  });
108
181
 
109
- logger.info('[NativeCrash] Previous crash drained → desktop + backend');
110
- return true;
182
+ /**
183
+ * DELETE ONLY AFTER THE BACKEND HAS IT.
184
+ *
185
+ * The read above is non-destructive, so the crash is still on disk right now. It is removed
186
+ * only once delivery has actually succeeded — previously the record was destroyed at read
187
+ * time, so a failed upload lost the crash permanently: the app had proof of a crash, deleted
188
+ * it, and then failed to send it.
189
+ *
190
+ * On failure we simply do NOT clear. The crash stays pending and is retried on the next
191
+ * launch, which is what makes this at-least-once. Duplicate delivery is safe because the
192
+ * crash carries a stable `crashId` that the backend dedupes on — a retry produces no second
193
+ * record.
194
+ *
195
+ * A record with no `crashId` is legacy: it predates stable ids, so it cannot be safely
196
+ * retried (a redelivery WOULD duplicate) and is cleared immediately, preserving exactly the
197
+ * old at-most-once behaviour for old data rather than silently changing it.
198
+ */
199
+ const crashId = seenKey;
200
+ if (!crashId) {
201
+ await mod.clearPendingCrash?.('');
202
+ logger.info('[NativeCrash] Legacy crash (no id) drained and cleared');
203
+ return 'cleared';
204
+ }
205
+
206
+ const delivered = await confirmDelivery();
207
+ if (delivered) {
208
+ await mod.clearPendingCrash?.(crashId);
209
+ logger.info(`[NativeCrash] Crash ${crashId} delivered → cleared`);
210
+ return 'cleared';
211
+ }
212
+ logger.info(`[NativeCrash] Crash ${crashId} not yet delivered → kept for retry`);
213
+ return 'kept';
214
+ } catch {
215
+ // Unknown state — stop the loop rather than risk spinning on a failure we cannot diagnose.
216
+ return 'none';
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Has the crash actually reached the backend?
222
+ *
223
+ * The reporter's sinks are fire-and-forget by design (a crash report must never block or throw), so
224
+ * "delivered" is asked of the transport rather than inferred from `report()` returning. If the
225
+ * transport is not up yet — the drain can win the race against SessionManager — the answer is NO and
226
+ * the crash stays on disk for the next launch. That is strictly better than the old behaviour, where
227
+ * exactly that race deleted the record and dropped the crash silently.
228
+ */
229
+ async function confirmDelivery(): Promise<boolean> {
230
+ try {
231
+ const { SessionManager } = await import('../session/SessionManager');
232
+ const transport = SessionManager.getExistingInstance()?.getBackendTransport();
233
+ // No transport yet — the drain can win the startup race. That is a delivery FAILURE, not a
234
+ // reason to give up: the record stays on disk and the next launch retries it. This exact
235
+ // path used to delete the crash and send nothing.
236
+ if (!transport) return false;
237
+ /**
238
+ * The BOOLEAN, not merely "it resolved".
239
+ *
240
+ * flushErrors used to return void and swallow its own errors, so awaiting it proved only
241
+ * that the call finished. Deleting a crash on that basis would have thrown away crashes
242
+ * that never reached the backend — the failure this whole change exists to prevent.
243
+ */
244
+ return (await transport.flushErrors()) === true;
111
245
  } catch {
112
246
  return false;
113
247
  }
@@ -609,7 +609,47 @@ export function EngagePromptProvider({
609
609
  };
610
610
 
611
611
  const drafts = result.attachments ?? [];
612
- if (engage.submitResponseConfirmed) {
612
+
613
+ /**
614
+ * Bug-report lane — opt in per campaign via `fileAsBugReport`.
615
+ *
616
+ * A FEEDBACK campaign fired by `shake_device` is almost always "something is
617
+ * broken", not "how do you feel", and those are different products: a bug report
618
+ * gets a status, a priority and an assignee on /diagnose/bug-reports, while a survey
619
+ * response is counted in aggregate and cannot be triaged.
620
+ *
621
+ * Routed INSTEAD of submitResponse rather than in addition — a dual write would
622
+ * create two rows for one submission and double-count the campaign on the Engage
623
+ * side.
624
+ *
625
+ * `responseId` doubles as the idempotency key so a retried submit cannot file the
626
+ * same report twice (BugReport.clientReportId is @unique).
627
+ *
628
+ * Falls through to the normal lane when the host facade predates `reportBugDetailed`
629
+ * — an older app bundling a newer config degrades to existing behaviour instead of
630
+ * dropping the user's report on the floor.
631
+ */
632
+ const bugLane =
633
+ campaign.fileAsBugReport && typeof (engage as any).reportBugDetailed === 'function'
634
+ ? ((engage as any).reportBugDetailed as (i: {
635
+ message: string;
636
+ title?: string;
637
+ clientReportId?: string;
638
+ }) => Promise<unknown>)
639
+ : null;
640
+
641
+ if (bugLane) {
642
+ const outcome = await bugLane({
643
+ message: result.reason ?? '',
644
+ title: campaign.name,
645
+ clientReportId: responseId,
646
+ });
647
+ // Surface a refusal as a throw so the existing retry/error UI handles it; a
648
+ // silent success here would tell the user their report was filed when it wasn't.
649
+ if (outcome && typeof outcome === 'object' && 'e' in (outcome as object)) {
650
+ throw new Error(`bug report failed: ${(outcome as { e: string }).e}`);
651
+ }
652
+ } else if (engage.submitResponseConfirmed) {
613
653
  // Confirmed lane: awaits real persistence (presign+PUT bytes → response
614
654
  // POST), reports stages, idempotent on responseId, throws on failure.
615
655
  await engage.submitResponseConfirmed(input, drafts, {
@@ -676,7 +716,13 @@ export function EngagePromptProvider({
676
716
  action: options?.action,
677
717
  });
678
718
  if (!campaign) {
679
- logger.debug('[Engage] no eligible campaign to render');
719
+ // Name the inputs so a silent mismatch (wrong trigger name, throttle
720
+ // suppression, type filter) is diagnosable from device logs alone.
721
+ logger.debug(
722
+ `[Engage] no eligible campaign to render (trigger=${options?.trigger ?? '—'} ` +
723
+ `candidates=${config.campaigns.length} ` +
724
+ `[${config.campaigns.map((c) => `${c.type}:${c.trigger ?? '—'}`).join(', ')}])`,
725
+ );
680
726
  return false;
681
727
  }
682
728
 
@@ -241,7 +241,21 @@ function RatingPrompt({ campaign, onSubmit, onDismiss, onStart, rating }: Prompt
241
241
 
242
242
  // ─── FEEDBACK render branch (free text + optional category/tags) ─────────────
243
243
 
244
- function FeedbackPrompt({ campaign, onSubmit, onDismiss, onStart }: PromptBranchProps): ReactElement {
244
+ function FeedbackPrompt(props: PromptBranchProps): ReactElement {
245
+ // A FEEDBACK campaign authored with structured questions (voice, scale, emoji,
246
+ // screenshot, multiple prompts) IS a survey — this branch only knows how to draw
247
+ // one text box plus category chips, and used to silently render a voice question
248
+ // as text (found live: "it was supposed to be a voice input but showed text").
249
+ // Anything beyond chips delegates to the full survey renderer, which owns the
250
+ // record → transcribe → approve voice flow.
251
+ const structured = (props.campaign.questions ?? []).some(
252
+ (q) => q.kind !== 'choice' && q.kind !== 'single' && q.kind !== 'multi',
253
+ );
254
+ if (structured) return <SurveyPrompt {...props} />;
255
+ return <FeedbackTextPrompt {...props} />;
256
+ }
257
+
258
+ function FeedbackTextPrompt({ campaign, onSubmit, onDismiss, onStart }: PromptBranchProps): ReactElement {
245
259
  const [text, setText] = useState('');
246
260
  const [selectedTags, setSelectedTags] = useState<string[]>([]);
247
261
 
@@ -500,7 +514,14 @@ function SurveyPrompt({ campaign, onSubmit, onDismiss, onStart }: PromptBranchPr
500
514
  return result;
501
515
  }, []);
502
516
 
517
+ // A survey where every question is optional could be submitted with nothing
518
+ // answered and nothing attached — an empty response row polluting analytics
519
+ // (found live: voice question, Submit tapped without ever recording). Content
520
+ // is what gates the final Submit; "Not now" remains the way out.
521
+ const hasAnyContent = (): boolean => questions.some((q) => isAnswered(q));
522
+
503
523
  const submit = (): void => {
524
+ if (!hasAnyContent()) return;
504
525
  const drafts = Object.values(attachments);
505
526
  onSubmit({
506
527
  answers: assemble(),
@@ -739,17 +760,25 @@ function SurveyPrompt({ campaign, onSubmit, onDismiss, onStart }: PromptBranchPr
739
760
  <Pressable accessibilityRole="button" accessibilityLabel="Start survey" onPress={() => { onStart?.(); setStep(0); }} style={styles.submitBtn}>
740
761
  <Text style={styles.submitText}>Start</Text>
741
762
  </Pressable>
742
- ) : current ? (
743
- <Pressable
744
- accessibilityRole="button"
745
- accessibilityLabel={step === questions.length - 1 || current.skipTo === 'end' ? 'Submit survey' : 'Continue survey'}
746
- disabled={current.required === true && !isAnswered(current)}
747
- onPress={advance}
748
- style={[styles.submitBtn, current.required === true && !isAnswered(current) ? styles.submitBtnDisabled : null]}
749
- >
750
- <Text style={styles.submitText}>{step === questions.length - 1 || current.skipTo === 'end' ? 'Submit' : 'Continue'}</Text>
751
- </Pressable>
752
- ) : null}
763
+ ) : current ? (() => {
764
+ const isSubmitStep = step === questions.length - 1 || current.skipTo === 'end';
765
+ // A required question blocks its own step; the submit step
766
+ // additionally requires the survey to carry ANY content at all.
767
+ const blocked =
768
+ (current.required === true && !isAnswered(current)) ||
769
+ (isSubmitStep && !hasAnyContent());
770
+ return (
771
+ <Pressable
772
+ accessibilityRole="button"
773
+ accessibilityLabel={isSubmitStep ? 'Submit survey' : 'Continue survey'}
774
+ disabled={blocked}
775
+ onPress={advance}
776
+ style={[styles.submitBtn, blocked ? styles.submitBtnDisabled : null]}
777
+ >
778
+ <Text style={styles.submitText}>{isSubmitStep ? 'Submit' : 'Continue'}</Text>
779
+ </Pressable>
780
+ );
781
+ })() : null}
753
782
  </View>
754
783
  </View>
755
784
  );
@@ -845,12 +874,19 @@ function VoiceQuestion({
845
874
  🎙 Voice note recorded · {formatElapsed(draft.durationMs ?? 0)}
846
875
  </Text>
847
876
  <Text style={styles.voiceHint}>
848
- Review the transcript below — edit it, then approve to send. Nothing is
849
- sent until you approve.
877
+ {(draft.transcript ?? '').trim().length > 0
878
+ ? 'Review the transcript below — edit it, then approve to send. Nothing is sent until you approve.'
879
+ : // An empty prefill is a device limitation (on many phones the recorder
880
+ // holds the microphone, so the on-device engine hears silence) — the
881
+ // recording itself was captured fine. Say so, or the blank field reads
882
+ // as a bug and the typed-transcript path goes undiscovered.
883
+ 'Your recording was captured, but automatic transcription wasn’t available on this device. Type what you said (optional), then approve to send.'}
850
884
  </Text>
851
885
  <TextInput
852
886
  style={styles.reasonInput}
853
- placeholder="Transcript (edit before approving)"
887
+ placeholder={(draft.transcript ?? '').trim().length > 0
888
+ ? 'Transcript (edit before approving)'
889
+ : 'Type what you said (optional)'}
854
890
  placeholderTextColor="#9097A3"
855
891
  value={draft.transcript ?? ''}
856
892
  onChangeText={onEditTranscript}
@@ -56,7 +56,7 @@ function uuidV4(): string {
56
56
  }
57
57
 
58
58
  export interface EngageTransportConfig {
59
- /** Backend API base URL (e.g. https://api.scalebun.com). */
59
+ /** Backend API base URL (e.g. https://api.scalebun.com/api/v1). */
60
60
  endpoint: string;
61
61
  /** SaaS multi-tenant client key — gates upload lanes + x-scalebun-client-key header. */
62
62
  clientKey?: string;
@@ -106,6 +106,21 @@ export function installEngageTriggerEngine(deps: EngageTriggerEngineDeps): () =>
106
106
 
107
107
  const fire = (options: ShowEngagePromptOptions): void => {
108
108
  try {
109
+ // A fire while the app is not foregrounded renders behind the lock
110
+ // screen (or into a backgrounded activity), records the impression,
111
+ // and permanently consumes a showOnce campaign that no human saw.
112
+ // Found live: a cold start with the screen off burned both a survey
113
+ // and an in-app message before the phone was ever unlocked.
114
+ if (appLifecycle.getCurrentState() !== 'active') {
115
+ logger.debug(
116
+ `[Engage] trigger suppressed (app ${appLifecycle.getCurrentState()}): ${options.trigger ?? 'manual'}`,
117
+ );
118
+ return;
119
+ }
120
+ logger.debug(
121
+ `[Engage] trigger fire: ${options.trigger ?? 'manual'}` +
122
+ (options.screen ? ` screen=${options.screen}` : ''),
123
+ );
109
124
  deps.fire(options);
110
125
  } catch (err) {
111
126
  logger.debug('[Engage] trigger fire failed:', (err as Error)?.message);