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). [![GitHub stars](https://img.shields.io/github/stars/off-grid-ai/OGAM?style=social)](https://github.com/off-grid-ai/OGAM) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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 = ({