@bugsession/react-native 0.0.0-stage → 0.1.0-beta.2

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/LICENSE ADDED
@@ -0,0 +1,4 @@
1
+ Proprietary Software
2
+
3
+ All rights reserved. No license is granted for redistribution or modification.
4
+ Contact the package owner for beta evaluation terms.
package/README.md CHANGED
@@ -1,3 +1,73 @@
1
- # Temporary Holding Version
1
+ # @bugsession/react-native — beta
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Opt-in screen recording, microphone narration and bounded runtime telemetry for React Native. This beta is for invited testers using non-sensitive test data. It is not ready for general production use. This SDK is proprietary (`UNLICENSED`), with all rights reserved. Beta use requires authorization from the package owner; see LICENSE.
4
+
5
+ ## Compatibility
6
+
7
+ The SDK supports React 18.3.1–19.x and React Native 0.76–0.88. Expo SDK 54–58 are supported; SDK 52–53 are best effort. CI builds Android against SDK 52, 53, 54, 57 and 58. Each Expo SDK selects its matching React and React Native versions; do not mix versions manually. iOS and Android require a native build and `@bugsession/capture-native`; Expo Go cannot capture screen video. Android uses MediaProjection and asks for screen-capture consent every session. The hosted recorder admits one session at a time.
8
+
9
+ ## Install
10
+
11
+ After publication:
12
+
13
+ ```sh
14
+ npm install @bugsession/react-native@beta @bugsession/capture-native@beta
15
+ npx expo install expo-audio expo-file-system
16
+ ```
17
+
18
+ For local evaluation, install the generated `.tgz` files instead. Configure microphone permission through your audio library and rebuild the native app. Explain recording to the tester and start only after their explicit action.
19
+
20
+ ## iOS example
21
+
22
+ ```tsx
23
+ import { BugSession } from '@bugsession/react-native';
24
+ import { IOSCapture } from '@bugsession/capture-native';
25
+ import * as FileSystem from 'expo-file-system/legacy';
26
+
27
+ BugSession.configure({
28
+ recorderUrl: 'https://beta.bugsession.dev',
29
+ recorderToken: projectIngestionKey, // Generate in dashboard → project → Connect your SDK.
30
+ device: { platform: 'ios', model: 'tester-device' },
31
+ application: { version: '1.0.0', gitCommit: appSourceCommit },
32
+ capture: {
33
+ ...IOSCapture,
34
+ async upload(url, uri, headers = {}) {
35
+ const response = await FileSystem.uploadAsync(url, uri, {
36
+ httpMethod: 'POST',
37
+ uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
38
+ headers,
39
+ });
40
+ if (response.status !== 200) throw new Error(`Upload failed: ${response.status}`);
41
+ },
42
+ },
43
+ });
44
+
45
+ // Or render <BugSession.Trigger /> for a floating bottom-right record button.
46
+ // Set position to bottom-left, top-right, or top-left; offset defaults to 24.
47
+ // Render it above your navigator, and provide onError to display failures.
48
+ // Stop within 160 seconds.
49
+ await BugSession.start();
50
+ BugSession.trackAction({ name: 'save-profile', screen: 'Profile' });
51
+ BugSession.trackNavigation('Settings');
52
+ const { id } = await BugSession.stop();
53
+ ```
54
+
55
+ Configure once while idle. Wire an automatic stop timer into your recording UI, keep the app foregrounded through upload, and display recording/upload failures. `BugSession.stop()` acknowledges queued processing; it does not promise completed analysis. Poll authenticated `GET /sessions/{id}/status` until `status` is `complete`, `processing-failed`, or `interrupted`, and inspect `processing.warnings`. `complete` with partial processing can mean missing narration.
56
+
57
+ React Navigation integration: call the function returned by `BugSession.navigationListener(navigationRef)` from both `NavigationContainer.onReady` and `onStateChange`. Expo Router apps can call `trackNavigation` when the committed pathname changes. Use `BugSession.Touchable` to instrument a Pressable, or `trackAction` directly. Send caught errors through `BugSession.captureError(error)`.
58
+
59
+ `<BugSession.Trigger />` renders a floating logo button that starts and stops sessions. Its default position is `bottom-right`; configure `position` as `bottom-left`, `top-right`, or `top-left`, and adjust `offset` in points. Render it as an overlay above the app navigator. Pass `onError` to surface permission, recorder, and upload failures. Supply `children` to replace the default mark.
60
+
61
+ Android uses `AndroidCapture` from `@bugsession/capture-native` plus an `audio` adapter implementing `prepare`, `start`, `stop` (returns a local URI), and `upload`. `prepare` must request microphone permission. `expo-audio` can provide this adapter. Android shows a system screen-capture prompt each session and a persistent capture notification; keep the app foregrounded through upload.
62
+
63
+ Fetch instrumentation omits request/response bodies, headers, URL credentials and query strings. Free-text redaction is best effort. Video, audio, screen labels, URL paths and arbitrary logs can contain private information. Record only test data and stop before entering credentials. Native crashes and process termination can lose in-memory telemetry. A maximum of 5,000 events is retained. Project SDK keys are extractable from an app build, so share them only with invited testers. Each key scopes uploads to one project; it cannot read recordings. Revoke unused keys. Keep recorder operator and Clerk secrets server-side.
64
+
65
+ ## Project-scoped beta uploads
66
+
67
+ Generate an SDK key from your dashboard project. Copy it into `recorderToken`; the backend derives project scope from the key. No SDK `projectId` option is needed. A project ID alone never authorizes uploads. Never distribute recorder operator or Clerk secret keys.
68
+
69
+ Keys allow recording creation, media/telemetry submission and processing status only for their project. They cannot list or download recordings. Revoking a key rejects subsequent requests, including an unfinished upload sequence; an already accepted upload or processing job can finish. Replace the app configuration before revoking a key still used by testers.
70
+
71
+ Hosted beta accepts native iOS ReplayKit and Android MediaProjection uploads. Limits are 3 active keys per project, 20 accepted recording starts per project per recorder hour, 600 requests per project per minute, 128 MiB per video, 32 MiB per audio upload, and 4 MiB telemetry bodies. The recorder still runs one recording or processing job at a time. Rate counters reset on restart. Mobile keys are extractable; treat them as limited ingestion capabilities, not account credentials.
72
+
73
+ After stopping, open the same project in the dashboard and refresh its recordings list to see status. Status polling using an SDK key returns a warning count, not raw processing warnings; inspect the dashboard/operator tools for processing details.
@@ -0,0 +1,26 @@
1
+ export type NativeRecording = {
2
+ uri: string;
3
+ firstVideoNativeMs: number;
4
+ firstAudioNativeMs: number | null;
5
+ droppedVideoFrames: number;
6
+ droppedAudioBuffers: number;
7
+ };
8
+ export type NativeCaptureAdapter = {
9
+ prepare?(): Promise<void>;
10
+ clock(): Promise<number>;
11
+ start(): Promise<void | {
12
+ firstVideoNativeMs: number;
13
+ }>;
14
+ stop(): Promise<NativeRecording>;
15
+ cancel(): Promise<void>;
16
+ upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
17
+ };
18
+ export declare function alignNativeRecording(recording: NativeRecording, offsetMs: number, uncertaintyMs: number): {
19
+ videoStartMs: number;
20
+ audioStartMs: number;
21
+ alignment: string;
22
+ videoAlignmentUncertaintyMs: number;
23
+ microphoneAvailable: boolean;
24
+ droppedVideoFrames: number;
25
+ droppedAudioBuffers: number;
26
+ };
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.alignNativeRecording = alignNativeRecording;
4
+ function alignNativeRecording(recording, offsetMs, uncertaintyMs) {
5
+ const videoStartMs = recording.firstVideoNativeMs + offsetMs;
6
+ const audioStartMs = recording.firstAudioNativeMs === null ? videoStartMs : recording.firstAudioNativeMs + offsetMs;
7
+ if (![videoStartMs, audioStartMs, uncertaintyMs].every(Number.isFinite) || videoStartMs < 0 || audioStartMs < videoStartMs || uncertaintyMs < 0) {
8
+ throw new Error('Invalid ReplayKit clock mapping');
9
+ }
10
+ return {
11
+ videoStartMs, audioStartMs, alignment: 'replaykit-host-clock',
12
+ videoAlignmentUncertaintyMs: uncertaintyMs,
13
+ microphoneAvailable: recording.firstAudioNativeMs !== null,
14
+ droppedVideoFrames: recording.droppedVideoFrames,
15
+ droppedAudioBuffers: recording.droppedAudioBuffers,
16
+ };
17
+ }
package/dist/core.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ export type Evidence = {
2
+ timestampMs: number;
3
+ type: string;
4
+ [key: string]: unknown;
5
+ };
6
+ export declare function safeUrl(input: string): string;
7
+ export declare function safeText(value: unknown): string;
8
+ export declare class SessionClock {
9
+ private source;
10
+ private epoch;
11
+ constructor(source?: () => number);
12
+ now(): number;
13
+ }
14
+ export declare class Collector {
15
+ clock: SessionClock;
16
+ private limit;
17
+ events: Evidence[];
18
+ dropped: number;
19
+ constructor(clock?: SessionClock, limit?: number);
20
+ add(type: string, data: Record<string, unknown>): void;
21
+ }
22
+ export declare function instrumentFetch(original: typeof fetch, collector: Collector): typeof fetch;
package/dist/core.js ADDED
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Collector = exports.SessionClock = void 0;
4
+ exports.safeUrl = safeUrl;
5
+ exports.safeText = safeText;
6
+ exports.instrumentFetch = instrumentFetch;
7
+ function safeUrl(input) {
8
+ try {
9
+ const u = new URL(input, 'http://relative.invalid');
10
+ // Paths can contain credentials too; redact common key/value path segments.
11
+ const path = u.pathname.replace(/\/(token|password|secret|api[-_]?key)\/[^/]+/gi, '/$1/[redacted]');
12
+ return (u.hostname === 'relative.invalid' ? '' : `${u.protocol}//${u.host}`) + path;
13
+ }
14
+ catch {
15
+ return '[invalid URL]';
16
+ }
17
+ }
18
+ function safeText(value) {
19
+ const text = value instanceof Error ? value.message : typeof value === 'string' ? value : '[non-string value omitted]';
20
+ return text
21
+ .replace(/https?:\/\/[^\s)"']+/g, (url) => safeUrl(url))
22
+ .replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [redacted]')
23
+ .replace(/((?:authorization|cookie|api[-_]?key|password|secret|token)\s*[=:]\s*)[^\s,;]+/gi, '$1[redacted]')
24
+ .slice(0, 4000);
25
+ }
26
+ class SessionClock {
27
+ constructor(source = () => performance.now()) {
28
+ this.source = source;
29
+ this.epoch = source();
30
+ }
31
+ now() { return Math.max(0, this.source() - this.epoch); }
32
+ }
33
+ exports.SessionClock = SessionClock;
34
+ class Collector {
35
+ constructor(clock = new SessionClock(), limit = 5000) {
36
+ this.clock = clock;
37
+ this.limit = limit;
38
+ this.events = [];
39
+ this.dropped = 0;
40
+ }
41
+ add(type, data) {
42
+ if (this.events.length >= this.limit) {
43
+ this.dropped++;
44
+ return;
45
+ }
46
+ this.events.push({ ...data, type, timestampMs: this.clock.now() });
47
+ }
48
+ }
49
+ exports.Collector = Collector;
50
+ function instrumentFetch(original, collector) {
51
+ return (async (input, init) => {
52
+ const url = safeUrl(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
53
+ const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase();
54
+ const start = collector.clock.now();
55
+ try {
56
+ const response = await original(input, init);
57
+ collector.add('network', { requestTimestampMs: start, method, url, status: response.status, durationMs: collector.clock.now() - start });
58
+ return response;
59
+ }
60
+ catch (error) {
61
+ collector.add('network', { requestTimestampMs: start, method, url, status: null, durationMs: collector.clock.now() - start, error: safeText(error) });
62
+ throw error;
63
+ }
64
+ });
65
+ }
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import { type PressableProps, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { type NativeCaptureAdapter } from './capture';
4
+ export type { NativeCaptureAdapter } from './capture';
5
+ export { Collector, SessionClock, safeText, safeUrl } from './core';
6
+ export type AudioAdapter = {
7
+ prepare(): Promise<void>;
8
+ start(): void;
9
+ stop(): Promise<string | null>;
10
+ upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
11
+ };
12
+ export type BugSessionOptions = {
13
+ recorderUrl: string;
14
+ recorderToken?: string;
15
+ audio?: AudioAdapter;
16
+ capture?: NativeCaptureAdapter;
17
+ device: Record<string, unknown>;
18
+ application: Record<string, unknown>;
19
+ };
20
+ export type BugSessionTriggerProps = {
21
+ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
22
+ offset?: number;
23
+ style?: StyleProp<ViewStyle>;
24
+ accessibilityLabel?: string;
25
+ children?: React.ReactNode;
26
+ onError?: (error: unknown) => void;
27
+ };
28
+ declare function captureError(error: unknown, source?: string): void;
29
+ export declare const BugSession: {
30
+ configure(config: BugSessionOptions): void;
31
+ readonly status: "idle" | "starting" | "recording" | "stopping";
32
+ start(): Promise<string>;
33
+ stop(): Promise<any>;
34
+ trackAction(action: {
35
+ name: string;
36
+ screen?: string;
37
+ }): void;
38
+ trackNavigation(screen: string): void;
39
+ captureError: typeof captureError;
40
+ /** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
41
+ navigationListener(ref: {
42
+ getCurrentRoute(): {
43
+ name: string;
44
+ } | undefined;
45
+ }): () => void;
46
+ Touchable({ name, screen, onPress, ...props }: PressableProps & {
47
+ name: string;
48
+ screen?: string;
49
+ }): React.JSX.Element;
50
+ Trigger({ position, offset, style, accessibilityLabel, children, onError }: BugSessionTriggerProps): React.JSX.Element;
51
+ };
package/dist/index.js ADDED
@@ -0,0 +1,254 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.BugSession = exports.safeUrl = exports.safeText = exports.SessionClock = exports.Collector = void 0;
7
+ const jsx_runtime_1 = require("react/jsx-runtime");
8
+ const react_1 = __importDefault(require("react"));
9
+ const react_native_1 = require("react-native");
10
+ const core_1 = require("./core");
11
+ const capture_1 = require("./capture");
12
+ var core_2 = require("./core");
13
+ Object.defineProperty(exports, "Collector", { enumerable: true, get: function () { return core_2.Collector; } });
14
+ Object.defineProperty(exports, "SessionClock", { enumerable: true, get: function () { return core_2.SessionClock; } });
15
+ Object.defineProperty(exports, "safeText", { enumerable: true, get: function () { return core_2.safeText; } });
16
+ Object.defineProperty(exports, "safeUrl", { enumerable: true, get: function () { return core_2.safeUrl; } });
17
+ const globals = globalThis;
18
+ let options;
19
+ let collector = null;
20
+ let session = null;
21
+ let state = 'idle';
22
+ let restore = null;
23
+ let currentScreen = 'Home';
24
+ let rawFetch;
25
+ function authHeaders() {
26
+ return options.recorderToken ? { Authorization: `Bearer ${options.recorderToken}` } : {};
27
+ }
28
+ async function calibrate(clock, source) {
29
+ const samples = [];
30
+ for (let i = 0; i < 5; i++) {
31
+ const sent = clock.now();
32
+ const remote = await source();
33
+ const received = clock.now();
34
+ if (!Number.isFinite(remote))
35
+ throw new Error('Invalid capture clock');
36
+ samples.push({ rtt: received - sent, offset: (sent + received) / 2 - remote });
37
+ }
38
+ return samples.sort((a, b) => a.rtt - b.rtt)[0];
39
+ }
40
+ async function request(path, data) {
41
+ const response = await rawFetch(`${options.recorderUrl}${path}`, {
42
+ ...(data === undefined ? {} : { method: 'POST', body: JSON.stringify(data) }),
43
+ headers: { ...authHeaders(), ...(data === undefined ? {} : { 'Content-Type': 'application/json' }) },
44
+ });
45
+ if (!response.ok)
46
+ throw new Error(`Recorder ${response.status}: ${await response.text()}`);
47
+ return response.json();
48
+ }
49
+ function install(c) {
50
+ const originalFetch = globalThis.fetch;
51
+ const wrappedFetch = (0, core_1.instrumentFetch)(rawFetch, c);
52
+ globalThis.fetch = wrappedFetch;
53
+ const originals = { log: console.log, warn: console.warn, error: console.error };
54
+ for (const level of ['log', 'warn', 'error']) {
55
+ console[level] = (...args) => {
56
+ c.add(level === 'error' ? 'error' : 'log', { level, message: args.map(core_1.safeText).join(' '), source: 'console', stack: args.find(a => a instanceof Error)?.stack ? (0, core_1.safeText)(args.find(a => a instanceof Error).stack) : undefined });
57
+ originals[level](...args);
58
+ };
59
+ }
60
+ const utils = globals.ErrorUtils;
61
+ const handler = utils?.getGlobalHandler();
62
+ const onError = (error, fatal) => {
63
+ captureError(error, fatal ? 'fatal' : 'uncaught');
64
+ handler?.(error, fatal);
65
+ };
66
+ if (utils && handler)
67
+ utils.setGlobalHandler(onError);
68
+ return () => {
69
+ if (globalThis.fetch === wrappedFetch)
70
+ globalThis.fetch = originalFetch;
71
+ for (const level of ['log', 'warn', 'error'])
72
+ console[level] = originals[level];
73
+ if (utils?.getGlobalHandler() === onError && handler)
74
+ utils.setGlobalHandler(handler);
75
+ };
76
+ }
77
+ function captureError(error, source = 'boundary') {
78
+ collector?.add('error', { source, message: (0, core_1.safeText)(error), stack: error instanceof Error ? (0, core_1.safeText)(error.stack ?? '') : undefined, screen: currentScreen });
79
+ }
80
+ exports.BugSession = {
81
+ configure(config) {
82
+ if (state !== 'idle')
83
+ throw new Error('Cannot configure an active BugSession');
84
+ options = { ...config, recorderUrl: config.recorderUrl.replace(/\/$/, '') };
85
+ },
86
+ get status() { return state; },
87
+ async start() {
88
+ if (!options)
89
+ throw new Error('Configure BugSession first');
90
+ if (react_native_1.Platform.OS !== 'android' && react_native_1.Platform.OS !== 'ios')
91
+ throw new Error('Recording supports iOS and Android only');
92
+ if (react_native_1.Platform.OS === 'ios' && !options.capture)
93
+ throw new Error('iOS needs the ReplayKit capture adapter and a native development build');
94
+ if (react_native_1.Platform.OS === 'android' && !options.audio)
95
+ throw new Error('Android needs a microphone adapter');
96
+ if (state !== 'idle')
97
+ throw new Error(`BugSession is ${state}`);
98
+ state = 'starting';
99
+ rawFetch = globalThis.fetch.bind(globalThis);
100
+ let createdId = null;
101
+ let audioStarted = false;
102
+ const isIOS = react_native_1.Platform.OS === 'ios';
103
+ const useNativeCapture = isIOS || (react_native_1.Platform.OS === 'android' && !!options.capture);
104
+ try {
105
+ if (react_native_1.Platform.OS === 'android' && options.capture?.prepare)
106
+ await options.capture.prepare();
107
+ if (!isIOS)
108
+ await options.audio.prepare();
109
+ const clock = new core_1.SessionClock();
110
+ const nativeClock = useNativeCapture ? await calibrate(clock, () => options.capture.clock()) : null;
111
+ const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
112
+ collector = new core_1.Collector(clock);
113
+ const captureBackend = isIOS ? 'replaykit' : useNativeCapture ? 'mediaprojection' : 'adb';
114
+ const result = await request('/sessions/start', { device: options.device, application: options.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
115
+ createdId = result.id;
116
+ const nativeStart = useNativeCapture ? await options.capture.start() : undefined;
117
+ const audioStartMs = clock.now();
118
+ if (!isIOS) {
119
+ options.audio.start();
120
+ audioStarted = true;
121
+ }
122
+ const nativeVideoStartMs = nativeStart && 'firstVideoNativeMs' in nativeStart
123
+ ? nativeStart.firstVideoNativeMs + nativeClock.offset
124
+ : undefined;
125
+ session = {
126
+ id: result.id, audioStartMs,
127
+ videoStartMs: isIOS ? 0 : Math.max(0, nativeVideoStartMs ?? result.videoHostStartMs + best.offset),
128
+ uncertaintyMs: best.rtt / 2, captureBackend,
129
+ ...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
130
+ };
131
+ restore = install(collector);
132
+ state = 'recording';
133
+ collector.add('navigation', { from: null, to: currentScreen });
134
+ return session.id;
135
+ }
136
+ catch (error) {
137
+ if (useNativeCapture)
138
+ await options.capture?.cancel().catch(() => { });
139
+ if (audioStarted)
140
+ await options.audio.stop().catch(() => null);
141
+ if (createdId)
142
+ await request(`/sessions/${createdId}/abort`, {}).catch(() => { });
143
+ collector = null;
144
+ session = null;
145
+ state = 'idle';
146
+ throw error;
147
+ }
148
+ },
149
+ async stop() {
150
+ if (state !== 'recording' || !session || !collector)
151
+ throw new Error('No recording is active');
152
+ state = 'stopping';
153
+ const c = collector;
154
+ const active = session;
155
+ c.add('session-stop', {});
156
+ restore?.();
157
+ restore = null;
158
+ try {
159
+ let media;
160
+ if (active.captureBackend === 'replaykit') {
161
+ const recording = await options.capture.stop();
162
+ media = { ...active, ...(0, capture_1.alignNativeRecording)(recording, active.nativeOffsetMs, active.nativeUncertaintyMs) };
163
+ await options.capture.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
164
+ }
165
+ else if (active.captureBackend === 'mediaprojection') {
166
+ const recording = await options.capture.stop();
167
+ const uri = await options.audio.stop();
168
+ const audioEndMs = c.clock.now();
169
+ if (!uri)
170
+ throw new Error('Microphone recording has no file');
171
+ await options.capture.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
172
+ await options.audio.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
173
+ media = {
174
+ ...active, audioEndMs, alignment: 'mediaprojection-host-clock',
175
+ videoAlignmentUncertaintyMs: Math.max(250, active.nativeUncertaintyMs ?? 0),
176
+ microphoneAvailable: true, droppedVideoFrames: recording.droppedVideoFrames,
177
+ };
178
+ }
179
+ else {
180
+ const uri = await options.audio.stop();
181
+ const audioEndMs = c.clock.now();
182
+ if (!uri)
183
+ throw new Error('Microphone recording has no file');
184
+ await options.audio.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
185
+ media = { ...active, audioEndMs };
186
+ }
187
+ return await request(`/sessions/${active.id}/stop`, {
188
+ durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped,
189
+ media,
190
+ });
191
+ }
192
+ catch (error) {
193
+ if (active.captureBackend !== 'adb')
194
+ await options.capture.cancel().catch(() => { });
195
+ // Release the device recorder, but preserve partial files for recovery.
196
+ await request(`/sessions/${active.id}/abort`, { reason: (0, core_1.safeText)(error), events: c.events, media: active, durationMs: c.clock.now() }).catch(() => { });
197
+ throw error;
198
+ }
199
+ finally {
200
+ collector = null;
201
+ session = null;
202
+ state = 'idle';
203
+ }
204
+ },
205
+ trackAction(action) {
206
+ collector?.add('action', { target: (0, core_1.safeText)(action.name), screen: (0, core_1.safeText)(action.screen ?? currentScreen) });
207
+ },
208
+ trackNavigation(screen) {
209
+ if (currentScreen !== screen)
210
+ collector?.add('navigation', { from: currentScreen, to: screen });
211
+ currentScreen = screen;
212
+ },
213
+ captureError,
214
+ /** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
215
+ navigationListener(ref) {
216
+ return () => { const route = ref.getCurrentRoute(); if (route)
217
+ exports.BugSession.trackNavigation(route.name); };
218
+ },
219
+ Touchable({ name, screen, onPress, ...props }) {
220
+ return (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { ...props, onPress: e => { exports.BugSession.trackAction({ name, screen }); onPress?.(e); } });
221
+ },
222
+ Trigger({ position = 'bottom-right', offset = 24, style, accessibilityLabel, children, onError }) {
223
+ const [busy, setBusy] = react_1.default.useState(false);
224
+ const recording = state === 'recording';
225
+ const edge = position.includes('right') ? { right: offset } : { left: offset };
226
+ const vertical = position.includes('bottom') ? { bottom: offset } : { top: offset };
227
+ const toggle = async () => {
228
+ if (busy)
229
+ return;
230
+ setBusy(true);
231
+ try {
232
+ if (state === 'recording')
233
+ await exports.BugSession.stop();
234
+ else
235
+ await exports.BugSession.start();
236
+ }
237
+ catch (error) {
238
+ onError?.(error);
239
+ }
240
+ finally {
241
+ setBusy(false);
242
+ }
243
+ };
244
+ return (0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "box-none", style: [styles.triggerLayer, edge, vertical], children: (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: accessibilityLabel ?? (recording ? 'Stop BugSession recording' : 'Start BugSession recording'), accessibilityState: { busy, selected: recording }, disabled: busy, onPress: () => { void toggle(); }, style: [styles.trigger, recording && styles.triggerRecording, style], children: children ?? (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.triggerMark, children: "b" }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.triggerDot, recording && styles.triggerDotRecording] })] }) }) });
245
+ },
246
+ };
247
+ const styles = react_native_1.StyleSheet.create({
248
+ triggerLayer: { position: 'absolute', zIndex: 9999 },
249
+ trigger: { width: 58, height: 58, borderRadius: 29, backgroundColor: '#173B34', alignItems: 'center', justifyContent: 'center', elevation: 8, shadowColor: '#000', shadowOpacity: 0.24, shadowRadius: 8, shadowOffset: { width: 0, height: 4 } },
250
+ triggerRecording: { backgroundColor: '#B9382F' },
251
+ triggerMark: { color: '#F4F4EC', fontSize: 31, fontWeight: '800', lineHeight: 38 },
252
+ triggerDot: { position: 'absolute', right: 9, bottom: 9, width: 10, height: 10, borderRadius: 5, backgroundColor: '#D8F36A', borderWidth: 1, borderColor: '#173B34' },
253
+ triggerDotRecording: { backgroundColor: '#FFF', borderColor: '#B9382F' },
254
+ });
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@bugsession/react-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.2",
4
+ "private": false,
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "peerDependencies": {
8
+ "react": ">=18.3.1 <20",
9
+ "react-native": ">=0.76.0 <0.89"
10
+ },
11
+ "description": "Opt-in React Native runtime evidence SDK",
12
+ "license": "UNLICENSED",
13
+ "react-native": "src/index.tsx",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "react-native": "./src/index.tsx",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.build.json",
30
+ "prepack": "npm run build"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "tag": "beta",
35
+ "registry": "https://registry.npmjs.org/"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/Anis-Ghliss/bugsession.git",
40
+ "directory": "packages/react-native"
41
+ },
42
+ "homepage": "https://github.com/Anis-Ghliss/bugsession/tree/main/packages/react-native#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/Anis-Ghliss/bugsession/issues"
45
+ }
46
+ }
package/src/capture.ts ADDED
@@ -0,0 +1,29 @@
1
+ export type NativeRecording = {
2
+ uri: string;
3
+ firstVideoNativeMs: number;
4
+ firstAudioNativeMs: number | null;
5
+ droppedVideoFrames: number;
6
+ droppedAudioBuffers: number;
7
+ };
8
+ export type NativeCaptureAdapter = {
9
+ prepare?(): Promise<void>;
10
+ clock(): Promise<number>;
11
+ start(): Promise<void | { firstVideoNativeMs: number }>;
12
+ stop(): Promise<NativeRecording>;
13
+ cancel(): Promise<void>;
14
+ upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
15
+ };
16
+ export function alignNativeRecording(recording: NativeRecording, offsetMs: number, uncertaintyMs: number) {
17
+ const videoStartMs = recording.firstVideoNativeMs + offsetMs;
18
+ const audioStartMs = recording.firstAudioNativeMs === null ? videoStartMs : recording.firstAudioNativeMs + offsetMs;
19
+ if (![videoStartMs, audioStartMs, uncertaintyMs].every(Number.isFinite) || videoStartMs < 0 || audioStartMs < videoStartMs || uncertaintyMs < 0) {
20
+ throw new Error('Invalid ReplayKit clock mapping');
21
+ }
22
+ return {
23
+ videoStartMs, audioStartMs, alignment: 'replaykit-host-clock',
24
+ videoAlignmentUncertaintyMs: uncertaintyMs,
25
+ microphoneAvailable: recording.firstAudioNativeMs !== null,
26
+ droppedVideoFrames: recording.droppedVideoFrames,
27
+ droppedAudioBuffers: recording.droppedAudioBuffers,
28
+ };
29
+ }
package/src/core.ts ADDED
@@ -0,0 +1,47 @@
1
+ export type Evidence = { timestampMs: number; type: string; [key: string]: unknown };
2
+
3
+ export function safeUrl(input: string): string {
4
+ try {
5
+ const u = new URL(input, 'http://relative.invalid');
6
+ // Paths can contain credentials too; redact common key/value path segments.
7
+ const path = u.pathname.replace(/\/(token|password|secret|api[-_]?key)\/[^/]+/gi, '/$1/[redacted]');
8
+ return (u.hostname === 'relative.invalid' ? '' : `${u.protocol}//${u.host}`) + path;
9
+ } catch { return '[invalid URL]'; }
10
+ }
11
+ export function safeText(value: unknown): string {
12
+ const text = value instanceof Error ? value.message : typeof value === 'string' ? value : '[non-string value omitted]';
13
+ return text
14
+ .replace(/https?:\/\/[^\s)"']+/g, (url) => safeUrl(url))
15
+ .replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [redacted]')
16
+ .replace(/((?:authorization|cookie|api[-_]?key|password|secret|token)\s*[=:]\s*)[^\s,;]+/gi, '$1[redacted]')
17
+ .slice(0, 4000);
18
+ }
19
+ export class SessionClock {
20
+ private epoch: number;
21
+ constructor(private source = () => performance.now()) { this.epoch = source(); }
22
+ now() { return Math.max(0, this.source() - this.epoch); }
23
+ }
24
+ export class Collector {
25
+ events: Evidence[] = [];
26
+ dropped = 0;
27
+ constructor(public clock = new SessionClock(), private limit = 5000) {}
28
+ add(type: string, data: Record<string, unknown>) {
29
+ if (this.events.length >= this.limit) { this.dropped++; return; }
30
+ this.events.push({ ...data, type, timestampMs: this.clock.now() });
31
+ }
32
+ }
33
+ export function instrumentFetch(original: typeof fetch, collector: Collector): typeof fetch {
34
+ return (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
35
+ const url = safeUrl(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
36
+ const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase();
37
+ const start = collector.clock.now();
38
+ try {
39
+ const response = await original(input, init);
40
+ collector.add('network', { requestTimestampMs: start, method, url, status: response.status, durationMs: collector.clock.now() - start });
41
+ return response;
42
+ } catch (error) {
43
+ collector.add('network', { requestTimestampMs: start, method, url, status: null, durationMs: collector.clock.now() - start, error: safeText(error) });
44
+ throw error;
45
+ }
46
+ }) as typeof fetch;
47
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,225 @@
1
+ import React from 'react';
2
+ import { Pressable, StyleSheet, Text, View, type PressableProps, type StyleProp, type ViewStyle, Platform } from 'react-native';
3
+ import { Collector, instrumentFetch, safeText, safeUrl, SessionClock } from './core';
4
+ import { alignNativeRecording, type NativeCaptureAdapter } from './capture';
5
+ export type { NativeCaptureAdapter } from './capture';
6
+ export { Collector, SessionClock, safeText, safeUrl } from './core';
7
+
8
+ export type AudioAdapter = {
9
+ prepare(): Promise<void>;
10
+ start(): void;
11
+ stop(): Promise<string | null>;
12
+ upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
13
+ };
14
+ export type BugSessionOptions = { recorderUrl: string; recorderToken?: string; audio?: AudioAdapter; capture?: NativeCaptureAdapter; device: Record<string, unknown>; application: Record<string, unknown> };
15
+ export type BugSessionTriggerProps = {
16
+ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
17
+ offset?: number;
18
+ style?: StyleProp<ViewStyle>;
19
+ accessibilityLabel?: string;
20
+ children?: React.ReactNode;
21
+ onError?: (error: unknown) => void;
22
+ };
23
+ type ErrorUtilsType = { getGlobalHandler(): (e: Error, fatal?: boolean) => void; setGlobalHandler(handler: (e: Error, fatal?: boolean) => void): void };
24
+ const globals = globalThis as typeof globalThis & { ErrorUtils?: ErrorUtilsType };
25
+ let options: BugSessionOptions;
26
+ let collector: Collector | null = null;
27
+ let session: { id: string; videoStartMs: number; audioStartMs: number; uncertaintyMs: number; captureBackend: 'adb' | 'replaykit' | 'mediaprojection'; nativeOffsetMs?: number; nativeUncertaintyMs?: number } | null = null;
28
+ let state: 'idle' | 'starting' | 'recording' | 'stopping' = 'idle';
29
+ let restore: (() => void) | null = null;
30
+ let currentScreen = 'Home';
31
+ let rawFetch: typeof fetch;
32
+ function authHeaders(): Record<string, string> {
33
+ return options.recorderToken ? { Authorization: `Bearer ${options.recorderToken}` } : {};
34
+ }
35
+ async function calibrate(clock: SessionClock, source: () => Promise<number>) {
36
+ const samples: { rtt: number; offset: number }[] = [];
37
+ for (let i = 0; i < 5; i++) {
38
+ const sent = clock.now(); const remote = await source(); const received = clock.now();
39
+ if (!Number.isFinite(remote)) throw new Error('Invalid capture clock');
40
+ samples.push({ rtt: received - sent, offset: (sent + received) / 2 - remote });
41
+ }
42
+ return samples.sort((a, b) => a.rtt - b.rtt)[0];
43
+ }
44
+ async function request(path: string, data?: unknown) {
45
+ const response = await rawFetch(`${options.recorderUrl}${path}`, {
46
+ ...(data === undefined ? {} : { method: 'POST', body: JSON.stringify(data) }),
47
+ headers: { ...authHeaders(), ...(data === undefined ? {} : { 'Content-Type': 'application/json' }) },
48
+ });
49
+ if (!response.ok) throw new Error(`Recorder ${response.status}: ${await response.text()}`);
50
+ return response.json();
51
+ }
52
+ function install(c: Collector) {
53
+ const originalFetch = globalThis.fetch;
54
+ const wrappedFetch = instrumentFetch(rawFetch, c);
55
+ globalThis.fetch = wrappedFetch;
56
+ const originals = { log: console.log, warn: console.warn, error: console.error };
57
+ for (const level of ['log', 'warn', 'error'] as const) {
58
+ console[level] = (...args: unknown[]) => {
59
+ c.add(level === 'error' ? 'error' : 'log', { level, message: args.map(safeText).join(' '), source: 'console', stack: args.find(a => a instanceof Error)?.stack ? safeText((args.find(a => a instanceof Error) as Error).stack) : undefined });
60
+ originals[level](...args);
61
+ };
62
+ }
63
+ const utils = globals.ErrorUtils;
64
+ const handler = utils?.getGlobalHandler();
65
+ const onError = (error: Error, fatal?: boolean) => {
66
+ captureError(error, fatal ? 'fatal' : 'uncaught');
67
+ handler?.(error, fatal);
68
+ };
69
+ if (utils && handler) utils.setGlobalHandler(onError);
70
+ return () => {
71
+ if (globalThis.fetch === wrappedFetch) globalThis.fetch = originalFetch;
72
+ for (const level of ['log', 'warn', 'error'] as const) console[level] = originals[level];
73
+ if (utils?.getGlobalHandler() === onError && handler) utils.setGlobalHandler(handler);
74
+ };
75
+ }
76
+ function captureError(error: unknown, source = 'boundary') {
77
+ collector?.add('error', { source, message: safeText(error), stack: error instanceof Error ? safeText(error.stack ?? '') : undefined, screen: currentScreen });
78
+ }
79
+
80
+ export const BugSession = {
81
+ configure(config: BugSessionOptions) {
82
+ if (state !== 'idle') throw new Error('Cannot configure an active BugSession');
83
+ options = { ...config, recorderUrl: config.recorderUrl.replace(/\/$/, '') };
84
+ },
85
+ get status() { return state; },
86
+ async start() {
87
+ if (!options) throw new Error('Configure BugSession first');
88
+ if (Platform.OS !== 'android' && Platform.OS !== 'ios') throw new Error('Recording supports iOS and Android only');
89
+ if (Platform.OS === 'ios' && !options.capture) throw new Error('iOS needs the ReplayKit capture adapter and a native development build');
90
+ if (Platform.OS === 'android' && !options.audio) throw new Error('Android needs a microphone adapter');
91
+ if (state !== 'idle') throw new Error(`BugSession is ${state}`);
92
+ state = 'starting';
93
+ rawFetch = globalThis.fetch.bind(globalThis);
94
+ let createdId: string | null = null;
95
+ let audioStarted = false;
96
+ const isIOS = Platform.OS === 'ios';
97
+ const useNativeCapture = isIOS || (Platform.OS === 'android' && !!options.capture);
98
+ try {
99
+ if (Platform.OS === 'android' && options.capture?.prepare) await options.capture.prepare();
100
+ if (!isIOS) await options.audio!.prepare();
101
+ const clock = new SessionClock();
102
+ const nativeClock = useNativeCapture ? await calibrate(clock, () => options.capture!.clock()) : null;
103
+ const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
104
+ collector = new Collector(clock);
105
+ const captureBackend = isIOS ? 'replaykit' : useNativeCapture ? 'mediaprojection' : 'adb';
106
+ const result = await request('/sessions/start', { device: options.device, application: options.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
107
+ createdId = result.id;
108
+ const nativeStart = useNativeCapture ? await options.capture!.start() : undefined;
109
+ const audioStartMs = clock.now();
110
+ if (!isIOS) { options.audio!.start(); audioStarted = true; }
111
+ const nativeVideoStartMs = nativeStart && 'firstVideoNativeMs' in nativeStart
112
+ ? nativeStart.firstVideoNativeMs + nativeClock!.offset
113
+ : undefined;
114
+ session = {
115
+ id: result.id, audioStartMs,
116
+ videoStartMs: isIOS ? 0 : Math.max(0, nativeVideoStartMs ?? result.videoHostStartMs + best.offset),
117
+ uncertaintyMs: best.rtt / 2, captureBackend,
118
+ ...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
119
+ };
120
+ restore = install(collector);
121
+ state = 'recording';
122
+ collector.add('navigation', { from: null, to: currentScreen });
123
+ return session.id;
124
+ } catch (error) {
125
+ if (useNativeCapture) await options.capture?.cancel().catch(() => {});
126
+ if (audioStarted) await options.audio!.stop().catch(() => null);
127
+ if (createdId) await request(`/sessions/${createdId}/abort`, {}).catch(() => {});
128
+ collector = null; session = null; state = 'idle'; throw error;
129
+ }
130
+ },
131
+ async stop() {
132
+ if (state !== 'recording' || !session || !collector) throw new Error('No recording is active');
133
+ state = 'stopping';
134
+ const c = collector;
135
+ const active = session;
136
+ c.add('session-stop', {});
137
+ restore?.(); restore = null;
138
+ try {
139
+ let media: Record<string, unknown>;
140
+ if (active.captureBackend === 'replaykit') {
141
+ const recording = await options.capture!.stop();
142
+ media = { ...active, ...alignNativeRecording(recording, active.nativeOffsetMs!, active.nativeUncertaintyMs!) };
143
+ await options.capture!.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
144
+ } else if (active.captureBackend === 'mediaprojection') {
145
+ const recording = await options.capture!.stop();
146
+ const uri = await options.audio!.stop();
147
+ const audioEndMs = c.clock.now();
148
+ if (!uri) throw new Error('Microphone recording has no file');
149
+ await options.capture!.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
150
+ await options.audio!.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
151
+ media = {
152
+ ...active, audioEndMs, alignment: 'mediaprojection-host-clock',
153
+ videoAlignmentUncertaintyMs: Math.max(250, active.nativeUncertaintyMs ?? 0),
154
+ microphoneAvailable: true, droppedVideoFrames: recording.droppedVideoFrames,
155
+ };
156
+ } else {
157
+ const uri = await options.audio!.stop();
158
+ const audioEndMs = c.clock.now();
159
+ if (!uri) throw new Error('Microphone recording has no file');
160
+ await options.audio!.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
161
+ media = { ...active, audioEndMs };
162
+ }
163
+ return await request(`/sessions/${active.id}/stop`, {
164
+ durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped,
165
+ media,
166
+ });
167
+ } catch (error) {
168
+ if (active.captureBackend !== 'adb') await options.capture!.cancel().catch(() => {});
169
+ // Release the device recorder, but preserve partial files for recovery.
170
+ await request(`/sessions/${active.id}/abort`, { reason: safeText(error), events: c.events, media: active, durationMs: c.clock.now() }).catch(() => {});
171
+ throw error;
172
+ } finally { collector = null; session = null; state = 'idle'; }
173
+ },
174
+ trackAction(action: { name: string; screen?: string }) {
175
+ collector?.add('action', { target: safeText(action.name), screen: safeText(action.screen ?? currentScreen) });
176
+ },
177
+ trackNavigation(screen: string) {
178
+ if (currentScreen !== screen) collector?.add('navigation', { from: currentScreen, to: screen });
179
+ currentScreen = screen;
180
+ },
181
+ captureError,
182
+ /** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
183
+ navigationListener(ref: { getCurrentRoute(): { name: string } | undefined }) {
184
+ return () => { const route = ref.getCurrentRoute(); if (route) BugSession.trackNavigation(route.name); };
185
+ },
186
+ Touchable({ name, screen, onPress, ...props }: PressableProps & { name: string; screen?: string }) {
187
+ return <Pressable {...props} onPress={e => { BugSession.trackAction({ name, screen }); onPress?.(e); }} />;
188
+ },
189
+ Trigger({ position = 'bottom-right', offset = 24, style, accessibilityLabel, children, onError }: BugSessionTriggerProps) {
190
+ const [busy, setBusy] = React.useState(false);
191
+ const recording = state === 'recording';
192
+ const edge = position.includes('right') ? { right: offset } : { left: offset };
193
+ const vertical = position.includes('bottom') ? { bottom: offset } : { top: offset };
194
+ const toggle = async () => {
195
+ if (busy) return;
196
+ setBusy(true);
197
+ try {
198
+ if (state === 'recording') await BugSession.stop();
199
+ else await BugSession.start();
200
+ } catch (error) { onError?.(error); }
201
+ finally { setBusy(false); }
202
+ };
203
+ return <View pointerEvents="box-none" style={[styles.triggerLayer, edge, vertical]}>
204
+ <Pressable
205
+ accessibilityRole="button"
206
+ accessibilityLabel={accessibilityLabel ?? (recording ? 'Stop BugSession recording' : 'Start BugSession recording')}
207
+ accessibilityState={{ busy, selected: recording }}
208
+ disabled={busy}
209
+ onPress={() => { void toggle(); }}
210
+ style={[styles.trigger, recording && styles.triggerRecording, style]}
211
+ >
212
+ {children ?? <><Text style={styles.triggerMark}>b</Text><View style={[styles.triggerDot, recording && styles.triggerDotRecording]} /></>}
213
+ </Pressable>
214
+ </View>;
215
+ },
216
+ };
217
+
218
+ const styles = StyleSheet.create({
219
+ triggerLayer: { position: 'absolute', zIndex: 9999 },
220
+ trigger: { width: 58, height: 58, borderRadius: 29, backgroundColor: '#173B34', alignItems: 'center', justifyContent: 'center', elevation: 8, shadowColor: '#000', shadowOpacity: 0.24, shadowRadius: 8, shadowOffset: { width: 0, height: 4 } },
221
+ triggerRecording: { backgroundColor: '#B9382F' },
222
+ triggerMark: { color: '#F4F4EC', fontSize: 31, fontWeight: '800', lineHeight: 38 },
223
+ triggerDot: { position: 'absolute', right: 9, bottom: 9, width: 10, height: 10, borderRadius: 5, backgroundColor: '#D8F36A', borderWidth: 1, borderColor: '#173B34' },
224
+ triggerDotRecording: { backgroundColor: '#FFF', borderColor: '#B9382F' },
225
+ });