@fixback/expo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fixback
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # @fixback/expo
2
+
3
+ The [Fixback](https://github.com/wemuda/fixback) capture SDK for **Expo / React
4
+ Native apps**: shake the device to open a feedback composer, and every report
5
+ ships with trace Evidence — console output, network metadata, screen changes —
6
+ plus an optional screenshot. Uncaught JS errors report themselves
7
+ (`source: auto`), exactly like the web SDK.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @fixback/expo
13
+ npx expo install expo-sensors react-native-view-shot @react-native-async-storage/async-storage
14
+ ```
15
+
16
+ The three Expo-ecosystem packages are peer dependencies (they carry the native
17
+ modules for the accelerometer, screenshots, and id persistence). All of them
18
+ work in Expo Go.
19
+
20
+ ## Quick start
21
+
22
+ ```tsx
23
+ import { FixbackProvider } from "@fixback/expo";
24
+
25
+ export default function App() {
26
+ return (
27
+ <FixbackProvider
28
+ options={{
29
+ key: "pk_…", // the Project's publishable key
30
+ origin: "https://com.acme.myapp", // an origin allowlisted on the Project
31
+ }}
32
+ >
33
+ <RootNavigator />
34
+ </FixbackProvider>
35
+ );
36
+ }
37
+ ```
38
+
39
+ Shake the device → the composer opens → write a comment, review (or remove)
40
+ the screenshot, send. There is no Kind to pick — Fixback's analysis classifies
41
+ the Issue (bug / improvement / idea) server-side.
42
+
43
+ ### The `origin` option
44
+
45
+ Fixback's ingest matches every request's `Origin` header against the Project's
46
+ allowed origins. Browsers send that header automatically; a native app does
47
+ not, so the SDK sends the `origin` you configure — canonicalized the same way
48
+ the dashboard stores allowlist entries (lowercased host, default ports elided,
49
+ path dropped), so a pasted mixed-case bundle id still matches. Add the same
50
+ value to your Project's **allowed origins** in the Fixback dashboard.
51
+ Convention: your app's reverse-DNS id as an https host
52
+ (`https://com.acme.myapp`), or simply your product's existing web origin.
53
+ Until the origin is allowlisted the SDK stays dormant (a dev-build warning
54
+ explains why).
55
+
56
+ ## Triggers
57
+
58
+ - **Shake** (default): tuned to a deliberate shake — threshold, peak count,
59
+ window, and cooldown are configurable via `options.shake`;
60
+ `shake: { enabled: false }` turns the gesture off.
61
+ - **Programmatic**: `useFixback().present()` in a component, or the
62
+ module-level `Fixback.present()` anywhere.
63
+
64
+ > **Development builds:** the Expo dev menu also opens on shake, so both
65
+ > appear. Use `present()` while developing; production builds have no dev
66
+ > menu.
67
+
68
+ ## Screens in reports
69
+
70
+ React Native has no URL, so tell Fixback where the Reporter is:
71
+
72
+ ```tsx
73
+ const { trackScreen } = useFixback();
74
+ // e.g. in your navigation container's state-change handler:
75
+ trackScreen(routeName);
76
+ ```
77
+
78
+ Each change records a `navigation` trace crumb, and the active screen names
79
+ the report's URL (`<origin>/<screen>`).
80
+
81
+ ## Options
82
+
83
+ | Option | Default | What it does |
84
+ | --- | --- | --- |
85
+ | `key` | — | The Project's publishable key (required). |
86
+ | `origin` | — | The origin sent on every request (required in practice — see above). |
87
+ | `apiUrl` | `https://api.fixback.dev` | Self-hosted Fixback API base. |
88
+ | `signedIdentity` | — | A server-minted identity JWT (`internal` tier). |
89
+ | `reporterName` / `reporterEmail` | — | Display-only reporter fields. |
90
+ | `capture` | server config | `{ console?, network? }` per-stream trace toggles. |
91
+ | `autoCapture` | `true` | Automatic uncaught-error reports. |
92
+ | `screenshots` | `true` | Capture a screenshot when the composer opens. |
93
+ | `beforeSend` | — | Reshape or drop (`null`) any report before transport. |
94
+ | `scrub` | `true` | The built-in URL/PII scrubbers. |
95
+ | `beforeBreadcrumb` | — | Filter/edit trace entries at the source. |
96
+ | `shake` | on | `{ enabled?, thresholdG?, minPeaks?, windowMs?, minGapMs?, cooldownMs?, sampleIntervalMs? }`. |
97
+
98
+ ## Privacy
99
+
100
+ The same client-side scrubbing as the web SDK: URLs lose query strings and
101
+ path PII, console/error text loses emails, bearer tokens, and long digit runs,
102
+ network crumbs never carry bodies or headers. One difference is deliberate:
103
+ mobile screenshots are **not** auto-masked — so a screenshot only ever ships
104
+ from the composer, where the Reporter sees exactly what will be sent and can
105
+ remove it. **Automatic error reports never attach a screenshot.** A screenshot
106
+ the Reporter removes (or that has been delivered) is deleted from the app's
107
+ cache, and `screenshots: false` disables capture entirely. `beforeSend`
108
+ remains the programmatic choke point.
109
+
110
+ ## What's not here (yet)
111
+
112
+ Invite redemption (deep links), element annotation/drawing, session replay,
113
+ unhandled-promise-rejection and native-crash capture. See the repo's
114
+ `docs/specs/0004-expo-reporter-sdk.md` and ADR-0021 for the full contract.
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The feedback **composer** — the mobile counterpart of the web overlay's
3
+ * report panel (spec 0004 §C), styled in the Signal language from the vendored
4
+ * token slice. Opens on shake (or `present()`), collects Kind + comment, shows
5
+ * the exact screenshot that will ship (removable — the mobile privacy stance,
6
+ * ADR-0021), and submits through the client the provider hands in.
7
+ *
8
+ * Deliberately thin: every decision beyond local UI state lives in
9
+ * `client.ts`, which is where the behaviour is unit-tested.
10
+ */
11
+ import type { ComposerDraft } from "./client";
12
+ import type { ScreenshotFile, SubmitResult } from "./submit";
13
+ /** What the provider wires into the composer. */
14
+ export interface FeedbackModalProps {
15
+ readonly visible: boolean;
16
+ /** The screenshot captured as the composer opened, if any. */
17
+ readonly screenshot: ScreenshotFile | null;
18
+ readonly onDismiss: () => void;
19
+ readonly onSubmit: (draft: ComposerDraft) => Promise<SubmitResult>;
20
+ /** Release a screenshot the Reporter removed or abandoned (spec 0004 §C). */
21
+ readonly onDiscardScreenshot?: (screenshot: ScreenshotFile) => void;
22
+ }
23
+ export declare function FeedbackModal({ visible, screenshot, onDismiss, onSubmit, onDiscardScreenshot, }: FeedbackModalProps): import("react").JSX.Element;
@@ -0,0 +1,201 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The feedback **composer** — the mobile counterpart of the web overlay's
4
+ * report panel (spec 0004 §C), styled in the Signal language from the vendored
5
+ * token slice. Opens on shake (or `present()`), collects Kind + comment, shows
6
+ * the exact screenshot that will ship (removable — the mobile privacy stance,
7
+ * ADR-0021), and submits through the client the provider hands in.
8
+ *
9
+ * Deliberately thin: every decision beyond local UI state lives in
10
+ * `client.ts`, which is where the behaviour is unit-tested.
11
+ */
12
+ import { useEffect, useRef, useState } from "react";
13
+ import { ActivityIndicator, Image, KeyboardAvoidingView, Modal, Platform, Pressable, StyleSheet, Text, TextInput, View, } from "react-native";
14
+ import { signal } from "./tokens";
15
+ const MONO_FONT = Platform.select({ ios: "Menlo", default: "monospace" });
16
+ /** How long the "Sent" confirmation lingers before the composer closes. */
17
+ const SENT_DISMISS_MS = 900;
18
+ export function FeedbackModal({ visible, screenshot, onDismiss, onSubmit, onDiscardScreenshot, }) {
19
+ const [comment, setComment] = useState("");
20
+ const [includeScreenshot, setIncludeScreenshot] = useState(true);
21
+ const [phase, setPhase] = useState("compose");
22
+ const dismissTimer = useRef(null);
23
+ // A fresh open starts a fresh draft.
24
+ useEffect(() => {
25
+ if (visible) {
26
+ setComment("");
27
+ setIncludeScreenshot(true);
28
+ setPhase("compose");
29
+ }
30
+ return () => {
31
+ if (dismissTimer.current) {
32
+ clearTimeout(dismissTimer.current);
33
+ dismissTimer.current = null;
34
+ }
35
+ };
36
+ }, [visible]);
37
+ const busy = phase === "sending" || phase === "sent";
38
+ async function send() {
39
+ if (busy)
40
+ return;
41
+ setPhase("sending");
42
+ // No Kind on the draft — analysis classifies the Issue server-side (ADR-0023).
43
+ const result = await onSubmit({
44
+ comment,
45
+ screenshot: includeScreenshot ? screenshot : null,
46
+ });
47
+ if (result.ok) {
48
+ setPhase("sent");
49
+ dismissTimer.current = setTimeout(onDismiss, SENT_DISMISS_MS);
50
+ }
51
+ else {
52
+ setPhase("failed");
53
+ }
54
+ }
55
+ /**
56
+ * Dismissal is never blocked — a stalled submission must not trap the
57
+ * Reporter in the composer (the in-flight request continues harmlessly).
58
+ * A draft's unsent screenshot is released on the way out; while sending or
59
+ * sent, the transport owns the file (released by the client on delivery).
60
+ */
61
+ function dismiss() {
62
+ if (screenshot &&
63
+ includeScreenshot &&
64
+ (phase === "compose" || phase === "failed")) {
65
+ onDiscardScreenshot?.(screenshot);
66
+ }
67
+ onDismiss();
68
+ }
69
+ function removeScreenshot() {
70
+ if (screenshot)
71
+ onDiscardScreenshot?.(screenshot);
72
+ setIncludeScreenshot(false);
73
+ }
74
+ return (_jsx(Modal, { visible: visible, transparent: true, animationType: "slide", onRequestClose: dismiss, children: _jsxs(View, { style: styles.backdrop, children: [_jsx(Pressable, { style: styles.backdropTouch, accessibilityLabel: "Dismiss feedback", onPress: dismiss }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.sheet, children: [_jsx(Text, { style: styles.overline, children: "FIXBACK \u00B7 REPORT" }), _jsx(Text, { style: styles.title, children: "Send feedback" }), _jsx(TextInput, { style: styles.comment, multiline: true, editable: !busy, placeholder: "What happened? What did you expect?", placeholderTextColor: signal.textGhost, value: comment, onChangeText: setComment, accessibilityLabel: "Feedback comment" }), screenshot && includeScreenshot ? (_jsxs(View, { style: styles.screenshotRow, children: [_jsx(Image, { source: { uri: screenshot.uri }, style: styles.screenshotThumb, resizeMode: "cover", accessibilityLabel: "Screenshot preview" }), _jsxs(View, { style: styles.screenshotMeta, children: [_jsx(Text, { style: styles.screenshotLabel, children: "Screenshot attached" }), _jsx(Text, { style: styles.screenshotHint, children: "Exactly this image is sent \u2014 remove it if it shows something private." })] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Remove screenshot", disabled: busy, onPress: removeScreenshot, children: _jsx(Text, { style: styles.removeLabel, children: "Remove" }) })] })) : null, phase === "failed" ? (_jsx(Text, { style: styles.failed, children: "Couldn't send \u2014 check your connection and try again." })) : null, phase === "sent" ? (_jsx(Text, { style: styles.sent, children: "Sent \u2014 thank you." })) : null, _jsxs(View, { style: styles.footer, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Cancel", onPress: dismiss, style: styles.cancelButton, children: _jsx(Text, { style: styles.cancelLabel, children: "Cancel" }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Send feedback", disabled: busy, onPress: () => void send(), style: [styles.sendButton, busy && styles.sendButtonBusy], children: phase === "sending" ? (_jsx(ActivityIndicator, { size: "small", color: signal.onEmphasis })) : (_jsx(Text, { style: styles.sendLabel, children: "Send" })) })] })] }) })] }) }));
75
+ }
76
+ const styles = StyleSheet.create({
77
+ backdrop: {
78
+ flex: 1,
79
+ justifyContent: "flex-end",
80
+ backgroundColor: signal.scrim,
81
+ },
82
+ backdropTouch: {
83
+ flex: 1,
84
+ },
85
+ sheet: {
86
+ backgroundColor: signal.surface,
87
+ borderTopLeftRadius: 14,
88
+ borderTopRightRadius: 14,
89
+ borderColor: signal.border,
90
+ borderWidth: 1,
91
+ paddingHorizontal: 16,
92
+ paddingTop: 14,
93
+ paddingBottom: 24,
94
+ },
95
+ overline: {
96
+ fontFamily: MONO_FONT,
97
+ fontSize: 10,
98
+ letterSpacing: 1.2,
99
+ color: signal.textFaint,
100
+ marginBottom: 2,
101
+ },
102
+ title: {
103
+ fontSize: 15,
104
+ fontWeight: "600",
105
+ color: signal.text,
106
+ marginBottom: 12,
107
+ },
108
+ comment: {
109
+ minHeight: 96,
110
+ borderWidth: 1,
111
+ borderColor: signal.borderControl,
112
+ borderRadius: 8,
113
+ backgroundColor: signal.canvas,
114
+ color: signal.text,
115
+ fontSize: 13,
116
+ padding: 10,
117
+ textAlignVertical: "top",
118
+ marginBottom: 12,
119
+ },
120
+ screenshotRow: {
121
+ flexDirection: "row",
122
+ alignItems: "center",
123
+ gap: 10,
124
+ borderWidth: 1,
125
+ borderColor: signal.border,
126
+ borderRadius: 8,
127
+ backgroundColor: signal.sunken,
128
+ padding: 8,
129
+ marginBottom: 12,
130
+ },
131
+ screenshotThumb: {
132
+ width: 40,
133
+ height: 72,
134
+ borderRadius: 4,
135
+ borderWidth: 1,
136
+ borderColor: signal.borderStrong,
137
+ backgroundColor: signal.surface,
138
+ },
139
+ screenshotMeta: {
140
+ flex: 1,
141
+ },
142
+ screenshotLabel: {
143
+ fontSize: 12,
144
+ fontWeight: "600",
145
+ color: signal.textPanel,
146
+ },
147
+ screenshotHint: {
148
+ fontSize: 11,
149
+ color: signal.textFaint,
150
+ marginTop: 2,
151
+ },
152
+ removeLabel: {
153
+ fontSize: 12,
154
+ fontWeight: "600",
155
+ color: signal.danger,
156
+ padding: 4,
157
+ },
158
+ failed: {
159
+ fontSize: 12,
160
+ color: signal.danger,
161
+ marginBottom: 10,
162
+ },
163
+ sent: {
164
+ fontSize: 12,
165
+ color: signal.success,
166
+ marginBottom: 10,
167
+ },
168
+ footer: {
169
+ flexDirection: "row",
170
+ justifyContent: "flex-end",
171
+ gap: 8,
172
+ },
173
+ cancelButton: {
174
+ borderWidth: 1,
175
+ borderColor: signal.borderControl,
176
+ borderRadius: 8,
177
+ paddingHorizontal: 14,
178
+ paddingVertical: 9,
179
+ backgroundColor: signal.surface,
180
+ },
181
+ cancelLabel: {
182
+ fontSize: 13,
183
+ color: signal.textMuted,
184
+ },
185
+ sendButton: {
186
+ borderRadius: 8,
187
+ paddingHorizontal: 18,
188
+ paddingVertical: 9,
189
+ backgroundColor: signal.accent,
190
+ minWidth: 72,
191
+ alignItems: "center",
192
+ },
193
+ sendButtonBusy: {
194
+ opacity: 0.8,
195
+ },
196
+ sendLabel: {
197
+ fontSize: 13,
198
+ fontWeight: "600",
199
+ color: signal.onEmphasis,
200
+ },
201
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `<FixbackProvider>` — the one component a host app mounts (spec 0004 §A).
3
+ *
4
+ * A thin React shell over the headless client: it starts/stops the client with
5
+ * the component lifecycle, arms the shake gesture while the client is ready,
6
+ * listens for composer-open requests, and renders the composer modal inside
7
+ * the app's own tree. `useFixback()` (and the module-level `Fixback` object,
8
+ * for non-React call sites) expose `present` / `dismiss` / `trackScreen`.
9
+ */
10
+ import { type ReactNode } from "react";
11
+ import { type FixbackOptions } from "./client";
12
+ /** What `useFixback()` returns. */
13
+ export interface FixbackHandle {
14
+ /** Open the feedback composer (a no-op while the SDK is dormant). */
15
+ present: () => void;
16
+ /** Close the composer if it is open. */
17
+ dismiss: () => void;
18
+ /** Record a screen change for the trace and the report `url` (spec 0004 §D). */
19
+ trackScreen: (name: string) => void;
20
+ }
21
+ /**
22
+ * The provider's handle, or an inert one outside a provider — the SDK never
23
+ * throws into the host app, hooks included.
24
+ */
25
+ export declare function useFixback(): FixbackHandle;
26
+ /**
27
+ * The module-level escape hatch (spec 0004 §B): `Fixback.present()` from any
28
+ * plain function — a settings row, a dev menu item, an error boundary.
29
+ */
30
+ export declare const Fixback: FixbackHandle;
31
+ export interface FixbackProviderProps {
32
+ readonly options: FixbackOptions;
33
+ readonly children?: ReactNode;
34
+ }
35
+ export declare function FixbackProvider({ options, children }: FixbackProviderProps): import("react").JSX.Element;
@@ -0,0 +1,102 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * `<FixbackProvider>` — the one component a host app mounts (spec 0004 §A).
4
+ *
5
+ * A thin React shell over the headless client: it starts/stops the client with
6
+ * the component lifecycle, arms the shake gesture while the client is ready,
7
+ * listens for composer-open requests, and renders the composer modal inside
8
+ * the app's own tree. `useFixback()` (and the module-level `Fixback` object,
9
+ * for non-React call sites) expose `present` / `dismiss` / `trackScreen`.
10
+ */
11
+ import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
12
+ import { createNativeAdapters, startShakeListener } from "./adapters";
13
+ import { createFixbackClient, } from "./client";
14
+ import { FeedbackModal } from "./FeedbackModal";
15
+ import { createShakeDetector } from "./shake";
16
+ const NOOP_HANDLE = {
17
+ present: () => { },
18
+ dismiss: () => { },
19
+ trackScreen: () => { },
20
+ };
21
+ const FixbackContext = createContext(null);
22
+ /**
23
+ * The provider's handle, or an inert one outside a provider — the SDK never
24
+ * throws into the host app, hooks included.
25
+ */
26
+ export function useFixback() {
27
+ return useContext(FixbackContext) ?? NOOP_HANDLE;
28
+ }
29
+ /** The most recently mounted provider's handle, for non-React call sites. */
30
+ let activeHandle = null;
31
+ /**
32
+ * The module-level escape hatch (spec 0004 §B): `Fixback.present()` from any
33
+ * plain function — a settings row, a dev menu item, an error boundary.
34
+ */
35
+ export const Fixback = {
36
+ present: () => activeHandle?.present(),
37
+ dismiss: () => activeHandle?.dismiss(),
38
+ trackScreen: (name) => activeHandle?.trackScreen(name),
39
+ };
40
+ export function FixbackProvider({ options, children }) {
41
+ // The client is created once per mounted provider; option changes after
42
+ // mount are deliberately ignored (remount to reconfigure), matching the web
43
+ // SDK's one-shot `init`.
44
+ const [client] = useState(() => createFixbackClient(options, createNativeAdapters()));
45
+ const [status, setStatus] = useState(client.getStatus());
46
+ const [composer, setComposer] = useState(null);
47
+ const composerOpenRef = useRef(false);
48
+ useEffect(() => {
49
+ const unsubscribe = client.onStatus(setStatus);
50
+ void client.start();
51
+ return () => {
52
+ unsubscribe();
53
+ client.destroy();
54
+ };
55
+ }, [client]);
56
+ useEffect(() => {
57
+ return client.onPresent((context) => {
58
+ if (composerOpenRef.current) {
59
+ // The composer is already open — this capture is never shown, so
60
+ // release its tmpfile instead of leaking it.
61
+ client.discardScreenshot(context.screenshot);
62
+ return;
63
+ }
64
+ composerOpenRef.current = true;
65
+ setComposer(context);
66
+ });
67
+ }, [client]);
68
+ // The shake gesture is armed only while the client can actually submit, so
69
+ // a dormant SDK never keeps the accelerometer running.
70
+ useEffect(() => {
71
+ if (status !== "ready")
72
+ return;
73
+ const config = client.shakeConfig();
74
+ if (!config.enabled)
75
+ return;
76
+ const detector = createShakeDetector({
77
+ ...config,
78
+ onShake: () => {
79
+ if (!composerOpenRef.current)
80
+ void client.present();
81
+ },
82
+ });
83
+ const subscription = startShakeListener((sample) => detector.sample(sample), config.sampleIntervalMs);
84
+ return () => subscription.remove();
85
+ }, [client, status]);
86
+ const handle = useMemo(() => ({
87
+ present: () => void client.present(),
88
+ dismiss: () => {
89
+ composerOpenRef.current = false;
90
+ setComposer(null);
91
+ },
92
+ trackScreen: (name) => client.trackScreen(name),
93
+ }), [client]);
94
+ useEffect(() => {
95
+ activeHandle = handle;
96
+ return () => {
97
+ if (activeHandle === handle)
98
+ activeHandle = null;
99
+ };
100
+ }, [handle]);
101
+ return (_jsxs(FixbackContext.Provider, { value: handle, children: [children, _jsx(FeedbackModal, { visible: composer !== null, screenshot: composer?.screenshot ?? null, onDismiss: handle.dismiss, onSubmit: (draft) => client.submit(draft), onDiscardScreenshot: (shot) => client.discardScreenshot(shot) })] }));
102
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The real React Native / Expo adapters behind the client's injectable seams
3
+ * (`FixbackAdapters`), plus the accelerometer wiring for the shake gesture.
4
+ *
5
+ * This is the only module (besides the UI) that imports platform packages, so
6
+ * everything else stays unit-testable in plain node. Every adapter is
7
+ * defensive: a missing native module or a capture failure degrades that one
8
+ * capability instead of throwing into the host app.
9
+ */
10
+ import type { FixbackAdapters } from "./client";
11
+ import type { ShakeSample } from "./shake";
12
+ /** Build the real adapter set `FixbackProvider` hands to the client. */
13
+ export declare function createNativeAdapters(): FixbackAdapters;
14
+ /** How often the accelerometer reports while the gesture is armed. */
15
+ export declare const SHAKE_UPDATE_INTERVAL_MS = 80;
16
+ /** A removable shake-listener handle. */
17
+ export interface ShakeSubscription {
18
+ remove(): void;
19
+ }
20
+ /**
21
+ * Feed accelerometer samples to a listener (the shake detector). Availability
22
+ * is checked first (a simulator has no accelerometer); an unavailable or
23
+ * missing sensor resolves to an inert subscription. `remove` also cancels a
24
+ * subscription still waiting on the availability check.
25
+ */
26
+ export declare function startShakeListener(onSample: (sample: ShakeSample) => void, updateIntervalMs?: number): ShakeSubscription;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The real React Native / Expo adapters behind the client's injectable seams
3
+ * (`FixbackAdapters`), plus the accelerometer wiring for the shake gesture.
4
+ *
5
+ * This is the only module (besides the UI) that imports platform packages, so
6
+ * everything else stays unit-testable in plain node. Every adapter is
7
+ * defensive: a missing native module or a capture failure degrades that one
8
+ * capability instead of throwing into the host app.
9
+ */
10
+ import AsyncStorage from "@react-native-async-storage/async-storage";
11
+ import { Accelerometer } from "expo-sensors";
12
+ import { AppState, Dimensions, Platform } from "react-native";
13
+ import { captureScreen, releaseCapture } from "react-native-view-shot";
14
+ import { globalConsole, globalXhr } from "./breadcrumbs";
15
+ import { globalErrorUtils } from "./error-capture";
16
+ /** Read the device environment: window size + `<os> <version>`. */
17
+ function readEnvironment() {
18
+ try {
19
+ const { width, height } = Dimensions.get("window");
20
+ return {
21
+ windowWidth: width,
22
+ windowHeight: height,
23
+ os: `${Platform.OS} ${String(Platform.Version)}`,
24
+ };
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ /**
31
+ * Capture the current screen as a png tmpfile — best-effort: any failure (no
32
+ * native module, capture denied) resolves to `null` and the report simply
33
+ * ships without a screenshot (spec 0004 §C).
34
+ */
35
+ async function captureScreenshot() {
36
+ try {
37
+ const uri = await captureScreen({
38
+ format: "png",
39
+ quality: 0.9,
40
+ result: "tmpfile",
41
+ });
42
+ if (typeof uri !== "string" || uri.length === 0)
43
+ return null;
44
+ return { uri, name: "screenshot.png", type: "image/png" };
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * Delete a captured screenshot's tmpfile. Best-effort and repeat-safe — an
52
+ * image the Reporter removed as private must not linger in the app's cache
53
+ * (spec 0004 §C).
54
+ */
55
+ function releaseScreenshot(uri) {
56
+ try {
57
+ releaseCapture(uri);
58
+ }
59
+ catch {
60
+ /* release is best-effort */
61
+ }
62
+ }
63
+ /** A development-only warning; production builds stay silent. */
64
+ function devWarn(message) {
65
+ const g = globalThis;
66
+ if (g.__DEV__ === true) {
67
+ try {
68
+ g.console?.warn?.(message);
69
+ }
70
+ catch {
71
+ /* never throw into the host app */
72
+ }
73
+ }
74
+ }
75
+ /** Build the real adapter set `FixbackProvider` hands to the client. */
76
+ export function createNativeAdapters() {
77
+ return {
78
+ storage: AsyncStorage,
79
+ environment: readEnvironment,
80
+ captureScreenshot,
81
+ releaseScreenshot,
82
+ consoleObj: globalConsole() ?? null,
83
+ xhr: globalXhr() ?? null,
84
+ errorUtils: globalErrorUtils() ?? null,
85
+ appState: AppState,
86
+ warn: devWarn,
87
+ };
88
+ }
89
+ /** How often the accelerometer reports while the gesture is armed. */
90
+ export const SHAKE_UPDATE_INTERVAL_MS = 80;
91
+ /**
92
+ * Feed accelerometer samples to a listener (the shake detector). Availability
93
+ * is checked first (a simulator has no accelerometer); an unavailable or
94
+ * missing sensor resolves to an inert subscription. `remove` also cancels a
95
+ * subscription still waiting on the availability check.
96
+ */
97
+ export function startShakeListener(onSample, updateIntervalMs = SHAKE_UPDATE_INTERVAL_MS) {
98
+ let removed = false;
99
+ let subscription = null;
100
+ void (async () => {
101
+ try {
102
+ const available = await Accelerometer.isAvailableAsync();
103
+ if (!available || removed)
104
+ return;
105
+ // The update interval is global to the sensor, shared with the host
106
+ // app's own listeners — only claim it when nobody else is listening, so
107
+ // arming the gesture never degrades a host feature's sample rate.
108
+ const listenerCount = typeof Accelerometer.getListenerCount === "function"
109
+ ? Accelerometer.getListenerCount()
110
+ : 0;
111
+ if (listenerCount === 0) {
112
+ Accelerometer.setUpdateInterval(updateIntervalMs);
113
+ }
114
+ subscription = Accelerometer.addListener(({ x, y, z }) => {
115
+ try {
116
+ onSample({ x, y, z, at: Date.now() });
117
+ }
118
+ catch {
119
+ /* the detector must never throw into the sensor callback */
120
+ }
121
+ });
122
+ if (removed) {
123
+ subscription.remove();
124
+ subscription = null;
125
+ }
126
+ }
127
+ catch {
128
+ // No sensor module — the gesture simply stays unarmed.
129
+ }
130
+ })();
131
+ return {
132
+ remove() {
133
+ removed = true;
134
+ try {
135
+ subscription?.remove();
136
+ }
137
+ catch {
138
+ /* teardown is best-effort */
139
+ }
140
+ subscription = null;
141
+ },
142
+ };
143
+ }