diff --git a/App.tsx b/App.tsx
index f3ace64ec..1e95d785b 100644
--- a/App.tsx
+++ b/App.tsx
@@ -39,13 +39,21 @@ import { useDownloadStore } from './src/stores/downloadStore';
import { ErrorBoundary } from './src/components/ErrorBoundary';
import { DebugLogsScreen } from './src/components/DebugLogsScreen';
import { LoadingDots } from './src/components/LoadingDots';
+import { AnalyticsConsentPrompt } from './src/components/AnalyticsConsentPrompt';
+import {
+ startAnalytics,
+ trackScreen,
+} from './src/services/analytics/analytics';
+import { featureSnapshot } from './src/services/analytics/featureSnapshot';
LogBox.ignoreAllLogs(); // Suppress all logs
let stopStartupProbe: (() => void) | null = null;
-// Dev-only: mirror logger output into the in-app Debug Logs viewer. The whole block
-// is behind __DEV__, so release builds keep main's no-op logger (zero logging cost).
-if (__DEV__) {
+let stopAnalytics: (() => void) | null = null;
+// Every build mirrors logger output into the in-app Debug Logs viewer and the size-capped log
+// file, so Report a problem carries a trace from a store build too. Log lines hold metadata only,
+// never chat content (docs/ANALYTICS.md, Report a problem). The console stays dev-only (logger.ts).
+{
const fmt = (a: unknown): string => {
if (a instanceof Error) return `${a.name}: ${a.message}`;
if (typeof a === 'string') return a;
@@ -68,7 +76,7 @@ if (__DEV__) {
initDebugLogFile();
// Immediately after the sink exists, so the first sample lands before anything heavy runs. The app
// was being killed by iOS at launch with the log going silent half a second in; this says where it
- // stops and what memory was doing when it did.
+ // stops and what memory was doing when it did. Dev builds only (the probe is a no-op elsewhere).
stopStartupProbe = startStartupMemoryProbe();
}
@@ -373,6 +381,9 @@ function App() {
logger.log('[BOOT] startup complete');
setIsInitializing(false);
+ // Settings are hydrated: analytics starts only now, and only if the person opted in.
+ stopAnalytics = startAnalytics(featureSnapshot);
+
// Reconcile downloaded Whisper models against disk at startup. presentModelIds
// isn't persisted (the filesystem is the source of truth), so it rehydrates
// empty — without this scan a freshly launched app shows an already-installed
@@ -406,6 +417,8 @@ function App() {
return () => {
startupGeneration.current += 1;
stopNetworkReconnectWatcher();
+ stopAnalytics?.();
+ stopAnalytics = null;
};
}, [initializeApp]);
@@ -447,7 +460,11 @@ function App() {
{AppRoot ? : null}
{
+ applyPendingProRedirect();
+ trackScreen(appNavigationRef.getCurrentRoute()?.name);
+ }}
+ onStateChange={() => trackScreen(appNavigationRef.getCurrentRoute()?.name)}
theme={{
dark: isDark,
colors: {
@@ -480,6 +497,7 @@ function App() {
>
+
{__DEV__ && showSyncStatusBar ? : null}
diff --git a/README.md b/README.md
index 630813f84..c86620572 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,9 @@
### The Swiss Army Knife of On-Device AI
-**Chat. Generate images. Use tools. See. Listen. All on your phone or Mac. All offline. Zero data leaves your device.**
+**Chat. Generate images. Use tools. See. Listen. All on your phone or Mac. All offline. Your chats and files never leave your device.**
+
+Anonymous usage data and crash reports only if you turn them on. See [what we share](docs/ANALYTICS.md).
[](https://github.com/off-grid-ai/OGAM)
[](LICENSE)
@@ -223,7 +225,7 @@ Built on the shoulders of giants:
**Off Grid AI** — Your AI, your device, your data.
-*No cloud. No data harvesting. Just AI that works anywhere.*
+*No cloud. Your chats and files never leave your device. Just AI that works anywhere.*
[Join the Community on Slack](https://join.slack.com/t/off-grid-mobile/shared_invite/zt-494gbuv5c-kj9tT7U0AS8Kq8UUO63_jw)
diff --git a/__tests__/hardening/batch9-diagnostics-debuglog.test.ts b/__tests__/hardening/batch9-diagnostics-debuglog.test.ts
index 5d831b032..3452e02d4 100644
--- a/__tests__/hardening/batch9-diagnostics-debuglog.test.ts
+++ b/__tests__/hardening/batch9-diagnostics-debuglog.test.ts
@@ -14,9 +14,9 @@
* (getDeviceTier thresholds + formatBytes are already COVERED-REAL in
* hardware.test.ts and are NOT duplicated here.)
*
- * 2. debugLogFile.ts — the dev file sink's size-cap/rotation logic (flush appends, checks
+ * 2. debugLogFile.ts — the file sink's size-cap/rotation logic (flush appends, checks
* stat, and truncates to the newest half once over MAX_BYTES), the flush-at-50-lines
- * trigger, the disabled no-op, and __DEV__/idempotent init. No existing test covers it.
+ * trigger, the disabled no-op, idempotent init, and the dev-only wire file. No existing test covers it.
* We stand up an in-memory RNFS so the REAL flush()/rotation runs end to end.
*/
@@ -109,7 +109,7 @@ describe('BATCH 9 — debugLogFile size-cap / rotation (real flush logic)', () =
: '';
beforeEach(() => {
- (global as any).__DEV__ = true; // the sink is __DEV__-gated
+ (global as any).__DEV__ = true; // the wire file is __DEV__-gated
jest.resetModules(); // fresh module-level `enabled`/`buffer` state per test
fileSystem = require('../harness/nativeFileSystem').defaultNativeFileSystemBoundary;
fileSystem.reset();
@@ -182,13 +182,16 @@ describe('BATCH 9 — debugLogFile size-cap / rotation (real flush logic)', () =
expect(mod.getDebugLogPath()).toBe('/mock/documents/offgrid-debug.log');
});
- it('initDebugLogFile is a no-op outside __DEV__', async () => {
+ it('a store build writes the log for Report a problem, but never the unbounded wire file', async () => {
(global as any).__DEV__ = false;
jest.resetModules();
+ fileSystem = require('../harness/nativeFileSystem').defaultNativeFileSystemBoundary;
const prodMod = require('../../src/utils/debugLogFile');
prodMod.initDebugLogFile();
prodMod.appendDebugLine('info', 'prod line');
+ prodMod.appendDebugLine('log', '[WIRE-STT] {"textLength":3}');
await jest.runOnlyPendingTimersAsync();
- expect(await readLog()).toBe('');
+ expect(await readLog()).toContain('prod line');
+ expect(await fileSystem.exists(prodMod.getWireLogPath())).toBe(false);
});
});
diff --git a/__tests__/harness/analyticsApp.ts b/__tests__/harness/analyticsApp.ts
new file mode 100644
index 000000000..a0d630047
--- /dev/null
+++ b/__tests__/harness/analyticsApp.ts
@@ -0,0 +1,139 @@
+/**
+ * The real App on the production navigation stack, for opt-in analytics journeys.
+ *
+ * Device boundaries only: the llama.rn engine, an in-memory disk holding one downloaded model, the
+ * RAM sensor, the OS app-state events, and the PostHog endpoint at `fetch`. A fresh install opens on
+ * Onboarding, so every journey passes Auto Setup the way a new user does.
+ */
+import { GB, installNativeBoundary, requireRTL, type NativeBoundary } from './nativeBoundary';
+import { installPostHogEndpoint, type PostHogEndpoint } from './postHogEndpoint';
+import { createDownloadedModel } from '../utils/factories';
+
+type DevGlobal = { __DEV__?: boolean };
+
+export interface AnalyticsApp {
+ boundary: NativeBoundary;
+ endpoint: PostHogEndpoint;
+ rtl: ReturnType;
+ view: ReturnType['render']>;
+}
+
+const originalDev = (global as DevGlobal).__DEV__;
+
+/** Put `__DEV__` back after a test that ran as a release build. */
+export function restoreBuildFlavor(): void {
+ (global as DevGlobal).__DEV__ = originalDev;
+}
+
+/**
+ * Launch the App. `release: true` runs it as a store build (`__DEV__` false), the only build that
+ * may start the analytics SDK. `relaunch: true` keeps the disk and storage of the previous launch.
+ */
+export async function launchApp(options: {
+ release: boolean;
+ relaunch?: boolean;
+ /** Installed before this version: onboarding was finished on an earlier launch. */
+ returningUser?: boolean;
+ platform?: 'ios' | 'android';
+}): Promise {
+ (global as DevGlobal).__DEV__ = !options.release;
+ const boundary = installNativeBoundary({
+ llama: true,
+ fs: true,
+ ram: { platform: options.platform ?? 'android', totalBytes: 12 * GB, availBytes: 8 * GB },
+ });
+ const endpoint = installPostHogEndpoint();
+ const docs = boundary.fs!.DocumentDirectoryPath;
+ const model = {
+ ...createDownloadedModel({
+ id: 'm',
+ name: 'Test Model',
+ fileName: 'small.gguf',
+ filePath: `${docs}/models/small.gguf`,
+ engine: 'llama',
+ fileSize: 500 * 1024 * 1024,
+ }),
+ // Where a Hugging Face download records it came from (a public repo).
+ origin: { repoId: 'unsloth/Qwen3-0.6B-GGUF', revision: 'main', path: 'small.gguf' },
+ };
+ // The disk a download leaves behind: the file and its record. Nothing else is pre-placed.
+ boundary.fs!.seedFile(model.filePath!, model.fileSize);
+ const storage = require('@react-native-async-storage/async-storage');
+ if (!options.relaunch) {
+ await storage.clear();
+ await storage.setItem('@local_llm/downloaded_models', JSON.stringify([model]));
+ if (options.returningUser) {
+ // What an earlier version left on disk: onboarding done, the setup tour dismissed.
+ await storage.setItem(
+ 'local-llm-app-storage',
+ JSON.stringify({ state: { hasCompletedOnboarding: true, checklistDismissed: true }, version: 0 }),
+ );
+ }
+ }
+ const g = globalThis as unknown as { window?: Record };
+ if (!g.window) g.window = { dispatchEvent: () => true, addEventListener: () => {}, removeEventListener: () => {} };
+ const React = require('react');
+ const rtl = requireRTL();
+ const App = require('../../App').default;
+ const view = rtl.render(React.createElement(App));
+ await rtl.waitFor(() => expect(view.queryByTestId('app-loading')).toBeNull(), { timeout: 10_000 });
+ return { boundary, endpoint, rtl, view };
+}
+
+/** A new user: Onboarding → Auto Setup (ticking the share row or not) → Skip for Now → Home. */
+export async function passOnboarding(app: AnalyticsApp, share: boolean): Promise {
+ const { view, rtl } = app;
+ rtl.fireEvent.press(await view.findByTestId('onboarding-skip'));
+ const skip = await view.findByTestId('auto-setup-skip', {}, { timeout: 10_000 });
+ const row = view.getByTestId('auto-setup-share-analytics');
+ expect(row.props.accessibilityState).toEqual({ checked: false });
+ if (share) {
+ rtl.fireEvent.press(row);
+ await rtl.waitFor(() =>
+ expect(view.getByTestId('auto-setup-share-analytics').props.accessibilityState).toEqual({ checked: true }),
+ );
+ }
+ rtl.fireEvent.press(skip);
+ await view.findByTestId('home-screen');
+}
+
+export async function openNewChat(app: AnalyticsApp): Promise {
+ const { view, rtl } = app;
+ rtl.fireEvent.press(view.getByTestId('browse-models-button'));
+ rtl.fireEvent.press(await view.findByTestId('text-model-row-m'));
+ await rtl.waitFor(() => expect(view.queryByTestId('text-model-row-m')).toBeNull());
+ rtl.fireEvent.press(view.getByTestId('new-chat-button'));
+ await view.findByTestId('chat-input');
+}
+
+/** Type and send a message; the model's reply (scripted at the engine leaf) renders. */
+export async function sendMessage(app: AnalyticsApp, prompt: string, reply: string): Promise {
+ const { view, rtl, boundary } = app;
+ boundary.llama!.scriptCompletion({ text: reply });
+ rtl.fireEvent.changeText(view.getByTestId('chat-input'), prompt);
+ rtl.fireEvent.press(view.getByTestId('send-button'));
+ await view.findByText(reply, {}, { timeout: 10_000 });
+ await rtl.waitFor(() => expect(view.queryByTestId('stop-button')).toBeNull());
+}
+
+/** The person leaves the app: the OS moves it to the background. */
+export async function sendToBackground(app: AnalyticsApp): Promise {
+ await app.rtl.act(async () => {
+ app.boundary.emitAppStateChange('background');
+ });
+}
+
+export async function bringToForeground(app: AnalyticsApp): Promise {
+ await app.rtl.act(async () => {
+ app.boundary.emitAppStateChange('active');
+ });
+}
+
+/** From a chat: back to the tabs, then the Settings tab. */
+export async function openSettings(app: AnalyticsApp): Promise {
+ const { view, rtl } = app;
+ const back = view.queryAllByLabelText('Back');
+ if (back.length > 0) rtl.fireEvent.press(back[0]);
+ rtl.fireEvent.press(await view.findByTestId('settings-tab'));
+ await view.findByTestId('settings-analytics');
+}
diff --git a/__tests__/harness/nativeBoundary.ts b/__tests__/harness/nativeBoundary.ts
index 6b7fef7ee..57e5e4dd1 100644
--- a/__tests__/harness/nativeBoundary.ts
+++ b/__tests__/harness/nativeBoundary.ts
@@ -464,6 +464,13 @@ function makeLlamaFake(
// Sticky Metal backend error (ggml-metal-context has_error): set when the app goes to background with a
// completion in flight on Metal; cleared only by a fresh initLlama (a new native context).
let backendFault = false;
+ // llama.cpp's own log (llama.rn toggleNativeLog / addNativeLogListener). While a lazy tool grammar
+ // waits for its trigger, llama.cpp logs every sampled token, with the token's text, at info level.
+ let nativeLogOn = false;
+ const nativeLogListeners: Array<(level: string, text: string) => void> = [];
+ const nativeLog = (level: string, text: string) => {
+ if (nativeLogOn) for (const listener of nativeLogListeners) listener(level, text);
+ };
let completionInFlight = false;
// The device shape of a completion that the backend fault ended: what streamed so far, no EOS, no stop
// word, no cap, NOT interrupted (rn-completion.cpp just sets has_next_token=false on the failed decode).
@@ -570,6 +577,11 @@ function makeLlamaFake(
if (backendFault) break; // the GPU refused the next decode (app went to background)
acc += c;
streamed = acc;
+ // A real token is about a word: log the word this character ends.
+ const word = /(\S+)$/.exec(acc.slice(0, -1))?.[1];
+ if (/\s/.test(c) && word) {
+ nativeLog('info', `Grammar still awaiting trigger after token ${acc.length} (\` ${word}\`)`);
+ }
onToken({ token: c, content: acc });
if (
scripted.pauseAfter &&
@@ -736,6 +748,13 @@ function makeLlamaFake(
const module: Record = {
loadLlamaModelInfo: jest.fn(async () => modelInfo ?? {}),
+ toggleNativeLog: jest.fn(async (enabled: boolean) => {
+ nativeLogOn = enabled;
+ }),
+ addNativeLogListener: jest.fn((listener: (level: string, text: string) => void) => {
+ nativeLogListeners.push(listener);
+ return { remove: () => nativeLogListeners.splice(nativeLogListeners.indexOf(listener), 1) };
+ }),
// Faithful to llama.rn/llama.cpp: the native loader reports gpu=true (+ the offload device
// list) when it actually offloaded layers (n_gpu_layers > 0), and gpu=false for a pure-CPU
// init. Echo that from the requested load params so the REAL captureGpuInfo → GenerationMeta
diff --git a/__tests__/harness/postHogEndpoint.ts b/__tests__/harness/postHogEndpoint.ts
new file mode 100644
index 000000000..b537e1d70
--- /dev/null
+++ b/__tests__/harness/postHogEndpoint.ts
@@ -0,0 +1,75 @@
+/**
+ * Fake PostHog Cloud EU endpoint — the network boundary for opt-in analytics.
+ *
+ * Installed at `fetch` (the SDK's only transport), it records every request to a PostHog host and
+ * answers like the real ingest API (`{ status: 1 }`). Every other URL still goes to the previous
+ * fetch (jest.setup's "network disabled" guard), so no test ever reaches the real host. Events are
+ * decoded exactly as the server would read them, including gzip bodies.
+ */
+import { gunzipSync } from 'node:zlib';
+
+export interface PostHogEvent {
+ event: string;
+ distinct_id?: string;
+ properties: Record;
+}
+
+export interface PostHogEndpoint {
+ /** Every URL the app requested on a PostHog host, in order. */
+ readonly urls: string[];
+ /** Every event delivered in a batch, in order. */
+ readonly events: PostHogEvent[];
+ /** The raw request bodies, decoded to text. */
+ readonly bodies: string[];
+ named(event: string): PostHogEvent[];
+ restore(): void;
+}
+
+const POSTHOG_HOST = /^https:\/\/[a-z0-9.-]*posthog\.com\//i;
+
+async function bodyBytes(body: unknown): Promise {
+ if (body === undefined || body === null) return Buffer.alloc(0);
+ if (typeof body === 'string') return Buffer.from(body, 'utf8');
+ if (body instanceof Uint8Array) return Buffer.from(body);
+ if (body instanceof ArrayBuffer) return Buffer.from(new Uint8Array(body));
+ const blob = body as { arrayBuffer?: () => Promise };
+ if (typeof blob.arrayBuffer === 'function') return Buffer.from(new Uint8Array(await blob.arrayBuffer()));
+ return Buffer.from(String(body), 'utf8');
+}
+
+async function decode(body: unknown): Promise {
+ const bytes = await bodyBytes(body);
+ const gzipped = bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
+ return (gzipped ? gunzipSync(bytes) : bytes).toString('utf8');
+}
+
+export function installPostHogEndpoint(): PostHogEndpoint {
+ const urls: string[] = [];
+ const events: PostHogEvent[] = [];
+ const bodies: string[] = [];
+ const previous = globalThis.fetch;
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (!POSTHOG_HOST.test(url)) return previous(input, init);
+ urls.push(url);
+ const text = await decode(init?.body);
+ bodies.push(text);
+ if (text) {
+ const payload = JSON.parse(text) as { batch?: PostHogEvent[] } & PostHogEvent;
+ for (const event of payload.batch ?? [payload]) events.push(event);
+ }
+ return new Response(JSON.stringify({ status: 1 }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }) as typeof fetch;
+ return {
+ urls,
+ events,
+ bodies,
+ named: name => events.filter(event => event.event === name),
+ restore: () => {
+ globalThis.fetch = previous;
+ },
+ };
+}
diff --git a/__tests__/integration/analytics/analyticsOwner.test.ts b/__tests__/integration/analytics/analyticsOwner.test.ts
new file mode 100644
index 000000000..b9b7fa867
--- /dev/null
+++ b/__tests__/integration/analytics/analyticsOwner.test.ts
@@ -0,0 +1,339 @@
+/**
+ * The analytics owner over its real stores and the real SDK, with only device boundaries faked
+ * (PostHog at `fetch`, the JS runtime's global error handler, device-info, the disk). Each case
+ * reports a fact the way its seam does and checks what reaches PostHog — the network is the
+ * terminal surface for these events (docs/ANALYTICS.md).
+ */
+import { GB, installNativeBoundary, requireRTL, type NativeBoundary } from '../../harness/nativeBoundary';
+import { installPostHogEndpoint, type PostHogEndpoint } from '../../harness/postHogEndpoint';
+import { restoreBuildFlavor } from '../../harness/analyticsApp';
+
+type Analytics = typeof import('../../../src/services/analytics/analytics');
+
+interface Rig {
+ boundary: NativeBoundary;
+ endpoint: PostHogEndpoint;
+ analytics: Analytics;
+ stop: () => void;
+ flush: () => Promise;
+ errorHandler: () => (error: unknown, isFatal?: boolean) => void;
+}
+
+let rig: Rig | undefined;
+
+afterEach(async () => {
+ rig?.stop();
+ await new Promise(resolve => setTimeout(resolve, 50));
+ rig?.endpoint.restore();
+ rig = undefined;
+ restoreBuildFlavor();
+});
+
+/** The JS runtime's global error handler (Hermes ErrorUtils), which the SDK hooks for crashes. */
+function installErrorUtils() {
+ let handler: (error: unknown, isFatal?: boolean) => void = () => {};
+ (global as unknown as { ErrorUtils: unknown }).ErrorUtils = {
+ setGlobalHandler: (next: typeof handler) => {
+ handler = next;
+ },
+ getGlobalHandler: () => handler,
+ };
+ return () => handler;
+}
+
+async function setup(
+ consent: { usage?: boolean; crashes?: boolean } | null,
+ device: { platform?: 'ios' | 'android'; deviceHardware?: string; socModel?: string } = {},
+): Promise {
+ (global as unknown as { __DEV__: boolean }).__DEV__ = false;
+ const boundary = installNativeBoundary({
+ fs: true,
+ ram: { platform: device.platform ?? 'android', totalBytes: 8 * GB, availBytes: 4 * GB },
+ deviceHardware: device.deviceHardware,
+ socModel: device.socModel,
+ });
+ const errorHandler = installErrorUtils();
+ const endpoint = installPostHogEndpoint();
+ // The App starts analytics only once its settings are read from disk.
+ await require('../../../src/stores/appStore').useAppStore.persist.rehydrate();
+ const analytics: Analytics = require('../../../src/services/analytics/analytics');
+ const { featureSnapshot } = require('../../../src/services/analytics/featureSnapshot');
+ const stop = analytics.startAnalytics(featureSnapshot);
+ if (consent) await analytics.setAnalyticsConsent(consent, 'settings');
+ const flush = async () => {
+ boundary.emitAppStateChange('background');
+ await new Promise(resolve => setTimeout(resolve, 30));
+ };
+ rig = { boundary, endpoint, analytics, stop, flush, errorHandler };
+ return rig;
+}
+
+const waitFor = async (check: () => void) => {
+ await requireRTL().waitFor(check);
+};
+
+it('with Crash reports only, usage events are dropped and a crash arrives scrubbed', async () => {
+ const { analytics, endpoint, flush, errorHandler } = await setup({ crashes: true });
+ analytics.featureUsed('tools');
+ const error = new TypeError('Cannot open "/Users/me/Diary.txt": my secret words');
+ error.stack = 'TypeError: Cannot open\n at openFile (/Users/me/app/index.bundle:10:3)';
+ errorHandler()(error, false);
+ await flush();
+ await waitFor(() => expect(endpoint.named('$exception')).toHaveLength(1));
+ expect(endpoint.named('feature_used')).toEqual([]);
+ expect(endpoint.named('analytics_opted_in')).toEqual([]);
+ const crash = endpoint.named('$exception')[0].properties;
+ const sent = JSON.stringify(crash);
+ expect(sent).not.toMatch(/secret|Diary|\/Users\/me/);
+ expect(crash.platform).toBe('android');
+ const [exception] = crash.$exception_list as Array<{ type: string; value: string }>;
+ expect(exception).toMatchObject({ type: 'TypeError', value: 'Cannot open' });
+});
+
+it('turning Crash reports off keeps usage flowing on a rebuilt client with the same ID', async () => {
+ const { analytics, endpoint, flush } = await setup({ usage: true, crashes: true });
+ analytics.track('chat_created');
+ await flush();
+ await waitFor(() => expect(endpoint.named('chat_created')).toHaveLength(1));
+ await analytics.setAnalyticsConsent({ crashes: false }, 'settings');
+ analytics.track('project_created');
+ await flush();
+ await waitFor(() => expect(endpoint.named('project_created')).toHaveLength(1));
+ expect(endpoint.named('project_created')[0].distinct_id).toBe(endpoint.named('chat_created')[0].distinct_id);
+});
+
+it('reports every action, flow and Pro funnel step with only the spec properties', async () => {
+ const { analytics, endpoint, flush } = await setup({ usage: true });
+ analytics.featureUsed('web_search');
+ analytics.documentAdded('pdf', 3 * GB);
+ analytics.track('problem_reported');
+ analytics.flowStep('model_transfer', 'device_picked');
+ analytics.flowFinished('model_transfer', 'done');
+ analytics.flowStarted('connector_add', 'opened');
+ analytics.flowStep('connector_add', 'opened');
+ analytics.flowStep('connector_add', 'connector_picked', { connector: 'custom' });
+ analytics.flowFinished('connector_add', 'failed', Object.assign(new Error('x'), { code: 'oauth_denied' }));
+ analytics.flowStarted('sync_pairing');
+ analytics.flowFinished('sync_pairing', 'cancelled');
+ analytics.proGateShown('sync');
+ analytics.proPaywallViewed();
+ analytics.proPurchased();
+ analytics.proPaywallViewed();
+ analytics.proPurchased();
+ await flush();
+ await waitFor(() => expect(endpoint.named('flow_finished')).toHaveLength(4));
+ expect(endpoint.named('feature_used')[0].properties.feature).toBe('web_search');
+ expect(endpoint.named('document_added')[0].properties).toMatchObject({ file_type: 'pdf', size_bucket: '2-4GB' });
+ expect(endpoint.named('problem_reported')).toHaveLength(1);
+ const finished = endpoint.named('flow_finished').map(e => e.properties);
+ expect(finished.map(f => [f.flow, f.outcome, f.failed_step, f.error_code])).toEqual([
+ ['connector_add', 'failed', 'connector_picked', 'oauth_denied'],
+ ['sync_pairing', 'cancelled', undefined, undefined],
+ ['pro_upgrade', 'done', undefined, undefined],
+ ['pro_upgrade', 'done', undefined, undefined],
+ ]);
+ const steps = endpoint.named('flow_step').map(e => [e.properties.flow, e.properties.step, e.properties.feature ?? e.properties.connector]);
+ expect(steps).toEqual([
+ ['connector_add', 'opened', undefined],
+ ['connector_add', 'connector_picked', 'custom'],
+ ['pro_upgrade', 'gate_shown', 'sync'],
+ ['pro_upgrade', 'paywall_viewed', undefined],
+ ['pro_upgrade', 'purchased', undefined],
+ ['pro_upgrade', 'paywall_viewed', undefined],
+ ['pro_upgrade', 'purchased', undefined],
+ ]);
+});
+
+it('downloads report start, finish speed, failure, and the image and voice setup funnels', async () => {
+ const { analytics, endpoint, flush } = await setup({ usage: true });
+ const { useDownloadStore } = require('../../../src/stores/downloadStore');
+ const entry = (modelKey: string, modelType: string, downloadId: string) => ({
+ modelKey, downloadId, modelId: modelType === 'image' ? 'anythingv5_cpu' : 'ggml-org/SmolVLM-GGUF',
+ fileName: 'f', quantization: modelType === 'text' ? 'Q4_K_M' : '', modelType, status: 'pending',
+ bytesDownloaded: 0, totalBytes: 1.5 * GB, combinedTotalBytes: 0, progress: 0, createdAt: 0,
+ });
+ analytics.flowStarted('image_setup', 'opened');
+ useDownloadStore.getState().add(entry('image:a', 'image', 'd1'));
+ useDownloadStore.getState().setCompleted('d1');
+ useDownloadStore.getState().add(entry('text:b', 'text', 'd2'));
+ useDownloadStore.getState().setStatus('d2', 'failed', { message: 'disk full at /data/x', code: 'disk_full' });
+ useDownloadStore.getState().retryEntry('text:b', 'd3');
+ useDownloadStore.getState().setStatus('d3', 'completed');
+ analytics.downloadEnded('never-started', 'done');
+ useDownloadStore.getState().setCompleted('unknown-id');
+ analytics.flowStarted('voice_setup', 'opened');
+ analytics.downloadStarted('tts:kokoro', { model: 'kokoro', modelType: 'tts' });
+ analytics.downloadEnded('tts:kokoro', 'done');
+ await new Promise(resolve => setTimeout(resolve, 20));
+ await flush();
+ await waitFor(() => expect(endpoint.named('model_download_finished')).toHaveLength(3));
+ expect(endpoint.named('model_download_started')).toHaveLength(4);
+ const failed = endpoint.named('model_download_failed')[0].properties;
+ expect(failed).toMatchObject({ model: 'ggml-org/SmolVLM-GGUF', quant: 'Q4_K_M', size_bucket: '1-2GB', network: 'unknown', error_code: 'disk_full' });
+ expect(JSON.stringify(failed)).not.toContain('/data/x');
+ const done = endpoint.named('model_download_finished')[0].properties;
+ expect(done.model).toBe('anythingv5_cpu');
+ expect(typeof done.free_storage_gb).toBe('number');
+ expect(endpoint.named('model_download_finished')[2].properties.mb_per_sec).toBeUndefined();
+ const funnels = endpoint.named('flow_finished').map(e => e.properties.flow);
+ expect(funnels).toEqual(['image_setup', 'voice_setup']);
+});
+
+it('reports the spec properties when the device cannot say its chip, memory or battery', async () => {
+ const { analytics, endpoint, flush } = await setup(null);
+ // Before opting in a download is not tracked at all, so its end later sends nothing.
+ analytics.downloadStarted('text:early', { model: 'custom', modelType: 'text' });
+ const DeviceInfo = require('react-native-device-info');
+ DeviceInfo.getTotalMemory.mockRejectedValueOnce(new Error('unavailable'));
+ DeviceInfo.getPowerState.mockRejectedValue(new Error('unavailable'));
+ DeviceInfo.getHardware.mockRejectedValueOnce(new Error('unavailable'));
+ await analytics.setAnalyticsConsent({ usage: true }, 'settings');
+ analytics.downloadEnded('text:early', 'done');
+ await expect(analytics.trackModelLoad({ model: 'custom', engine: 'llama.cpp', backend: 'cpu' }, async () => 'ok')).resolves.toBe('ok');
+ analytics.generationStarted('image', { model: 'custom', engine: 'onnx', backend: 'npu' })('stopped');
+ await new Promise(resolve => setTimeout(resolve, 20));
+ await flush();
+ await waitFor(() => expect(endpoint.named('generation_finished')).toHaveLength(1));
+ await waitFor(() => expect(endpoint.named('model_loaded')).toHaveLength(1));
+ expect(endpoint.named('model_loaded')[0].properties.soc).toBe('unknown');
+ const generation = endpoint.named('generation_finished')[0].properties;
+ expect(generation).toMatchObject({ modality: 'image', outcome: 'stopped' });
+ expect(generation.battery_pct).toBeUndefined();
+ expect(generation.charging).toBeUndefined();
+ expect(endpoint.named('model_download_finished')).toEqual([]);
+ DeviceInfo.getPowerState.mockResolvedValue({ batteryLevel: 0.64, batteryState: 'unplugged', lowPowerMode: false });
+});
+
+it('a cold launch or a system dialog (inactive, then active) is not a new app open', async () => {
+ const { endpoint, flush, boundary } = await setup({ usage: true });
+ boundary.emitAppStateChange('inactive');
+ boundary.emitAppStateChange('active');
+ boundary.emitAppStateChange('inactive');
+ boundary.emitAppStateChange('active');
+ await flush();
+ await waitFor(() => expect(endpoint.named('app_backgrounded')).toHaveLength(1));
+ expect(endpoint.named('app_opened')).toHaveLength(1);
+ boundary.emitAppStateChange('active');
+ await flush();
+ await waitFor(() => expect(endpoint.named('app_backgrounded')).toHaveLength(2));
+ expect(endpoint.named('app_opened')).toHaveLength(2);
+});
+
+it('sends a daily features snapshot once per day and survives unreadable storage', async () => {
+ const { analytics, endpoint, flush, boundary } = await setup({ usage: true });
+ const AsyncStorage = require('@react-native-async-storage/async-storage');
+ boundary.emitAppStateChange('active');
+ boundary.emitAppStateChange('background');
+ boundary.emitAppStateChange('active');
+ analytics.trackScreen('HomeTab');
+ boundary.emitAppStateChange('background');
+ boundary.emitAppStateChange('background');
+ boundary.emitAppStateChange('active');
+ analytics.trackScreen('HomeTab');
+ analytics.trackScreen(undefined);
+ analytics.trackScreen('Settings');
+ AsyncStorage.getItem.mockRejectedValueOnce(new Error('unreadable'));
+ AsyncStorage.setItem.mockRejectedValueOnce(new Error('unwritable'));
+ await AsyncStorage.removeItem('offgrid.analytics.snapshotDay');
+ boundary.emitAppStateChange('background');
+ boundary.emitAppStateChange('active');
+ await new Promise(resolve => setTimeout(resolve, 20));
+ await flush();
+ await waitFor(() => expect(endpoint.named('$screen')).toHaveLength(2));
+ const snapshots = endpoint.named('features_snapshot');
+ expect(snapshots.length).toBeGreaterThanOrEqual(1);
+ expect(snapshots[0].properties).toMatchObject({ voice_input: false, image_generation: false, vision: false, web_search: true, tools: true, remote_server: false });
+ const [home, settings] = endpoint.named('$screen').map(e => e.properties);
+ expect(home).toMatchObject({ screen: 'HomeTab' });
+ expect(home.prev_screen).toBeUndefined();
+ expect(settings).toMatchObject({ screen: 'Settings', prev_screen: 'HomeTab' });
+ expect(typeof settings.prev_ms).toBe('number');
+});
+
+it('a corrupt unclean-exit marker from the last launch is ignored and cleared', async () => {
+ const AsyncStorage = require('@react-native-async-storage/async-storage');
+ await AsyncStorage.setItem('offgrid.analytics.uncleanExit', '{not json');
+ const { endpoint, flush } = await setup({ usage: true, crashes: true });
+ await new Promise(resolve => setTimeout(resolve, 20));
+ await flush();
+ await waitFor(() => expect(endpoint.named('analytics_opted_in')).toHaveLength(1));
+ expect(endpoint.named('unclean_exit')).toEqual([]);
+ expect(await AsyncStorage.getItem('offgrid.analytics.uncleanExit')).toBeNull();
+});
+
+it('every event says tablet, Pro and the chip as the device reports them', async () => {
+ const { analytics, endpoint, flush } = await setup(null, { platform: 'ios' });
+ const DeviceInfo = require('react-native-device-info');
+ DeviceInfo.isTablet.mockReturnValue(true);
+ const { useAppStore } = require('../../../src/stores/appStore');
+ // A Pro change before opting in has no client to tell.
+ useAppStore.getState().setHasRegisteredPro(true);
+ await analytics.setAnalyticsConsent({ usage: true }, 'settings');
+ useAppStore.getState().setHasRegisteredPro(false);
+ useAppStore.getState().setHasRegisteredPro(true);
+ await analytics.trackModelLoad({ model: 'custom', engine: 'llama.cpp', backend: 'gpu' }, async () => undefined);
+ await new Promise(resolve => setTimeout(resolve, 20));
+ await flush();
+ await waitFor(() => expect(endpoint.named('model_loaded')).toHaveLength(1));
+ DeviceInfo.isTablet.mockReturnValue(false);
+ const loaded = endpoint.named('model_loaded')[0].properties;
+ expect(loaded).toMatchObject({ device_class: 'tablet', pro: true, platform: 'ios' });
+ expect(String(loaded.soc)).toMatch(/^A1\d/);
+});
+
+it('a Snapdragon with an NPU names its generation, and a load failure is reported and rethrown', async () => {
+ const { analytics, endpoint, flush } = await setup({ usage: true }, { deviceHardware: 'qcom', socModel: 'SM8650' });
+ const failure = Object.assign(new Error('out of memory at /data/x'), { code: 'oom' });
+ await expect(
+ analytics.trackModelLoad({ model: 'custom', engine: 'llama.cpp', backend: 'npu' }, async () => {
+ throw failure;
+ }),
+ ).rejects.toBe(failure);
+ await new Promise(resolve => setTimeout(resolve, 20));
+ await flush();
+ await waitFor(() => expect(endpoint.named('model_load_failed')).toHaveLength(1));
+ const failed = endpoint.named('model_load_failed')[0].properties;
+ expect(failed.soc).toBe('qualcomm-8gen2');
+ expect(failed.error_code).toBe('oom');
+});
+
+it('each generation ends once; voice generations finish without an unclean-exit marker', async () => {
+ const { analytics, endpoint, flush } = await setup({ usage: true });
+ const AsyncStorage = require('@react-native-async-storage/async-storage');
+ const text = analytics.generationStarted('text', { model: 'custom', engine: 'llama.cpp', backend: 'cpu' });
+ let finishLoad: () => void = () => {};
+ const load = analytics.trackModelLoad({ model: 'custom', engine: 'llama.cpp', backend: 'cpu' }, () =>
+ new Promise(resolve => {
+ finishLoad = resolve;
+ }),
+ );
+ // The reply ends while a newer load holds the marker: the load's marker stays.
+ text('failed', { error: Object.assign(new Error('x'), { code: 'context_full' }) });
+ text('done');
+ await new Promise(resolve => setTimeout(resolve, 10));
+ expect(JSON.parse(await AsyncStorage.getItem('offgrid.analytics.uncleanExit')).stage).toBe('load');
+ finishLoad();
+ await load;
+ const stt = analytics.generationStarted('stt', { model: 'base.en', engine: 'whisper', backend: 'cpu' });
+ expect(await AsyncStorage.getItem('offgrid.analytics.uncleanExit')).toBeNull();
+ stt('done');
+ await new Promise(resolve => setTimeout(resolve, 20));
+ await flush();
+ await waitFor(() => expect(endpoint.named('generation_finished')).toHaveLength(2));
+ const [first, voice] = endpoint.named('generation_finished').map(e => e.properties);
+ expect(first).toMatchObject({ modality: 'text', outcome: 'failed', error_code: 'context_full' });
+ expect(voice).toMatchObject({ modality: 'stt', outcome: 'done' });
+ expect(voice.error_code).toBeUndefined();
+});
+
+it('time on a screen stops while the app is in the background', async () => {
+ const { analytics, endpoint, flush, boundary } = await setup({ usage: true });
+ boundary.emitAppStateChange('background');
+ boundary.emitAppStateChange('active');
+ analytics.trackScreen('HomeTab');
+ boundary.emitAppStateChange('background');
+ analytics.trackScreen('Chat');
+ await flush();
+ await waitFor(() => expect(endpoint.named('$screen')).toHaveLength(2));
+ expect(endpoint.named('$screen')[1].properties).toMatchObject({ screen: 'Chat', prev_screen: 'HomeTab' });
+});
diff --git a/__tests__/integration/analytics/consentPrompts.rendered.test.tsx b/__tests__/integration/analytics/consentPrompts.rendered.test.tsx
new file mode 100644
index 000000000..d644d2c4c
--- /dev/null
+++ b/__tests__/integration/analytics/consentPrompts.rendered.test.tsx
@@ -0,0 +1,125 @@
+/**
+ * Consent prompts outside Auto Setup (docs/ANALYTICS.md, Consent).
+ *
+ * SPEC (OGAM user's view):
+ * - Someone who installed before this version and was never asked sees "Help us fix what breaks"
+ * once, after their first successful reply. "Share" turns analytics on (source
+ * `first_chat_prompt`). It never shows again.
+ * - When a model fails to load and the person has not opted in, "Help us fix this?" asks once;
+ * after "Not now" it does not ask again for 30 days. "Share" turns analytics on (source
+ * `failure_prompt`).
+ * - "What we share" lists what is and is not sent.
+ */
+import {
+ launchApp,
+ openNewChat,
+ restoreBuildFlavor,
+ sendMessage,
+ sendToBackground,
+ type AnalyticsApp,
+} from '../../harness/analyticsApp';
+
+jest.unmock('@react-navigation/native');
+
+let app: AnalyticsApp | undefined;
+
+afterEach(async () => {
+ await require('../../../src/services/generationService').generationService.stopGeneration();
+ app?.view.unmount();
+ await new Promise(resolve => setTimeout(resolve, 50));
+ app?.endpoint.restore();
+ app = undefined;
+ restoreBuildFlavor();
+});
+
+async function newChatFromHome(current: AnalyticsApp): Promise {
+ current.rtl.fireEvent.press(current.view.getByLabelText('Back'));
+ current.rtl.fireEvent.press(await current.view.findByTestId('new-chat-button'));
+ await current.view.findByTestId('chat-input');
+}
+
+async function failToLoad(current: AnalyticsApp): Promise {
+ const { view, rtl, boundary } = current;
+ boundary.llama!.scriptInitFailure(true);
+ rtl.fireEvent.changeText(view.getByTestId('chat-input'), 'hello');
+ rtl.fireEvent.press(view.getByTestId('send-button'));
+ await rtl.waitFor(() => expect(boundary.llama!.calls.initLlama.length).toBeGreaterThan(0));
+}
+
+it('an existing user is asked once after their first reply, and Share opts them in', async () => {
+ app = await launchApp({ release: true, returningUser: true });
+ const current = app;
+ const { view, rtl, endpoint } = current;
+ await view.findByTestId('home-screen');
+ await openNewChat(current);
+ expect(view.queryByText('Help us fix what breaks')).toBeNull();
+ await sendMessage(current, 'first', 'First reply.');
+ expect(await view.findByText('Help us fix what breaks')).toBeTruthy();
+ expect(view.getByText('Send anonymous crash reports and usage. Your chats stay on this device.')).toBeTruthy();
+ rtl.fireEvent.press(view.getByLabelText('Share'));
+ await rtl.waitFor(() => expect(view.queryByText('Help us fix what breaks')).toBeNull());
+
+ await newChatFromHome(current);
+ await sendMessage(current, 'second', 'Second reply.');
+ await sendToBackground(current);
+ await rtl.waitFor(() => expect(endpoint.named('generation_finished')).toHaveLength(1));
+ expect(view.queryByText('Help us fix what breaks')).toBeNull();
+ expect(endpoint.named('analytics_opted_in').map(e => e.properties.source)).toEqual(['first_chat_prompt']);
+});
+
+it('a failed model load asks once; Not now keeps analytics off and the next failure does not ask', async () => {
+ app = await launchApp({ release: true, returningUser: true });
+ const current = app;
+ const { view, rtl, endpoint } = current;
+ await view.findByTestId('home-screen');
+ await openNewChat(current);
+ expect(view.queryByText('Help us fix this?')).toBeNull();
+ await failToLoad(current);
+ expect(await view.findByText('Help us fix this?')).toBeTruthy();
+ rtl.fireEvent.press(view.getByLabelText('Not now'));
+ await rtl.waitFor(() => expect(view.queryByText('Help us fix this?')).toBeNull());
+
+ // The failed load deselected the model: pick it again from Home and send again.
+ const loadsBefore = current.boundary.llama!.calls.initLlama.length;
+ rtl.fireEvent.press(view.getByLabelText('Back'));
+ await openNewChat(current);
+ await failToLoad(current);
+ await rtl.waitFor(() => expect(current.boundary.llama!.calls.initLlama.length).toBeGreaterThan(loadsBefore));
+ expect(view.queryByText('Help us fix this?')).toBeNull();
+ await sendToBackground(current);
+ await new Promise(resolve => setTimeout(resolve, 200));
+ expect(endpoint.urls).toEqual([]);
+});
+
+it('Share on the failure prompt opts in with source failure_prompt', async () => {
+ app = await launchApp({ release: true, returningUser: true });
+ const current = app;
+ const { view, rtl, endpoint } = current;
+ await view.findByTestId('home-screen');
+ await openNewChat(current);
+ await failToLoad(current);
+ rtl.fireEvent.press(await view.findByLabelText('Share'));
+ await rtl.waitFor(() => expect(view.queryByText('Help us fix this?')).toBeNull());
+ await sendToBackground(current);
+ await rtl.waitFor(() => expect(endpoint.named('analytics_opted_in')).toHaveLength(1));
+ expect(endpoint.named('analytics_opted_in')[0].properties.source).toBe('failure_prompt');
+});
+
+it('What we share, from Auto Setup, lists what is and is not sent', async () => {
+ app = await launchApp({ release: false });
+ const { view, rtl } = app;
+ rtl.fireEvent.press(await view.findByTestId('onboarding-skip'));
+ rtl.fireEvent.press(await view.findByTestId('auto-setup-what-we-share', {}, { timeout: 10_000 }));
+ expect(await view.findByTestId('analytics-we-share')).toBeTruthy();
+ expect(view.getByText('Your chats, prompts, replies, transcripts or reasoning.')).toBeTruthy();
+ expect(view.getByTestId('analytics-we-never-share')).toBeTruthy();
+ rtl.fireEvent.press(view.getByLabelText('Back'));
+ await rtl.waitFor(() => expect(view.queryByTestId('analytics-we-share')).toBeNull());
+ expect(view.getByTestId('auto-setup-skip')).toBeTruthy();
+ await passOnboardingFromSetup(app);
+});
+
+async function passOnboardingFromSetup(current: AnalyticsApp): Promise {
+ current.rtl.fireEvent.press(current.view.getByTestId('auto-setup-skip'));
+ await current.view.findByTestId('home-screen');
+}
diff --git a/__tests__/integration/analytics/consentRulesAndFacts.test.ts b/__tests__/integration/analytics/consentRulesAndFacts.test.ts
new file mode 100644
index 000000000..c463ae10f
--- /dev/null
+++ b/__tests__/integration/analytics/consentRulesAndFacts.test.ts
@@ -0,0 +1,108 @@
+/**
+ * The consent-prompt rules, the features snapshot and the model facts, over the real stores
+ * (docs/ANALYTICS.md: Consent, `features_snapshot`, Shared value rules). The rendered prompts are
+ * covered by consentPrompts.rendered.test.tsx; these are the edges the journeys do not reach.
+ */
+import { Platform } from 'react-native';
+import { useAppStore } from '../../../src/stores/appStore';
+import { useRemoteServerStore } from '../../../src/stores/remoteServerStore';
+import { registerHook, HOOKS, _clearHooksForTesting } from '../../../src/bootstrap/hookRegistry';
+import {
+ closeConsentPrompt,
+ maybePromptAfterFailure,
+ maybePromptAfterFirstChat,
+ useConsentPromptStore,
+} from '../../../src/services/analytics/consentPrompt';
+import { featureSnapshot } from '../../../src/services/analytics/featureSnapshot';
+import { imageModelFacts, textModelFacts, REMOTE_MODEL_FACTS } from '../../../src/services/analytics/modelFacts';
+import { createDownloadedModel } from '../../utils/factories';
+
+const DAY = 24 * 60 * 60 * 1000;
+
+beforeEach(() => {
+ closeConsentPrompt();
+ useAppStore.getState().resetSettings();
+ useAppStore.getState().updateSettings({ analyticsAsked: false, analyticsLastPromptAt: null });
+ _clearHooksForTesting();
+});
+
+describe('consent prompt rules', () => {
+ it('asks after a failure once, then waits 30 days', () => {
+ expect(useConsentPromptStore.getState().kind).toBeNull();
+ maybePromptAfterFailure();
+ expect(useConsentPromptStore.getState().kind).toBe('failure');
+ closeConsentPrompt();
+ maybePromptAfterFailure();
+ expect(useConsentPromptStore.getState().kind).toBeNull();
+ useAppStore.getState().updateSettings({ analyticsLastPromptAt: Date.now() - 31 * DAY });
+ maybePromptAfterFailure();
+ expect(useConsentPromptStore.getState().kind).toBe('failure');
+ });
+
+ it('one prompt at a time, and never for someone already opted in', () => {
+ maybePromptAfterFailure();
+ maybePromptAfterFirstChat();
+ expect(useConsentPromptStore.getState().kind).toBe('failure');
+ closeConsentPrompt();
+ useAppStore.getState().updateSettings({ analyticsCrashes: true, analyticsLastPromptAt: null, analyticsAsked: false });
+ maybePromptAfterFailure();
+ maybePromptAfterFirstChat();
+ expect(useConsentPromptStore.getState().kind).toBeNull();
+ });
+
+ it('the first-chat prompt is once ever', () => {
+ maybePromptAfterFirstChat();
+ expect(useConsentPromptStore.getState().kind).toBe('first_chat');
+ closeConsentPrompt();
+ maybePromptAfterFirstChat();
+ expect(useConsentPromptStore.getState().kind).toBeNull();
+ });
+});
+
+describe('features snapshot', () => {
+ it('reports each feature as switched on or off, Pro features included', () => {
+ useAppStore.getState().setDownloadedModels([]);
+ useAppStore.getState().setActiveModelId(null);
+ useAppStore.getState().updateSettings({ enabledTools: [] });
+ expect(featureSnapshot()).toEqual({
+ voice_input: false, image_generation: false, vision: false, web_search: false, tools: false, remote_server: false,
+ });
+
+ const llamaVision = createDownloadedModel({ id: 'v', engine: 'llama', isVisionModel: true });
+ const litert = { ...createDownloadedModel({ id: 'l', engine: 'litert' }), liteRTVision: true };
+ useAppStore.getState().setDownloadedModels([llamaVision, litert]);
+ useAppStore.getState().setActiveModelId('v');
+ useAppStore.getState().setActiveImageModelId('img');
+ useAppStore.getState().updateSettings({ enabledTools: ['web_search'] });
+ useRemoteServerStore.getState().addServer({ name: 'Mac', endpoint: 'http://192.168.1.2:1234', providerType: 'openai-compatible' });
+ registerHook(HOOKS.analyticsProFeatures, () => ({ tts: true, sync: false }));
+ expect(featureSnapshot()).toMatchObject({
+ image_generation: true, vision: true, web_search: true, tools: true, remote_server: true, tts: true, sync: false,
+ });
+ useAppStore.getState().setActiveModelId('l');
+ expect(featureSnapshot().vision).toBe(true);
+ });
+});
+
+describe('model facts', () => {
+ it('names the route without any file name', () => {
+ expect(textModelFacts(true)).toEqual(REMOTE_MODEL_FACTS);
+ useAppStore.getState().setActiveModelId('missing');
+ expect(textModelFacts(false)).toMatchObject({ model: 'custom', engine: 'llama.cpp' });
+ useAppStore.getState().setDownloadedModels([
+ { ...createDownloadedModel({ id: 'local_import/Family Recipes.gguf', engine: 'litert', quantization: '' }) },
+ ]);
+ useAppStore.getState().setActiveModelId('local_import/Family Recipes.gguf');
+ useAppStore.getState().updateSettings({ liteRTBackend: 'npu' });
+ expect(textModelFacts(false)).toEqual({ model: 'custom', quant: undefined, engine: 'litert', backend: 'npu' });
+
+ expect(imageModelFacts({ id: 'sd_qnn', backend: 'qnn' } as never)).toEqual({ model: 'sd_qnn', engine: 'onnx', backend: 'npu' });
+ expect(imageModelFacts({ id: 'sd_coreml', backend: 'coreml' } as never)).toEqual({ model: 'sd_coreml', engine: 'coreml', backend: 'gpu' });
+ const os = Platform.OS;
+ Object.defineProperty(Platform, 'OS', { value: 'ios', configurable: true });
+ expect(imageModelFacts(undefined)).toEqual({ model: 'custom', engine: 'coreml', backend: 'cpu' });
+ Object.defineProperty(Platform, 'OS', { value: 'android', configurable: true });
+ expect(imageModelFacts(undefined).engine).toBe('onnx');
+ Object.defineProperty(Platform, 'OS', { value: os, configurable: true });
+ });
+});
diff --git a/__tests__/integration/analytics/optInFromAutoSetup.rendered.test.tsx b/__tests__/integration/analytics/optInFromAutoSetup.rendered.test.tsx
new file mode 100644
index 000000000..55c144d5f
--- /dev/null
+++ b/__tests__/integration/analytics/optInFromAutoSetup.rendered.test.tsx
@@ -0,0 +1,86 @@
+/**
+ * Opt-in analytics from Auto Setup (docs/ANALYTICS.md, Rules + Consent).
+ *
+ * SPEC (OGAM user's view): analytics is off by default. A person who passes Auto Setup without
+ * ticking "Share anonymous usage and crash reports" and then chats sends nothing to PostHog. A
+ * person who ticks it sends `analytics_opted_in` (source `setup`) and a `generation_finished` for
+ * the reply, and neither their prompt nor the reply ever leaves the device.
+ *
+ * The real App runs as a store build. Only device boundaries are faked; the PostHog endpoint is
+ * faked at `fetch`, and what reaches it is the terminal artifact (the network is the surface).
+ */
+import {
+ launchApp,
+ openNewChat,
+ passOnboarding,
+ restoreBuildFlavor,
+ sendMessage,
+ sendToBackground,
+ type AnalyticsApp,
+} from '../../harness/analyticsApp';
+
+jest.unmock('@react-navigation/native');
+
+const PROMPT = 'plan my sister birthday at the lake house';
+const REPLY = 'Book the lake house for Saturday and invite the cousins.';
+
+let app: AnalyticsApp | undefined;
+
+afterEach(async () => {
+ await require('../../../src/services/generationService').generationService.stopGeneration();
+ app?.view.unmount();
+ // Let the client's last flush land on the fake endpoint before the endpoint goes away.
+ await new Promise(resolve => setTimeout(resolve, 50));
+ app?.endpoint.restore();
+ app = undefined;
+ restoreBuildFlavor();
+});
+
+it('a default install sends nothing to PostHog through Auto Setup and a chat', async () => {
+ app = await launchApp({ release: true });
+ await passOnboarding(app, false);
+ await openNewChat(app);
+ await sendMessage(app, PROMPT, REPLY);
+ await sendToBackground(app);
+
+ expect(app.view.getByText(REPLY)).toBeTruthy();
+ // Give a would-be flush every chance to happen before asserting that none did.
+ await new Promise(resolve => setTimeout(resolve, 300));
+ expect(app.endpoint.urls).toEqual([]);
+});
+
+it('ticking the Auto Setup row sends opt-in and the reply outcome, never the chat', async () => {
+ app = await launchApp({ release: true });
+ await passOnboarding(app, true);
+ // Opting in alone does not chat; nothing about a reply exists yet.
+ expect(app.endpoint.named('generation_finished')).toEqual([]);
+ await openNewChat(app);
+ await sendMessage(app, PROMPT, REPLY);
+ await sendToBackground(app);
+
+ const endpoint = app.endpoint;
+ await app.rtl.waitFor(() => expect(endpoint.named('generation_finished')).toHaveLength(1));
+ expect(endpoint.named('analytics_opted_in').map(e => e.properties.source)).toEqual(['setup']);
+
+ const finished = endpoint.named('generation_finished')[0].properties;
+ expect(finished).toMatchObject({ modality: 'text', engine: 'llama.cpp', outcome: 'done', platform: 'android' });
+ // The public repo, never the file name.
+ expect(finished.model).toBe('unsloth/Qwen3-0.6B-GGUF');
+ expect(endpoint.named('message_sent')[0].properties).toMatchObject({ has_image: false, has_document: false });
+
+ // Only the events docs/ANALYTICS.md lists: none of the SDK's own lifecycle events, whose
+ // "Application Opened" carries the deep link the app was opened with.
+ const listed = new Set([
+ 'app_opened', 'app_backgrounded', '$screen', 'analytics_opted_in', 'model_loaded',
+ 'generation_finished', 'chat_created', 'message_sent', 'features_snapshot',
+ 'flow_started', 'flow_step', 'flow_finished',
+ ]);
+ expect(endpoint.events.map(e => e.event).filter(name => !listed.has(name))).toEqual([]);
+ // Only event batches: no flags, no remote config, no surveys.
+ expect(endpoint.urls.every(url => url === 'https://eu.i.posthog.com/batch/')).toBe(true);
+ // Never the chat, never a file name.
+ const sent = endpoint.bodies.join('\n');
+ expect(sent).not.toContain('lake house');
+ expect(sent).not.toContain('cousins');
+ expect(sent).not.toContain('small.gguf');
+});
diff --git a/__tests__/integration/analytics/optOutInSettings.rendered.test.tsx b/__tests__/integration/analytics/optOutInSettings.rendered.test.tsx
new file mode 100644
index 000000000..55b8888f0
--- /dev/null
+++ b/__tests__/integration/analytics/optOutInSettings.rendered.test.tsx
@@ -0,0 +1,92 @@
+/**
+ * Opting out in Settings (docs/ANALYTICS.md, Rules).
+ *
+ * SPEC (OGAM user's view): turning off both Usage data and Crash reports stops every send, drops
+ * what is queued and resets the anonymous ID. Turning analytics back on later starts a new,
+ * unrelated anonymous ID.
+ *
+ * The real App runs as a store build over device boundaries; PostHog is faked at `fetch`.
+ */
+import {
+ bringToForeground,
+ launchApp,
+ openNewChat,
+ openSettings,
+ passOnboarding,
+ restoreBuildFlavor,
+ sendMessage,
+ sendToBackground,
+ type AnalyticsApp,
+} from '../../harness/analyticsApp';
+
+jest.unmock('@react-navigation/native');
+
+let app: AnalyticsApp | undefined;
+
+afterEach(async () => {
+ await require('../../../src/services/generationService').generationService.stopGeneration();
+ app?.view.unmount();
+ // Let the client's last flush land on the fake endpoint before the endpoint goes away.
+ await new Promise(resolve => setTimeout(resolve, 50));
+ app?.endpoint.restore();
+ app = undefined;
+ restoreBuildFlavor();
+});
+
+/** The switch's rendered state: Android's native switch draws `on`, iOS's draws `value`. */
+function isOn(element: { props: Record }): boolean {
+ return Boolean(element.props.on ?? element.props.value);
+}
+
+async function chatFromHome(current: AnalyticsApp, prompt: string, reply: string): Promise {
+ current.rtl.fireEvent.press(current.view.getByTestId('home-tab'));
+ current.rtl.fireEvent.press(await current.view.findByTestId('new-chat-button'));
+ await current.view.findByTestId('chat-input');
+ await sendMessage(current, prompt, reply);
+}
+
+it('turning both toggles off stops sending and a later opt-in uses a new anonymous ID', async () => {
+ app = await launchApp({ release: true });
+ const current = app;
+ const { view, rtl, endpoint } = current;
+ await passOnboarding(current, true);
+ await openNewChat(current);
+ await sendMessage(current, 'first question', 'First answer.');
+ await sendToBackground(current);
+ await rtl.waitFor(() => expect(endpoint.named('generation_finished')).toHaveLength(1));
+ const firstId = endpoint.named('generation_finished')[0].distinct_id;
+ expect(firstId).toBeTruthy();
+
+ await bringToForeground(current);
+ await openSettings(current);
+ const usage = view.getByLabelText('Usage data');
+ const crashes = view.getByLabelText('Crash reports');
+ expect(isOn(usage)).toBe(true);
+ expect(isOn(crashes)).toBe(true);
+ rtl.fireEvent(usage, 'valueChange', false);
+ rtl.fireEvent(crashes, 'valueChange', false);
+ await rtl.waitFor(() => {
+ expect(isOn(view.getByLabelText('Usage data'))).toBe(false);
+ expect(isOn(view.getByLabelText('Crash reports'))).toBe(false);
+ });
+
+ const sentBeforeOptOut = endpoint.urls.length;
+ await chatFromHome(current, 'second question', 'Second answer.');
+ await sendToBackground(current);
+ await new Promise(resolve => setTimeout(resolve, 300));
+ expect(view.getByText('Second answer.')).toBeTruthy();
+ expect(endpoint.urls).toHaveLength(sentBeforeOptOut);
+
+ // Back on: a fresh anonymous ID, unrelated to the first one.
+ await bringToForeground(current);
+ await openSettings(current);
+ rtl.fireEvent(view.getByLabelText('Usage data'), 'valueChange', true);
+ await rtl.waitFor(() => expect(isOn(view.getByLabelText('Usage data'))).toBe(true));
+ await chatFromHome(current, 'third question', 'Third answer.');
+ await sendToBackground(current);
+ await rtl.waitFor(() => expect(endpoint.named('generation_finished')).toHaveLength(2));
+ expect(endpoint.named('analytics_opted_in').map(e => e.properties.source)).toEqual(['setup', 'settings']);
+ const secondId = endpoint.named('generation_finished')[1].distinct_id;
+ expect(secondId).toBeTruthy();
+ expect(secondId).not.toBe(firstId);
+});
diff --git a/__tests__/integration/analytics/reportProblemLogHasNoChat.rendered.test.tsx b/__tests__/integration/analytics/reportProblemLogHasNoChat.rendered.test.tsx
new file mode 100644
index 000000000..100b50e49
--- /dev/null
+++ b/__tests__/integration/analytics/reportProblemLogHasNoChat.rendered.test.tsx
@@ -0,0 +1,129 @@
+/**
+ * Report a problem sends a debug log with no chat content (docs/ANALYTICS.md, Report a problem).
+ *
+ * SPEC (OGAM user's view): after chatting, Settings → Report a problem shows the debug log, and
+ * "Send to support" hands it to the share sheet. Neither the log the person sees nor what is shared
+ * holds their prompt or the model's reply. It works with analytics off.
+ *
+ * The real App runs as a store build: the debug log is on in every build, so this is the log a
+ * person actually sends. The share sheet is the OS boundary: it is replaced by a recorder of what
+ * the app handed it.
+ */
+import {
+ launchApp,
+ openNewChat,
+ openSettings,
+ passOnboarding,
+ restoreBuildFlavor,
+ sendMessage,
+ type AnalyticsApp,
+} from '../../harness/analyticsApp';
+
+jest.unmock('@react-navigation/native');
+
+const PROMPT = 'my passport number is in the drawer by the window';
+const REPLY = 'Keep the passport drawer locked when guests visit.';
+
+let app: AnalyticsApp | undefined;
+
+afterEach(async () => {
+ await require('../../../src/services/generationService').generationService.stopGeneration();
+ app?.view.unmount();
+ // Let the client's last flush land on the fake endpoint before the endpoint goes away.
+ await new Promise(resolve => setTimeout(resolve, 50));
+ app?.endpoint.restore();
+ app = undefined;
+ restoreBuildFlavor();
+});
+
+it('the shared debug log holds the chat trace but none of the prompt or reply', async () => {
+ app = await launchApp({ release: true });
+ const shared: Array<{ message?: string; url?: string }> = [];
+ require('react-native').Share.share = async (content: { message?: string; url?: string }) => {
+ shared.push(content);
+ return { action: 'sharedAction' };
+ };
+ const { view, rtl } = app;
+ await passOnboarding(app, false);
+ await openNewChat(app);
+ await sendMessage(app, PROMPT, REPLY);
+
+ await openSettings(app);
+ rtl.fireEvent.press(view.getByTestId('settings-report-problem'));
+ const send = await view.findByTestId('debug-logs-send-report');
+ // The person sees the log first, and it is not empty.
+ expect(view.queryByText('No logs yet')).toBeNull();
+ expect(view.queryByText(/passport/)).toBeNull();
+
+ expect(shared).toHaveLength(0);
+ rtl.fireEvent.press(send);
+ await rtl.waitFor(() => expect(shared).toHaveLength(1));
+
+ const message = shared[0].message ?? '';
+ expect(message).toContain('support@getoffgridai.co');
+ // The trace of the chat is there: the generation ran and was logged.
+ expect(message).toMatch(/\[(GEN|LLM|ToolLoop|GenService)/);
+ expect(message).not.toMatch(/passport|drawer|window|guests/i);
+});
+
+it('on iOS the log goes as an attached file, and closing the viewer sends nothing', async () => {
+ app = await launchApp({ release: true, platform: 'ios' });
+ const shared: Array<{ message?: string; url?: string }> = [];
+ require('react-native').Share.share = async (content: { message?: string; url?: string }) => {
+ shared.push(content);
+ return { action: 'sharedAction' };
+ };
+ const { view, rtl } = app;
+ await passOnboarding(app, false);
+ await openSettings(app);
+ rtl.fireEvent.press(view.getByTestId('settings-report-problem'));
+ rtl.fireEvent.press(await view.findByTestId('debug-logs-close'));
+ await rtl.waitFor(() => expect(view.queryByTestId('debug-logs-send-report')).toBeNull());
+ expect(shared).toHaveLength(0);
+
+ rtl.fireEvent.press(view.getByTestId('settings-report-problem'));
+ rtl.fireEvent.press(await view.findByTestId('debug-logs-send-report'));
+ await rtl.waitFor(() => expect(shared).toHaveLength(1));
+ expect(shared[0].url).toMatch(/^file:\/\/.*offgrid-debug\.log$/);
+ expect(shared[0].message).toContain('support@getoffgridai.co');
+ expect(shared[0].message).not.toContain('---');
+});
+
+it('an unreadable log still opens the share sheet with the support address', async () => {
+ app = await launchApp({ release: true });
+ const shared: Array<{ message?: string; url?: string }> = [];
+ require('react-native').Share.share = async (content: { message?: string; url?: string }) => {
+ shared.push(content);
+ throw new Error('share sheet dismissed');
+ };
+ const fs = require('react-native-fs');
+ fs.readFile = async () => {
+ throw new Error('EACCES');
+ };
+ const { view, rtl } = app;
+ await passOnboarding(app, false);
+ await openSettings(app);
+ rtl.fireEvent.press(view.getByTestId('settings-report-problem'));
+ rtl.fireEvent.press(await view.findByTestId('debug-logs-send-report'));
+ await rtl.waitFor(() => expect(shared).toHaveLength(1));
+ expect(shared[0].url).toBeUndefined();
+ expect(shared[0].message).toContain('support@getoffgridai.co');
+});
+
+it('with no log on disk yet, the share sheet still gets the support address', async () => {
+ app = await launchApp({ release: true });
+ const shared: Array<{ message?: string; url?: string }> = [];
+ require('react-native').Share.share = async (content: { message?: string; url?: string }) => {
+ shared.push(content);
+ return { action: 'sharedAction' };
+ };
+ require('react-native-fs').exists = async () => false;
+ const { view, rtl } = app;
+ await passOnboarding(app, false);
+ await openSettings(app);
+ rtl.fireEvent.press(view.getByTestId('settings-report-problem'));
+ rtl.fireEvent.press(await view.findByTestId('debug-logs-send-report'));
+ await rtl.waitFor(() => expect(shared).toHaveLength(1));
+ expect(shared[0].message).toContain('support@getoffgridai.co');
+ expect(shared[0].message).not.toContain('---');
+});
diff --git a/__tests__/integration/analytics/uncleanExit.rendered.test.tsx b/__tests__/integration/analytics/uncleanExit.rendered.test.tsx
new file mode 100644
index 000000000..d6ebd2e24
--- /dev/null
+++ b/__tests__/integration/analytics/uncleanExit.rendered.test.tsx
@@ -0,0 +1,82 @@
+/**
+ * `unclean_exit` (docs/ANALYTICS.md, Crashes).
+ *
+ * SPEC (OGAM user's view, opted in): if the app dies in the foreground while a model is loading,
+ * the next launch of the same version reports one `unclean_exit` with stage `load`. If it was in
+ * the background when it died (the OS reclaiming memory), nothing is reported.
+ *
+ * The death is the device boundary: the native load is held open (it never returns), the app is
+ * torn down without any clean shutdown, and a new launch reads the same disk and storage.
+ */
+import {
+ launchApp,
+ passOnboarding,
+ restoreBuildFlavor,
+ sendToBackground,
+ type AnalyticsApp,
+} from '../../harness/analyticsApp';
+
+jest.unmock('@react-navigation/native');
+
+const launched: AnalyticsApp[] = [];
+
+afterEach(async () => {
+ for (const app of launched.reverse()) {
+ app.view.unmount();
+ // Let the client's last flush land on the fake endpoint before the endpoint goes away.
+ await new Promise(resolve => setTimeout(resolve, 50));
+ app.endpoint.restore();
+ }
+ launched.length = 0;
+ restoreBuildFlavor();
+});
+
+/** Opt in at Auto Setup, then start a chat whose model load never finishes. */
+async function dieWhileLoading(inBackground: boolean): Promise {
+ const app = await launchApp({ release: true });
+ launched.push(app);
+ const { view, rtl, boundary } = app;
+ await passOnboarding(app, true);
+ boundary.llama!.scriptMultimodalHold();
+ rtl.fireEvent.press(view.getByTestId('browse-models-button'));
+ rtl.fireEvent.press(await view.findByTestId('text-model-row-m'));
+ rtl.fireEvent.press(await view.findByTestId('new-chat-button'));
+ rtl.fireEvent.changeText(await view.findByTestId('chat-input'), 'hello');
+ rtl.fireEvent.press(view.getByTestId('send-button'));
+ await rtl.waitFor(() => expect(boundary.llama!.multimodalHoldActive()).toBe(true));
+ expect(view.getByText(/Loading Test Model/)).toBeTruthy();
+ if (inBackground) await sendToBackground(app);
+ // The process dies here: no unmount clean-up runs before the next launch reads the disk.
+}
+
+async function relaunch(): Promise {
+ const next = await launchApp({ release: true, relaunch: true });
+ launched.push(next);
+ await next.view.findByTestId('home-screen');
+ await sendToBackground(next);
+ // This launch's own first event: the earlier, dead launch never sends `app_opened` with a cold start.
+ await next.rtl.waitFor(() =>
+ expect(next.endpoint.named('app_opened').some(e => typeof e.properties.cold_start_ms === 'number')).toBe(true),
+ );
+ return next;
+}
+
+it('a foreground death during a model load is reported once on the next launch', async () => {
+ await dieWhileLoading(false);
+ const next = await relaunch();
+ const reports = next.endpoint.named('unclean_exit');
+ expect(reports).toHaveLength(1);
+ expect(reports[0].properties).toMatchObject({
+ stage: 'load',
+ model: 'unsloth/Qwen3-0.6B-GGUF',
+ engine: 'llama.cpp',
+ backend: 'cpu',
+ });
+ expect(typeof reports[0].properties.elapsed_ms).toBe('number');
+});
+
+it('a death in the background is not reported', async () => {
+ await dieWhileLoading(true);
+ const next = await relaunch();
+ expect(next.endpoint.named('unclean_exit')).toEqual([]);
+});
diff --git a/__tests__/integration/settings/devToolingReleaseGate.rendered.test.tsx b/__tests__/integration/settings/devToolingReleaseGate.rendered.test.tsx
index 4dca9411e..1450b3d0d 100644
--- a/__tests__/integration/settings/devToolingReleaseGate.rendered.test.tsx
+++ b/__tests__/integration/settings/devToolingReleaseGate.rendered.test.tsx
@@ -63,11 +63,11 @@ describe('developer tooling release gate (rendered, real App)', () => {
const { view, rtl } = await bootReturningUser(false);
// BEFORE the Settings tap: the user is on Home, the Settings screen is not yet shown.
- expect(view.queryByText('Privacy First')).toBeNull();
+ expect(view.queryByText('Report a problem')).toBeNull();
expect(view.queryByTestId('dev-sync-strip')).toBeNull();
rtl.fireEvent.press(view.getByTestId('settings-tab'));
- await rtl.waitFor(() => expect(view.getByText('Privacy First')).toBeTruthy());
+ await rtl.waitFor(() => expect(view.getByText('Report a problem')).toBeTruthy());
expect(view.queryByTestId('seed-demo-data')).toBeNull();
expect(view.queryByText('Load demo data')).toBeNull();
@@ -82,11 +82,11 @@ describe('developer tooling release gate (rendered, real App)', () => {
it('a developer build shows the sync strip and both developer controls in Settings', async () => {
const { view, rtl } = await bootReturningUser(true);
- expect(view.queryByText('Privacy First')).toBeNull();
+ expect(view.queryByText('Report a problem')).toBeNull();
expect(view.getByTestId('dev-sync-strip')).toBeTruthy();
rtl.fireEvent.press(view.getByTestId('settings-tab'));
- await rtl.waitFor(() => expect(view.getByText('Privacy First')).toBeTruthy());
+ await rtl.waitFor(() => expect(view.getByText('Report a problem')).toBeTruthy());
expect(view.getByTestId('seed-demo-data')).toBeTruthy();
expect(view.getByText('Load demo data')).toBeTruthy();
diff --git a/__tests__/rntl/components/VoiceModelsPanel.test.tsx b/__tests__/rntl/components/VoiceModelsPanel.test.tsx
index b0febee37..dac8501b6 100644
--- a/__tests__/rntl/components/VoiceModelsPanel.test.tsx
+++ b/__tests__/rntl/components/VoiceModelsPanel.test.tsx
@@ -93,7 +93,7 @@ describe('VoiceModelsPanel', () => {
it('shows the RAM privacy banner', async () => {
const { getByText } = await renderPanel();
- expect(getByText(/nothing is sent anywhere/)).toBeTruthy();
+ expect(getByText(/your voice never leaves it/)).toBeTruthy();
});
it('shows the installed model without speaker choices', async () => {
diff --git a/__tests__/rntl/screens/ModelSettingsScreen.test.tsx b/__tests__/rntl/screens/ModelSettingsScreen.test.tsx
index c25aa4852..143e3ce2b 100644
--- a/__tests__/rntl/screens/ModelSettingsScreen.test.tsx
+++ b/__tests__/rntl/screens/ModelSettingsScreen.test.tsx
@@ -941,6 +941,10 @@ describe('ModelSettingsScreen', () => {
liteRTTemperature: undefined as any,
liteRTTopP: undefined as any,
liteRTMaxTokens: undefined as any,
+ analyticsUsage: false,
+ analyticsCrashes: false,
+ analyticsAsked: false,
+ analyticsLastPromptAt: null,
},
});
diff --git a/__tests__/rntl/screens/SettingsScreen.test.tsx b/__tests__/rntl/screens/SettingsScreen.test.tsx
index 1930ba0e2..6187c49bf 100644
--- a/__tests__/rntl/screens/SettingsScreen.test.tsx
+++ b/__tests__/rntl/screens/SettingsScreen.test.tsx
@@ -184,12 +184,13 @@ describe('SettingsScreen', () => {
// The three theme options are rendered - pressing one calls setThemeMode
});
- it('renders Privacy First section', () => {
- const { getByText } = render();
- expect(getByText('Privacy First')).toBeTruthy();
- expect(
- getByText(/All your data stays on this device/),
- ).toBeTruthy();
+ it('renders the Analytics section with both consent toggles off by default', () => {
+ const { getByText, getByLabelText } = render();
+ expect(getByText('Analytics')).toBeTruthy();
+ expect(Boolean(getByLabelText('Usage data').props.on ?? getByLabelText('Usage data').props.value)).toBe(false);
+ expect(Boolean(getByLabelText('Crash reports').props.on ?? getByLabelText('Crash reports').props.value)).toBe(false);
+ expect(getByText('What we share')).toBeTruthy();
+ expect(getByText('Report a problem')).toBeTruthy();
});
it('renders about section text', () => {
diff --git a/__tests__/unit/analytics/scrub.test.ts b/__tests__/unit/analytics/scrub.test.ts
new file mode 100644
index 000000000..af4c39e42
--- /dev/null
+++ b/__tests__/unit/analytics/scrub.test.ts
@@ -0,0 +1,97 @@
+/**
+ * Crash report scrubbing (docs/ANALYTICS.md, Crashes): a path keeps only an app code file name,
+ * a person's file becomes ``, quoted text, URLs and IP addresses are dropped, and anything
+ * after a colon goes. Type, file, line and function stay.
+ */
+import { fileName, scrubExceptionProperties, scrubMessage } from '../../../src/services/analytics/scrub';
+
+describe('crash report scrubbing', () => {
+ it.each([
+ ['Cannot read /var/mobile/Containers/Data/notes/secret.txt', 'Cannot read '],
+ ['Bad token in /data/app/index.android.bundle', 'Bad token in index.android.bundle'],
+ ['Could not open Taxes2025.pdf now', 'Could not open now'],
+ ['Failed to load qwen-private-finetune.gguf', 'Failed to load '],
+ ['Failed to parse "my diary entry" here', 'Failed to parse here'],
+ ["TypeError: undefined is not a function", 'TypeError'],
+ ['fetch https://my.server.lan/v1/chat?q=private failed', 'fetch failed'],
+ ['connect 192.168.1.20:8080 refused', 'connect refused'],
+ ['Plain message', 'Plain message'],
+ ])('%p → %p', (raw, clean) => {
+ expect(scrubMessage(raw)).toBe(clean);
+ });
+
+ it('only scrubs text', () => {
+ expect(scrubMessage(undefined)).toBeUndefined();
+ expect(fileName('/a/b/c.js?x=1#y')).toBe('c.js');
+ expect(fileName('C:\\Users\\me\\app.js')).toBe('app.js');
+ expect(fileName('')).toBeUndefined();
+ expect(fileName('/a/b/')).toBeUndefined();
+ expect(fileName(42)).toBeUndefined();
+ });
+
+ it('keeps type, file name, line and function, and drops every other $exception property', () => {
+ const scrubbed = scrubExceptionProperties({
+ platform: 'android',
+ $exception_level: 'fatal',
+ $exception_message: 'raw "secret" text',
+ $exception_steps: [{ message: 'typed something' }],
+ $exception_list: [
+ {
+ type: 'Error',
+ value: 'Could not open "/Users/me/Taxes 2025.pdf": denied',
+ mechanism: { handled: false },
+ stacktrace: {
+ type: 'raw',
+ frames: [
+ {
+ platform: 'hermes',
+ abs_path: '/data/app/index.android.bundle',
+ function: 'openFile',
+ lineno: 12,
+ colno: 4,
+ in_app: true,
+ context_line: 'const secret = "x"',
+ vars: { prompt: 'hello' },
+ },
+ ],
+ },
+ },
+ { type: 'TypeError', value: 'x is undefined' },
+ ],
+ });
+ expect(scrubbed).toEqual({
+ platform: 'android',
+ $exception_level: 'fatal',
+ $exception_list: [
+ {
+ type: 'Error',
+ value: 'Could not open',
+ mechanism: { handled: false },
+ stacktrace: {
+ type: 'raw',
+ frames: [
+ { platform: 'hermes', filename: 'index.android.bundle', function: 'openFile', lineno: 12, colno: 4, in_app: true },
+ ],
+ },
+ },
+ { type: 'TypeError', value: 'x is undefined', mechanism: undefined },
+ ],
+ });
+ });
+
+ it('a frame list without a type is still raw, and no list stays absent', () => {
+ expect(
+ scrubExceptionProperties({ $exception_list: [{ type: 'E', stacktrace: { frames: [{ filename: 'a/b.js' }] } }] }),
+ ).toEqual({
+ $exception_list: [
+ {
+ type: 'E',
+ value: undefined,
+ mechanism: undefined,
+ stacktrace: { type: 'raw', frames: [{ platform: undefined, filename: 'b.js', function: undefined, lineno: undefined, colno: undefined, in_app: undefined }] },
+ },
+ ],
+ });
+ expect(scrubExceptionProperties({ a: 1 })).toEqual({ a: 1 });
+ });
+});
diff --git a/__tests__/unit/analytics/uncleanExit.test.ts b/__tests__/unit/analytics/uncleanExit.test.ts
new file mode 100644
index 000000000..dde93c70b
--- /dev/null
+++ b/__tests__/unit/analytics/uncleanExit.test.ts
@@ -0,0 +1,35 @@
+/**
+ * The unclean-exit marker never reports this launch's own work as the previous launch's death.
+ */
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { markStageEnded, markStageStarted, takeUncleanExit } from '../../../src/services/analytics/uncleanExit';
+
+it("a load already running in this launch is not the last launch's death, and its marker stays", async () => {
+ markStageStarted({ stage: 'load', model: 'custom', engine: 'llama.cpp', backend: 'cpu', startedAt: Date.now(), appVersion: '1.0.0' });
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(await takeUncleanExit('1.0.0')).toBeNull();
+ expect(JSON.parse((await AsyncStorage.getItem('offgrid.analytics.uncleanExit'))!).stage).toBe('load');
+ markStageEnded('load');
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(await AsyncStorage.getItem('offgrid.analytics.uncleanExit')).toBeNull();
+});
+
+it('a marker from an earlier version is cleared and not reported', async () => {
+ await AsyncStorage.setItem(
+ 'offgrid.analytics.uncleanExit',
+ JSON.stringify({ stage: 'generate', model: 'custom', engine: 'llama.cpp', backend: 'cpu', startedAt: 1, appVersion: '0.9.0', foreground: true, launch: 'earlier' }),
+ );
+ // An update happened in between: a different version's death is not reported.
+ expect(await takeUncleanExit('1.0.0')).toBeNull();
+ expect(await AsyncStorage.getItem('offgrid.analytics.uncleanExit')).toBeNull();
+});
+
+it("a read that races this launch's own marker write never deletes the new marker", async () => {
+ const earlier = JSON.stringify({ stage: 'load', model: 'custom', engine: 'llama.cpp', backend: 'cpu', startedAt: 1, appVersion: '1.0.0', foreground: true, launch: 'earlier' });
+ markStageStarted({ stage: 'generate', model: 'custom', engine: 'llama.cpp', backend: 'cpu', startedAt: Date.now(), appVersion: '1.0.0' });
+ // The disk read lands before this launch's write: it still holds the earlier launch's marker.
+ (AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce(earlier);
+ expect(await takeUncleanExit('1.0.0')).toMatchObject({ stage: 'load', launch: 'earlier' });
+ expect(JSON.parse((await AsyncStorage.getItem('offgrid.analytics.uncleanExit'))!).stage).toBe('generate');
+ markStageEnded('generate');
+});
diff --git a/__tests__/unit/analytics/values.test.ts b/__tests__/unit/analytics/values.test.ts
new file mode 100644
index 000000000..cecf94349
--- /dev/null
+++ b/__tests__/unit/analytics/values.test.ts
@@ -0,0 +1,117 @@
+/**
+ * The spec's value rules (docs/ANALYTICS.md, "Shared value rules"): pure functions, checked as
+ * input → output tables.
+ */
+import {
+ batteryPct,
+ bytesToGb,
+ computeBackend,
+ contextBucket,
+ errorCode,
+ fileExtension,
+ ignore,
+ languageOnly,
+ modelRef,
+ ms,
+ oneDecimal,
+ releaseChannel,
+ sizeBucket,
+ toolFeature,
+} from '../../../src/services/analytics/values';
+
+const GB = 1024 * 1024 * 1024;
+
+describe('analytics value rules', () => {
+ it.each([
+ ['owner/repo/file.gguf', undefined, 'owner/repo'],
+ ['anything', 'unsloth/Qwen3-GGUF', 'unsloth/Qwen3-GGUF'],
+ ['m', 'unsloth/Qwen3-GGUF/sub/path', 'unsloth/Qwen3-GGUF'],
+ ['local_import/my secret notes.gguf', undefined, 'custom'],
+ ['local_my_zip_123', undefined, 'custom'],
+ ['recovered_family.gguf', undefined, 'custom'],
+ ['anythingv5_cpu', undefined, 'anythingv5_cpu'],
+ ['image:anythingv5_cpu', undefined, 'anythingv5_cpu'],
+ ['image:local_my_zip_123', undefined, 'custom'],
+ ['has spaces in it', undefined, 'custom'],
+ [null, undefined, 'custom'],
+ [undefined, 'not a repo', 'custom'],
+ ])('model %p (origin %p) is sent as %p', (id, repo, expected) => {
+ expect(modelRef(id as string | null | undefined, repo)).toBe(expected);
+ });
+
+ it.each([
+ [undefined, undefined],
+ [0, undefined],
+ [0.5 * GB, '<1GB'],
+ [1.5 * GB, '1-2GB'],
+ [3 * GB, '2-4GB'],
+ [6 * GB, '4-8GB'],
+ [9 * GB, '>8GB'],
+ ])('size %p is bucketed as %p', (bytes, bucket) => {
+ expect(sizeBucket(bytes)).toBe(bucket);
+ });
+
+ it.each([
+ [undefined, undefined],
+ [0, undefined],
+ [1000, '<2k'],
+ [4000, '2-8k'],
+ [16000, '8-32k'],
+ [64000, '>32k'],
+ ])('context of %p tokens is bucketed as %p', (tokens, bucket) => {
+ expect(contextBucket(tokens)).toBe(bucket);
+ });
+
+ it('rounds durations, rates, sizes and battery as the spec says', () => {
+ expect(ms(12.6)).toBe(13);
+ expect(ms(undefined)).toBeUndefined();
+ expect(ms(Number.NaN)).toBeUndefined();
+ expect(oneDecimal(12.34)).toBe(12.3);
+ expect(oneDecimal(null)).toBeUndefined();
+ expect(bytesToGb(1.26 * GB)).toBe(1.3);
+ expect(bytesToGb(undefined)).toBeUndefined();
+ expect(batteryPct(0.64)).toBe(60);
+ expect(batteryPct(-1)).toBeUndefined();
+ expect(batteryPct(undefined)).toBeUndefined();
+ });
+
+ it('error codes come from our error classes, never a message', () => {
+ expect(errorCode(Object.assign(new Error('Not enough space for /Users/me/x'), { code: 'disk_full' }))).toBe('disk_full');
+ class OverridableMemoryError extends Error {
+ name = 'OverridableMemoryError';
+ }
+ expect(errorCode(new OverridableMemoryError('my private prompt'))).toBe('OverridableMemoryError');
+ expect(errorCode(new Error('my private prompt'))).toBe('error');
+ expect(errorCode({ code: 'has spaces: and text' })).toBe('error');
+ expect(errorCode('a raw message')).toBe('error');
+ expect(errorCode(undefined)).toBe('error');
+ });
+
+ it('keeps the language only, the channel, the extension and the feature', () => {
+ expect(languageOnly('en-GB')).toBe('en');
+ expect(languageOnly('pt_BR')).toBe('pt');
+ expect(languageOnly(undefined)).toBe('unknown');
+ expect(releaseChannel('0.0.117')).toBe('stable');
+ expect(releaseChannel('0.0.117-beta.1')).toBe('nightly');
+ expect(fileExtension('Quarterly Plan.PDF')).toBe('pdf');
+ expect(fileExtension('no-extension')).toBeUndefined();
+ expect(fileExtension(undefined)).toBeUndefined();
+ expect(toolFeature('web_search')).toBe('web_search');
+ expect(toolFeature('read_url')).toBe('web_search');
+ expect(toolFeature('clipboard_read')).toBe('clipboard');
+ expect(toolFeature('browser_open')).toBe('browser_action');
+ expect(toolFeature('calculator')).toBe('tools');
+ });
+
+ it.each([
+ ['htp', 'npu'], ['npu', 'npu'], ['qnn', 'npu'],
+ ['metal', 'gpu'], ['opencl', 'gpu'], ['gpu', 'gpu'], ['coreml', 'gpu'],
+ ['cpu', 'cpu'], ['mnn', 'cpu'], [undefined, 'cpu'],
+ ])('backend setting %p is %p', (setting, backend) => {
+ expect(computeBackend(setting)).toBe(backend);
+ });
+
+ it('a dropped failure resolves to nothing', () => {
+ expect(ignore()).toBeUndefined();
+ });
+});
diff --git a/__tests__/utils/testHelpers.ts b/__tests__/utils/testHelpers.ts
index 30c42eb91..b75c6520e 100644
--- a/__tests__/utils/testHelpers.ts
+++ b/__tests__/utils/testHelpers.ts
@@ -83,6 +83,10 @@ export const resetStores = (): void => {
liteRTTemperature: 0.7,
liteRTTopP: 0.9,
liteRTMaxTokens: 4096,
+ analyticsUsage: false,
+ analyticsCrashes: false,
+ analyticsAsked: false,
+ analyticsLastPromptAt: null,
},
downloadedImageModels: [],
activeImageModelId: null,
diff --git a/altstore-source.json b/altstore-source.json
index 0308e5e59..ac501cac8 100644
--- a/altstore-source.json
+++ b/altstore-source.json
@@ -13,7 +13,7 @@
"bundleIdentifier": "ai.offgridmobile",
"developerName": "Off Grid",
"subtitle": "On-device AI",
- "localizedDescription": "Run large language models, generate images with Stable Diffusion, and transcribe speech with Whisper — all on-device, fully offline, no cloud required.",
+ "localizedDescription": "Run large language models, generate images with Stable Diffusion, and transcribe speech with Whisper — all on-device, fully offline, no cloud required. Your chats and files never leave your device; anonymous usage data and crash reports only if you turn them on.",
"iconURL": "https://raw.githubusercontent.com/alichherawalla/offline-mobile-llm-manager/main/ios/OffgridMobile/Images.xcassets/AppIcon.appiconset/icon.png",
"tintColor": "000000",
"versions": [
diff --git a/docs/ANALYTICS.md b/docs/ANALYTICS.md
new file mode 100644
index 000000000..a561e83a2
--- /dev/null
+++ b/docs/ANALYTICS.md
@@ -0,0 +1,124 @@
+# Opt-in analytics
+
+Off Grid AI Mobile (OGAM) and Off Grid AI Desktop (OGAD) send anonymous usage data and crash reports to PostHog Cloud EU, only after the user opts in. This file is the single list of what we send. OGAM and OGAD keep an identical copy; change both together.
+
+## Rules
+
+- Off by default. The SDK is not created until the user opts in. Before opt-in the app makes no request to PostHog.
+- Opt-out shuts the SDK down, drops anything queued and resets the anonymous ID.
+- One random anonymous ID per install, made by the SDK. We never call `identify`, never send an email, license key, account ID or device hardware ID.
+- PostHog project: EU Cloud, "discard client IP" on, GeoIP off, no autocapture, no session replay, no feature flags, no remote config, no surveys.
+- Debug builds (`__DEV__`, unpackaged Electron) never start the SDK.
+- Consent is per device. It never syncs between devices.
+- Data retention: 12 months.
+
+## Never send
+
+- Prompts, replies, transcripts, reasoning, tool arguments or tool results.
+- File names, document names, paths, URLs, domains, hostnames or IP addresses (including remote server and LAN addresses).
+- Search terms.
+- Names of imported model files, private Hugging Face repos or custom MCP connectors. Send `custom` instead.
+- Error messages or stack frames before scrubbing (see Crashes).
+
+## Properties on every event
+
+| Property | Values |
+|---|---|
+| `platform` | `ios`, `android`, `macos`, `windows`, `linux` |
+| `app_version` | e.g. `0.0.115` |
+| `channel` | `stable`, `nightly` |
+| `pro` | `true`, `false` |
+| `device_class` | `phone`, `tablet`, `desktop` |
+| `ram_gb` | total RAM, rounded to whole GB |
+| `os_version` | e.g. `18.1`, `15` |
+| `locale` | language only, e.g. `en` |
+
+## Shared value rules
+
+- `model`: catalog ID, or public Hugging Face `owner/repo`, or `custom`. Plus `quant` (e.g. `Q4_K_M`) and `size_bucket` (`<1GB`, `1-2GB`, `2-4GB`, `4-8GB`, `>8GB`).
+- `engine`: `llama.cpp`, `mlx`, `litert`, `executorch`, `coreml`, `onnx`, `whisper`, `remote`.
+- `backend`: `cpu`, `gpu`, `npu`, `remote`.
+- `error_code`: a fixed code from our own error classes, never a raw message.
+- Durations in milliseconds, rounded. Rates rounded to one decimal.
+
+## Events
+
+### App and screens
+
+| Event | Properties |
+|---|---|
+| `app_opened` | `cold_start_ms` |
+| `app_backgrounded` | `session_ms` |
+| `$screen` (OGAM, SDK `screen()`) / `screen_viewed` (OGAD) | `screen`, `prev_screen`, `prev_ms` |
+
+### Consent
+
+| Event | Properties |
+|---|---|
+| `analytics_opted_in` | `source`: `setup`, `settings`, `failure_prompt`, `first_chat_prompt` |
+
+### Models
+
+| Event | Properties |
+|---|---|
+| `model_download_started` | `model`, `quant`, `size_bucket`, `network` (`wifi`, `cellular`, `ethernet`, `unknown`), `free_storage_gb` |
+| `model_download_finished` | same + `duration_ms`, `mb_per_sec` |
+| `model_download_failed` | same + `error_code` |
+| `model_loaded` | `model`, `quant`, `engine`, `backend`, `soc`, `free_ram_gb`, `load_ms` |
+| `model_load_failed` | same + `error_code` |
+
+### Generation
+
+| Event | Properties |
+|---|---|
+| `generation_finished` | `modality` (`text`, `vision`, `image`, `stt`, `tts`, `embedding`), `model`, `engine`, `backend`, `outcome` (`done`, `stopped`, `failed`), `ttft_ms`, `tokens_per_sec`, `context_tokens_bucket` (`<2k`, `2-8k`, `8-32k`, `>32k`), `battery_pct` (rounded to 10), `charging`, `error_code`, `source` (`app`, `api`, `extension`; OGAD only) |
+
+### Actions
+
+| Event | Properties |
+|---|---|
+| `chat_created` | none |
+| `message_sent` | `has_image`, `has_document`, `tools_enabled` |
+| `reply_regenerated` | none |
+| `project_created` | none |
+| `document_added` | `file_type` (an extension the app opens, else `other`; `none` without one), `size_bucket` |
+| `feature_used` | `feature`: `voice_input`, `tts`, `image_generation`, `vision`, `web_search`, `tools`, `mcp_tool`, `remote_server`, `browser_action`, `clipboard`, `meetings`, `sync` |
+| `features_snapshot` | once a day: one `true`/`false` property per feature that is switched on |
+| `problem_reported` | none |
+
+### Flows (funnels)
+
+All multi-step journeys use three events with a `flow` property, so every funnel is built the same way in PostHog.
+
+| Event | Properties |
+|---|---|
+| `flow_started` | `flow` |
+| `flow_step` | `flow`, `step` |
+| `flow_finished` | `flow`, `outcome` (`done`, `failed`, `cancelled`), `duration_ms`, `failed_step`, `error_code` |
+
+| `flow` | Steps |
+|---|---|
+| `onboarding` | `setup_shown`, `download_started`, `download_done`, `model_loaded`, `first_message` |
+| `model_transfer` | `opened`, `device_picked`, `model_picked`, `started` |
+| `sync_pairing` | `opened`, `code_shown`, `code_entered`, `paired` |
+| `remote_server` | `opened`, `address_entered`, `tested`, `connected` |
+| `voice_setup` | `opened`, `model_picked`, `downloaded` |
+| `image_setup` | `opened`, `model_picked`, `downloaded` |
+| `connector_add` | `opened`, `connector_picked` (`catalog_id` or `custom`), `authorised` |
+| `pro_upgrade` | `gate_shown` (`feature`), `paywall_viewed`, `trial_started`, `purchased` |
+
+### Crashes
+
+| Event | Properties |
+|---|---|
+| `$exception` | SDK exception capture. Scrub message and frames before send: replace paths with the file name only, drop any quoted text, keep type, file name, line, function. A file name is kept only for the app's own code (e.g. `index.js`); a person's file, the home folder and the account name become `` / ``. |
+| `unclean_exit` | Sent on the next launch when the app died in the foreground during a model load or a generation. `stage` (`load`, `generate`), `model`, `engine`, `backend`, `free_ram_gb`, `elapsed_ms`. Not sent after a background kill, an app update or a reboot. |
+| `llama_server_crashed` (OGAD) | `model`, `engine`, `backend`, `exit_code`, `classified` |
+
+## Report a problem
+
+- OGAM: shows the debug log, then opens the share sheet addressed to support. iOS attaches the log file; Android puts the log's latest part in the message text.
+- OGAD: shows the debug log with Copy, then opens a mail draft to the support address; the user pastes the log into it (a `mailto:` link cannot attach a file).
+- The debug log never holds chat content: no log line may include prompts, replies, transcripts or tool output.
+- The user sees the log before it is sent.
+- It works whether or not analytics is on.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 6a3022a74..417213e10 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -922,7 +922,7 @@ export const SPACING = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32 };
- Generate images with seed control for reproducibility
- Save to device gallery or share directly
-**Privacy:** Zero network activity after model download. Prompts never leave device.
+**Privacy:** Prompts and images never leave the device. Opt-in analytics (see ANALYTICS.md) sends anonymous usage data only if the person turns it on.
### 3. Document Analysis with Vision
@@ -1124,8 +1124,12 @@ All data stored in app's private storage, inaccessible to other apps (Android sa
- Hugging Face CDN (model file downloads)
- xororz HuggingFace repos (image model listings)
+**Opt-in analytics (off by default):**
+- PostHog Cloud EU (`eu.i.posthog.com`), only after the person turns on Usage data or Crash reports
+- Anonymous events and scrubbed crash reports; never chats, files, prompts or replies (see `docs/ANALYTICS.md`)
+
**After model download:**
-- Zero network activity
+- No other network activity unless you turn on a feature that uses it (analytics, a remote server, web search, Sync)
- Enable airplane mode and use indefinitely
- All inference happens on-device
diff --git a/docs/brand_tone_voice.md b/docs/brand_tone_voice.md
index 04d6faf96..3c30d24a4 100644
--- a/docs/brand_tone_voice.md
+++ b/docs/brand_tone_voice.md
@@ -24,7 +24,7 @@ That's the energy in every word Off Grid writes.
### 1. Generous, not transactional
-Off Grid asks nothing from you. No account. No API key. No data. The voice reflects this -- it gives without expecting anything back. It doesn't ask you to share, subscribe, follow, or review. It just hands you the thing.
+Off Grid asks nothing from you. No account. No API key. Your chats and files never leave your device; anonymous usage data and crash reports are sent only if you turn them on. The voice reflects this -- it gives without expecting anything back. It doesn't ask you to share, subscribe, follow, or review. It just hands you the thing.
This means copy never implies a trade. No "get access to," no "unlock," no implied paywall. The frame is always: here's what you have.
@@ -41,7 +41,7 @@ Specificity is how you earn trust without asking for it.
```
"Phi-3 Mini runs at ~28 tok/s on a Snapdragon 8 Gen 3. Llama 3.1 8B runs at ~12 tok/s on the same chip."
-"On-device. Offline. Zero data leaves your phone. Not a marketing claim -- airplane mode works."
+"On-device. Offline. Your chats never leave your phone. Not a marketing claim -- airplane mode works."
```
```
@@ -111,7 +111,7 @@ Every piece of Off Grid content follows the same arc:
**Recognition -> Return -> Freedom**
1. **Recognition**: Name what's been happening. "Every query you've sent to a cloud AI has been logged, stored, and used."
-2. **Return**: Show what's being given back. "With Off Grid, the model runs on your phone. Nothing leaves it."
+2. **Return**: Show what's being given back. "With Off Grid, the model runs on your phone. Your chats never leave it."
3. **Freedom**: Hand them the capability without condition. "Go offline. It still works. It's yours."
This isn't about doubt and resolution like a pricing tool. It's about something that was taken being handed back. The tone is quiet, clear, and generous -- not triumphant or righteous.
@@ -129,7 +129,7 @@ Short sentences. State what it does, then prove it. The reader should feel like
```
"Chat. Generate images. Use tools. See. Listen.
-All on your phone. All offline. Zero data leaves your device.
+All on your phone. All offline. Your chats and files never leave your device.
No account. No API key. No subscription. Download and go."
```
@@ -368,7 +368,7 @@ Load failed: "Load failed -- the file may be corrupted. Delete it and re-downloa
Primary: **"Private AI. No cloud. No compromise."**
Alternatives:
-- "Run AI on your phone. Nothing leaves it."
+- "Run AI on your phone. Your chats never leave it."
- "The Swiss Army Knife of On-Device AI."
- "AI that stays on your device."
- "No cloud. No account. No compromise."
@@ -382,6 +382,7 @@ Before publishing any Off Grid content:
- [ ] Does it feel like something being given, not sold?
- [ ] Are privacy claims stated as mechanisms ("the model runs in RAM"), not promises ("we value your privacy")?
+- [ ] Do privacy claims stay true with opt-in analytics on? Say "your chats and files never leave your device", never "nothing leaves" or "no telemetry".
- [ ] Does every performance claim include a number and a device?
- [ ] Are device requirements stated before steps?
- [ ] Does it follow the recognition -> return -> freedom arc?
diff --git a/docs/plans/offline-recordings.md b/docs/plans/offline-recordings.md
index 4b283c0f2..289235b1a 100644
--- a/docs/plans/offline-recordings.md
+++ b/docs/plans/offline-recordings.md
@@ -8,7 +8,7 @@ Tier: Pro-gated
A Pro surface in the mobile app to record audio on-device, transcribe it locally
with Whisper, and generate an LLM title, summary, and action items. Recording works
-in the foreground and in the background (lock screen). Nothing leaves the phone.
+in the foreground and in the background (lock screen). Recordings and transcripts never leave the phone.
This is mobile's native analog of the desktop meeting recorder. The desktop recorder
relies on macOS ScreenCaptureKit + system-audio loopback, which iOS and Android do not
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-about-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/after-about-ios.png
new file mode 100644
index 000000000..7506b6d2e
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-about-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-auto-setup-android.png b/docs/pr-screenshots/feature/opt-in-analytics/after-auto-setup-android.png
new file mode 100644
index 000000000..c85138497
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-auto-setup-android.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-auto-setup-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/after-auto-setup-ios.png
new file mode 100644
index 000000000..47e11b443
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-auto-setup-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-first-chat-prompt-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/after-first-chat-prompt-ios.png
new file mode 100644
index 000000000..7e01b34fa
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-first-chat-prompt-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-home-desktop-card-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/after-home-desktop-card-ios.png
new file mode 100644
index 000000000..89b7bc10b
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-home-desktop-card-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-pro-detail-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/after-pro-detail-ios.png
new file mode 100644
index 000000000..d639eccce
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-pro-detail-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-report-problem-android.png b/docs/pr-screenshots/feature/opt-in-analytics/after-report-problem-android.png
new file mode 100644
index 000000000..7c91e2476
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-report-problem-android.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-report-problem-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/after-report-problem-ios.png
new file mode 100644
index 000000000..3436bfcef
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-report-problem-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-report-problem-share-android.png b/docs/pr-screenshots/feature/opt-in-analytics/after-report-problem-share-android.png
new file mode 100644
index 000000000..c9081c937
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-report-problem-share-android.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-report-problem-share-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/after-report-problem-share-ios.png
new file mode 100644
index 000000000..d2001880c
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-report-problem-share-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-settings-analytics-android.png b/docs/pr-screenshots/feature/opt-in-analytics/after-settings-analytics-android.png
new file mode 100644
index 000000000..1bb15b4d4
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-settings-analytics-android.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-settings-analytics-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/after-settings-analytics-ios.png
new file mode 100644
index 000000000..2603bf80b
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-settings-analytics-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-settings-analytics-on-android.png b/docs/pr-screenshots/feature/opt-in-analytics/after-settings-analytics-on-android.png
new file mode 100644
index 000000000..d1f7938f9
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-settings-analytics-on-android.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-settings-analytics-on-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/after-settings-analytics-on-ios.png
new file mode 100644
index 000000000..7a563a7b2
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-settings-analytics-on-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-share-sheet-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/after-share-sheet-ios.png
new file mode 100644
index 000000000..8325252b4
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-share-sheet-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/after-what-we-share-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/after-what-we-share-ios.png
new file mode 100644
index 000000000..7bcad2e87
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/after-what-we-share-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/before-about-android.png b/docs/pr-screenshots/feature/opt-in-analytics/before-about-android.png
new file mode 100644
index 000000000..fec73356d
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/before-about-android.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/before-about-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/before-about-ios.png
new file mode 100644
index 000000000..6c9a09f35
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/before-about-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/before-auto-setup-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/before-auto-setup-ios.png
new file mode 100644
index 000000000..12d501691
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/before-auto-setup-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/before-home-desktop-card-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/before-home-desktop-card-ios.png
new file mode 100644
index 000000000..d3011119b
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/before-home-desktop-card-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/before-pro-detail-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/before-pro-detail-ios.png
new file mode 100644
index 000000000..fcf9707ef
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/before-pro-detail-ios.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/before-pro-voice-models-android.png b/docs/pr-screenshots/feature/opt-in-analytics/before-pro-voice-models-android.png
new file mode 100644
index 000000000..bee94abb9
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/before-pro-voice-models-android.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/before-settings-privacy-android.png b/docs/pr-screenshots/feature/opt-in-analytics/before-settings-privacy-android.png
new file mode 100644
index 000000000..c43327009
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/before-settings-privacy-android.png differ
diff --git a/docs/pr-screenshots/feature/opt-in-analytics/before-settings-privacy-ios.png b/docs/pr-screenshots/feature/opt-in-analytics/before-settings-privacy-ios.png
new file mode 100644
index 000000000..d83b73ac0
Binary files /dev/null and b/docs/pr-screenshots/feature/opt-in-analytics/before-settings-privacy-ios.png differ
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index b100ad292..ad52935d2 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -17,7 +17,7 @@
require "json"
require "fileutils"
-# Off Grid ships no telemetry. Disable fastlane's anonymous usage analytics too.
+# Off Grid sends analytics only when the person opts in. Disable fastlane's anonymous usage analytics.
opt_out_usage
REPO_ROOT = File.expand_path("..", __dir__)
diff --git a/fastlane/RELEASING.md b/fastlane/RELEASING.md
index c0c8605fd..ce25889cf 100644
--- a/fastlane/RELEASING.md
+++ b/fastlane/RELEASING.md
@@ -3,8 +3,9 @@
Hand-written guide. Note: `fastlane/README.md` is auto-generated by fastlane (it gets
overwritten every time fastlane runs), so durable docs live here.
-Off Grid ships no telemetry; fastlane's own anonymous analytics are disabled in the
-Fastfile via `opt_out_usage`.
+Off Grid sends anonymous usage data and crash reports only when the person opts in (see
+`docs/ANALYTICS.md`); chats and files never leave the device. fastlane's own anonymous analytics
+are disabled in the Fastfile via `opt_out_usage`.
## Setup
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index ce1eb5db4..b2d9f5ba9 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -1,4 +1,4 @@
-Off Grid is a fully on-device AI assistant. Large language models run directly on your phone via llama.cpp and GGUF — no cloud, no account, no telemetry. Airplane-mode tested.
+Off Grid is a fully on-device AI assistant. Large language models run directly on your phone via llama.cpp and GGUF. No cloud, no account. Your chats and files never leave your device; anonymous usage data and crash reports only if you turn them on. Airplane-mode tested.
Capabilities:
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
index 439b82f7d..240b3c52f 100644
--- a/fastlane/metadata/android/en-US/short_description.txt
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -1 +1 @@
-Run open-source LLMs on your device. Voice, vision, image gen — all offline.
+On-device AI: chat, voice, vision, images. Your chats never leave your phone.
diff --git a/ios/OffgridMobile/PrivacyInfo.xcprivacy b/ios/OffgridMobile/PrivacyInfo.xcprivacy
index 53631f09b..3376f7b12 100644
--- a/ios/OffgridMobile/PrivacyInfo.xcprivacy
+++ b/ios/OffgridMobile/PrivacyInfo.xcprivacy
@@ -41,7 +41,73 @@
NSPrivacyCollectedDataTypes
-
+
+
+ NSPrivacyCollectedDataType
+ NSPrivacyCollectedDataTypeProductInteraction
+ NSPrivacyCollectedDataTypeLinked
+
+ NSPrivacyCollectedDataTypeTracking
+
+ NSPrivacyCollectedDataTypePurposes
+
+ NSPrivacyCollectedDataTypePurposeAnalytics
+ NSPrivacyCollectedDataTypePurposeAppFunctionality
+
+
+
+ NSPrivacyCollectedDataType
+ NSPrivacyCollectedDataTypeCrashData
+ NSPrivacyCollectedDataTypeLinked
+
+ NSPrivacyCollectedDataTypeTracking
+
+ NSPrivacyCollectedDataTypePurposes
+
+ NSPrivacyCollectedDataTypePurposeAnalytics
+ NSPrivacyCollectedDataTypePurposeAppFunctionality
+
+
+
+ NSPrivacyCollectedDataType
+ NSPrivacyCollectedDataTypePerformanceData
+ NSPrivacyCollectedDataTypeLinked
+
+ NSPrivacyCollectedDataTypeTracking
+
+ NSPrivacyCollectedDataTypePurposes
+
+ NSPrivacyCollectedDataTypePurposeAnalytics
+ NSPrivacyCollectedDataTypePurposeAppFunctionality
+
+
+
+ NSPrivacyCollectedDataType
+ NSPrivacyCollectedDataTypeOtherDiagnosticData
+ NSPrivacyCollectedDataTypeLinked
+
+ NSPrivacyCollectedDataTypeTracking
+
+ NSPrivacyCollectedDataTypePurposes
+
+ NSPrivacyCollectedDataTypePurposeAnalytics
+ NSPrivacyCollectedDataTypePurposeAppFunctionality
+
+
+
+ NSPrivacyCollectedDataType
+ NSPrivacyCollectedDataTypeDeviceID
+ NSPrivacyCollectedDataTypeLinked
+
+ NSPrivacyCollectedDataTypeTracking
+
+ NSPrivacyCollectedDataTypePurposes
+
+ NSPrivacyCollectedDataTypePurposeAnalytics
+ NSPrivacyCollectedDataTypePurposeAppFunctionality
+
+
+
NSPrivacyTracking
diff --git a/jest.config.js b/jest.config.js
index 0ad98da3f..76d9b6c85 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -115,5 +115,18 @@ module.exports = {
'./src/services/modelLoadErrors.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
'./src/components/ImageGenAdviceCard.tsx': { statements: 100, branches: 100, functions: 100, lines: 100 },
'./src/components/VoiceRecordButton/derive.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
+ // Opt-in analytics (docs/ANALYTICS.md): every new module is held to 100% on every axis.
+ './src/services/analytics/analytics.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
+ './src/services/analytics/client.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
+ './src/services/analytics/consentPrompt.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
+ './src/services/analytics/featureSnapshot.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
+ './src/services/analytics/modelFacts.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
+ './src/services/analytics/scrub.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
+ './src/services/analytics/uncleanExit.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
+ './src/services/analytics/values.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
+ './src/services/problemReport.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
+ './src/components/AnalyticsConsentPrompt.tsx': { statements: 100, branches: 100, functions: 100, lines: 100 },
+ './src/screens/AnalyticsInfoScreen.tsx': { statements: 100, branches: 100, functions: 100, lines: 100 },
+ './src/screens/SettingsAnalyticsSection.tsx': { statements: 100, branches: 100, functions: 100, lines: 100 },
},
};
diff --git a/jest.setup.ts b/jest.setup.ts
index f3cfe1244..df9834b9c 100644
--- a/jest.setup.ts
+++ b/jest.setup.ts
@@ -468,6 +468,14 @@ jest.mock('react-native-device-info', () => ({
getDeviceId: jest.fn(() => 'test-device-id'),
getHardware: jest.fn(() => Promise.resolve('unknown')),
getIpAddress: jest.fn(() => Promise.resolve('192.168.1.20')),
+ // Battery + form factor (analytics `battery_pct`, `charging`, `device_class`).
+ getPowerState: jest.fn(() => Promise.resolve({ batteryLevel: 0.64, batteryState: 'unplugged', lowPowerMode: false })),
+ isTablet: jest.fn(() => false),
+ // Synchronous build/device identity the PostHog SDK reads for its app properties.
+ getApplicationName: jest.fn(() => 'Off Grid'),
+ getBundleId: jest.fn(() => 'ai.offgridmobile'),
+ getManufacturerSync: jest.fn(() => 'Google'),
+ isEmulatorSync: jest.fn(() => false),
}));
// react-native-image-picker mock
diff --git a/package-lock.json b/package-lock.json
index eb70c7623..498668567 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -35,6 +35,7 @@
"llama.rn": "0.13.0-rc.7",
"node-html-parser": "^7.1.0",
"patch-package": "^8.0.1",
+ "posthog-react-native": "^4.81.10",
"react": "19.2.0",
"react-compiler-runtime": "^1.0.0",
"react-native": "0.83.1",
@@ -5517,6 +5518,21 @@
"url": "https://opencollective.com/pkgr"
}
},
+ "node_modules/@posthog/core": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.60.0.tgz",
+ "integrity": "sha512-d8jmcvJPmgjkcW0kVbIkDXcD3jJ+y/wS6oA4PeEGL914Rj8iM9HfB6VWvMWc6W57UJwpI9+L0hFJkizraVBA1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/types": "^1.416.0"
+ }
+ },
+ "node_modules/@posthog/types": {
+ "version": "1.416.0",
+ "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.416.0.tgz",
+ "integrity": "sha512-0/9+02s/0uDyrH4G/qUg1yyl6gXUg8Uuxw/PlDad3KLzo9FFS9zS/kAQaEshx/saVGupj1aAy/4EUBxWisBogw==",
+ "license": "MIT"
+ },
"node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
@@ -14719,6 +14735,79 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
+ "node_modules/posthog-react-native": {
+ "version": "4.81.10",
+ "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.81.10.tgz",
+ "integrity": "sha512-vUX1uHDwOJB7EJ/j8N105A9lQ5xdiSjpaonWyqd6rTdDNjUsuqApKGDWQEfhCMhJfeVmdNM2sM65gnXkf3No3A==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/core": "^1.59.4",
+ "@posthog/types": "^1.416.0"
+ },
+ "bin": {
+ "posthog-react-native-xcode": "tooling/posthog-xcode.sh"
+ },
+ "peerDependencies": {
+ "@posthog/react-native-plugin": ">= 2.9.3",
+ "@react-native-async-storage/async-storage": ">=1.0.0",
+ "@react-navigation/native": ">= 5.0.0",
+ "expo-application": ">= 4.0.0",
+ "expo-device": ">= 4.0.0",
+ "expo-file-system": ">= 13.0.0",
+ "expo-localization": ">= 11.0.0",
+ "expo-updates": ">= 0.25.0",
+ "posthog-react-native-session-replay": ">= 1.6.0",
+ "react-native-device-info": ">= 10.0.0",
+ "react-native-localize": ">= 3.0.0",
+ "react-native-navigation": ">= 6.0.0",
+ "react-native-safe-area-context": ">= 4.0.0",
+ "react-native-svg": ">= 15.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@posthog/react-native-plugin": {
+ "optional": true
+ },
+ "@react-native-async-storage/async-storage": {
+ "optional": true
+ },
+ "@react-navigation/native": {
+ "optional": true
+ },
+ "expo-application": {
+ "optional": true
+ },
+ "expo-device": {
+ "optional": true
+ },
+ "expo-file-system": {
+ "optional": true
+ },
+ "expo-localization": {
+ "optional": true
+ },
+ "expo-updates": {
+ "optional": true
+ },
+ "posthog-react-native-session-replay": {
+ "optional": true
+ },
+ "react-native-device-info": {
+ "optional": true
+ },
+ "react-native-localize": {
+ "optional": true
+ },
+ "react-native-navigation": {
+ "optional": true
+ },
+ "react-native-safe-area-context": {
+ "optional": true
+ },
+ "react-native-svg": {
+ "optional": true
+ }
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -17413,7 +17502,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
diff --git a/package.json b/package.json
index 9e7844f62..2b28fc2fd 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"llama.rn": "0.13.0-rc.7",
"node-html-parser": "^7.1.0",
"patch-package": "^8.0.1",
+ "posthog-react-native": "^4.81.10",
"react": "19.2.0",
"react-compiler-runtime": "^1.0.0",
"react-native": "0.83.1",
diff --git a/pro b/pro
index 752337cb9..13b529588 160000
--- a/pro
+++ b/pro
@@ -1 +1 @@
-Subproject commit 752337cb94b18f20464a02342e57540f9cdbc3b8
+Subproject commit 13b529588b41f90d457dbaa5c7d8b70a78cda283
diff --git a/src/bootstrap/hookRegistry.ts b/src/bootstrap/hookRegistry.ts
index cade6e933..b9824285b 100644
--- a/src/bootstrap/hookRegistry.ts
+++ b/src/bootstrap/hookRegistry.ts
@@ -98,4 +98,7 @@ export const HOOKS = {
/** (text: string, timestamp: number) => void — core copied text locally. Pro records it through
* the shared clipboard owner instead of waiting for a delayed native clipboard notification. */
clipboardRecordLocalText: 'clipboard.recordLocalText',
+ /** () => Record — Pro features switched on, for the daily analytics
+ * `features_snapshot` (tts, mcp_tool, sync, clipboard, meetings, browser_action). */
+ analyticsProFeatures: 'analytics.proFeatures',
} as const;
diff --git a/src/components/AnalyticsConsentPrompt.tsx b/src/components/AnalyticsConsentPrompt.tsx
new file mode 100644
index 000000000..070d661f6
--- /dev/null
+++ b/src/components/AnalyticsConsentPrompt.tsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import { CenteredAlert } from './CenteredAlert';
+import {
+ closeConsentPrompt,
+ useConsentPromptStore,
+} from '../services/analytics/consentPrompt';
+import { setAnalyticsConsent } from '../services/analytics/analytics';
+import { ignore } from '../services/analytics/values';
+
+const COPY = {
+ failure: { title: 'Help us fix this?', source: 'failure_prompt' },
+ first_chat: { title: 'Help us fix what breaks', source: 'first_chat_prompt' },
+} as const;
+
+const BODY = 'Send anonymous crash reports and usage. Your chats stay on this device.';
+
+/** The one consent prompt shown outside Auto Setup. Mounted once at the app root. */
+export const AnalyticsConsentPrompt: React.FC = () => {
+ const kind = useConsentPromptStore(s => s.kind);
+ if (!kind) return null;
+ const copy = COPY[kind];
+ return (
+ {
+ setAnalyticsConsent({ usage: true, crashes: true }, copy.source).catch(ignore);
+ },
+ },
+ ]}
+ />
+ );
+};
diff --git a/src/components/DebugLogsScreen/index.tsx b/src/components/DebugLogsScreen/index.tsx
index db6ebd35e..0691671c8 100644
--- a/src/components/DebugLogsScreen/index.tsx
+++ b/src/components/DebugLogsScreen/index.tsx
@@ -24,9 +24,11 @@ interface DebugLogsScreenProps {
visible: boolean;
onClose: () => void;
syncOnly?: boolean;
+ /** Report a problem: the person reads the log here, then sends it to support. */
+ onSend?: () => void;
}
-export const DebugLogsScreen: React.FC = ({ visible, onClose, syncOnly = false }) => {
+export const DebugLogsScreen: React.FC = ({ visible, onClose, syncOnly = false, onSend }) => {
const theme = useTheme();
const styles = useThemedStyles(createStyles);
const { logs, clearLogs } = useDebugLogsStore();
@@ -106,7 +108,7 @@ export const DebugLogsScreen: React.FC = ({ visible, onClo
{syncOnly ? 'Sync Debug Logs' : 'Debug Logs'}
{visibleLogs.length} log entries
-
+
@@ -129,6 +131,20 @@ export const DebugLogsScreen: React.FC = ({ visible, onClo
)}
+ {onSend && (
+
+
+
+ Send to support
+
+
+ )}
+
{/* Action Buttons */}
@@ -141,7 +157,8 @@ export const DebugLogsScreen: React.FC = ({ visible, onClo
Share
- {!syncOnly &&
+ {/* Report a problem sends the log file; clearing only this view would hide what is sent. */}
+ {!syncOnly && !onSend &&
Clear
}
diff --git a/src/components/RemoteServerEditor/useRemoteServerForm.ts b/src/components/RemoteServerEditor/useRemoteServerForm.ts
index de3e7503a..7cc9e541e 100644
--- a/src/components/RemoteServerEditor/useRemoteServerForm.ts
+++ b/src/components/RemoteServerEditor/useRemoteServerForm.ts
@@ -10,6 +10,7 @@ import {
} from '../../types';
import { isPrivateNetworkEndpoint } from '../../services/httpClient';
import { AlertState, initialAlertState, showAlert } from '../CustomAlert';
+import { flowStarted, flowStep } from '../../services/analytics/analytics';
interface FormOptions {
server?: RemoteServer;
@@ -97,7 +98,8 @@ export function useRemoteServerForm({
if (!cancelled) setApiKey('');
});
} else {
- // Reset form for new server
+ // Reset form for new server. The flow starts only when the editor opens, not when it hides.
+ if (visible) flowStarted('remote_server', 'opened');
setName('');
setEndpoint('');
setApiKey('');
@@ -190,6 +192,7 @@ export function useRemoteServerForm({
const handleTestConnection = useCallback(async () => {
if (!validateForm()) return;
+ flowStep('remote_server', 'address_entered');
setIsTesting(true);
setTestResult(null);
setDiscoveredModels([]);
diff --git a/src/components/SharePromptSheet.tsx b/src/components/SharePromptSheet.tsx
index 593ae207b..c47a61fdb 100644
--- a/src/components/SharePromptSheet.tsx
+++ b/src/components/SharePromptSheet.tsx
@@ -34,7 +34,7 @@ export const SharePromptSheet: React.FC = ({ visible, onC
- Off Grid AI is completely free, open-source, and private - your data never leaves your device. Help grow the movement for accessible, private AI by spreading the word.
+ Off Grid AI is completely free, open-source, and private - your chats and files never leave your device. Help grow the movement for accessible, private AI by spreading the word.
handleEngage(GITHUB_URL)}>
diff --git a/src/components/models/VoiceModelsSheet.tsx b/src/components/models/VoiceModelsSheet.tsx
index 8ee744db1..11360c8d7 100644
--- a/src/components/models/VoiceModelsSheet.tsx
+++ b/src/components/models/VoiceModelsSheet.tsx
@@ -6,6 +6,7 @@ import { VoiceModelsUpsell } from './VoiceModelsUpsell';
import { useSlot, SLOTS } from '../../bootstrap/slotRegistry';
import { useThemedStyles } from '../../theme';
import type { ThemeColors } from '../../theme';
+import { proGateShown } from '../../services/analytics/analytics';
type Props = {
visible: boolean;
@@ -39,6 +40,7 @@ export const VoiceModelsSheet: React.FC = ({
// a dead-end "not available" line. Close the sheet first so the Pro screen
// isn't presented while this bottom sheet is still dismissing (iOS).
const handleGetPro = () => {
+ proGateShown('voice');
onClose();
(navigation as unknown as { navigate: (name: string) => void }).navigate('ProDetail');
};
diff --git a/src/config/analytics.ts b/src/config/analytics.ts
new file mode 100644
index 000000000..4694b96bc
--- /dev/null
+++ b/src/config/analytics.ts
@@ -0,0 +1,7 @@
+/**
+ * Opt-in analytics (PostHog Cloud EU). See docs/ANALYTICS.md for what is sent.
+ *
+ * The key is PostHog's public project key (safe in source). A debug build never creates the SDK.
+ */
+export const POSTHOG_API_KEY = 'phc_BwRDGUM5E9wbpBDK62KhicxHPoQrmhRFKQppaZCUrFzU';
+export const POSTHOG_HOST = 'https://eu.i.posthog.com';
diff --git a/src/hooks/useOpenProTools.ts b/src/hooks/useOpenProTools.ts
index 2f9819f49..ff3b7208f 100644
--- a/src/hooks/useOpenProTools.ts
+++ b/src/hooks/useOpenProTools.ts
@@ -5,6 +5,7 @@ import { RootStackParamList } from '../navigation/types';
import { useAppStore } from '../stores/appStore';
import { selectHasProAccess } from '../stores/proAccessSlice';
import { PRO_TOOLS_SCREEN } from './useIsProActive';
+import { proGateShown } from '../services/analytics/analytics';
/**
* One callback for "open the Pro Tools destination", used everywhere the user can
@@ -25,6 +26,7 @@ export function useOpenProTools(): () => void {
if (hasAccess && hasProScreen) {
navigation.navigate(PRO_TOOLS_SCREEN as any);
} else {
+ proGateShown('tools');
navigation.navigate('ProDetail');
}
};
diff --git a/src/hooks/useOpenSync.ts b/src/hooks/useOpenSync.ts
index 458b7824e..4d5178225 100644
--- a/src/hooks/useOpenSync.ts
+++ b/src/hooks/useOpenSync.ts
@@ -4,6 +4,7 @@ import { useCallback } from 'react';
import type { RootStackParamList } from '../navigation/types';
import { selectHasProAccess } from '../stores/proAccessSlice';
import { useAppStore } from '../stores';
+import { proGateShown } from '../services/analytics/analytics';
/**
* Single navigation gate for the user-facing Sync control center.
@@ -24,9 +25,11 @@ export function useOpenSync(): {
// screen's own header read "Device Not Active".
const isSyncUnlocked = useAppStore(selectHasProAccess);
const openSync = useCallback(() => {
+ if (!isSyncUnlocked) proGateShown('sync');
navigation.navigate(isSyncUnlocked ? 'Sync' : 'ProDetail');
}, [isSyncUnlocked, navigation]);
const openSyncNotifications = useCallback(() => {
+ if (!isSyncUnlocked) proGateShown('sync');
navigation.navigate(isSyncUnlocked ? 'Notifications' : 'ProDetail');
}, [isSyncUnlocked, navigation]);
diff --git a/src/hooks/useWhisperTranscription.ts b/src/hooks/useWhisperTranscription.ts
index 7ebb4f24b..f78e0a371 100644
--- a/src/hooks/useWhisperTranscription.ts
+++ b/src/hooks/useWhisperTranscription.ts
@@ -258,7 +258,7 @@ export const useWhisperTranscription = ({ ensureModelReady, onCaptureEnded }: Us
logger.log('[Whisper] Starting realtime transcription...');
await whisperService.startRealtimeTranscription((result) => {
- logger.log('[Whisper] Transcription result:', result.isCapturing, result.text?.slice(0, 50));
+ logger.log('[Whisper] Transcription result:', result.isCapturing, `${result.text?.length ?? 0}ch`);
if (isCancelled.current || !mountedRef.current) return;
diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
index acea552d0..2f52f0e40 100644
--- a/src/navigation/AppNavigator.tsx
+++ b/src/navigation/AppNavigator.tsx
@@ -38,6 +38,7 @@ import {
RemoteServerEditorScreen,
ProDetailScreen,
AboutScreen,
+ AnalyticsInfoScreen,
ToolsScreen,
} from '../screens';
import {
@@ -241,6 +242,7 @@ export const AppNavigator: React.FC = () => {
component={AboutScreen}
options={{ headerShown: false }}
/>
+
| undefined;
// Former ChatsStack
Chat: { conversationId?: string; projectId?: string };
diff --git a/src/screens/AboutScreen.tsx b/src/screens/AboutScreen.tsx
index 83ba4c349..73d274c81 100644
--- a/src/screens/AboutScreen.tsx
+++ b/src/screens/AboutScreen.tsx
@@ -39,7 +39,7 @@ export const AboutScreen: React.FC = () => {
Off Grid AI
Version {appVersion()}
- Local AI that runs entirely on your phone. No cloud, no telemetry, nothing leaves the device.
+ Local AI that runs entirely on your phone. Your chats and files never leave your device. Anonymous usage data and crash reports only if you turn them on.
diff --git a/src/screens/AnalyticsInfoScreen.tsx b/src/screens/AnalyticsInfoScreen.tsx
new file mode 100644
index 000000000..e80543a2b
--- /dev/null
+++ b/src/screens/AnalyticsInfoScreen.tsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useNavigation } from '@react-navigation/native';
+import Icon from 'react-native-vector-icons/Feather';
+import { useTheme, useThemedStyles } from '../theme';
+import type { ThemeColors, ThemeShadows } from '../theme';
+import { SPACING, TYPOGRAPHY } from '../constants';
+
+/** Plain-words summary of docs/ANALYTICS.md. Keep the two lists in step with that file. */
+const WE_SHARE = [
+ 'Which screens you open and how long you stay.',
+ 'Model downloads and loads: which public model, its size, how fast it went, and whether it worked.',
+ 'How each reply went: speed, time to the first word, and whether it finished, stopped or failed.',
+ 'Which features you use, such as voice, images, tools or sync.',
+ 'Steps in setup and other journeys, so we can see where people get stuck.',
+ 'Crash reports, with file paths and any typed text removed.',
+ 'Your device type, RAM, system version, app version and language.',
+];
+
+const WE_NEVER_SHARE = [
+ 'Your chats, prompts, replies, transcripts or reasoning.',
+ 'What tools read or return.',
+ 'File names, document names or anything inside your files.',
+ 'Web addresses, server addresses or IP addresses.',
+ 'What you search for.',
+ 'Names of models you imported yourself.',
+ 'Your email, license key, account or device ID.',
+];
+
+const ListBlock: React.FC<{
+ title: string;
+ icon: string;
+ items: string[];
+ testID: string;
+}> = ({ title, icon, items, testID }) => {
+ const { colors } = useTheme();
+ const styles = useThemedStyles(createStyles);
+ return (
+
+ {title}
+ {items.map(item => (
+
+
+ {item}
+
+ ))}
+
+ );
+};
+
+/** "What we share": what opt-in analytics sends, and what it never sends. */
+export const AnalyticsInfoScreen: React.FC = () => {
+ const navigation = useNavigation();
+ const { colors } = useTheme();
+ const styles = useThemedStyles(createStyles);
+
+ return (
+
+
+ navigation.goBack()}
+ style={styles.backButton}
+ accessibilityRole="button"
+ accessibilityLabel="Back"
+ >
+
+
+ What we share
+
+
+
+
+ Off by default. If you turn on Usage data or Crash reports, Off Grid AI sends anonymous
+ reports that help us fix what breaks on your device. Your chats and files never leave it.
+ Turn it off any time in Settings.
+
+
+
+
+
+ );
+};
+
+const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
+ container: { flex: 1, backgroundColor: colors.background },
+ header: {
+ flexDirection: 'row' as const,
+ alignItems: 'center' as const,
+ justifyContent: 'space-between' as const,
+ paddingHorizontal: SPACING.md,
+ paddingVertical: SPACING.sm,
+ },
+ backButton: { width: 36, height: 36, justifyContent: 'center' as const },
+ headerTitle: { ...TYPOGRAPHY.h3, color: colors.text },
+ content: { padding: SPACING.md, gap: SPACING.lg, paddingBottom: SPACING.xxl },
+ intro: { ...TYPOGRAPHY.body, color: colors.textSecondary },
+ section: { gap: SPACING.sm },
+ sectionTitle: { ...TYPOGRAPHY.label, color: colors.primary },
+ row: { flexDirection: 'row' as const, gap: SPACING.sm, alignItems: 'flex-start' as const },
+ rowIcon: { marginTop: 3 },
+ rowText: { ...TYPOGRAPHY.body, color: colors.text, flex: 1 },
+});
diff --git a/src/screens/AutoSetupScreen.tsx b/src/screens/AutoSetupScreen.tsx
index 3f8d0f8e8..bd8778921 100644
--- a/src/screens/AutoSetupScreen.tsx
+++ b/src/screens/AutoSetupScreen.tsx
@@ -1,5 +1,6 @@
-import React, { useEffect, useMemo, useSyncExternalStore } from 'react';
-import { ScrollView, Text, View, useWindowDimensions } from 'react-native';
+import React, { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
+import { Pressable, ScrollView, Text, View, useWindowDimensions } from 'react-native';
+import Icon from 'react-native-vector-icons/Feather';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Button, Card } from '../components';
@@ -13,6 +14,14 @@ import {
createAutoSetupSession,
type AutoSetupSession,
} from '../services/autoSetupService';
+import {
+ flowFinished,
+ flowStarted,
+ proGateShown,
+ setAnalyticsConsent,
+} from '../services/analytics/analytics';
+import { ignore } from '../services/analytics/values';
+import { useAppStore } from '../stores/appStore';
import { useTheme, useThemedStyles } from '../theme';
import type { ThemeColors, ThemeShadows } from '../theme';
@@ -51,6 +60,23 @@ export const AutoSetupScreen: React.FC = ({
return () => session.dispose();
}, [session]);
+ useEffect(() => {
+ flowStarted('onboarding', 'setup_shown');
+ }, []);
+
+ const [shareAnalytics, setShareAnalytics] = useState(
+ () => useAppStore.getState().settings.analyticsUsage,
+ );
+ const toggleShareAnalytics = () => {
+ const next = !shareAnalytics;
+ setShareAnalytics(next);
+ setAnalyticsConsent({ usage: next, crashes: next }, 'setup').catch(ignore);
+ };
+ // Every way out of Auto Setup passed the consent row, so the person has been asked.
+ const markConsentAsked = () => {
+ useAppStore.getState().updateSettings({ analyticsAsked: true, analyticsLastPromptAt: Date.now() });
+ };
+
const selected =
snapshot.plans.find(plan => plan.tier === snapshot.selectedTier) ??
snapshot.plans[0];
@@ -108,6 +134,39 @@ export const AutoSetupScreen: React.FC = ({
)}
+
+
+
+
+
+ Share anonymous usage and crash reports
+
+
+ Helps us fix what breaks on your device. Your chats never leave it.
+
+
+
+ navigation.push('AnalyticsInfo')}
+ accessibilityRole="link"
+ testID="auto-setup-what-we-share"
+ >
+ What we share
+
+
+
= 700 && styles.planGridWide]}>
{snapshot.plans.map(plan => (
= ({
))}
{VoiceIndicator ? (
navigation.push('ProDetail')}
+ onPress={() => {
+ proGateShown('voice');
+ navigation.push('ProDetail');
+ }}
style={styles.planItem}
/>
) : null}
@@ -183,6 +245,7 @@ export const AutoSetupScreen: React.FC = ({
: `Download ${formatBytes(plan.totalBytes)}`
}
onPress={() => {
+ markConsentAsked();
session.start().catch(() => undefined);
}}
loading={starting}
@@ -206,13 +269,20 @@ export const AutoSetupScreen: React.FC = ({
diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts
index a1e306cc9..4d43ffab5 100644
--- a/src/screens/ChatScreen/useChatGenerationActions.ts
+++ b/src/screens/ChatScreen/useChatGenerationActions.ts
@@ -44,6 +44,7 @@ import {
GenerationMeta,
} from '../../types';
import logger from '../../utils/logger';
+import { featureUsed, messageSent, track } from '../../services/analytics/analytics';
import { deleteChatImages, imagesNotDeletedAlert } from '../../services/chatImageCleanup';
import { ModelReadyOutcome, ensureReadyOrAlert } from './modelReadiness';
import { describeTurnStatus } from '@offgrid/rag/documents';
@@ -317,7 +318,7 @@ export async function shouldRouteToImageGenerationFn(
// so "why didn't 'draw a dog' make an image?" is answerable from the logs (esp. the
// voice path), never a guess.
logger.log(
- `[ROUTE-SM] route? text="${text.slice(0, 60)}" force=${
+ `[ROUTE-SM] route? text=${text.length}ch force=${
forceImageMode ?? false
} mode=${
deps.settings.imageGenerationMode
@@ -993,10 +994,7 @@ export async function dispatchGenerationFn(
// [ROUTE-SM]: confirms the turn reached the router (esp. the voice path) + the
// final routed destination — so a "pipeline never triggered" is visible in logs.
logger.log(
- `[ROUTE-SM] dispatch text="${text.slice(
- 0,
- 60,
- )}" imageMode=${imageMode} hasImageModel=${!!deps.activeImageModel}`,
+ `[ROUTE-SM] dispatch text=${text.length}ch imageMode=${imageMode} hasImageModel=${!!deps.activeImageModel}`,
);
// ONE decision seam (resolveTurnKind); a NEW turn has no recorded kind so the route rule decides.
const kind = await resolveTurnKind(deps, {
@@ -1033,6 +1031,15 @@ export type SendCall = {
startGeneration: (convId: string, text: string, assistantEnabled?: boolean) => Promise;
setDebugInfo: SetState;
};
+function reportMessageSent(attachments: MediaAttachment[] | undefined): void {
+ const hasImage = !!attachments?.some(attachment => attachment.type === 'image');
+ messageSent({
+ hasImage,
+ hasDocument: !!attachments?.some(attachment => attachment.type === 'document'),
+ toolsEnabled: (useAppStore.getState().settings.enabledTools?.length ?? 0) > 0,
+ });
+ if (hasImage) featureUsed('vision');
+}
export async function handleSendFn(
deps: GenerationDeps,
call: SendCall,
@@ -1060,6 +1067,7 @@ export async function handleSendFn(
);
deps.setActiveConversation(targetConversationId);
}
+ reportMessageSent(attachments);
// Cross-modality serialization: queue if any generation is running (routed later).
if (
generationService.getState().isGenerating ||
@@ -1206,6 +1214,8 @@ export async function regenerateResponseFn(
},
});
if (result.handled) {
+ // Counted where a regenerate really runs: a bail-out or a not-ready retry is not a second one.
+ track('reply_regenerated');
generationSession.end('handled');
return;
}
@@ -1227,6 +1237,7 @@ export async function regenerateResponseFn(
return;
}
logger.log('[RESEND-SM] regenerate → reached LLM generate path');
+ track('reply_regenerated');
// LiteRT: native history must be rewound to match the JS messages we're about to replay.
// Dispatched via the service (no engine branch here); a no-op for engines without a KV cache.
invalidateActiveConversation();
diff --git a/src/screens/HomeScreen/components/DesktopPromoCard.tsx b/src/screens/HomeScreen/components/DesktopPromoCard.tsx
index 5a746fb2c..d0e8472ad 100644
--- a/src/screens/HomeScreen/components/DesktopPromoCard.tsx
+++ b/src/screens/HomeScreen/components/DesktopPromoCard.tsx
@@ -54,7 +54,7 @@ export const DesktopPromoCard: React.FC = () => {
- Chat, image, voice, and projects that answer from your own docs - on your Mac, on-device. Plus a private layer that remembers what you see, rewinds your day, searches it all at once, and drafts to-dos and actions you approve. Nothing leaves the device.
+ Chat, image, voice, and projects that answer from your own docs - on your Mac, on-device. Plus a private layer that remembers what you see, rewinds your day, searches it all at once, and drafts to-dos and actions you approve. Your chats and files never leave the device.
diff --git a/src/screens/HomeScreen/components/loadingTips.ts b/src/screens/HomeScreen/components/loadingTips.ts
index 15a1bfa95..736a4887b 100644
--- a/src/screens/HomeScreen/components/loadingTips.ts
+++ b/src/screens/HomeScreen/components/loadingTips.ts
@@ -18,7 +18,7 @@ export const LOADING_TIPS: readonly LoadingTip[] = [
text: 'Raise the temperature toward 1.5-2 for more creative replies. Keep it near 0 for precise answers.',
},
{
- text: "The model runs in your phone's RAM. Nothing is sent anywhere.",
+ text: "The model runs in your phone's RAM. Your chats never leave it.",
},
{
text: 'Bigger models answer better but load slower and use more memory.',
diff --git a/src/screens/ModelsScreen/index.tsx b/src/screens/ModelsScreen/index.tsx
index d06393eb8..d759bb256 100644
--- a/src/screens/ModelsScreen/index.tsx
+++ b/src/screens/ModelsScreen/index.tsx
@@ -18,6 +18,7 @@ import type { ModelTab } from './types';
import { ScreenHeader } from '../../components/ScreenHeader';
import { checkForNewModels, useModelCatalogStore, withRemoteCatalog, type CatalogCheckResult } from '../../stores/modelCatalogStore';
import { CatalogCheckSheet } from '../../components/models/CatalogCheckSheet';
+import { proGateShown } from '../../services/analytics/analytics';
const MODEL_TABS: ReadonlyArray<{ key: ModelTab; label: string; testID?: string }> = [
{ key: 'text', label: 'Text' },
@@ -266,7 +267,12 @@ export const ModelsScreen: React.FC = ({ embedded = false })
{vm.activeTab === 'voice' && (
VoiceModelsPanel
?
- : vm.navigation.navigate('ProDetail')} />
+ : {
+ proGateShown('voice');
+ vm.navigation.navigate('ProDetail');
+ }}
+ />
)}
{/* Transcription Models Tab (speech-to-text, core). */}
diff --git a/src/screens/ModelsScreen/useModelsScreen.ts b/src/screens/ModelsScreen/useModelsScreen.ts
index 64eb36ccf..8633b81be 100644
--- a/src/screens/ModelsScreen/useModelsScreen.ts
+++ b/src/screens/ModelsScreen/useModelsScreen.ts
@@ -21,6 +21,7 @@ import { useImageModels } from './useImageModels';
import { importGgufFiles, getErrorMessage } from './importHelpers';
import { isPickerStuck } from '../../utils/pickerErrorUtils';
import { isLiteRTFileName } from '../../utils/modelHelpers';
+import { flowStarted } from '../../services/analytics/analytics';
type ZipImportDeps = {
addDownloadedImageModel: (model: ONNXImageModel) => void;
@@ -89,6 +90,8 @@ export function useModelsScreen() {
const image = useImageModels(setAlertState);
useEffect(() => {
+ if (activeTab === 'image') flowStarted('image_setup', 'opened');
+ if (activeTab === 'voice' || activeTab === 'transcription') flowStarted('voice_setup', 'opened');
if (activeTab === 'image' && image.availableHFModels.length === 0 && !image.hfModelsLoading) {
image.loadHFModels();
}
diff --git a/src/screens/ProDetailScreen/index.tsx b/src/screens/ProDetailScreen/index.tsx
index 90ed266a1..81fc14626 100644
--- a/src/screens/ProDetailScreen/index.tsx
+++ b/src/screens/ProDetailScreen/index.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
import {
View,
Text,
@@ -30,6 +30,7 @@ import { ProManageSection } from './ProManageSection';
import { ProIncludedSection } from './ProIncludedSection';
import { ProUnlockModal } from './ProUnlockModal';
import type { RootStackParamList } from '../../navigation/types';
+import { proPaywallViewed, proPurchased } from '../../services/analytics/analytics';
// Off Grid AI Pro is the ambient intelligence layer across desktop + phone, not a
// mobile feature list. These pillars mirror the early-access page framing.
@@ -47,7 +48,7 @@ const PILLARS = [
{
icon: 'shield',
title: 'Private by architecture',
- desc: 'The model runs on your own hardware. No training on your data, no server to leak. Open source, so you can check.',
+ desc: 'The model runs on your own hardware. Your chats and files never leave your devices, and nothing trains on them. Open source, so you can check.',
},
{
icon: 'refresh-cw',
@@ -94,9 +95,16 @@ export const ProDetailScreen: React.FC = () => {
};
const openVerifyModal = () => setVerifyModalVisible(true);
+ useEffect(() => {
+ if (!hasProAccess) proPaywallViewed();
+ // Once per visit: a paywall view is the screen opening, not every access change.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
// Activation verified: load the pro bundle now so Pro lights up live (the
// reactive appRoot slot mounts the engine without a restart). Registries dedupe.
const handleUnlocked = () => {
+ proPurchased();
loadProFeatures(true).catch(() => {});
};
@@ -176,7 +184,7 @@ export const ProDetailScreen: React.FC = () => {
Ambient and proactive. It sees your day, remembers it, and gets
ahead of you - and the model runs on your own hardware, so
- nothing is sent anywhere.
+ your chats and files never leave your devices.
diff --git a/src/screens/SettingsAnalyticsSection.tsx b/src/screens/SettingsAnalyticsSection.tsx
new file mode 100644
index 000000000..f8a103f12
--- /dev/null
+++ b/src/screens/SettingsAnalyticsSection.tsx
@@ -0,0 +1,92 @@
+import React, { useState } from 'react';
+import { Switch, Text, TouchableOpacity, View } from 'react-native';
+import Icon from 'react-native-vector-icons/Feather';
+import { AnimatedEntry } from '../components/AnimatedEntry';
+import { DebugLogsScreen } from '../components/DebugLogsScreen';
+import { useAppStore } from '../stores/appStore';
+import { setAnalyticsConsent } from '../services/analytics/analytics';
+import { sendProblemReport } from '../services/problemReport';
+import { ignore } from '../services/analytics/values';
+import type { ThemeColors } from '../theme';
+import type { createStyles } from './SettingsScreen.styles';
+
+interface SettingsAnalyticsSectionProps {
+ focusTrigger: number;
+ styles: ReturnType;
+ colors: ThemeColors;
+ onWhatWeShare: () => void;
+}
+
+/** Settings → Analytics: the two consent toggles, what is shared, and Report a problem. */
+export const SettingsAnalyticsSection: React.FC = ({
+ focusTrigger,
+ styles,
+ colors,
+ onWhatWeShare,
+}) => {
+ const usage = useAppStore(s => s.settings.analyticsUsage);
+ const crashes = useAppStore(s => s.settings.analyticsCrashes);
+ const [showLog, setShowLog] = useState(false);
+
+ const toggle = (label: string, value: boolean, onChange: (next: boolean) => void) => (
+
+
+ {label}
+
+
+
+ );
+
+ return (
+
+ Analytics
+
+ {toggle('Usage data', usage, next => {
+ setAnalyticsConsent({ usage: next }, 'settings').catch(ignore);
+ })}
+ {toggle('Crash reports', crashes, next => {
+ setAnalyticsConsent({ crashes: next }, 'settings').catch(ignore);
+ })}
+
+
+
+
+
+ What we share
+ Your chats and files never leave your device
+
+
+
+ setShowLog(true)}
+ testID="settings-report-problem"
+ >
+
+
+
+
+ Report a problem
+ Check the debug log, then send it to us
+
+
+
+
+ {showLog ? (
+ setShowLog(false)}
+ onSend={() => {
+ sendProblemReport().catch(ignore);
+ }}
+ />
+ ) : null}
+
+ );
+};
diff --git a/src/screens/SettingsScreen.styles.ts b/src/screens/SettingsScreen.styles.ts
index 3d125aa1a..58d7c632c 100644
--- a/src/screens/SettingsScreen.styles.ts
+++ b/src/screens/SettingsScreen.styles.ts
@@ -124,29 +124,11 @@ export const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
color: colors.textMuted,
lineHeight: 18,
},
- privacyCard: {
- alignItems: 'center' as const,
- backgroundColor: colors.surface,
- },
- privacyIconContainer: {
- width: 36,
- height: 36,
- borderRadius: 18,
- backgroundColor: 'transparent',
- alignItems: 'center' as const,
- justifyContent: 'center' as const,
- marginBottom: SPACING.md,
- },
- privacyTitle: {
- ...TYPOGRAPHY.h3,
- color: colors.text,
+ sectionLabel: {
+ ...TYPOGRAPHY.label,
+ color: colors.textMuted,
marginBottom: SPACING.sm,
- },
- privacyText: {
- ...TYPOGRAPHY.body,
- color: colors.textSecondary,
- textAlign: 'center' as const,
- lineHeight: 20,
+ marginLeft: SPACING.xs,
},
devButton: {
flexDirection: 'row' as const,
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
index 521904848..83821f891 100644
--- a/src/screens/SettingsScreen.tsx
+++ b/src/screens/SettingsScreen.tsx
@@ -17,10 +17,10 @@ import {
} from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
-import { Card } from '../components';
import { createStyles } from './SettingsScreen.styles';
import { SettingsAppearanceRow } from './SettingsAppearanceRow';
import { SettingsCommunitySections } from './SettingsCommunitySections';
+import { SettingsAnalyticsSection } from './SettingsAnalyticsSection';
import { AnimatedEntry } from '../components/AnimatedEntry';
import { AnimatedListItem } from '../components/AnimatedListItem';
import { MadeWithLove } from '../components/MadeWithLove';
@@ -315,19 +315,13 @@ export const SettingsScreen: React.FC = () => {
- {/* Privacy */}
-
-
-
-
-
- Privacy First
-
- All your data stays on this device. No conversations, prompts, or
- personal information is ever sent to any server.
-
-
-
+ {/* Analytics: opt-in consent, what is shared, Report a problem */}
+ navigation.navigate('AnalyticsInfo')}
+ />
{/* Pro feature sections registered at runtime by @offgrid/pro */}
{settingsSections.map((Section, i) => (
diff --git a/src/screens/index.ts b/src/screens/index.ts
index d578338e9..2cbf5207c 100644
--- a/src/screens/index.ts
+++ b/src/screens/index.ts
@@ -24,4 +24,5 @@ export { RemoteServersScreen } from './RemoteServersScreen';
export { RemoteServerEditorScreen } from './RemoteServerEditorScreen';
export { ProDetailScreen } from './ProDetailScreen';
export { AboutScreen } from './AboutScreen';
+export { AnalyticsInfoScreen } from './AnalyticsInfoScreen';
export { ToolsScreen } from './ToolsScreen';
diff --git a/src/services/activeModelService/index.ts b/src/services/activeModelService/index.ts
index 134b16faa..8b5a115aa 100644
--- a/src/services/activeModelService/index.ts
+++ b/src/services/activeModelService/index.ts
@@ -15,7 +15,7 @@ import {
selectedTextModelIdOf,
} from './selectedTextModel';
import { activeModelSnapshot } from './snapshot';
-import type { DownloadedModel } from '../../types';
+import type { DownloadedModel, ONNXImageModel } from '../../types';
import type {
ActiveModelInfo,
ResourceUsage,
@@ -30,6 +30,8 @@ import {
estimateTextModelMemoryMB,
} from './memory';
import { doLoadTextModel, doLoadImageModel, checkImageModelCanLoad } from './loaders';
+import { trackModelLoad } from '../analytics/analytics';
+import { downloadedModelFacts, imageModelFacts } from '../analytics/modelFacts';
import {
getResourceUsage as _getResourceUsage,
syncWithNativeState as _syncWithNativeState,
@@ -177,6 +179,17 @@ class ActiveModelService {
if (!model) {
throw new Error('Model not found');
}
+ await trackModelLoad(downloadedModelFacts(model, store.settings), () =>
+ this.loadFoundTextModel(model, modelId, timeoutMs, opts),
+ );
+ }
+ private async loadFoundTextModel(
+ model: DownloadedModel,
+ modelId: string,
+ timeoutMs: number,
+ opts?: { override?: boolean },
+ ): Promise {
+ const store = useAppStore.getState();
// Use estimated runtime RAM (file size + overhead), not just file size,
// so the residency budget reflects the model's real memory footprint.
// GPU-aware overhead: a GPU/NPU backend adds working buffers in system RAM the flat CPU 1.5× misses.
@@ -310,6 +323,20 @@ class ActiveModelService {
if (!model) {
throw new Error('Model not found');
}
+ await trackModelLoad(imageModelFacts(model), () =>
+ this.loadFoundImageModel(model, modelId, imageThreads, needsThreadReload, timeoutMs, opts),
+ );
+ }
+ // eslint-disable-next-line max-params
+ private async loadFoundImageModel(
+ model: ONNXImageModel,
+ modelId: string,
+ imageThreads: number,
+ needsThreadReload: boolean,
+ timeoutMs: number,
+ opts?: { override?: boolean },
+ ): Promise {
+ const store = useAppStore.getState();
// Integrity gate (B): a partial extraction crashes the native server as a misleading "backend unsupported" — throw typed error so the UI says "re-download".
if (model.backend === 'mnn' || model.backend === 'qnn') {
const integ = await validateImageModelDir(model.modelPath, model.backend);
diff --git a/src/services/analytics/analytics.ts b/src/services/analytics/analytics.ts
new file mode 100644
index 000000000..57d77c3c1
--- /dev/null
+++ b/src/services/analytics/analytics.ts
@@ -0,0 +1,511 @@
+/**
+ * Opt-in analytics: the single owner of consent, the SDK client, super properties, screens,
+ * flows and the events in docs/ANALYTICS.md. Every other module reports facts through the
+ * functions here; none of them imports the SDK.
+ *
+ * - Off by default. The client exists only while Usage data or Crash reports is on, and only in a
+ * release build with a key. Before that, every call here is a no-op and nothing is queued.
+ * - Usage data gates every event except `$exception` and `unclean_exit`, which Crash reports gates.
+ * - Turning both off opts out: what is queued is dropped and the anonymous ID is reset.
+ */
+import { AppState, Platform, type AppStateStatus } from 'react-native';
+import DeviceInfo from 'react-native-device-info';
+import RNFS from 'react-native-fs';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { useAppStore } from '../../stores/appStore';
+import { selectHasProAccess } from '../../stores/proAccessSlice';
+import { appVersion } from '../../utils/appVersion';
+import { hardwareService } from '../hardware';
+import { analyticsBuildAllowed, createAnalyticsClient, type AnalyticsClient } from './client';
+import { scrubExceptionProperties } from './scrub';
+import { markForeground, markStageEnded, markStageStarted, takeUncleanExit } from './uncleanExit';
+import { maybePromptAfterFailure, maybePromptAfterFirstChat } from './consentPrompt';
+import {
+ batteryPct,
+ bytesToGb,
+ contextBucket,
+ errorCode,
+ ignore,
+ languageOnly,
+ ms,
+ oneDecimal,
+ releaseChannel,
+ sizeBucket,
+} from './values';
+
+type Properties = Record;
+export type ConsentSource = 'setup' | 'settings' | 'failure_prompt' | 'first_chat_prompt';
+export type Flow =
+ | 'onboarding'
+ | 'model_transfer'
+ | 'sync_pairing'
+ | 'remote_server'
+ | 'voice_setup'
+ | 'image_setup'
+ | 'connector_add'
+ | 'pro_upgrade';
+export type Feature =
+ | 'voice_input'
+ | 'tts'
+ | 'image_generation'
+ | 'vision'
+ | 'web_search'
+ | 'tools'
+ | 'mcp_tool'
+ | 'remote_server'
+ | 'browser_action'
+ | 'clipboard'
+ | 'meetings'
+ | 'sync';
+export type Modality = 'text' | 'vision' | 'image' | 'stt' | 'tts' | 'embedding';
+export type Outcome = 'done' | 'stopped' | 'failed';
+
+/** What a model is, in the spec's words: `model`, `quant`, `engine`, `backend`. */
+export interface ModelFacts {
+ model: string;
+ quant?: string;
+ engine: string;
+ backend: string;
+}
+
+const CRASH_EVENTS = new Set(['$exception', 'unclean_exit']);
+const SNAPSHOT_DAY_KEY = 'offgrid.analytics.snapshotDay';
+const GB = 1024 * 1024 * 1024;
+const MB = 1024 * 1024;
+const moduleLoadedAt = Date.now();
+
+let client: AnalyticsClient | null = null;
+let clientCrashes = false;
+let consentChain: Promise = Promise.resolve();
+let stopListening: (() => void) | null = null;
+/** Set by startAnalytics, before any snapshot can run. */
+let featureSnapshot: () => Record;
+let foregroundAt = Date.now();
+let ramGb: number | undefined;
+
+// ── consent + client ───────────────────────────────────────────────────────────
+
+function consent(): { usage: boolean; crashes: boolean } {
+ const { analyticsUsage, analyticsCrashes } = useAppStore.getState().settings;
+ return { usage: !!analyticsUsage, crashes: !!analyticsCrashes };
+}
+
+function beforeSend(event: { event: string; properties?: Properties }) {
+ const { usage, crashes } = consent();
+ const crash = CRASH_EVENTS.has(event.event);
+ if (crash ? !crashes : !usage) return null;
+ // An event can be captured before registration lands; every event still carries the spec's
+ // properties.
+ const properties = { ...superProperties(), ...(ramGb === undefined ? {} : { ram_gb: ramGb }), ...event.properties };
+ if (event.event !== '$exception') return { ...event, properties };
+ return { ...event, properties: scrubExceptionProperties(properties) };
+}
+
+function superProperties(): Properties {
+ const version = appVersion();
+ const locale = Intl.DateTimeFormat().resolvedOptions().locale;
+ return {
+ platform: Platform.OS,
+ app_version: version,
+ channel: releaseChannel(version),
+ pro: selectHasProAccess(useAppStore.getState()),
+ device_class: DeviceInfo.isTablet() ? 'tablet' : 'phone',
+ os_version: DeviceInfo.getSystemVersion(),
+ locale: languageOnly(locale),
+ };
+}
+
+async function applyConsent(): Promise {
+ const { usage, crashes } = consent();
+ if (!usage && !crashes) {
+ const previous = client;
+ client = null;
+ if (previous) await previous.forget();
+ return;
+ }
+ if (!analyticsBuildAllowed() || (client && clientCrashes === crashes)) return;
+ if (client) {
+ const previous = client;
+ client = null;
+ await previous.close();
+ }
+ client = createAnalyticsClient({ crashes, beforeSend });
+ clientCrashes = crashes;
+ client.register(superProperties());
+ DeviceInfo.getTotalMemory()
+ .then(total => {
+ ramGb = Math.round(total / GB);
+ client?.register({ ram_gb: ramGb });
+ })
+ .catch(ignore);
+}
+
+/** Bring the client in line with the stored consent. Calls run one at a time, in order. */
+function syncClient(): Promise {
+ consentChain = consentChain.then(applyConsent).catch(ignore);
+ return consentChain;
+}
+
+function capture(event: string, properties: Properties = {}): void {
+ if (!client) return;
+ const clean: Properties = {};
+ for (const [key, value] of Object.entries(properties)) {
+ if (value !== undefined) clean[key] = value;
+ }
+ client.capture(event, clean);
+}
+
+/**
+ * Store consent and (re)build the client to match. Turning analytics on from fully off sends
+ * `analytics_opted_in` with the source of the choice.
+ */
+export function setAnalyticsConsent(
+ next: { usage?: boolean; crashes?: boolean },
+ source: ConsentSource,
+): Promise {
+ const before = consent();
+ const after = {
+ usage: next.usage ?? before.usage,
+ crashes: next.crashes ?? before.crashes,
+ };
+ useAppStore.getState().updateSettings({
+ analyticsUsage: after.usage,
+ analyticsCrashes: after.crashes,
+ analyticsAsked: true,
+ });
+ // Never rejects: a consent change must not surface as an error in the UI that made it.
+ return syncClient()
+ .then(() => {
+ if (!before.usage && after.usage) capture('analytics_opted_in', { source });
+ })
+ .catch(ignore);
+}
+
+/** Called once the app has finished starting and its settings are hydrated. */
+export function startAnalytics(features: () => Record): () => void {
+ featureSnapshot = features;
+ stopListening?.();
+ stopListening = useAppStore.subscribe((state, prev) => {
+ const now = state.settings;
+ const was = prev.settings;
+ if (now.analyticsUsage !== was.analyticsUsage || now.analyticsCrashes !== was.analyticsCrashes) {
+ syncClient().catch(ignore);
+ }
+ const pro = selectHasProAccess(state);
+ if (pro !== selectHasProAccess(prev)) client?.register({ pro });
+ });
+ foregroundAt = Date.now();
+ stopAppState?.();
+ const appStateSubscription = AppState.addEventListener('change', onAppStateChange);
+ stopAppState = () => appStateSubscription.remove();
+ syncClient()
+ .then(async () => {
+ capture('app_opened', { cold_start_ms: ms(Date.now() - moduleLoadedAt) });
+ const unclean = await takeUncleanExit(appVersion());
+ if (unclean) {
+ capture('unclean_exit', {
+ stage: unclean.stage,
+ model: unclean.model,
+ engine: unclean.engine,
+ backend: unclean.backend,
+ free_ram_gb: unclean.free_ram_gb,
+ elapsed_ms: ms(Date.now() - unclean.startedAt),
+ });
+ }
+ await snapshotFeaturesDaily();
+ })
+ .catch(ignore);
+ // The App unmounting: send what is queued and stop the client's timers. The ID is kept.
+ return () => {
+ stopListening?.();
+ stopListening = null;
+ stopAppState?.();
+ stopAppState = null;
+ const previous = client;
+ client = null;
+ previous?.close().catch(ignore);
+ };
+}
+
+// ── app lifecycle + screens ────────────────────────────────────────────────────
+
+let currentScreen: string | null = null;
+let screenShownAt: number | null = null;
+let screenElapsed = 0;
+
+let stopAppState: (() => void) | null = null;
+let inBackground = false;
+
+/**
+ * Only a real trip to the background counts. iOS also passes through `inactive` at a cold launch and
+ * under every system dialog; counting that sent a second `app_opened` 128 ms after launch.
+ */
+function onAppStateChange(next: AppStateStatus): void {
+ if (next === 'background' && !inBackground) {
+ inBackground = true;
+ appBackgrounded();
+ } else if (next === 'active' && inBackground) {
+ inBackground = false;
+ appForegrounded();
+ }
+}
+
+function appForegrounded(): void {
+ foregroundAt = Date.now();
+ markForeground(true);
+ if (currentScreen) screenShownAt = Date.now();
+ capture('app_opened');
+ snapshotFeaturesDaily().catch(ignore);
+}
+
+function appBackgrounded(): void {
+ markForeground(false);
+ if (screenShownAt !== null) screenElapsed += Date.now() - screenShownAt;
+ screenShownAt = null;
+ capture('app_backgrounded', { session_ms: ms(Date.now() - foregroundAt) });
+}
+
+/** A route name only (never params). */
+export function trackScreen(name: string | undefined): void {
+ if (!name || name === currentScreen) return;
+ const now = Date.now();
+ const shownMs = screenShownAt === null ? 0 : now - screenShownAt;
+ const prevMs = currentScreen ? screenElapsed + shownMs : undefined;
+ client?.screen(name, { screen: name, prev_screen: currentScreen ?? undefined, prev_ms: ms(prevMs) });
+ currentScreen = name;
+ screenShownAt = now;
+ screenElapsed = 0;
+}
+
+async function snapshotFeaturesDaily(): Promise {
+ if (!client || !consent().usage) return;
+ const today = new Date().toISOString().slice(0, 10);
+ const last = await AsyncStorage.getItem(SNAPSHOT_DAY_KEY).catch(ignore);
+ if (last === today) return;
+ await AsyncStorage.setItem(SNAPSHOT_DAY_KEY, today).catch(ignore);
+ capture('features_snapshot', featureSnapshot());
+}
+
+// ── flows ──────────────────────────────────────────────────────────────────────
+
+const flows = new Map();
+
+export function flowStarted(flow: Flow, firstStep?: string, properties?: Properties): void {
+ flows.set(flow, { startedAt: Date.now() });
+ capture('flow_started', { flow });
+ if (firstStep) flowStep(flow, firstStep, properties);
+}
+
+/** A step counts only inside a flow that started. */
+export function flowStep(flow: Flow, step: string, properties?: Properties): void {
+ const state = flows.get(flow);
+ if (!state || state.lastStep === step) return;
+ state.lastStep = step;
+ capture('flow_step', { flow, step, ...properties });
+}
+
+export function flowFinished(flow: Flow, outcome: 'done' | 'failed' | 'cancelled', error?: unknown): void {
+ const state = flows.get(flow);
+ if (!state) return;
+ flows.delete(flow);
+ capture('flow_finished', {
+ flow,
+ outcome,
+ duration_ms: ms(Date.now() - state.startedAt),
+ failed_step: outcome === 'failed' ? state.lastStep : undefined,
+ error_code: outcome === 'failed' && error !== undefined ? errorCode(error) : undefined,
+ });
+}
+
+/** A Pro feature was gated: the person was sent to the Pro page instead of the feature. */
+export function proGateShown(feature: string): void {
+ flowStarted('pro_upgrade', 'gate_shown', { feature });
+}
+
+export function proPaywallViewed(): void {
+ if (!flows.has('pro_upgrade')) flowStarted('pro_upgrade');
+ flowStep('pro_upgrade', 'paywall_viewed');
+}
+
+/** Pro was activated on this device (the purchase itself happens on the web). */
+export function proPurchased(): void {
+ flowStep('pro_upgrade', 'purchased');
+ flowFinished('pro_upgrade', 'done');
+}
+
+// ── actions ────────────────────────────────────────────────────────────────────
+
+export function track(
+ event: 'chat_created' | 'reply_regenerated' | 'project_created' | 'problem_reported',
+): void {
+ capture(event);
+}
+
+export function featureUsed(feature: Feature): void {
+ capture('feature_used', { feature });
+}
+
+export function messageSent(facts: { hasImage: boolean; hasDocument: boolean; toolsEnabled: boolean }): void {
+ capture('message_sent', {
+ has_image: facts.hasImage,
+ has_document: facts.hasDocument,
+ tools_enabled: facts.toolsEnabled,
+ });
+ flowStep('onboarding', 'first_message');
+ flowFinished('onboarding', 'done');
+}
+
+export function documentAdded(extension: string | undefined, sizeBytes: number | undefined): void {
+ capture('document_added', { file_type: extension, size_bucket: sizeBucket(sizeBytes) });
+}
+
+// ── models ─────────────────────────────────────────────────────────────────────
+
+function freeRamGb(): number | undefined {
+ return oneDecimal(hardwareService.getAvailableMemoryGB());
+}
+
+async function socLabel(): Promise {
+ try {
+ const soc = await hardwareService.getSoCInfo();
+ return soc.appleChip ?? (soc.qnnVariant ? `${soc.vendor}-${soc.qnnVariant}` : soc.vendor);
+ } catch {
+ return 'unknown';
+ }
+}
+
+/** Run a model load and report `model_loaded` / `model_load_failed`. The result is unchanged. */
+export async function trackModelLoad(facts: ModelFacts, run: () => Promise): Promise {
+ const startedAt = Date.now();
+ const free = freeRamGb();
+ markStageStarted({ stage: 'load', ...facts, free_ram_gb: free, startedAt, appVersion: appVersion() });
+ const report = (event: string, extra: Properties) => {
+ socLabel()
+ .then(soc => capture(event, { ...facts, soc, free_ram_gb: free, load_ms: ms(Date.now() - startedAt), ...extra }))
+ .catch(ignore);
+ };
+ try {
+ const result = await run();
+ markStageEnded('load');
+ report('model_loaded', {});
+ flowStep('onboarding', 'model_loaded');
+ return result;
+ } catch (error) {
+ markStageEnded('load');
+ report('model_load_failed', { error_code: errorCode(error) });
+ maybePromptAfterFailure();
+ throw error;
+ }
+}
+
+// ── generation ─────────────────────────────────────────────────────────────────
+
+export interface GenerationMetrics {
+ ttftMs?: number;
+ tokensPerSec?: number;
+ contextTokens?: number;
+ error?: unknown;
+}
+
+export type GenerationEnd = (outcome: Outcome, metrics?: GenerationMetrics) => void;
+
+const MARKED_MODALITIES = new Set(['text', 'vision', 'image']);
+
+/** Start timing one generation. Call the returned function exactly once when it ends. */
+export function generationStarted(modality: Modality, facts: ModelFacts): GenerationEnd {
+ const startedAt = Date.now();
+ const marked = MARKED_MODALITIES.has(modality);
+ if (marked) {
+ markStageStarted({ stage: 'generate', ...facts, free_ram_gb: freeRamGb(), startedAt, appVersion: appVersion() });
+ }
+ let ended = false;
+ return (outcome, metrics = {}) => {
+ if (ended) return;
+ ended = true;
+ if (marked) markStageEnded('generate');
+ DeviceInfo.getPowerState()
+ .catch(() => ({}) as { batteryLevel?: number; batteryState?: string })
+ .then(power => {
+ capture('generation_finished', {
+ modality,
+ model: facts.model,
+ engine: facts.engine,
+ backend: facts.backend,
+ outcome,
+ ttft_ms: ms(metrics.ttftMs),
+ tokens_per_sec: oneDecimal(metrics.tokensPerSec),
+ context_tokens_bucket: contextBucket(metrics.contextTokens),
+ battery_pct: batteryPct(power?.batteryLevel),
+ charging: power?.batteryState ? power.batteryState === 'charging' || power.batteryState === 'full' : undefined,
+ error_code: outcome === 'failed' ? errorCode(metrics.error) : undefined,
+ });
+ })
+ .catch(ignore);
+ if (outcome === 'failed') maybePromptAfterFailure();
+ if (outcome === 'done' && (modality === 'text' || modality === 'vision')) maybePromptAfterFirstChat();
+ };
+}
+
+// ── downloads ──────────────────────────────────────────────────────────────────
+
+export interface DownloadFacts {
+ model: string;
+ quant?: string;
+ sizeBytes?: number;
+ modelType: 'text' | 'image' | 'stt' | 'tts';
+}
+
+const downloads = new Map }>();
+
+function setupFlow(modelType: DownloadFacts['modelType']): Flow | null {
+ if (modelType === 'image') return 'image_setup';
+ if (modelType === 'stt' || modelType === 'tts') return 'voice_setup';
+ return null;
+}
+
+export function downloadStarted(key: string, facts: DownloadFacts): void {
+ // Not opted in: no disk read, nothing kept.
+ if (!client) return;
+ const base = Promise.resolve()
+ .then(() => RNFS.getFSInfo())
+ .then(info => bytesToGb(info.freeSpace))
+ .catch(ignore)
+ .then(free => ({
+ model: facts.model,
+ quant: facts.quant,
+ size_bucket: sizeBucket(facts.sizeBytes),
+ // No connection-type reader is installed; see docs/ANALYTICS.md `network`.
+ network: 'unknown',
+ free_storage_gb: free,
+ }));
+ downloads.set(key, { facts, startedAt: Date.now(), base });
+ base.then(props => capture('model_download_started', props)).catch(ignore);
+ const flow = setupFlow(facts.modelType);
+ if (flow) flowStep(flow, 'model_picked');
+}
+
+export function downloadEnded(key: string, outcome: 'done' | 'failed', error?: unknown): void {
+ const entry = downloads.get(key);
+ if (!entry) return;
+ downloads.delete(key);
+ const durationMs = Date.now() - entry.startedAt;
+ entry.base
+ .then(props => {
+ if (outcome === 'done') {
+ // A same-millisecond finish (a resumed file already on disk) counts as one millisecond.
+ const mbPerSec = entry.facts.sizeBytes
+ ? entry.facts.sizeBytes / MB / (Math.max(durationMs, 1) / 1000)
+ : undefined;
+ capture('model_download_finished', { ...props, duration_ms: ms(durationMs), mb_per_sec: oneDecimal(mbPerSec) });
+ } else {
+ capture('model_download_failed', { ...props, duration_ms: ms(durationMs), error_code: errorCode(error) });
+ }
+ })
+ .catch(ignore);
+ const flow = setupFlow(entry.facts.modelType);
+ if (flow && outcome === 'done') {
+ flowStep(flow, 'downloaded');
+ flowFinished(flow, 'done');
+ }
+ if (outcome === 'failed') maybePromptAfterFailure();
+}
diff --git a/src/services/analytics/client.ts b/src/services/analytics/client.ts
new file mode 100644
index 000000000..1aa8013aa
--- /dev/null
+++ b/src/services/analytics/client.ts
@@ -0,0 +1,94 @@
+/**
+ * The ONE module that imports the PostHog SDK. It builds a client with every privacy option the
+ * spec requires (docs/ANALYTICS.md) and gives the analytics owner four verbs: capture, screen,
+ * register, and shut down (keeping or forgetting the anonymous ID).
+ */
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import PostHog, { PostHogPersistedProperty } from 'posthog-react-native';
+import { POSTHOG_API_KEY, POSTHOG_HOST } from '../../config/analytics';
+import { ignore, languageOnly } from './values';
+
+type Properties = Record;
+type CaptureEvent = { event: string; properties?: Properties };
+
+/**
+ * Remote config is fetched on every start and cannot be switched off by an option. This app uses
+ * no flags, surveys or remote settings, so the client skips the request: the only requests it
+ * makes are event batches.
+ */
+class OffGridPostHog extends PostHog {
+ reloadRemoteConfigAsync(): Promise {
+ return Promise.resolve(undefined);
+ }
+}
+
+export interface AnalyticsClientOptions {
+ crashes: boolean;
+ /** Return the event to send, or null to drop it. */
+ beforeSend: (event: CaptureEvent) => CaptureEvent | null;
+}
+
+export interface AnalyticsClient {
+ capture(event: string, properties?: Properties): void;
+ screen(name: string, properties?: Properties): void;
+ register(properties: Properties): void;
+ /** Send what is queued and stop. The anonymous ID is kept. */
+ close(): Promise;
+ /** Opt-out: drop what is queued, reset the anonymous ID and stop. */
+ forget(): Promise;
+}
+
+/** Debug builds and a missing key never create the SDK. */
+export function analyticsBuildAllowed(): boolean {
+ return !__DEV__ && POSTHOG_API_KEY.length > 0;
+}
+
+function appProperties(props: T): T {
+ return { ...props, $timezone: null, $locale: languageOnly(props.$locale ?? undefined) };
+}
+
+export function createAnalyticsClient(options: AnalyticsClientOptions): AnalyticsClient {
+ const posthog = new OffGridPostHog(POSTHOG_API_KEY, {
+ host: POSTHOG_HOST,
+ persistence: 'file',
+ // The app's own AsyncStorage, named explicitly rather than discovered as an optional peer.
+ customStorage: AsyncStorage,
+ // The app sends its own `app_opened` / `app_backgrounded`. The SDK's lifecycle events are not
+ // in the spec, and its "Application Opened" carries the deep link URL the app opened with.
+ captureAppLifecycleEvents: false,
+ enableSessionReplay: false,
+ preloadFeatureFlags: false,
+ disableRemoteFeatureFlags: true,
+ disableSurveys: true,
+ disableGeoip: true,
+ personProfiles: 'never',
+ setDefaultPersonProperties: false,
+ capturePushNotificationSubscriptions: false,
+ capturePushNotificationOpened: false,
+ customAppProperties: appProperties,
+ errorTracking: {
+ autocapture: options.crashes
+ ? { uncaughtExceptions: true, unhandledRejections: true, console: false, nativeCrashes: false }
+ : false,
+ exceptionSteps: { enabled: false },
+ },
+ before_send: event => options.beforeSend(event as CaptureEvent) as typeof event | null,
+ });
+
+ return {
+ capture: (event, properties) => posthog.capture(event, properties as never),
+ screen: (name, properties) => {
+ posthog.screen(name, properties as never).catch(ignore);
+ },
+ register: properties => {
+ posthog.register(properties as never).catch(ignore);
+ },
+ close: () => posthog.shutdown(),
+ forget: async () => {
+ await posthog.optOut();
+ posthog.setPersistedProperty(PostHogPersistedProperty.Queue, null);
+ posthog.reset();
+ await posthog.shutdown();
+ },
+ };
+}
diff --git a/src/services/analytics/consentPrompt.ts b/src/services/analytics/consentPrompt.ts
new file mode 100644
index 000000000..60b69a4ef
--- /dev/null
+++ b/src/services/analytics/consentPrompt.ts
@@ -0,0 +1,46 @@
+/**
+ * When to ask for analytics consent outside Auto Setup (docs/ANALYTICS.md, Consent):
+ *
+ * - After a model download, model load or generation fails, when the person has not opted in and
+ * was not asked in the last 30 days.
+ * - Once ever, after the first successful reply, for a person who installed before this version
+ * and was never asked.
+ *
+ * This module owns only WHICH prompt is open. The prompt component renders it and records the
+ * answer through the analytics owner.
+ */
+import { create } from 'zustand';
+import { useAppStore } from '../../stores/appStore';
+
+export type ConsentPromptKind = 'failure' | 'first_chat';
+
+const PROMPT_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000;
+
+export const useConsentPromptStore = create<{ kind: ConsentPromptKind | null }>(() => ({ kind: null }));
+
+function optedIn(): boolean {
+ const { analyticsUsage, analyticsCrashes } = useAppStore.getState().settings;
+ return !!analyticsUsage || !!analyticsCrashes;
+}
+
+/** Each caller records the ask first, so a second prompt can never open over the first. */
+function show(kind: ConsentPromptKind): void {
+ useAppStore.getState().updateSettings({ analyticsAsked: true, analyticsLastPromptAt: Date.now() });
+ useConsentPromptStore.setState({ kind });
+}
+
+export function maybePromptAfterFailure(): void {
+ if (optedIn()) return;
+ const last = useAppStore.getState().settings.analyticsLastPromptAt;
+ if (last && Date.now() - last < PROMPT_INTERVAL_MS) return;
+ show('failure');
+}
+
+export function maybePromptAfterFirstChat(): void {
+ if (optedIn() || useAppStore.getState().settings.analyticsAsked) return;
+ show('first_chat');
+}
+
+export function closeConsentPrompt(): void {
+ useConsentPromptStore.setState({ kind: null });
+}
diff --git a/src/services/analytics/featureSnapshot.ts b/src/services/analytics/featureSnapshot.ts
new file mode 100644
index 000000000..2df9e3890
--- /dev/null
+++ b/src/services/analytics/featureSnapshot.ts
@@ -0,0 +1,25 @@
+/**
+ * `features_snapshot` (docs/ANALYTICS.md): one true/false per feature that is switched on.
+ * Core reads its own stores; Pro adds its features through HOOKS.analyticsProFeatures.
+ */
+import { useAppStore } from '../../stores/appStore';
+import { useRemoteServerStore } from '../../stores/remoteServerStore';
+import { useWhisperStore } from '../../stores/whisperStore';
+import { callHook, HOOKS } from '../../bootstrap/hookRegistry';
+
+export function featureSnapshot(): Record {
+ const app = useAppStore.getState();
+ const tools = app.settings.enabledTools;
+ const active = app.downloadedModels.find(model => model.id === app.activeModelId);
+ const visionFlag = active?.engine === 'litert' ? active.liteRTVision : active?.isVisionModel;
+ const vision = !!visionFlag;
+ return {
+ voice_input: !!useWhisperStore.getState().downloadedModelId,
+ image_generation: !!app.activeImageModelId,
+ vision,
+ web_search: tools.includes('web_search'),
+ tools: tools.length > 0,
+ remote_server: useRemoteServerStore.getState().servers.length > 0,
+ ...callHook>(HOOKS.analyticsProFeatures),
+ };
+}
diff --git a/src/services/analytics/modelFacts.ts b/src/services/analytics/modelFacts.ts
new file mode 100644
index 000000000..077074016
--- /dev/null
+++ b/src/services/analytics/modelFacts.ts
@@ -0,0 +1,40 @@
+/**
+ * The spec's model facts (`model`, `quant`, `engine`, `backend`) for the models this app runs.
+ * Remote models are always `custom`: their names come from the person's own server.
+ */
+import { Platform } from 'react-native';
+import { useAppStore, type AppSettings } from '../../stores/appStore';
+import type { DownloadedModel, ONNXImageModel } from '../../types';
+import type { ModelFacts } from './analytics';
+import { computeBackend, modelRef } from './values';
+
+export const REMOTE_MODEL_FACTS: ModelFacts = { model: 'custom', engine: 'remote', backend: 'remote' };
+
+export function downloadedModelFacts(model: DownloadedModel, settings: AppSettings): ModelFacts {
+ const litert = model.engine === 'litert';
+ return {
+ model: modelRef(model.id, model.origin?.repoId),
+ quant: model.quantization || undefined,
+ engine: litert ? 'litert' : 'llama.cpp',
+ backend: computeBackend(litert ? settings.liteRTBackend : settings.inferenceBackend),
+ };
+}
+
+/** The selected text model, or the remote route when one is in use. */
+export function textModelFacts(remote: boolean): ModelFacts {
+ if (remote) return REMOTE_MODEL_FACTS;
+ const { downloadedModels, activeModelId, settings } = useAppStore.getState();
+ const model = downloadedModels.find(m => m.id === activeModelId);
+ return model
+ ? downloadedModelFacts(model, settings)
+ : { model: 'custom', engine: 'llama.cpp', backend: computeBackend(settings.inferenceBackend) };
+}
+
+export function imageModelFacts(model: ONNXImageModel | undefined): ModelFacts {
+ const backend = model?.backend;
+ return {
+ model: modelRef(model?.id),
+ engine: backend === 'coreml' || (!backend && Platform.OS === 'ios') ? 'coreml' : 'onnx',
+ backend: computeBackend(backend),
+ };
+}
diff --git a/src/services/analytics/scrub.ts b/src/services/analytics/scrub.ts
new file mode 100644
index 000000000..dee3a5232
--- /dev/null
+++ b/src/services/analytics/scrub.ts
@@ -0,0 +1,104 @@
+/**
+ * Crash report scrubbing (docs/ANALYTICS.md, "Crashes"). Runs on every `$exception` before the
+ * SDK sends it: a path becomes its file name, quoted text and URLs are dropped, and anything after
+ * a colon in a message is dropped because it may echo what the person typed. Type, file name,
+ * line and function are kept.
+ *
+ * File and document names are on the never-send list, so a path keeps its name only when it is the
+ * app's own code (`index.android.bundle`); a person's file, bare or in a path, becomes ``.
+ */
+
+type Frame = Record;
+type ExceptionEntry = Record & {
+ stacktrace?: { frames?: Frame[]; type?: string };
+};
+
+const URL_PATTERN = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi;
+const IPV4_PATTERN = /\b\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?\b/g;
+const QUOTED_PATTERN = /(["'`])(?:(?!\1).)*\1/g;
+const PATH_PATTERN = /(?:[A-Za-z]:)?(?:[\\/][^\\/\s:"'`]+)+[\\/]([^\\/\s:"'`]+)/g;
+// The app's own code: its file name helps find the bug and names nothing of the person's.
+const CODE_EXTENSIONS = new Set(
+ 'js jsx ts tsx mjs cjs mts cts bundle hbc so dylib wasm java kt swift m mm cpp cc c h'.split(' '),
+);
+// A file a person owns: a document, media, an archive or a model file.
+const USER_EXTENSIONS = new Set(
+ ('pdf doc docx xls xlsx ppt pptx txt md csv tsv json rtf pages key numbers htm html png jpg jpeg ' +
+ 'gif heic webp svg mp3 m4a wav ogg mp4 mov mkv zip gguf safetensors onnx bin pte tflite litertlm task')
+ .split(' '),
+);
+// Punctuation that may close a sentence after a file name.
+const TRAILING = new Set(['.', ',', ';', ')', '!', '?']);
+
+function extensionOf(name: string): string {
+ const dot = name.lastIndexOf('.');
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
+}
+
+function codeFileOnly(_path: string, name: string): string {
+ return CODE_EXTENSIONS.has(extensionOf(name)) ? name : '';
+}
+
+/** A bare file name a person owns becomes ``; the punctuation after it stays. */
+function userFileToken(token: string): string {
+ let end = token.length;
+ while (end > 0 && TRAILING.has(token[end - 1])) end -= 1;
+ const name = token.slice(0, end);
+ return USER_EXTENSIONS.has(extensionOf(name)) ? `${token.slice(end)}` : token;
+}
+
+export function fileName(path: unknown): string | undefined {
+ if (typeof path !== 'string' || !path) return undefined;
+ const query = path.search(/[?#]/);
+ const clean = query >= 0 ? path.slice(0, query) : path;
+ return clean.split(/[\\/]/).at(-1) || undefined;
+}
+
+export function scrubMessage(message: unknown): string | undefined {
+ if (typeof message !== 'string') return undefined;
+ const scrubbed = message
+ .replace(URL_PATTERN, '')
+ .replace(IPV4_PATTERN, '')
+ .replace(QUOTED_PATTERN, '')
+ .replace(PATH_PATTERN, codeFileOnly)
+ .split(/(\s+)/)
+ .map(userFileToken)
+ .join('');
+ const colon = scrubbed.indexOf(':');
+ return (colon >= 0 ? scrubbed.slice(0, colon) : scrubbed).replace(/\s+/g, ' ').trim();
+}
+
+function scrubFrame(frame: Frame): Frame {
+ return {
+ platform: frame.platform,
+ filename: fileName(frame.filename ?? frame.abs_path),
+ function: frame.function,
+ lineno: frame.lineno,
+ colno: frame.colno,
+ in_app: frame.in_app,
+ };
+}
+
+function scrubException(entry: ExceptionEntry): ExceptionEntry {
+ const frames = entry.stacktrace?.frames;
+ return {
+ type: entry.type,
+ value: scrubMessage(entry.value),
+ mechanism: entry.mechanism,
+ ...(frames ? { stacktrace: { type: entry.stacktrace?.type ?? 'raw', frames: frames.map(scrubFrame) } } : {}),
+ };
+}
+
+/** Keep only the scrubbed exception list and its level from the SDK's `$exception_*` properties. */
+export function scrubExceptionProperties(
+ properties: Record,
+): Record {
+ const next: Record = {};
+ for (const [key, value] of Object.entries(properties)) {
+ if (!key.startsWith('$exception')) next[key] = value;
+ }
+ const list = properties.$exception_list;
+ if (Array.isArray(list)) next.$exception_list = list.map(e => scrubException(e as ExceptionEntry));
+ if (properties.$exception_level !== undefined) next.$exception_level = properties.$exception_level;
+ return next;
+}
diff --git a/src/services/analytics/uncleanExit.ts b/src/services/analytics/uncleanExit.ts
new file mode 100644
index 000000000..bd7b3dc3b
--- /dev/null
+++ b/src/services/analytics/uncleanExit.ts
@@ -0,0 +1,64 @@
+/**
+ * Unclean-exit marker (docs/ANALYTICS.md, `unclean_exit`).
+ *
+ * A model load or a generation writes a small marker when it starts and clears it when it ends.
+ * Going to the background marks it not-foreground. If the next launch of the SAME app version finds
+ * a foreground marker, the app died in the foreground mid-load or mid-generation.
+ */
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { ignore } from './values';
+
+const KEY = 'offgrid.analytics.uncleanExit';
+
+export interface UncleanExitMarker {
+ stage: 'load' | 'generate';
+ model: string;
+ engine: string;
+ backend: string;
+ free_ram_gb?: number;
+ startedAt: number;
+ appVersion: string;
+ foreground: boolean;
+ launch: string;
+}
+
+/** This launch. A marker written by this launch is never reported as the previous one's death. */
+const LAUNCH = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+let current: UncleanExitMarker | null = null;
+
+function write(): void {
+ const op = current ? AsyncStorage.setItem(KEY, JSON.stringify(current)) : AsyncStorage.removeItem(KEY);
+ op.catch(ignore);
+}
+
+export function markStageStarted(marker: Omit): void {
+ current = { ...marker, foreground: true, launch: LAUNCH };
+ write();
+}
+
+export function markStageEnded(stage: UncleanExitMarker['stage']): void {
+ if (current?.stage !== stage) return;
+ current = null;
+ write();
+}
+
+export function markForeground(foreground: boolean): void {
+ if (!current || current.foreground === foreground) return;
+ current = { ...current, foreground };
+ write();
+}
+
+/** Read and clear the previous launch's marker. Returns it only when it counts as an unclean exit. */
+export async function takeUncleanExit(appVersion: string): Promise {
+ let stored: UncleanExitMarker | null = null;
+ try {
+ const raw = await AsyncStorage.getItem(KEY);
+ stored = raw ? (JSON.parse(raw) as UncleanExitMarker) : null;
+ } catch {
+ stored = null;
+ }
+ if (stored?.launch === LAUNCH) return null;
+ if (!current) await AsyncStorage.removeItem(KEY).catch(ignore);
+ if (!stored?.foreground || stored.appVersion !== appVersion) return null;
+ return stored;
+}
diff --git a/src/services/analytics/values.ts b/src/services/analytics/values.ts
new file mode 100644
index 000000000..b120cc408
--- /dev/null
+++ b/src/services/analytics/values.ts
@@ -0,0 +1,114 @@
+/**
+ * Pure value rules for analytics properties (docs/ANALYTICS.md, "Shared value rules").
+ * No I/O here, so every rule is checked by plain unit tests.
+ */
+
+const GB = 1024 * 1024 * 1024;
+
+/** Analytics never breaks the app: a failed boundary read or send is dropped. */
+export const ignore = (): undefined => undefined;
+
+/** Model ids that hold a file name the person chose (imports, recovered files). */
+const CUSTOM_ID = /^(local|recovered)_/;
+const HF_REPO_PREFIX = /^([\w.-]+)\/([\w.-]+)(?:\/|$)/;
+const CATALOG_ID = /^[\w.-]+$/;
+const KIND_PREFIX = /^(?:image|stt|tts):/;
+
+/**
+ * Catalog ID, public Hugging Face `owner/repo`, or `custom`. Anything that may carry a file
+ * name the person chose is `custom`.
+ */
+export function modelRef(rawId: string | null | undefined, repoId?: string): string {
+ if (repoId && HF_REPO_PREFIX.test(repoId)) return repoId.split('/').slice(0, 2).join('/');
+ // Download rows key image models as `image:`; the kind is not part of the ID.
+ const id = rawId?.replace(KIND_PREFIX, '');
+ if (!id || CUSTOM_ID.test(id)) return 'custom';
+ const repo = HF_REPO_PREFIX.exec(id);
+ if (repo) return `${repo[1]}/${repo[2]}`;
+ return CATALOG_ID.test(id) ? id : 'custom';
+}
+
+export function sizeBucket(bytes: number | null | undefined): string | undefined {
+ if (!bytes || bytes <= 0) return undefined;
+ const gb = bytes / GB;
+ if (gb < 1) return '<1GB';
+ if (gb < 2) return '1-2GB';
+ if (gb < 4) return '2-4GB';
+ if (gb < 8) return '4-8GB';
+ return '>8GB';
+}
+
+export function contextBucket(tokens: number | null | undefined): string | undefined {
+ if (!tokens || tokens <= 0) return undefined;
+ if (tokens < 2048) return '<2k';
+ if (tokens < 8192) return '2-8k';
+ if (tokens < 32768) return '8-32k';
+ return '>32k';
+}
+
+/** Durations: whole milliseconds. */
+export function ms(value: number | null | undefined): number | undefined {
+ return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined;
+}
+
+/** Rates and sizes: one decimal. */
+export function oneDecimal(value: number | null | undefined): number | undefined {
+ return typeof value === 'number' && Number.isFinite(value) ? Math.round(value * 10) / 10 : undefined;
+}
+
+export function bytesToGb(bytes: number | null | undefined): number | undefined {
+ return typeof bytes === 'number' && Number.isFinite(bytes) ? oneDecimal(bytes / GB) : undefined;
+}
+
+/** Battery level (0..1) as a percentage rounded to 10. */
+export function batteryPct(level: number | null | undefined): number | undefined {
+ if (typeof level !== 'number' || level < 0) return undefined;
+ return Math.round(level * 10) * 10;
+}
+
+const SAFE_CODE = /^[A-Za-z][\w.-]{0,47}$/;
+
+/**
+ * A fixed code from our own error classes, never a raw message: the error's `code`, else its
+ * class name, else `error`.
+ */
+export function errorCode(error: unknown): string {
+ if (error && typeof error === 'object') {
+ const code = (error as { code?: unknown }).code;
+ if (typeof code === 'string' && SAFE_CODE.test(code)) return code;
+ const name = (error as { name?: unknown }).name;
+ if (typeof name === 'string' && name !== 'Error' && SAFE_CODE.test(name)) return name;
+ }
+ return 'error';
+}
+
+/** `en-GB` → `en`. */
+export function languageOnly(locale: string | undefined): string {
+ return (locale ?? '').split(/[-_]/)[0].toLowerCase() || 'unknown';
+}
+
+/** Store builds call themselves `x.y.z`; anything with a suffix is a beta. */
+export function releaseChannel(version: string): 'stable' | 'nightly' {
+ return /-/.test(version) ? 'nightly' : 'stable';
+}
+
+/** `.PDF` / `notes.pdf` → `pdf`. Never the name. */
+export function fileExtension(name: string | undefined): string | undefined {
+ const match = /\.([A-Za-z0-9]{1,8})$/.exec(name ?? '');
+ return match ? match[1].toLowerCase() : undefined;
+}
+
+/** Which `feature_used` value a built-in tool counts as. */
+export function toolFeature(toolName: string): 'web_search' | 'clipboard' | 'browser_action' | 'tools' {
+ if (toolName === 'web_search' || toolName === 'read_url') return 'web_search';
+ if (/clipboard/i.test(toolName)) return 'clipboard';
+ if (/^browser/i.test(toolName)) return 'browser_action';
+ return 'tools';
+}
+
+/** llama.cpp/LiteRT backend settings → `cpu` / `gpu` / `npu`. */
+export function computeBackend(setting: string | undefined): 'cpu' | 'gpu' | 'npu' {
+ if (setting === 'htp' || setting === 'npu' || setting === 'qnn') return 'npu';
+ if (setting === 'metal' || setting === 'opencl' || setting === 'gpu' || setting === 'coreml') return 'gpu';
+ return 'cpu';
+}
diff --git a/src/services/autoSetupService.ts b/src/services/autoSetupService.ts
index f880be1f7..363caeac7 100644
--- a/src/services/autoSetupService.ts
+++ b/src/services/autoSetupService.ts
@@ -2,6 +2,7 @@ import { modelDownloadService } from './modelDownloadService';
import type { ModelDownload, ModelDownloadStartRequest } from './modelDownloadService/types';
import { useAppStore } from '../stores';
import { activeModelService } from './activeModelService';
+import { flowFinished, flowStep } from './analytics/analytics';
import { uniformDownloadId } from './modelDownloadService/uniformId';
import {
loadAutoSetupCompatibleCatalog,
@@ -222,9 +223,11 @@ export function createAutoSetupSession(
phase: 'failed',
error: failure.error ?? 'A model download failed. Try again.',
});
+ flowFinished('onboarding', 'failed', failure.error);
} else if (allCompleted) {
activeIds.clear();
publish({ phase: 'completed', error: null });
+ flowStep('onboarding', 'download_done');
}
} finally {
refreshInFlight = false;
@@ -294,7 +297,11 @@ export function createAutoSetupSession(
outcomes,
error: null,
});
- if (activeIds.size === 0) return;
+ flowStep('onboarding', 'download_started');
+ if (activeIds.size === 0) {
+ flowStep('onboarding', 'download_done');
+ return;
+ }
const [text, image, stt] = plan.items;
const jobs = [
@@ -334,6 +341,7 @@ export function createAutoSetupSession(
outcomes: next,
error: message(failure.reason),
});
+ flowFinished('onboarding', 'failed', failure.reason);
return;
}
await refreshDownloads();
diff --git a/src/services/documentService.ts b/src/services/documentService.ts
index 33f9f8f4b..8e286761c 100644
--- a/src/services/documentService.ts
+++ b/src/services/documentService.ts
@@ -10,6 +10,8 @@ import { statFile } from '../utils/fileStat';
import { MediaAttachment } from '../types';
import { pdfExtractor } from './pdfExtractor';
import { generateId } from '../utils/generateId';
+import { documentAdded } from './analytics/analytics';
+import { fileExtension } from './analytics/values';
// File extensions we can read as text
const TEXT_EXTENSIONS = [
@@ -199,6 +201,7 @@ class DocumentService {
const documentId = await RNFS.hash(uri.replace(/^file:\/\//, ''), 'sha256');
const { startDocumentIngest } = await import('./documents');
startDocumentIngest({ id: documentId, name, kind: isPdf ? 'pdf' : 'text', path: uri });
+ documentAdded(fileExtension(name), facts.size);
return { id, type: 'document', uri, fileName: name, fileSize: facts.size, documentId };
}
diff --git a/src/services/generationService.ts b/src/services/generationService.ts
index 3d431ec61..91ede2afc 100644
--- a/src/services/generationService.ts
+++ b/src/services/generationService.ts
@@ -12,6 +12,8 @@ import { contextCompactionService } from './contextCompaction';
import logger from '../utils/logger';
import { maybeScheduleSharePrompt } from '../utils/sharePrompt';
import { checkProPromptForText } from './proPrompt';
+import { generationStarted, type GenerationMetrics, type Outcome } from './analytics/analytics';
+import { textModelFacts } from './analytics/modelFacts';
import {
buildGenerationMetaImpl,
buildToolLoopHandlersImpl,
@@ -92,6 +94,8 @@ class GenerationService {
private reasoningBuffer: string = '';
private totalReasoningLength: number = 0;
private flushTimer: ReturnType | null = null;
+ /** How the last attempt ended, read once the whole turn (fallbacks included) settles. */
+ private analyticsEnd: { outcome: Outcome; metrics: GenerationMetrics } | null = null;
/** Get the current provider (local or remote) */
private getCurrentProvider() {
@@ -179,7 +183,7 @@ class GenerationService {
contextUsage?: GenerationRequest['contextUsage'],
): Promise {
logger.log(`[REMOTE-SM] generateResponse entry conv=${conversationId} msgs=${messages.length}`);
- return this.withModelFallback(conversationId, messages, async (route, prepared) => {
+ return this.withTrackedModelFallback(conversationId, messages, async (route, prepared) => {
const request = { conversationId, messages, onFirstToken, contextUsage,
prepared, preservePartialOnError: false };
if (route.kind === 'remote') await generateRemoteResponseImpl(this, request);
@@ -224,11 +228,37 @@ class GenerationService {
];
}
- private async withModelFallback(
+ /** One `generation_finished` per turn, however many fallback routes it took. */
+ private async withTrackedModelFallback(
conversationId: string,
messages: Message[],
run: (route: FallbackRoute, prepared: boolean) => Promise,
canRetry: () => boolean = () => true,
+ ): Promise {
+ const needsVision = messages.some(message =>
+ message.attachments?.some(attachment => attachment.type === 'image'),
+ );
+ const endAnalytics = generationStarted(
+ needsVision ? 'vision' : 'text',
+ textModelFacts(this.isUsingRemoteProvider()),
+ );
+ this.analyticsEnd = null;
+ try {
+ const result = await this.withModelFallback(conversationId, messages, run, canRetry);
+ const end = this.analyticsEnd as { outcome: Outcome; metrics: GenerationMetrics } | null;
+ endAnalytics(end?.outcome ?? 'stopped', end?.metrics);
+ return result;
+ } catch (error) {
+ endAnalytics('failed', { error });
+ throw error;
+ }
+ }
+
+ private async withModelFallback(
+ conversationId: string,
+ messages: Message[],
+ run: (route: FallbackRoute, prepared: boolean) => Promise,
+ canRetry: () => boolean,
): Promise {
const routes = this.fallbackRoutes(messages);
let failedName = routes[0].name;
@@ -315,7 +345,7 @@ class GenerationService {
options.onToolCallComplete?.(name, result);
},
};
- return this.withModelFallback(conversationId, messages, async (route, prepared) => {
+ return this.withTrackedModelFallback(conversationId, messages, async (route, prepared) => {
if (route.kind === 'remote') {
return generateRemoteWithToolsImpl(this, {
conversationId, messages,
@@ -549,8 +579,27 @@ class GenerationService {
});
}
+ /** Keep this attempt's outcome and speed for the turn's `generation_finished` (read before reset). */
+ private recordAnalyticsEnd(signal: ChatQueueSignal): void {
+ if (!this.state.isGenerating && this.state.startTime === null) return;
+ if (signal !== 'done') {
+ this.analyticsEnd = { outcome: signal === 'stop' ? 'stopped' : 'failed', metrics: {} };
+ return;
+ }
+ const meta = this.buildGenerationMeta();
+ this.analyticsEnd = {
+ outcome: 'done',
+ metrics: {
+ ttftMs: meta.timeToFirstToken,
+ tokensPerSec: meta.tokensPerSecond,
+ contextTokens: meta.contextPromptTokens,
+ },
+ };
+ }
+
/** `signal`: how the reply ended. A Stop or a failure holds the queue for the user. */
private resetState(signal: ChatQueueSignal = 'done'): void {
+ this.recordAnalyticsEnd(signal);
this.state = { ...this.state, queueHold: chatQueueHoldAfter(signal) };
const hasQueuedItems = this.state.queuedMessages.length > 0;
if (this.flushTimer) {
diff --git a/src/services/httpClient.ts b/src/services/httpClient.ts
index 55ad7b272..39ce34b3c 100644
--- a/src/services/httpClient.ts
+++ b/src/services/httpClient.ts
@@ -405,7 +405,7 @@ function parseNDJSONLine(
final
? '[HttpClient] Failed to parse final NDJSON line:'
: '[HttpClient] Failed to parse NDJSON line:',
- (final ? line : trimmed).substring(0, 100),
+ `${(final ? line : trimmed).length}ch`,
);
}
}
diff --git a/src/services/imageGenerationService.ts b/src/services/imageGenerationService.ts
index 5f5b970de..603e20c1a 100644
--- a/src/services/imageGenerationService.ts
+++ b/src/services/imageGenerationService.ts
@@ -30,6 +30,8 @@ import {
ActiveImageModel,
RunGenerationOptions,
} from './imageGenerationTypes';
+import { featureUsed, generationStarted, type ModelFacts } from './analytics/analytics';
+import { imageModelFacts, REMOTE_MODEL_FACTS } from './analytics/modelFacts';
export { isInFlight } from './imageGenerationTypes';
export type {
@@ -405,15 +407,32 @@ class ImageGenerationService {
};
this.job = job;
this._lastParams = params; // so a failure card's Retry can re-run this exact request
+ featureUsed('image_generation');
+ const endAnalytics = generationStarted('image', this.analyticsFacts());
const run = this._generate(job, params, opts);
job.settled = run.catch(() => null);
try {
- return await run;
+ const result = await run;
+ if (result) endAnalytics('done');
+ else endAnalytics(job.cancelled ? 'stopped' : 'failed', { error: this.state.error ?? undefined });
+ return result;
+ } catch (error) {
+ endAnalytics(job.cancelled ? 'stopped' : 'failed', { error });
+ throw error;
} finally {
if (this.job === job) this.job = null;
}
}
+ /** The image route this request will use, in the spec's terms. */
+ private analyticsFacts(): ModelFacts {
+ if (useRemoteServerStore.getState().getActiveRemoteMediaServer('image')?.mediaModels?.image) {
+ return REMOTE_MODEL_FACTS;
+ }
+ const { activeImageModelId, downloadedImageModels } = useAppStore.getState();
+ return imageModelFacts(downloadedImageModels.find(m => m.id === activeImageModelId));
+ }
+
private async _generate(
job: ImageJob,
params: GenerateImageParams,
diff --git a/src/services/intentClassifier.ts b/src/services/intentClassifier.ts
index c70b25107..003070dd5 100644
--- a/src/services/intentClassifier.ts
+++ b/src/services/intentClassifier.ts
@@ -194,20 +194,21 @@ class IntentClassifier {
: options;
const trimmedMessage = message.trim().toLowerCase();
- const logMsg = trimmedMessage.slice(0, 60);
+ // Length only: the message is what the person typed and never goes in the log.
+ const logMsg = `${trimmedMessage.length}ch`;
// Check cache first
const cacheKey = trimmedMessage.slice(0, 200); // Limit key size
const cachedIntent = intentCache.get(cacheKey);
if (cachedIntent) {
- logger.log(`[ROUTE-SM] classify CACHE intent=${cachedIntent} msg="${logMsg}"`);
+ logger.log(`[ROUTE-SM] classify CACHE intent=${cachedIntent} msg=${logMsg}`);
return cachedIntent;
}
// Fast pattern matching
const patternResult = this.classifyByPattern(trimmedMessage);
if (patternResult !== null) {
- logger.log(`[ROUTE-SM] classify PATTERN intent=${patternResult} msg="${logMsg}"`);
+ logger.log(`[ROUTE-SM] classify PATTERN intent=${patternResult} msg=${logMsg}`);
this.cacheIntent(cacheKey, patternResult);
return patternResult;
}
@@ -216,7 +217,7 @@ class IntentClassifier {
if (opts.useLLM) {
try {
const llmResult = await this.classifyWithLLM(message, opts);
- logger.log(`[ROUTE-SM] classify LLM intent=${llmResult} msg="${logMsg}"`);
+ logger.log(`[ROUTE-SM] classify LLM intent=${llmResult} msg=${logMsg}`);
this.cacheIntent(cacheKey, llmResult);
return llmResult;
} catch (error) {
@@ -225,7 +226,7 @@ class IntentClassifier {
}
// Default to text intent if uncertain
- logger.log(`[ROUTE-SM] classify DEFAULT→text (uncertain, llm=${opts.useLLM}) msg="${logMsg}"`);
+ logger.log(`[ROUTE-SM] classify DEFAULT→text (uncertain, llm=${opts.useLLM}) msg=${logMsg}`);
return 'text';
}
diff --git a/src/services/litert.ts b/src/services/litert.ts
index b85d18b9b..d5ba2bab3 100644
--- a/src/services/litert.ts
+++ b/src/services/litert.ts
@@ -332,7 +332,8 @@ class LiteRTService {
this.emitter!.addListener(EVENT_COMPLETE, (benchmarkJson: string) => {
logger.log(TAG, `sendMessage — complete, content=${this.currentContent.length} chars`);
// [WIRE] Full raw event stream + accumulated content/reasoning, for real-format fixtures.
- logger.log(`[WIRE-LITERT] ${JSON.stringify({ stream: __wire, content: this.currentContent, reasoning: this.currentReasoning })}`);
+ // Shape only: the reply and reasoning never go in the log.
+ logger.log(`[WIRE-LITERT] ${JSON.stringify({ events: __wire.length, contentLength: this.currentContent.length, reasoningLength: this.currentReasoning.length })}`);
this.clearSubscriptions();
this.currentToolCallHandler = null;
@@ -386,8 +387,8 @@ class LiteRTService {
callbacks.onError(new Error(message));
}),
this.emitter!.addListener(EVENT_TOOL_CALL, async (json: string) => {
- logger.log(TAG, `sendMessage — tool call received: ${json.substring(0, 200)}`);
- logger.log(`[WIRE-LITERT-TOOL] ${json}`); // [WIRE] full untruncated raw litert tool_call json from-device
+ // Never the arguments: they can hold what the person asked for.
+ logger.log(TAG, `sendMessage — tool call received: ${json.length}ch`);
try {
const { id, name, arguments: args } = JSON.parse(json) as {
id: string;
@@ -438,14 +439,14 @@ class LiteRTService {
): Promise {
const { imageUris, audioUris } = media ?? {};
const { onToken, onToolCall, onReasoning } = handlers ?? {};
- logger.log(TAG, `generateRaw — text=${text.length}ch, hasToolHandler=${!!onToolCall}, imageCount=${imageUris?.length ?? 0}, audioCount=${audioUris?.length ?? 0}, first100="${text.substring(0, 100)}"`);
+ logger.log(TAG, `generateRaw — text=${text.length}ch, hasToolHandler=${!!onToolCall}, imageCount=${imageUris?.length ?? 0}, audioCount=${audioUris?.length ?? 0}`);
this.currentToolCallHandler = onToolCall ?? null;
return new Promise((resolve, reject) => {
this.sendMessage(text, {
onToken: t => onToken?.(t),
onReasoning: t => onReasoning?.(t),
onComplete: (fullContent, _reasoning, stats) => {
- logger.log(TAG, `generateRaw — complete, response=${fullContent.length}ch, first200="${fullContent.substring(0, 200)}"`);
+ logger.log(TAG, `generateRaw — complete, response=${fullContent.length}ch`);
this._lastBenchmarkStats = stats;
resolve(fullContent);
},
diff --git a/src/services/llm.ts b/src/services/llm.ts
index 04e6b6ee7..7f429a380 100644
--- a/src/services/llm.ts
+++ b/src/services/llm.ts
@@ -376,7 +376,7 @@ class LLMService {
const completionResult = await safeCompletion(ctx, () => ctx.completion(completionParams, (data: any) => {
if (__wire.length < 500) __wire.push({ token: data.token, content: data.content, reasoning_content: data.reasoning_content, tool_calls: data.tool_calls }); // [WIRE]
if (!this.isGenerating || !data.token) return;
- if (!firstReceived) { firstReceived = true; firstTokenMs = Date.now() - startTime; logger.log(`[LLM][THINKING] First token raw data — token: ${JSON.stringify(data.token)}, content: ${JSON.stringify(data.content)}, reasoning_content: ${JSON.stringify(data.reasoning_content)}`); }
+ if (!firstReceived) { firstReceived = true; firstTokenMs = Date.now() - startTime; logger.log(`[LLM][THINKING] First token — hasContent=${!!data.content}, hasReasoning=${!!data.reasoning_content}`); }
tokenCount++;
const content = getStreamingDelta(data.content ?? (!data.reasoning_content ? data.token : undefined), streamedContentSoFar);
const reasoningContent = getStreamingDelta(data.reasoning_content || undefined, streamedReasoningSoFar);
@@ -389,7 +389,8 @@ class LLMService {
}), 'generateResponse');
const cr = completionResult as any;
// [WIRE] Full raw stream + final result, so we can build fixtures from real Gemma/Qwen wire format.
- logger.log(`[WIRE-LLAMA] ${JSON.stringify({ model: this.currentModelPath, stream: __wire, final: { content: cr?.content, text: cr?.text, reasoning_content: cr?.reasoning_content, tool_calls: cr?.tool_calls } })}`);
+ // Shape only: the reply, reasoning and tool arguments never go in the log.
+ logger.log(`[WIRE-LLAMA] ${JSON.stringify({ model: this.currentModelPath, events: __wire.length, final: { contentLength: cr?.content?.length ?? 0, textLength: cr?.text?.length ?? 0, reasoningLength: cr?.reasoning_content?.length ?? 0, toolCalls: cr?.tool_calls?.length ?? 0 } })}`);
// Capture how the reply ended so the UI can flag a cut-off one (B15 cap, or a backend fault).
this.performanceStats = {
...recordGenerationStats(startTime, firstTokenMs, tokenCount),
diff --git a/src/services/llmNativeLog.ts b/src/services/llmNativeLog.ts
index b3cddfa78..f2542bdd2 100644
--- a/src/services/llmNativeLog.ts
+++ b/src/services/llmNativeLog.ts
@@ -25,7 +25,19 @@ const RING_SIZE = 40;
* They are still kept in the ring buffer, which is the point of this passthrough: a load failure can
* attach the real reason. The ring is memory and costs nothing; the file is what hurt.
*/
-const PER_TENSOR_NOISE = /create_tensor|loading tensor|done_getting_tensors|load_tensors:/;
+// `llama_graph_n_input_tensors` names one graph node per line (hundreds per load): it filled the whole
+// Report a problem view and pushed every useful line out.
+const PER_TENSOR_NOISE =
+ /create_tensor|loading tensor|done_getting_tensors|load_tensors:|llama_graph_n_input_tensors/;
+
+/**
+ * Lines llama.cpp emits once PER SAMPLED TOKEN while a lazy tool grammar waits for its trigger, e.g.
+ * "Grammar still awaiting trigger after token 7932 (` gift`)". They carry the reply's own words, and
+ * the debug log never holds chat content (docs/ANALYTICS.md, Report a problem), so they are dropped.
+ */
+const PER_TOKEN_LINE = /Grammar (?:still awaiting trigger|triggered) /;
+/** llama.cpp quotes token and prompt text in backticks; keep the line, never the text. */
+const QUOTED_TEXT = /`[^`]*`/g;
const recent: string[] = [];
let started = false;
@@ -37,8 +49,10 @@ export function ensureNativeLogCapture(): void {
toggleNativeLog(true);
addNativeLogListener((level: string, text: string) => {
const severity = (level || 'info').trim();
- const line = `${severity}: ${(text || '').trim()}`;
- // Ring ALWAYS: this is what makes a load failure explain itself.
+ const raw = (text || '').trim();
+ if (PER_TOKEN_LINE.test(raw)) return;
+ const line = `${severity}: ${raw.replace(QUOTED_TEXT, '`…`')}`;
+ // Ring every other line: this is what makes a load failure explain itself.
recent.push(line);
if (recent.length > RING_SIZE) recent.shift();
// File only when it is worth a write. Anything not per-tensor still goes through, so
diff --git a/src/services/llmToolGeneration.ts b/src/services/llmToolGeneration.ts
index 506b0e710..1f006df23 100644
--- a/src/services/llmToolGeneration.ts
+++ b/src/services/llmToolGeneration.ts
@@ -173,8 +173,7 @@ export async function generateWithToolsImpl(
tool_choice: 'auto',
...buildThinkingCompletionParams(deps.isThinkingEnabled, deps.isGemma4Model, settings.reasoningBudget),
};
- logger.log('[LLM-Tools] === INPUT ===');
- logger.log(JSON.stringify(completionParams, null, 2));
+ logger.log(`[LLM-Tools] input messages=${completionParams.messages?.length ?? 0} tools=${options.tools?.length ?? 0}`);
const completionResult: any = await safeCompletion(deps.context, () => deps.context.completion(completionParams as any, (data: any) => {
if (!generating) return;
if (data.tool_calls) {
@@ -202,16 +201,12 @@ export async function generateWithToolsImpl(
if (visible) options.onStream?.({ content: visible });
}
}), 'generateWithTools');
- logger.log('[LLM-Tools] === OUTPUT ===');
- logger.log(JSON.stringify(completionResult, null, 2));
- // [WIRE] full tool-generation input+output on ONE tagged line so the lossless wire file captures the
- // whole payload (the pretty-printed dumps above are separate untagged lines the tee can't match).
- logger.log(`[WIRE-LLAMA-TOOL] ${JSON.stringify({ input: completionParams, output: completionResult })}`);
+ // Shape only: prompts, replies and tool arguments never go in the log.
+ logger.log(`[WIRE-LLAMA-TOOL] ${JSON.stringify({ textLength: completionResult?.text?.length ?? 0, contentLength: completionResult?.content?.length ?? 0, toolCalls: completionResult?.tool_calls?.length ?? 0 })}`);
const cr = completionResult;
- logger.log(`[LLM-Tools] Completion done: streamed=${tokenCount} tokens, response="${fullResponse.substring(0, 100)}"`);
+ logger.log(`[LLM-Tools] Completion done: streamed=${tokenCount} tokens, response=${fullResponse.length}ch`);
logger.log(`[LLM-Tools] Result: predicted=${cr?.tokens_predicted}, evaluated=${cr?.tokens_evaluated}, context_full=${cr?.context_full}, stopped_eos=${cr?.stopped_eos}`);
- logger.log(`[LLM-Tools] Result text="${(cr?.text || '').substring(0, 200)}", content="${(cr?.content || '').substring(0, 200)}"`);
// If streaming didn't capture tokens, fall back to the CLEAN parsed content — NEVER cr.text, which
// carries the raw <|channel>thought… reasoning markers. reasoning falls back likewise.
diff --git a/src/services/localDreamGenerator.ts b/src/services/localDreamGenerator.ts
index da89efd1f..6768336ec 100644
--- a/src/services/localDreamGenerator.ts
+++ b/src/services/localDreamGenerator.ts
@@ -156,7 +156,7 @@ class LocalDreamGeneratorService {
}
private buildResult(params: ImageGenerationParams, result: any): GeneratedImage {
- logger.log(`[WIRE-IMAGE] ${JSON.stringify(result)}`); // [WIRE] raw native generateImage result shape from-device
+ logger.log(`[WIRE-IMAGE] ${JSON.stringify({ keys: Object.keys(result ?? {}) })}`); // shape only: the result can echo the prompt
return {
id: result.id,
prompt: params.prompt,
diff --git a/src/services/pdfExtractor.ts b/src/services/pdfExtractor.ts
index 0247f2c8f..dc0a9c9e2 100644
--- a/src/services/pdfExtractor.ts
+++ b/src/services/pdfExtractor.ts
@@ -27,7 +27,7 @@ class PDFExtractor {
try {
const __text = await PDFExtractorModule.extractText(filePath, maxChars);
- logger.log(`[WIRE-PDF] ${JSON.stringify({ filePath, maxChars, textLength: __text?.length, sample: (__text ?? '').slice(0, 200) })}`); // [WIRE] native PDF→text extraction shape
+ logger.log(`[WIRE-PDF] ${JSON.stringify({ maxChars, textLength: __text?.length })}`); // [WIRE] native PDF→text extraction shape; never the path (it names the document)
return __text;
} catch (error: any) {
// Guard against NullPointerException when bridge promise is rejected after teardown
diff --git a/src/services/proPrompt.ts b/src/services/proPrompt.ts
index 3d19c33e5..78cf4057e 100644
--- a/src/services/proPrompt.ts
+++ b/src/services/proPrompt.ts
@@ -1,5 +1,6 @@
import { selectHasProAccess } from '../stores/proAccessSlice';
import { useAppStore } from '../stores/appStore';
+import { proGateShown } from './analytics/analytics';
// One automatic offer, after the user has completed enough work to understand the product.
// The persisted `proAhaTriggeredBy` marker makes this an install-level decision. Later upgrade
@@ -21,6 +22,7 @@ export function subscribeProPrompt(listener: ProPromptListener): () => void {
}
export function emitProPrompt(variant: ProPromptVariant): void {
+ proGateShown(`aha_${variant}`);
listeners.forEach(l => l(variant));
}
diff --git a/src/services/problemReport.ts b/src/services/problemReport.ts
new file mode 100644
index 000000000..d8bef1ca1
--- /dev/null
+++ b/src/services/problemReport.ts
@@ -0,0 +1,42 @@
+/**
+ * Report a problem (docs/ANALYTICS.md): send the debug log to support. Works whether or not
+ * analytics is on. The person has already seen the log in the Debug Logs view before this runs.
+ *
+ * No mail-composer library is installed, so this opens the system share sheet: Mail is one of its
+ * targets. iOS attaches the log file; Android's share sheet takes text only, so the log's most
+ * recent part goes in the message.
+ */
+import { Platform, Share } from 'react-native';
+import RNFS from 'react-native-fs';
+import { flushDebugLogNow, getDebugLogPath } from '../utils/debugLogFile';
+import { SUPPORT_EMAIL } from '../utils/supportEmail';
+import { appBuildLabel, appVersion } from '../utils/appVersion';
+import { track } from './analytics/analytics';
+
+/** Android share text stays well under the platform's transaction limit. */
+const MAX_INLINE_LOG_CHARS = 100_000;
+
+async function readLog(path: string): Promise {
+ try {
+ if (!(await RNFS.exists(path))) return null;
+ return await RNFS.readFile(path, 'utf8');
+ } catch {
+ return null;
+ }
+}
+
+export async function sendProblemReport(): Promise {
+ await flushDebugLogNow();
+ const path = getDebugLogPath();
+ const log = await readLog(path);
+ const header =
+ `To: ${SUPPORT_EMAIL}\n${appBuildLabel()}\n\nWhat went wrong:\n\n`;
+ const content = Platform.OS === 'ios' && log !== null
+ ? { message: header, url: `file://${path}` }
+ : { message: log ? `${header}---\n${log.slice(-MAX_INLINE_LOG_CHARS)}` : header };
+ track('problem_reported');
+ await Share.share(
+ { title: 'Report a problem', ...content },
+ { subject: `[Problem] Off Grid AI v${appVersion()}` },
+ );
+}
diff --git a/src/services/providers/openAICompatibleStream.ts b/src/services/providers/openAICompatibleStream.ts
index 8a55f513b..1bce12c9a 100644
--- a/src/services/providers/openAICompatibleStream.ts
+++ b/src/services/providers/openAICompatibleStream.ts
@@ -161,7 +161,8 @@ export function processDelta(
state: OpenAIStreamState,
ctx: DeltaCtx,
): void {
- logger.log(`[WIRE-REMOTE] ${JSON.stringify(delta)}`); // [WIRE] raw OpenAI-compatible/Ollama delta from-device
+ // Shape only: streamed text and tool arguments never go in the log.
+ logger.log(`[WIRE-REMOTE] ${JSON.stringify({ keys: Object.keys(delta ?? {}) })}`);
if (delta.content) {
ctx.thinkTagParser.process(
delta.content,
@@ -238,7 +239,7 @@ function handleOllamaChatLine(
req: { callbacks: StreamCallbacks; signal: AbortSignal; abort: () => void },
): void {
if (req.signal.aborted) return;
- logger.log(`[WIRE-OLLAMA] ${JSON.stringify(line)}`); // [WIRE] raw Ollama /api/chat NDJSON line from-device
+ logger.log(`[WIRE-OLLAMA] ${JSON.stringify({ keys: Object.keys(line ?? {}), done: (line as { done?: unknown })?.done })}`);
if (line.error) {
streamState.streamErrorOccurred = true;
@@ -257,7 +258,7 @@ function handleOllamaChatLine(
if (msg.tool_calls) {
for (const tc of msg.tool_calls) {
if (tc.function?.name) {
- logger.log(`[OllamaChat] tool_call: ${tc.function.name}(${typeof tc.function.arguments === 'string' ? tc.function.arguments.substring(0, 100) : JSON.stringify(tc.function.arguments).substring(0, 100)})`);
+ logger.log(`[OllamaChat] tool_call: ${tc.function.name}`);
streamState.accumulatedToolCalls.push(tc);
}
}
diff --git a/src/services/remoteServerManager.ts b/src/services/remoteServerManager.ts
index e05e82858..c31d6ff04 100644
--- a/src/services/remoteServerManager.ts
+++ b/src/services/remoteServerManager.ts
@@ -35,6 +35,7 @@ import {
} from './remoteServerManagerUtils';
import { remoteAuthorizationHeaders } from './remoteTransportPolicy';
import { activateOffGridDesktopModel } from './offGridDesktopModels';
+import { featureUsed, flowFinished, flowStep } from './analytics/analytics';
/** Normalize an endpoint for identity comparison (lowercase, no trailing slashes). */
const trimSlash = (url: string): string => {
@@ -72,6 +73,8 @@ class RemoteServerManager {
const server = store.getServerById(id);
if (!server) throw new Error('Failed to create server');
+ flowStep('remote_server', 'connected');
+ flowFinished('remote_server', 'done');
await createProviderForServerImpl(server);
logger.log('[RemoteServerManager] Added server:', server.name);
@@ -168,9 +171,11 @@ class RemoteServerManager {
endpoint: string,
apiKey?: string,
): Promise {
- return useRemoteServerStore
+ const result = await useRemoteServerStore
.getState()
.testConnectionByEndpoint(endpoint, apiKey);
+ if (result.success) flowStep('remote_server', 'tested');
+ return result;
}
/**
@@ -199,6 +204,7 @@ class RemoteServerManager {
serverId: string,
modelId: string,
): Promise {
+ featureUsed('remote_server');
return setActiveRemoteTextModelImpl(serverId, modelId);
}
diff --git a/src/services/tools/handlers.ts b/src/services/tools/handlers.ts
index 4e742ac6f..29e6dc7e6 100644
--- a/src/services/tools/handlers.ts
+++ b/src/services/tools/handlers.ts
@@ -5,6 +5,8 @@ import { ToolCall, ToolResult } from './types';
import { useDocumentTurnStatusStore } from '../documents/progressStore';
import type * as DocumentChatTurn from '../documents/chatTurn';
import logger from '../../utils/logger';
+import { featureUsed } from '../analytics/analytics';
+import { toolFeature } from '../analytics/values';
function makeResult(call: ToolCall, start: number, opts: { content: string; error?: string }): ToolResult {
return { toolCallId: call.id, name: call.name, content: opts.content, error: opts.error, durationMs: Date.now() - start };
@@ -16,6 +18,7 @@ function requireString(call: ToolCall, param: string): string | null {
export async function executeToolCall(call: ToolCall): Promise {
const start = Date.now();
+ featureUsed(toolFeature(call.name));
try {
const content = await dispatchTool(call);
return makeResult(call, start, { content });
@@ -375,7 +378,7 @@ async function handleReadUrl(rawUrl: string): Promise {
return text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}\n\n[Content truncated]` : text;
} catch (e: any) {
- logger.error(`[Tools] read_url FAILED for "${url}": ${e?.message || e}`);
+ logger.error(`[Tools] read_url FAILED: ${e?.message || e}`);
throw e;
} finally { clearTimeout(timeout); }
}
diff --git a/src/services/whisperService.ts b/src/services/whisperService.ts
index 863efdeff..acfc888b4 100644
--- a/src/services/whisperService.ts
+++ b/src/services/whisperService.ts
@@ -15,6 +15,9 @@ import { RealtimeStartBarrier } from './realtimeStartBarrier';
import { WhisperModelDownloads } from './whisperModelDownloads';
import { useRemoteServerStore } from '../stores/remoteServerStore';
import { remoteMediaRuntime } from './remoteMediaRuntime';
+import { featureUsed, generationStarted } from './analytics/analytics';
+import { modelRef } from './analytics/values';
+import { fileName } from './analytics/scrub';
// Re-export the model catalog + transcription normalizer (moved to whisperModels.ts
// to keep this file within the max-lines budget). Behavior-neutral: every existing
@@ -234,6 +237,12 @@ class WhisperService {
this.realtimeStart.begin();
this.isTranscribing = true;
const session = ++this.realtimeSession;
+ featureUsed('voice_input');
+ const endAnalytics = generationStarted('stt', {
+ model: modelRef(fileName(this.currentModelPath)?.replace(/^ggml-|\.bin$/g, '')),
+ engine: 'whisper',
+ backend: 'cpu',
+ });
// Create a promise that resolves when the native side fully finishes. The session holds the
// native lane until then, so a file job or a release never overlaps live capture.
@@ -253,6 +262,8 @@ class WhisperService {
logger.log('[WhisperService] Permission granted:', hasPermission);
if (!hasPermission) {
+ // The person's choice, not a failure: no failed generation, no consent prompt.
+ endAnalytics('stopped');
throw new Error('Microphone permission denied');
}
@@ -284,10 +295,7 @@ class WhisperService {
// This session already holds the native lane; the fallback runs inside it.
const fileText = await this.transcribeLocalFile(path, { language });
logger.log(
- `[WhisperService] Realtime captured nothing — file transcript: "${fileText.slice(
- 0,
- 50,
- )}"`,
+ `[WhisperService] Realtime captured nothing — file transcript: ${fileText.length}ch`,
);
return fileText;
} catch (fileErr) {
@@ -337,11 +345,11 @@ class WhisperService {
logger.log('[WhisperService] Event received:', {
isCapturing: evt.isCapturing,
hasData: !!evt.data,
- text: evt.data?.result?.slice(0, 50),
+ textLength: evt.data?.result?.length ?? 0,
});
// [WIRE] raw realtime transcription event shape from-device (voice-mode STT path) — full result +
// segments + timing, so we can ground the realtime-transcript fixtures (distinct from file transcribe).
- logger.log(`[WIRE-STT-REALTIME] ${JSON.stringify(evt)}`);
+ logger.log(`[WIRE-STT-REALTIME] ${JSON.stringify({ isCapturing: evt.isCapturing, textLength: evt.data?.result?.length ?? 0, processTime: evt.processTime, recordingTime: evt.recordingTime })}`);
const { isCapturing, data, processTime, recordingTime } = evt;
@@ -362,6 +370,7 @@ class WhisperService {
// The native callback cannot await the fallback transcription.
// eslint-disable-next-line no-void
void resolveFinalText(data?.result || '').then(finalText => {
+ endAnalytics('done');
onResult({
text: finalText,
isCapturing: false,
@@ -384,6 +393,7 @@ class WhisperService {
this.realtimeStart.settle();
if (recordedFile) audioRecorderService.cancelRecording();
logger.error('[WhisperService] transcribeRealtime error:', error);
+ endAnalytics('failed', { error });
this.isTranscribing = false;
this.stopFn = null;
resolveTranscriptionStopped();
@@ -534,7 +544,8 @@ class WhisperService {
});
const __res = await promise;
- logger.log(`[WIRE-STT] ${JSON.stringify(__res)}`); // [WIRE] raw whisper.rn transcribe result (segments/text) from-device
+ // Shape only: the transcript is what the person said and never goes in the log.
+ logger.log(`[WIRE-STT] ${JSON.stringify({ textLength: __res?.result?.length ?? 0 })}`);
const { result } = __res;
return cleanTranscription(result);
}
diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts
index 252d34ca4..6061735f5 100644
--- a/src/stores/appStore.ts
+++ b/src/stores/appStore.ts
@@ -126,6 +126,13 @@ export type AppSettings = {
autoDiscoverRemoteModels?: boolean;
/** LAN server types to probe when Scan network is used. */
remoteScanKinds?: Array<'gateway' | 'ollama' | 'lmstudio'>;
+ /** Opt-in analytics consent. Per device: never part of the synced model settings. */
+ analyticsUsage: boolean;
+ analyticsCrashes: boolean;
+ /** The person has seen the consent choice (Auto Setup or a prompt). */
+ analyticsAsked: boolean;
+ /** When a consent prompt was last shown, so a declined prompt waits 30 days. */
+ analyticsLastPromptAt: number | null;
};
type ThemeMode = 'system' | 'light' | 'dark';
@@ -280,6 +287,10 @@ export const DEFAULT_SETTINGS: AppSettings = {
liteRTTemperature: 0.7,
liteRTTopP: 0.9,
liteRTMaxTokens: 4096,
+ analyticsUsage: false,
+ analyticsCrashes: false,
+ analyticsAsked: false,
+ analyticsLastPromptAt: null,
};
export const selectIsLiteRT = (state: AppState): boolean =>
@@ -380,7 +391,13 @@ export const useAppStore = create()(
},
resetSettings: () => {
const before = get().settings;
- const after = { ...DEFAULT_SETTINGS };
+ // Consent resets to off (analytics opts out); whether the person was already asked does not,
+ // so a reset never brings back the one-time prompt.
+ const after = {
+ ...DEFAULT_SETTINGS,
+ analyticsAsked: before.analyticsAsked,
+ analyticsLastPromptAt: before.analyticsLastPromptAt,
+ };
set({ settings: after });
emitChangedModelSettings(before, after);
},
diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts
index 777db9950..7e0bcad1e 100644
--- a/src/stores/chatStore.ts
+++ b/src/stores/chatStore.ts
@@ -31,6 +31,7 @@ import {
messagePutMutation,
} from '../services/sync/mutation';
import { appendPlaceFor, neighbourMessage, versionParent } from '../utils/chatThread';
+import { track } from '../services/analytics/analytics';
/**
* The portion of the in-progress stream that is safe to SPEAK in voice mode —
@@ -226,6 +227,7 @@ export const useChatStore = create()(
activeConversationId: id,
}));
emitSyncMutation(conversationPutMutation(conversation));
+ track('chat_created');
return id;
},
diff --git a/src/stores/downloadStore.ts b/src/stores/downloadStore.ts
index 37b600e96..db2d08eae 100644
--- a/src/stores/downloadStore.ts
+++ b/src/stores/downloadStore.ts
@@ -6,6 +6,8 @@ import logger from '../utils/logger';
// existing `from '../stores/downloadStore'` importer of these keeps working unchanged.
import { DownloadStatus, DownloadEntry, isActiveStatus } from '../utils/downloadStatus';
import { sampleProgressRate } from '@offgrid/ui';
+import { downloadEnded, downloadStarted } from '../services/analytics/analytics';
+import { modelRef } from '../services/analytics/values';
export type { DownloadStatus, DownloadEntry };
export type { ModelType } from '../utils/downloadStatus';
@@ -57,7 +59,42 @@ function terminalRateFields(
: { rateSample: undefined, bytesPerSecond: undefined };
}
-export const useDownloadStore = create((set) => ({
+function addEntry(state: DownloadStoreState, entry: DownloadEntry): Partial {
+ if (state.downloads[entry.modelKey]) return state;
+ return {
+ downloads: { ...state.downloads, [entry.modelKey]: entry },
+ downloadIdIndex: {
+ ...state.downloadIdIndex,
+ [entry.downloadId]: entry.modelKey,
+ ...(entry.mmProjDownloadId ? { [entry.mmProjDownloadId]: entry.modelKey } : {}),
+ },
+ };
+}
+/** Report a download start. A hydrated or restored row is not a new start, so only add/retry call this. */
+function reportStarted(entry: DownloadEntry | undefined): void {
+ if (!entry) return;
+ downloadStarted(entry.modelKey, {
+ model: modelRef(entry.modelId),
+ quant: entry.quantization || undefined,
+ sizeBytes: entry.combinedTotalBytes || entry.totalBytes,
+ modelType: entry.modelType,
+ });
+}
+
+/** Report the end of the main file's download (a vision sidecar never ends the download). */
+function reportEnd(
+ state: DownloadStoreState,
+ downloadId: string,
+ status: DownloadStatus,
+ code?: string,
+): void {
+ const entry = state.downloads[state.downloadIdIndex[downloadId] ?? ''];
+ if (entry?.downloadId !== downloadId) return;
+ if (status === 'failed') downloadEnded(entry.modelKey, 'failed', { code });
+ else if (status === 'completed') downloadEnded(entry.modelKey, 'done');
+}
+
+export const useDownloadStore = create((set, get) => ({
downloads: {},
downloadIdIndex: {},
repairingVisionIds: {},
@@ -120,17 +157,11 @@ export const useDownloadStore = create((set) => ({
// remove() them first. This enforces "one logical entry per model/file"
// and prevents a fresh start path from silently replacing a visible failed
// entry that the product rules say must persist until explicit user action.
- add: (entry) => set(state => {
- if (state.downloads[entry.modelKey]) return state;
- return {
- downloads: { ...state.downloads, [entry.modelKey]: entry },
- downloadIdIndex: {
- ...state.downloadIdIndex,
- [entry.downloadId]: entry.modelKey,
- ...(entry.mmProjDownloadId ? { [entry.mmProjDownloadId]: entry.modelKey } : {}),
- },
- };
- }),
+ add: (entry) => {
+ if (get().downloads[entry.modelKey]) return;
+ set(state => addEntry(state, entry));
+ reportStarted(get().downloads[entry.modelKey]);
+ },
setMmProjDownloadId: (modelKey, mmProjDownloadId) => set(state => {
const entry = state.downloads[modelKey];
@@ -206,7 +237,9 @@ export const useDownloadStore = create((set) => ({
};
}),
- setStatus: (downloadId, status, error) => set(state => {
+ setStatus: (downloadId, status, error) => {
+ reportEnd(get(), downloadId, status, error?.code);
+ set(state => {
const modelKey = state.downloadIdIndex[downloadId];
if (!modelKey) return state;
const entry = state.downloads[modelKey];
@@ -244,7 +277,8 @@ export const useDownloadStore = create((set) => ({
},
},
};
- }),
+ });
+ },
setProcessing: (downloadId) => set(state => {
const modelKey = state.downloadIdIndex[downloadId];
@@ -256,7 +290,9 @@ export const useDownloadStore = create((set) => ({
};
}),
- setCompleted: (downloadId) => set(state => {
+ setCompleted: (downloadId) => {
+ reportEnd(get(), downloadId, 'completed');
+ set(state => {
const modelKey = state.downloadIdIndex[downloadId];
if (!modelKey) return state;
const entry = state.downloads[modelKey];
@@ -272,7 +308,8 @@ export const useDownloadStore = create((set) => ({
},
},
};
- }),
+ });
+ },
setMmProjCompleted: (mmProjDownloadId, bytes) => set(state => {
const modelKey = state.downloadIdIndex[mmProjDownloadId];
@@ -292,7 +329,8 @@ export const useDownloadStore = create((set) => ({
};
}),
- retryEntry: (modelKey, newDownloadId) => set(state => {
+ retryEntry: (modelKey, newDownloadId) => {
+ set(state => {
const entry = state.downloads[modelKey];
if (!entry) return state;
const newIndex = { ...state.downloadIdIndex };
@@ -326,7 +364,9 @@ export const useDownloadStore = create((set) => ({
},
downloadIdIndex: newIndex,
};
- }),
+ });
+ reportStarted(get().downloads[modelKey]);
+ },
remove: (modelKey) => set(state => {
const entry = state.downloads[modelKey];
diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts
index fa05a8c42..6d383a183 100644
--- a/src/stores/projectStore.ts
+++ b/src/stores/projectStore.ts
@@ -16,6 +16,7 @@ import {
emitSyncMutation,
projectPutMutation,
} from '../services/sync/mutation';
+import { track } from '../services/analytics/analytics';
interface ProjectState {
projects: Project[];
@@ -121,6 +122,7 @@ export const useProjectStore = create()(
projects: [...state.projects, project],
}));
emitSyncMutation(projectPutMutation(project));
+ track('project_created');
return project;
},
diff --git a/src/utils/debugLogFile.ts b/src/utils/debugLogFile.ts
index 6ef572d15..c0063060c 100644
--- a/src/utils/debugLogFile.ts
+++ b/src/utils/debugLogFile.ts
@@ -1,5 +1,5 @@
/**
- * Dev-only persistent log file sink.
+ * Persistent log file sink. Report a problem sends this file to support, in every build.
*
* RN 0.83 moved JS console logs off the Metro terminal into React Native DevTools,
* and RN's console never reaches the iOS device syslog — so neither `metro` stdout
@@ -12,8 +12,8 @@
* --domain-type appDataContainer --domain-identifier \
* --source Documents/offgrid-debug.log --destination /tmp/offgrid-debug.log
*
- * It is entirely behind __DEV__ (wired in App.tsx) — release builds never touch it.
- * Writes are buffered + flushed on an interval so high-frequency logging never
+ * It runs in every build (wired in App.tsx). Log lines hold metadata only, never chat content
+ * (docs/ANALYTICS.md, Report a problem). Writes are buffered + flushed on an interval so high-frequency logging never
* blocks the JS thread, and the file is rotated at a size cap so it can't grow
* unbounded. Logging must NEVER throw, so every FS call is best-effort.
*/
@@ -31,6 +31,7 @@ const FLUSH_AT_LINES = 50;
// native tool-call dumps). It is NEVER rotated or size-capped, so a long
// multi-model ground-truth run can't drop the earliest captures — the whole point
// is to miss nothing. Pull it alongside the debug log; delete it manually when done.
+// Dev builds only: a store build must not grow a file without bound.
const WIRE_LOG_PATH = `${RNFS.DocumentDirectoryPath}/offgrid-wire.log`;
const WIRE_CAPTURE = /\[WIRE-|\[LLM-Tools\]|tool call received/;
let wireBuffer: string[] = [];
@@ -62,7 +63,15 @@ async function flushWire(): Promise {
await RNFS.appendFile(WIRE_LOG_PATH, chunk, 'utf8').catch(() => {});
}
-async function flush(): Promise {
+/** The flush writing now; the next one waits for it, so lines reach the file in order. */
+let inFlight: Promise = Promise.resolve();
+
+function flush(): Promise {
+ inFlight = inFlight.then(writeBuffered).catch(() => {});
+ return inFlight;
+}
+
+async function writeBuffered(): Promise {
timer = null;
if (buffer.length === 0) return;
const chunk = buffer.join('');
@@ -98,14 +107,14 @@ function scheduleFlush(): void {
* JS thread - or a process the OS is about to kill - never reaches it, and the log stops exactly
* where the interesting part begins. That is how an 11-second main-thread block left no trace.
*/
-export function flushDebugLogNow(): void {
- if (!enabled) return;
- flush().catch(() => {});
+export function flushDebugLogNow(): Promise {
+ if (!enabled) return Promise.resolve();
+ return flush().catch(() => {});
}
-/** Begin capturing to the file. Idempotent; no-op outside __DEV__. */
+/** Begin capturing to the file. Idempotent. */
export function initDebugLogFile(): void {
- if (!__DEV__ || enabled) return;
+ if (enabled) return;
enabled = true;
// Append (don't wipe) a session marker so a prior session's tail survives a
// reload/crash for post-mortem, while the size cap keeps it bounded.
@@ -138,7 +147,7 @@ export function appendDebugLine(level: string, message: string): void {
}
// Tee model-wire captures into the lossless, never-rotated file so a long capture
// run can't drop the earliest lines.
- if (WIRE_CAPTURE.test(message)) {
+ if (__DEV__ && WIRE_CAPTURE.test(message)) {
wireBuffer.push(line);
if (wireBuffer.length >= 20) {
flushWire().catch(() => {});