@scalebun/react-native 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/scalebun/core/crash/CrashStorage.kt +115 -1
- package/android/src/main/java/com/scalebun/core/crash/NativeCrashHandler.kt +14 -0
- package/android/src/main/java/com/scalebun/replaysdk/ReplaySdkModule.kt +25 -9
- package/android/src/main/java/com/scalebun/replaysdk/voice/VoiceTranscriber.kt +68 -2
- package/android/src/main/java/com/scalebun/rn/crash/ScaleBunCrashModule.kt +66 -2
- package/ios/Crash/ScaleBunCrashBridge.mm +6 -0
- package/ios/Crash/ScaleBunCrashHandler.swift +14 -1
- package/ios/Crash/ScaleBunCrashModule.swift +14 -0
- package/ios/Crash/ScaleBunCrashSignalBridge.h +32 -1
- package/ios/Crash/ScaleBunCrashSignalBridge.m +172 -7
- package/ios/Crash/ScaleBunCrashSignalStress.c +137 -0
- package/ios/Crash/ScaleBunCrashStorage.swift +75 -0
- package/lib/commonjs/analytics/EventTracker.js +30 -2
- package/lib/commonjs/analytics/EventTracker.js.map +1 -1
- package/lib/commonjs/bootstrap/SDKBootstrapper.js +19 -14
- package/lib/commonjs/bootstrap/SDKBootstrapper.js.map +1 -1
- package/lib/commonjs/core/config/schema.js +13 -5
- package/lib/commonjs/core/config/schema.js.map +1 -1
- package/lib/commonjs/core/context/device.js +130 -0
- package/lib/commonjs/core/context/device.js.map +1 -1
- package/lib/commonjs/debug/replayWiring.js +2 -1
- package/lib/commonjs/debug/replayWiring.js.map +1 -1
- package/lib/commonjs/features/bugreport/BugReportFeature.js +112 -0
- package/lib/commonjs/features/bugreport/BugReportFeature.js.map +1 -0
- package/lib/commonjs/features/bugreport/buildPayload.js +97 -0
- package/lib/commonjs/features/bugreport/buildPayload.js.map +1 -0
- package/lib/commonjs/features/crash/CrashReporter.js +25 -1
- package/lib/commonjs/features/crash/CrashReporter.js.map +1 -1
- package/lib/commonjs/features/crash/capture.js +27 -9
- package/lib/commonjs/features/crash/capture.js.map +1 -1
- package/lib/commonjs/features/crash/nativeCrashBridge.js +163 -28
- package/lib/commonjs/features/crash/nativeCrashBridge.js.map +1 -1
- package/lib/commonjs/features/engage/EngagePromptProvider.js +36 -2
- package/lib/commonjs/features/engage/EngagePromptProvider.js.map +1 -1
- package/lib/commonjs/features/engage/EngagePromptView.js +47 -14
- package/lib/commonjs/features/engage/EngagePromptView.js.map +1 -1
- package/lib/commonjs/features/engage/engageTriggerEngine.js +10 -0
- package/lib/commonjs/features/engage/engageTriggerEngine.js.map +1 -1
- package/lib/commonjs/features/engage/engageTypes.js.map +1 -1
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +2 -1
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js.map +1 -1
- package/lib/commonjs/features/ota/OtaOrchestrator.js +63 -5
- package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
- package/lib/commonjs/features/ota/deviceAttributes.js +48 -0
- package/lib/commonjs/features/ota/deviceAttributes.js.map +1 -0
- package/lib/commonjs/features/ota/geoCountry.js +93 -0
- package/lib/commonjs/features/ota/geoCountry.js.map +1 -0
- package/lib/commonjs/features/ota/useOtaUpdate.js +4 -1
- package/lib/commonjs/features/ota/useOtaUpdate.js.map +1 -1
- package/lib/commonjs/features/performance/PerformanceFeature.js +23 -9
- package/lib/commonjs/features/performance/PerformanceFeature.js.map +1 -1
- package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js +69 -9
- package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
- package/lib/commonjs/features/performance/transport/PerformanceTransport.js +25 -0
- package/lib/commonjs/features/performance/transport/PerformanceTransport.js.map +1 -1
- package/lib/commonjs/features/session/BackendSessionAdapter.js +128 -11
- package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
- package/lib/commonjs/features/session/JourneyEventPipeline.js +1 -0
- package/lib/commonjs/features/session/JourneyEventPipeline.js.map +1 -1
- package/lib/commonjs/features/session/ReplayCaptureManager.js +46 -0
- package/lib/commonjs/features/session/ReplayCaptureManager.js.map +1 -1
- package/lib/commonjs/features/session/SessionManager.js +42 -12
- package/lib/commonjs/features/session/SessionManager.js.map +1 -1
- package/lib/commonjs/features/session/nativeCapture.js +4 -4
- package/lib/commonjs/features/session/nativeCapture.js.map +1 -1
- package/lib/commonjs/features/session/outboxFrameCapture.js +3 -2
- package/lib/commonjs/features/session/outboxFrameCapture.js.map +1 -1
- package/lib/commonjs/features/session/sessionTypes.js.map +1 -1
- package/lib/commonjs/index.js +13 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/public/ScaleBunFacade.js +150 -2
- package/lib/commonjs/public/ScaleBunFacade.js.map +1 -1
- package/lib/commonjs/specs/NativeScaleBunCrash.js.map +1 -1
- package/lib/module/analytics/EventTracker.js +29 -1
- package/lib/module/analytics/EventTracker.js.map +1 -1
- package/lib/module/bootstrap/SDKBootstrapper.js +17 -12
- package/lib/module/bootstrap/SDKBootstrapper.js.map +1 -1
- package/lib/module/core/config/schema.js +13 -5
- package/lib/module/core/config/schema.js.map +1 -1
- package/lib/module/core/context/device.js +119 -0
- package/lib/module/core/context/device.js.map +1 -1
- package/lib/module/debug/replayWiring.js +2 -1
- package/lib/module/debug/replayWiring.js.map +1 -1
- package/lib/module/features/bugreport/BugReportFeature.js +105 -0
- package/lib/module/features/bugreport/BugReportFeature.js.map +1 -0
- package/lib/module/features/bugreport/buildPayload.js +91 -0
- package/lib/module/features/bugreport/buildPayload.js.map +1 -0
- package/lib/module/features/crash/CrashReporter.js +25 -1
- package/lib/module/features/crash/CrashReporter.js.map +1 -1
- package/lib/module/features/crash/capture.js +28 -9
- package/lib/module/features/crash/capture.js.map +1 -1
- package/lib/module/features/crash/nativeCrashBridge.js +161 -28
- package/lib/module/features/crash/nativeCrashBridge.js.map +1 -1
- package/lib/module/features/engage/EngagePromptProvider.js +36 -2
- package/lib/module/features/engage/EngagePromptProvider.js.map +1 -1
- package/lib/module/features/engage/EngagePromptView.js +47 -14
- package/lib/module/features/engage/EngagePromptView.js.map +1 -1
- package/lib/module/features/engage/engageTriggerEngine.js +10 -0
- package/lib/module/features/engage/engageTriggerEngine.js.map +1 -1
- package/lib/module/features/engage/engageTypes.js.map +1 -1
- package/lib/module/features/journey/ScaleBunDebugRoot.js +2 -1
- package/lib/module/features/journey/ScaleBunDebugRoot.js.map +1 -1
- package/lib/module/features/ota/OtaOrchestrator.js +63 -5
- package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
- package/lib/module/features/ota/deviceAttributes.js +43 -0
- package/lib/module/features/ota/deviceAttributes.js.map +1 -0
- package/lib/module/features/ota/geoCountry.js +86 -0
- package/lib/module/features/ota/geoCountry.js.map +1 -0
- package/lib/module/features/ota/useOtaUpdate.js +4 -1
- package/lib/module/features/ota/useOtaUpdate.js.map +1 -1
- package/lib/module/features/performance/PerformanceFeature.js +23 -9
- package/lib/module/features/performance/PerformanceFeature.js.map +1 -1
- package/lib/module/features/performance/collectors/FrameMetricsCollector.js +70 -10
- package/lib/module/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
- package/lib/module/features/performance/transport/PerformanceTransport.js +25 -0
- package/lib/module/features/performance/transport/PerformanceTransport.js.map +1 -1
- package/lib/module/features/session/BackendSessionAdapter.js +128 -11
- package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
- package/lib/module/features/session/JourneyEventPipeline.js +1 -0
- package/lib/module/features/session/JourneyEventPipeline.js.map +1 -1
- package/lib/module/features/session/ReplayCaptureManager.js +46 -0
- package/lib/module/features/session/ReplayCaptureManager.js.map +1 -1
- package/lib/module/features/session/SessionManager.js +42 -12
- package/lib/module/features/session/SessionManager.js.map +1 -1
- package/lib/module/features/session/nativeCapture.js +4 -5
- package/lib/module/features/session/nativeCapture.js.map +1 -1
- package/lib/module/features/session/outboxFrameCapture.js +3 -2
- package/lib/module/features/session/outboxFrameCapture.js.map +1 -1
- package/lib/module/features/session/sessionTypes.js.map +1 -1
- package/lib/module/index.js +3 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/public/ScaleBunFacade.js +150 -2
- package/lib/module/public/ScaleBunFacade.js.map +1 -1
- package/lib/module/specs/NativeScaleBunCrash.js.map +1 -1
- package/lib/typescript/analytics/EventTracker.d.ts +20 -0
- package/lib/typescript/analytics/EventTracker.d.ts.map +1 -1
- package/lib/typescript/bootstrap/SDKBootstrapper.d.ts.map +1 -1
- package/lib/typescript/core/config/schema.d.ts +1 -1
- package/lib/typescript/core/config/schema.d.ts.map +1 -1
- package/lib/typescript/core/context/device.d.ts +46 -0
- package/lib/typescript/core/context/device.d.ts.map +1 -1
- package/lib/typescript/debug/replayWiring.d.ts.map +1 -1
- package/lib/typescript/features/bugreport/BugReportFeature.d.ts +59 -0
- package/lib/typescript/features/bugreport/BugReportFeature.d.ts.map +1 -0
- package/lib/typescript/features/bugreport/buildPayload.d.ts +51 -0
- package/lib/typescript/features/bugreport/buildPayload.d.ts.map +1 -0
- package/lib/typescript/features/crash/CrashReporter.d.ts +20 -1
- package/lib/typescript/features/crash/CrashReporter.d.ts.map +1 -1
- package/lib/typescript/features/crash/capture.d.ts +21 -2
- package/lib/typescript/features/crash/capture.d.ts.map +1 -1
- package/lib/typescript/features/crash/nativeCrashBridge.d.ts +16 -3
- package/lib/typescript/features/crash/nativeCrashBridge.d.ts.map +1 -1
- package/lib/typescript/features/engage/EngagePromptProvider.d.ts.map +1 -1
- package/lib/typescript/features/engage/EngagePromptView.d.ts.map +1 -1
- package/lib/typescript/features/engage/engageTriggerEngine.d.ts.map +1 -1
- package/lib/typescript/features/engage/engageTypes.d.ts +15 -0
- package/lib/typescript/features/engage/engageTypes.d.ts.map +1 -1
- package/lib/typescript/features/journey/ScaleBunDebugRoot.d.ts.map +1 -1
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +8 -2
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
- package/lib/typescript/features/ota/deviceAttributes.d.ts +18 -0
- package/lib/typescript/features/ota/deviceAttributes.d.ts.map +1 -0
- package/lib/typescript/features/ota/geoCountry.d.ts +14 -0
- package/lib/typescript/features/ota/geoCountry.d.ts.map +1 -0
- package/lib/typescript/features/ota/useOtaUpdate.d.ts +20 -2
- package/lib/typescript/features/ota/useOtaUpdate.d.ts.map +1 -1
- package/lib/typescript/features/performance/PerformanceFeature.d.ts +0 -1
- package/lib/typescript/features/performance/PerformanceFeature.d.ts.map +1 -1
- package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts +33 -0
- package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts.map +1 -1
- package/lib/typescript/features/performance/transport/PerformanceTransport.d.ts.map +1 -1
- package/lib/typescript/features/session/BackendSessionAdapter.d.ts +42 -2
- package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
- package/lib/typescript/features/session/JourneyEventPipeline.d.ts +2 -0
- package/lib/typescript/features/session/JourneyEventPipeline.d.ts.map +1 -1
- package/lib/typescript/features/session/ReplayCaptureManager.d.ts +38 -1
- package/lib/typescript/features/session/ReplayCaptureManager.d.ts.map +1 -1
- package/lib/typescript/features/session/SessionManager.d.ts.map +1 -1
- package/lib/typescript/features/session/nativeCapture.d.ts +2 -1
- package/lib/typescript/features/session/nativeCapture.d.ts.map +1 -1
- package/lib/typescript/features/session/outboxFrameCapture.d.ts.map +1 -1
- package/lib/typescript/features/session/sessionTypes.d.ts +20 -3
- package/lib/typescript/features/session/sessionTypes.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +1 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/public/ScaleBunFacade.d.ts +58 -0
- package/lib/typescript/public/ScaleBunFacade.d.ts.map +1 -1
- package/lib/typescript/specs/NativeScaleBunCrash.d.ts +14 -0
- package/lib/typescript/specs/NativeScaleBunCrash.d.ts.map +1 -1
- package/lib/typescript/vendor/protocol/index.d.ts +17 -17
- package/package.json +1 -1
- package/src/analytics/EventTracker.ts +28 -1
- package/src/bootstrap/SDKBootstrapper.ts +15 -14
- package/src/core/config/schema.ts +13 -5
- package/src/core/context/device.ts +146 -0
- package/src/debug/replayWiring.ts +2 -1
- package/src/features/bugreport/BugReportFeature.ts +128 -0
- package/src/features/bugreport/buildPayload.ts +116 -0
- package/src/features/crash/CrashReporter.ts +40 -1
- package/src/features/crash/capture.ts +36 -9
- package/src/features/crash/nativeCrashBridge.ts +156 -22
- package/src/features/engage/EngagePromptProvider.tsx +48 -2
- package/src/features/engage/EngagePromptView.tsx +51 -15
- package/src/features/engage/engageTriggerEngine.ts +15 -0
- package/src/features/engage/engageTypes.ts +15 -0
- package/src/features/journey/ScaleBunDebugRoot.tsx +2 -1
- package/src/features/ota/OtaOrchestrator.ts +75 -6
- package/src/features/ota/deviceAttributes.ts +46 -0
- package/src/features/ota/geoCountry.ts +85 -0
- package/src/features/ota/useOtaUpdate.ts +20 -2
- package/src/features/performance/PerformanceFeature.ts +22 -9
- package/src/features/performance/collectors/FrameMetricsCollector.ts +72 -10
- package/src/features/performance/transport/PerformanceTransport.ts +18 -0
- package/src/features/session/BackendSessionAdapter.ts +137 -10
- package/src/features/session/JourneyEventPipeline.ts +3 -0
- package/src/features/session/ReplayCaptureManager.ts +53 -1
- package/src/features/session/SessionManager.ts +46 -7
- package/src/features/session/nativeCapture.ts +3 -2
- package/src/features/session/outboxFrameCapture.ts +3 -2
- package/src/features/session/sessionTypes.ts +26 -3
- package/src/index.ts +3 -0
- package/src/public/ScaleBunFacade.ts +153 -2
- package/src/specs/NativeScaleBunCrash.ts +16 -0
|
@@ -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
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
|
120
|
+
if (!mod?.drainPendingCrash) return 'none';
|
|
71
121
|
|
|
72
122
|
const crash = await mod.drainPendingCrash();
|
|
73
|
-
if (!crash) return
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
<
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
849
|
-
|
|
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=
|
|
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}
|
|
@@ -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);
|
|
@@ -110,6 +110,21 @@ export interface EngageCampaignConfig {
|
|
|
110
110
|
storeUrl?: string;
|
|
111
111
|
highRatingThreshold?: number;
|
|
112
112
|
routeLowToFeedback?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Route this campaign's submission to BUG REPORTS instead of Engage responses.
|
|
115
|
+
*
|
|
116
|
+
* Engage responses and bug reports are different products: a survey response measures sentiment
|
|
117
|
+
* in aggregate, while a bug report is a single item that gets a status, a priority and an
|
|
118
|
+
* assignee on /diagnose/bug-reports. A FEEDBACK campaign triggered by `shake_device` is almost
|
|
119
|
+
* always the second kind — someone shook the phone because something is broken.
|
|
120
|
+
*
|
|
121
|
+
* Opt-IN per campaign rather than rerouting every FEEDBACK campaign, because rerouting would
|
|
122
|
+
* silently break existing sentiment surveys, and because the destination should be visible to
|
|
123
|
+
* whoever configures the campaign rather than inferred from its trigger.
|
|
124
|
+
*
|
|
125
|
+
* Absent/false keeps the existing behaviour exactly.
|
|
126
|
+
*/
|
|
127
|
+
fileAsBugReport?: boolean;
|
|
113
128
|
enabled: boolean;
|
|
114
129
|
}
|
|
115
130
|
|
|
@@ -30,6 +30,7 @@ import React, {
|
|
|
30
30
|
import { View, NativeEventEmitter, NativeModules } from 'react-native';
|
|
31
31
|
import { emitAutomaticEvent } from '../../analytics/automaticEvents';
|
|
32
32
|
import { resolveTouchTarget, describeTouchTarget } from './touchTarget';
|
|
33
|
+
import { resolvePlatformOS } from '../../core/context/device';
|
|
33
34
|
|
|
34
35
|
// ─── Lazy imports to avoid native module cascade at load time ────────────────
|
|
35
36
|
// We only import TYPE references at the top level; actual modules are require()'d
|
|
@@ -159,7 +160,7 @@ export function ScaleBunDebugRoot({
|
|
|
159
160
|
byteSize: result.sizeBytes || Math.round(result.base64.length * 0.75),
|
|
160
161
|
format: (result.format ?? 'jpeg') as string,
|
|
161
162
|
frameHash: (result as any).frameHash as string | undefined,
|
|
162
|
-
platform: (
|
|
163
|
+
platform: resolvePlatformOS() as 'android' | 'ios',
|
|
163
164
|
};
|
|
164
165
|
} catch {
|
|
165
166
|
return null;
|
|
@@ -11,10 +11,14 @@ import { noThrow } from '../../core/lifecycle/crashSafe';
|
|
|
11
11
|
import NativeScaleBunOta from '../../specs/NativeScaleBunOta';
|
|
12
12
|
import type { OtaCheckRequest, OtaCheckResponse, OtaBundlePayload } from './OtaTypes';
|
|
13
13
|
import { otaEventEmitter } from './OtaEventEmitter';
|
|
14
|
+
import { getDeviceCountry, prefetchDeviceCountry } from './geoCountry';
|
|
15
|
+
import { deviceTargetingAttributes } from './deviceAttributes';
|
|
16
|
+
import { K_INSTALLATION_ID } from '../../analytics/EventTracker';
|
|
14
17
|
import { verifyBundleSignature, type SignatureConfig } from './signature';
|
|
15
18
|
import { detectOtaEnvironment, type OtaEnvironment } from './environment';
|
|
16
19
|
import { retryWithBackoff, isRetryableNetworkError, HttpStatusError } from './retry';
|
|
17
20
|
import { createStorageBackend, type StorageBackend } from '../../storage/StorageBackend';
|
|
21
|
+
import { resolveMobileOS } from '../../core/context/device';
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* Identity marker injected into the bundle by `scalebun ota publish` before
|
|
@@ -67,6 +71,36 @@ function subscribeNativeProgress(
|
|
|
67
71
|
/** POST at most once per this interval; first and terminal ticks always go. */
|
|
68
72
|
const PROGRESS_POST_INTERVAL_MS = 1_000;
|
|
69
73
|
|
|
74
|
+
/**
|
|
75
|
+
* P8 — the SDK's canonical per-install id, shared with the analytics lane.
|
|
76
|
+
*
|
|
77
|
+
* OTA used to REQUIRE a caller-supplied `installationId`, and the documented
|
|
78
|
+
* example passed `deviceId` — so OTA telemetry lived in its own identity
|
|
79
|
+
* namespace, disconnected from the id every other lane reports. Every join
|
|
80
|
+
* from OTA data to CDP data (blast radius, MRR-at-risk, churn bands) was a
|
|
81
|
+
* cross-namespace join that matched by luck.
|
|
82
|
+
*
|
|
83
|
+
* Reads (or mints, for OTA-only apps that never initialize analytics) the same
|
|
84
|
+
* persisted value `EventTracker` uses, via the same storage backend and the
|
|
85
|
+
* same exported key — one install, one id, every lane.
|
|
86
|
+
*/
|
|
87
|
+
function resolveInstallationId(): string {
|
|
88
|
+
try {
|
|
89
|
+
const storage = createStorageBackend();
|
|
90
|
+
const existing = storage.get(K_INSTALLATION_ID);
|
|
91
|
+
if (existing) return existing;
|
|
92
|
+
const id = `inst-${Math.random().toString(36).slice(2, 10)}${Math.random()
|
|
93
|
+
.toString(36)
|
|
94
|
+
.slice(2, 10)}`;
|
|
95
|
+
storage.set(K_INSTALLATION_ID, id);
|
|
96
|
+
return id;
|
|
97
|
+
} catch {
|
|
98
|
+
// Storage unavailable (tests, exotic hosts): a per-process id keeps sync
|
|
99
|
+
// functional; it will stabilize on the first run where storage works.
|
|
100
|
+
return `inst-ephemeral-${Math.random().toString(36).slice(2, 10)}`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
70
104
|
/**
|
|
71
105
|
* Deliver queued OTA lifecycle events to the backend's batch endpoint.
|
|
72
106
|
*
|
|
@@ -93,13 +127,18 @@ async function deliverOtaEvents(params: {
|
|
|
93
127
|
}): Promise<void> {
|
|
94
128
|
const events = otaEventEmitter.flush();
|
|
95
129
|
if (!events.length) return;
|
|
130
|
+
// A platform OTA cannot serve is not reported as Android — it is not reported at all.
|
|
131
|
+
const mobileOS = resolveMobileOS();
|
|
132
|
+
if (!mobileOS) return;
|
|
96
133
|
|
|
97
134
|
const items = events.map((e) => ({
|
|
98
135
|
kind: 'ota_event' as const,
|
|
99
136
|
type: e.type,
|
|
100
137
|
bundleId: e.bundleId,
|
|
101
138
|
installationId: params.installationId,
|
|
102
|
-
platform
|
|
139
|
+
// OTA bundles are compiled per platform, so this genuinely is ios|android. Narrowed via
|
|
140
|
+
// resolveMobileOS so a non-mobile RN target is skipped rather than served an Android bundle.
|
|
141
|
+
platform: mobileOS,
|
|
103
142
|
appVersion: params.appVersion,
|
|
104
143
|
durationMs: e.durationMs,
|
|
105
144
|
errorCode: e.error ?? e.reason,
|
|
@@ -244,6 +283,11 @@ export class OtaOrchestrator {
|
|
|
244
283
|
// no way to know what the device was actually on.
|
|
245
284
|
this.hydrateCurrentBundleFromSlots();
|
|
246
285
|
|
|
286
|
+
// Warm the device-country cache (edge Worker lookup) so checks can carry
|
|
287
|
+
// a country when the API itself sits behind no geo-stamping CDN.
|
|
288
|
+
// Fire-and-forget and failure-soft — a check without a country is valid.
|
|
289
|
+
void prefetchDeviceCountry();
|
|
290
|
+
|
|
247
291
|
// Prove the bundle we installed is the bundle that loaded.
|
|
248
292
|
this.verifyRunningBundleIdentity();
|
|
249
293
|
|
|
@@ -472,15 +516,24 @@ export class OtaOrchestrator {
|
|
|
472
516
|
// never carried it — so every device resolved to `default` no matter what
|
|
473
517
|
// the host app asked for. Undefined fields are omitted by JSON.stringify,
|
|
474
518
|
// so an app that supplies nothing behaves exactly as before.
|
|
519
|
+
// OTA has no bundle for a non-mobile target; claiming Android there would install the wrong one.
|
|
520
|
+
const mobileOS = resolveMobileOS();
|
|
521
|
+
if (!mobileOS) throw new Error('OTA is not supported on this platform');
|
|
475
522
|
const payload: OtaCheckRequest = {
|
|
476
523
|
appVersion: requestParams.appVersion,
|
|
477
|
-
platform:
|
|
524
|
+
platform: mobileOS,
|
|
478
525
|
installationId: requestParams.installationId,
|
|
479
526
|
currentBundleId: this.currentBundle?.id,
|
|
480
527
|
currentBundleHash: this.currentBundle?.sha256,
|
|
481
528
|
channelName: requestParams.channelName,
|
|
482
|
-
country
|
|
483
|
-
|
|
529
|
+
// Host-supplied country wins; otherwise the cached edge-Worker lookup
|
|
530
|
+
// fills in (see geoCountry.ts). Undefined when neither knows — the
|
|
531
|
+
// server renders unknown as unknown rather than guessing.
|
|
532
|
+
country: requestParams.country ?? getDeviceCountry() ?? undefined,
|
|
533
|
+
// Hardware/OS context under the host's own attributes (host wins on
|
|
534
|
+
// collision) — this is what lets ATTRIBUTE rules target device classes
|
|
535
|
+
// (deviceModel / manufacturer / brand / osVersion / formFactor).
|
|
536
|
+
attributes: { ...deviceTargetingAttributes(), ...requestParams.attributes },
|
|
484
537
|
segmentIds: requestParams.segmentIds,
|
|
485
538
|
lifecycleStage: requestParams.lifecycleStage,
|
|
486
539
|
// Runtime compatibility context. Without these the backend cannot tell
|
|
@@ -529,13 +582,29 @@ export class OtaOrchestrator {
|
|
|
529
582
|
* Downloads, stage, and apply an update in one step.
|
|
530
583
|
* Sprint 5: emits lifecycle events + starts boot-guard heartbeat.
|
|
531
584
|
*/
|
|
532
|
-
async sync(
|
|
585
|
+
async sync(rawParams: {
|
|
533
586
|
apiUrl: string;
|
|
534
587
|
clientKey: string;
|
|
535
588
|
appVersion: string;
|
|
536
|
-
|
|
589
|
+
/** Omit to use the SDK's canonical per-install id (recommended — P8). */
|
|
590
|
+
installationId?: string;
|
|
591
|
+
// P1: these five existed on checkForUpdate and on the wire contract, but
|
|
592
|
+
// sync() — the only caller that matters — never accepted them, so they
|
|
593
|
+
// were structurally undefined on every real device: every install pinned
|
|
594
|
+
// to the `default` channel and the whole targeting engine matched nothing.
|
|
595
|
+
// The C1 comment in OtaTypes.ts claimed this was fixed; the plumbing had
|
|
596
|
+
// stopped exactly one function short of true.
|
|
597
|
+
channelName?: string;
|
|
598
|
+
country?: string;
|
|
599
|
+
attributes?: Record<string, string | number>;
|
|
600
|
+
segmentIds?: string[];
|
|
601
|
+
lifecycleStage?: string;
|
|
537
602
|
autoRestart?: boolean;
|
|
538
603
|
}): Promise<SyncResult> {
|
|
604
|
+
const params = {
|
|
605
|
+
...rawParams,
|
|
606
|
+
installationId: rawParams.installationId ?? resolveInstallationId(),
|
|
607
|
+
};
|
|
539
608
|
try {
|
|
540
609
|
if (!this.enabled || !NativeScaleBunOta) {
|
|
541
610
|
return { status: 'ERROR', error: 'OTA native module disabled or missing' };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Dimensions, Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hardware/OS context auto-attached to OTA check payloads so ATTRIBUTE targeting
|
|
5
|
+
* rules can address device classes without any host-app wiring:
|
|
6
|
+
*
|
|
7
|
+
* deviceModel — 'SM-A155F', '23129RAA4G'… (Android; iOS does not expose it in JS)
|
|
8
|
+
* manufacturer — 'samsung', 'xiaomi'… (lowercased: rules shouldn't fail on casing)
|
|
9
|
+
* brand — 'redmi' vs manufacturer 'xiaomi' — sub-brands are real segments
|
|
10
|
+
* osVersion — Android Release ('14') / iOS version ('17.5')
|
|
11
|
+
* formFactor — 'phone' | 'tablet' | 'tv' (Android tablets via the 600dp
|
|
12
|
+
* smallest-width convention — the same line AndroidX draws)
|
|
13
|
+
*
|
|
14
|
+
* Host-supplied attributes always win on key collision: an app that knows better
|
|
15
|
+
* (e.g. its own device tiering) must not be overridden by generic detection.
|
|
16
|
+
* Everything here is best-effort — a missing constant yields a missing key,
|
|
17
|
+
* never a guessed value.
|
|
18
|
+
*/
|
|
19
|
+
export function deviceTargetingAttributes(): Record<string, string> {
|
|
20
|
+
const out: Record<string, string> = {};
|
|
21
|
+
try {
|
|
22
|
+
const c = (Platform.constants ?? {}) as Record<string, unknown>;
|
|
23
|
+
if (Platform.OS === 'android') {
|
|
24
|
+
if (typeof c.Model === 'string' && c.Model) out.deviceModel = c.Model;
|
|
25
|
+
if (typeof c.Manufacturer === 'string' && c.Manufacturer) {
|
|
26
|
+
out.manufacturer = c.Manufacturer.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
if (typeof c.Brand === 'string' && c.Brand) out.brand = c.Brand.toLowerCase();
|
|
29
|
+
if (typeof c.Release === 'string' && c.Release) out.osVersion = c.Release;
|
|
30
|
+
const screen = Dimensions.get('screen');
|
|
31
|
+
const smallestDp = Math.min(screen.width, screen.height);
|
|
32
|
+
out.formFactor = Platform.isTV ? 'tv' : smallestDp >= 600 ? 'tablet' : 'phone';
|
|
33
|
+
} else {
|
|
34
|
+
const osVersion = typeof c.osVersion === 'string' ? c.osVersion : String(Platform.Version ?? '');
|
|
35
|
+
if (osVersion) out.osVersion = osVersion;
|
|
36
|
+
out.formFactor = Platform.isTV
|
|
37
|
+
? 'tv'
|
|
38
|
+
: (c as { interfaceIdiom?: string }).interfaceIdiom === 'pad'
|
|
39
|
+
? 'tablet'
|
|
40
|
+
: 'phone';
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// Targeting enrichment must never break a check. Missing keys are honest.
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|