From 22c7af829ed067fa4fb80a3d033d39e6cc3eecf7 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Fri, 23 Oct 2026 14:13:01 +0530 Subject: [PATCH 01/47] The app can send opt-in analytics through the PostHog React Native SDK --- package-lock.json | 91 ++++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + 2 files changed, 91 insertions(+), 1 deletion(-) 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", From bacf608129973e3a119c9e99b0030bc5559366e4 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Fri, 23 Oct 2026 14:13:02 +0530 Subject: [PATCH 02/47] Analytics stays off until the person opts in, and opting out resets the anonymous ID --- docs/ANALYTICS.md | 123 ++++++ src/bootstrap/hookRegistry.ts | 3 + src/config/analytics.ts | 7 + src/services/analytics/analytics.ts | 458 ++++++++++++++++++++++ src/services/analytics/client.ts | 89 +++++ src/services/analytics/consentPrompt.ts | 46 +++ src/services/analytics/featureSnapshot.ts | 26 ++ src/services/analytics/scrub.ts | 69 ++++ src/services/analytics/uncleanExit.ts | 63 +++ src/services/analytics/values.ts | 108 +++++ src/stores/appStore.ts | 11 + 11 files changed, 1003 insertions(+) create mode 100644 docs/ANALYTICS.md create mode 100644 src/config/analytics.ts create mode 100644 src/services/analytics/analytics.ts create mode 100644 src/services/analytics/client.ts create mode 100644 src/services/analytics/consentPrompt.ts create mode 100644 src/services/analytics/featureSnapshot.ts create mode 100644 src/services/analytics/scrub.ts create mode 100644 src/services/analytics/uncleanExit.ts create mode 100644 src/services/analytics/values.ts diff --git a/docs/ANALYTICS.md b/docs/ANALYTICS.md new file mode 100644 index 000000000..c1d67fd09 --- /dev/null +++ b/docs/ANALYTICS.md @@ -0,0 +1,123 @@ +# 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` (extension only), `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. | +| `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 + +- Opens the mail composer to the support address with the debug log attached. If no mail app is set up, use the share sheet. +- 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/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/config/analytics.ts b/src/config/analytics.ts new file mode 100644 index 000000000..3c77a8c8d --- /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. + * + * An empty key, or a debug build, means the SDK is never created. + */ +export const POSTHOG_API_KEY = ''; +export const POSTHOG_HOST = 'https://eu.i.posthog.com'; diff --git a/src/services/analytics/analytics.ts b/src/services/analytics/analytics.ts new file mode 100644 index 000000000..0fb054105 --- /dev/null +++ b/src/services/analytics/analytics.ts @@ -0,0 +1,458 @@ +/** + * Opt-in analytics: the single owner of consent, the SDK client, super properties, screens, + * flows and the events in docs/ANALYTICS.md. Every other module reports facts through the + * functions here; none of them imports the SDK. + * + * - Off by default. The client exists only while Usage data or Crash reports is on, and only in a + * release build with a key. Before that, every call here is a no-op and nothing is queued. + * - Usage data gates every event except `$exception` and `unclean_exit`, which Crash reports gates. + * - Turning both off opts out: what is queued is dropped and the anonymous ID is reset. + */ +import { Platform } from 'react-native'; +import DeviceInfo from 'react-native-device-info'; +import RNFS from 'react-native-fs'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useAppStore } from '../../stores/appStore'; +import { selectHasProAccess } from '../../stores/proAccessSlice'; +import { appVersion } from '../../utils/appVersion'; +import { hardwareService } from '../hardware'; +import { analyticsBuildAllowed, createAnalyticsClient, type AnalyticsClient } from './client'; +import { scrubExceptionProperties } from './scrub'; +import { markForeground, markStageEnded, markStageStarted, takeUncleanExit } from './uncleanExit'; +import { maybePromptAfterFailure, maybePromptAfterFirstChat } from './consentPrompt'; +import { + batteryPct, + bytesToGb, + contextBucket, + errorCode, + languageOnly, + ms, + oneDecimal, + releaseChannel, + sizeBucket, +} from './values'; + +type Properties = Record; +export type ConsentSource = 'setup' | 'settings' | 'failure_prompt' | 'first_chat_prompt'; +export type Flow = + | 'onboarding' + | 'model_transfer' + | 'sync_pairing' + | 'remote_server' + | 'voice_setup' + | 'image_setup' + | 'connector_add' + | 'pro_upgrade'; +export type Feature = + | 'voice_input' + | 'tts' + | 'image_generation' + | 'vision' + | 'web_search' + | 'tools' + | 'mcp_tool' + | 'remote_server' + | 'browser_action' + | 'clipboard' + | 'meetings' + | 'sync'; +export type Modality = 'text' | 'vision' | 'image' | 'stt' | 'tts' | 'embedding'; +export type Outcome = 'done' | 'stopped' | 'failed'; + +/** What a model is, in the spec's words: `model`, `quant`, `engine`, `backend`. */ +export interface ModelFacts { + model: string; + quant?: string; + engine: string; + backend: string; +} + +const CRASH_EVENTS = new Set(['$exception', 'unclean_exit']); +const SNAPSHOT_DAY_KEY = 'offgrid.analytics.snapshotDay'; +const GB = 1024 * 1024 * 1024; +const MB = 1024 * 1024; +const moduleLoadedAt = Date.now(); + +let client: AnalyticsClient | null = null; +let clientCrashes = false; +let consentChain: Promise = Promise.resolve(); +let stopListening: (() => void) | null = null; +let featureSnapshot: () => Record = () => ({}); +let foregroundAt = Date.now(); + +// ── consent + client ─────────────────────────────────────────────────────────── + +function consent(): { usage: boolean; crashes: boolean } { + const { analyticsUsage, analyticsCrashes } = useAppStore.getState().settings; + return { usage: !!analyticsUsage, crashes: !!analyticsCrashes }; +} + +function beforeSend(event: { event: string; properties?: Properties }) { + const { usage, crashes } = consent(); + const crash = CRASH_EVENTS.has(event.event); + if (crash ? !crashes : !usage) return null; + if (event.event !== '$exception') return event; + return { ...event, properties: scrubExceptionProperties(event.properties ?? {}) }; +} + +function superProperties(): Properties { + const version = appVersion(); + let locale: string | undefined; + try { + locale = Intl.DateTimeFormat().resolvedOptions().locale; + } catch { + locale = undefined; + } + return { + platform: Platform.OS, + app_version: version, + channel: releaseChannel(version), + pro: selectHasProAccess(useAppStore.getState()), + device_class: DeviceInfo.isTablet?.() ? 'tablet' : 'phone', + os_version: DeviceInfo.getSystemVersion(), + locale: languageOnly(locale), + }; +} + +async function applyConsent(): Promise { + const { usage, crashes } = consent(); + if (!usage && !crashes) { + const previous = client; + client = null; + if (previous) await previous.forget(); + return; + } + if (!analyticsBuildAllowed() || (client && clientCrashes === crashes)) return; + if (client) { + const previous = client; + client = null; + await previous.close(); + } + client = createAnalyticsClient({ crashes, beforeSend }); + clientCrashes = crashes; + client.register(superProperties()); + DeviceInfo.getTotalMemory() + .then(total => client?.register({ ram_gb: Math.round(total / GB) })) + .catch(() => undefined); +} + +/** Bring the client in line with the stored consent. Calls run one at a time, in order. */ +function syncClient(): Promise { + consentChain = consentChain.then(applyConsent).catch(() => undefined); + return consentChain; +} + +function capture(event: string, properties: Properties = {}): void { + if (!client) return; + const clean: Properties = {}; + for (const [key, value] of Object.entries(properties)) { + if (value !== undefined) clean[key] = value; + } + client.capture(event, clean); +} + +/** + * Store consent and (re)build the client to match. Turning analytics on from fully off sends + * `analytics_opted_in` with the source of the choice. + */ +export async function setAnalyticsConsent( + next: { usage?: boolean; crashes?: boolean }, + source: ConsentSource, +): Promise { + const before = consent(); + const after = { + usage: next.usage ?? before.usage, + crashes: next.crashes ?? before.crashes, + }; + useAppStore.getState().updateSettings({ + analyticsUsage: after.usage, + analyticsCrashes: after.crashes, + analyticsAsked: true, + }); + await syncClient(); + if (!before.usage && after.usage) capture('analytics_opted_in', { source }); +} + +/** Called once the app has finished starting and its settings are hydrated. */ +export function startAnalytics(options: { features?: () => Record } = {}): () => void { + if (options.features) featureSnapshot = options.features; + stopListening?.(); + stopListening = useAppStore.subscribe((state, prev) => { + const now = state.settings; + const was = prev.settings; + if (now.analyticsUsage !== was.analyticsUsage || now.analyticsCrashes !== was.analyticsCrashes) { + syncClient().catch(() => undefined); + } + const pro = selectHasProAccess(state); + if (pro !== selectHasProAccess(prev)) client?.register({ pro }); + }); + foregroundAt = Date.now(); + syncClient() + .then(async () => { + capture('app_opened', { cold_start_ms: ms(Date.now() - moduleLoadedAt) }); + const unclean = await takeUncleanExit(appVersion()); + if (unclean) { + capture('unclean_exit', { + stage: unclean.stage, + model: unclean.model, + engine: unclean.engine, + backend: unclean.backend, + free_ram_gb: unclean.free_ram_gb, + elapsed_ms: ms(Date.now() - unclean.startedAt), + }); + } + await snapshotFeaturesDaily(); + }) + .catch(() => undefined); + return () => { + stopListening?.(); + stopListening = null; + }; +} + +// ── app lifecycle + screens ──────────────────────────────────────────────────── + +let currentScreen: string | null = null; +let screenShownAt: number | null = null; +let screenElapsed = 0; + +export function appForegrounded(): void { + foregroundAt = Date.now(); + markForeground(true); + if (currentScreen) screenShownAt = Date.now(); + capture('app_opened'); + snapshotFeaturesDaily().catch(() => undefined); +} + +export function appBackgrounded(): void { + markForeground(false); + if (screenShownAt !== null) screenElapsed += Date.now() - screenShownAt; + screenShownAt = null; + capture('app_backgrounded', { session_ms: ms(Date.now() - foregroundAt) }); +} + +/** A route name only (never params). */ +export function trackScreen(name: string | undefined): void { + if (!name || name === currentScreen) return; + const now = Date.now(); + const prevMs = currentScreen + ? screenElapsed + (screenShownAt === null ? 0 : now - screenShownAt) + : undefined; + client?.screen(name, { screen: name, prev_screen: currentScreen ?? undefined, prev_ms: ms(prevMs) }); + currentScreen = name; + screenShownAt = now; + screenElapsed = 0; +} + +async function snapshotFeaturesDaily(): Promise { + if (!client || !consent().usage) return; + const today = new Date().toISOString().slice(0, 10); + const last = await AsyncStorage.getItem(SNAPSHOT_DAY_KEY).catch(() => null); + if (last === today) return; + await AsyncStorage.setItem(SNAPSHOT_DAY_KEY, today).catch(() => undefined); + capture('features_snapshot', featureSnapshot()); +} + +// ── flows ────────────────────────────────────────────────────────────────────── + +const flows = new Map(); + +export function flowStarted(flow: Flow, firstStep?: string, properties?: Properties): void { + flows.set(flow, { startedAt: Date.now() }); + capture('flow_started', { flow }); + if (firstStep) flowStep(flow, firstStep, properties); +} + +/** A step counts only inside a flow that started. */ +export function flowStep(flow: Flow, step: string, properties?: Properties): void { + const state = flows.get(flow); + if (!state || state.lastStep === step) return; + state.lastStep = step; + capture('flow_step', { flow, step, ...properties }); +} + +export function flowFinished(flow: Flow, outcome: 'done' | 'failed' | 'cancelled', error?: unknown): void { + const state = flows.get(flow); + if (!state) return; + flows.delete(flow); + capture('flow_finished', { + flow, + outcome, + duration_ms: ms(Date.now() - state.startedAt), + failed_step: outcome === 'failed' ? state.lastStep : undefined, + error_code: outcome === 'failed' && error !== undefined ? errorCode(error) : undefined, + }); +} + +// ── actions ──────────────────────────────────────────────────────────────────── + +export function track( + event: 'chat_created' | 'reply_regenerated' | 'project_created' | 'problem_reported', +): void { + capture(event); +} + +export function featureUsed(feature: Feature): void { + capture('feature_used', { feature }); +} + +export function messageSent(facts: { hasImage: boolean; hasDocument: boolean; toolsEnabled: boolean }): void { + capture('message_sent', { + has_image: facts.hasImage, + has_document: facts.hasDocument, + tools_enabled: facts.toolsEnabled, + }); + flowStep('onboarding', 'first_message'); + flowFinished('onboarding', 'done'); +} + +export function documentAdded(extension: string | undefined, sizeBytes: number | undefined): void { + capture('document_added', { file_type: extension, size_bucket: sizeBucket(sizeBytes) }); +} + +// ── models ───────────────────────────────────────────────────────────────────── + +function freeRamGb(): number | undefined { + return oneDecimal(hardwareService.getAvailableMemoryGB()); +} + +async function socLabel(): Promise { + try { + const soc = await hardwareService.getSoCInfo(); + return soc.appleChip ?? (soc.qnnVariant ? `${soc.vendor}-${soc.qnnVariant}` : soc.vendor); + } catch { + return 'unknown'; + } +} + +/** Run a model load and report `model_loaded` / `model_load_failed`. The result is unchanged. */ +export async function trackModelLoad(facts: ModelFacts, run: () => Promise): Promise { + const startedAt = Date.now(); + const free = freeRamGb(); + markStageStarted({ stage: 'load', ...facts, free_ram_gb: free, startedAt, appVersion: appVersion() }); + const report = (event: string, extra: Properties) => { + socLabel() + .then(soc => capture(event, { ...facts, soc, free_ram_gb: free, load_ms: ms(Date.now() - startedAt), ...extra })) + .catch(() => undefined); + }; + try { + const result = await run(); + markStageEnded('load'); + report('model_loaded', {}); + flowStep('onboarding', 'model_loaded'); + return result; + } catch (error) { + markStageEnded('load'); + report('model_load_failed', { error_code: errorCode(error) }); + maybePromptAfterFailure(); + throw error; + } +} + +// ── generation ───────────────────────────────────────────────────────────────── + +export interface GenerationMetrics { + ttftMs?: number; + tokensPerSec?: number; + contextTokens?: number; + error?: unknown; +} + +export type GenerationEnd = (outcome: Outcome, metrics?: GenerationMetrics) => void; + +const MARKED_MODALITIES = new Set(['text', 'vision', 'image']); + +/** Start timing one generation. Call the returned function exactly once when it ends. */ +export function generationStarted(modality: Modality, facts: ModelFacts): GenerationEnd { + const startedAt = Date.now(); + const marked = MARKED_MODALITIES.has(modality); + if (marked) { + markStageStarted({ stage: 'generate', ...facts, free_ram_gb: freeRamGb(), startedAt, appVersion: appVersion() }); + } + let ended = false; + return (outcome, metrics = {}) => { + if (ended) return; + ended = true; + if (marked) markStageEnded('generate'); + DeviceInfo.getPowerState() + .catch(() => ({}) as { batteryLevel?: number; batteryState?: string }) + .then(power => { + capture('generation_finished', { + modality, + model: facts.model, + engine: facts.engine, + backend: facts.backend, + outcome, + ttft_ms: ms(metrics.ttftMs), + tokens_per_sec: oneDecimal(metrics.tokensPerSec), + context_tokens_bucket: contextBucket(metrics.contextTokens), + battery_pct: batteryPct(power?.batteryLevel), + charging: power?.batteryState ? power.batteryState === 'charging' || power.batteryState === 'full' : undefined, + error_code: outcome === 'failed' ? errorCode(metrics.error) : undefined, + }); + }) + .catch(() => undefined); + if (outcome === 'failed') maybePromptAfterFailure(); + if (outcome === 'done' && (modality === 'text' || modality === 'vision')) maybePromptAfterFirstChat(); + }; +} + +// ── downloads ────────────────────────────────────────────────────────────────── + +export interface DownloadFacts { + model: string; + quant?: string; + sizeBytes?: number; + modelType: 'text' | 'image' | 'stt' | 'tts'; +} + +const downloads = new Map }>(); + +function setupFlow(modelType: DownloadFacts['modelType']): Flow | null { + if (modelType === 'image') return 'image_setup'; + if (modelType === 'stt' || modelType === 'tts') return 'voice_setup'; + return null; +} + +export function downloadStarted(key: string, facts: DownloadFacts): void { + const base = RNFS.getFSInfo() + .then(info => bytesToGb(info.freeSpace)) + .catch(() => undefined) + .then(free => ({ + model: facts.model, + quant: facts.quant, + size_bucket: sizeBucket(facts.sizeBytes), + // No connection-type reader is installed; see docs/ANALYTICS.md `network`. + network: 'unknown', + free_storage_gb: free, + })); + downloads.set(key, { facts, startedAt: Date.now(), base }); + base.then(props => capture('model_download_started', props)).catch(() => undefined); + const flow = setupFlow(facts.modelType); + if (flow) flowStep(flow, 'model_picked'); +} + +export function downloadEnded(key: string, outcome: 'done' | 'failed', error?: unknown): void { + const entry = downloads.get(key); + if (!entry) return; + downloads.delete(key); + const durationMs = Date.now() - entry.startedAt; + entry.base + .then(props => { + if (outcome === 'done') { + const mbPerSec = entry.facts.sizeBytes && durationMs > 0 + ? entry.facts.sizeBytes / MB / (durationMs / 1000) + : undefined; + capture('model_download_finished', { ...props, duration_ms: ms(durationMs), mb_per_sec: oneDecimal(mbPerSec) }); + } else { + capture('model_download_failed', { ...props, duration_ms: ms(durationMs), error_code: errorCode(error) }); + } + }) + .catch(() => undefined); + const flow = setupFlow(entry.facts.modelType); + if (flow && outcome === 'done') { + flowStep(flow, 'downloaded'); + flowFinished(flow, 'done'); + } + if (outcome === 'failed') maybePromptAfterFailure(); +} diff --git a/src/services/analytics/client.ts b/src/services/analytics/client.ts new file mode 100644 index 000000000..5f32a3cfa --- /dev/null +++ b/src/services/analytics/client.ts @@ -0,0 +1,89 @@ +/** + * The ONE module that imports the PostHog SDK. It builds a client with every privacy option the + * spec requires (docs/ANALYTICS.md) and gives the analytics owner four verbs: capture, screen, + * register, and shut down (keeping or forgetting the anonymous ID). + */ +import PostHog, { PostHogPersistedProperty } from 'posthog-react-native'; +import { POSTHOG_API_KEY, POSTHOG_HOST } from '../../config/analytics'; +import { languageOnly } from './values'; + +type Properties = Record; +type CaptureEvent = { event: string; properties?: Properties }; + +/** + * Remote config is fetched on every start and cannot be switched off by an option. This app uses + * no flags, surveys or remote settings, so the client skips the request: the only requests it + * makes are event batches. + */ +class OffGridPostHog extends PostHog { + reloadRemoteConfigAsync(): Promise { + return Promise.resolve(undefined); + } +} + +export interface AnalyticsClientOptions { + crashes: boolean; + /** Return the event to send, or null to drop it. */ + beforeSend: (event: CaptureEvent) => CaptureEvent | null; +} + +export interface AnalyticsClient { + capture(event: string, properties?: Properties): void; + screen(name: string, properties?: Properties): void; + register(properties: Properties): void; + /** Send what is queued and stop. The anonymous ID is kept. */ + close(): Promise; + /** Opt-out: drop what is queued, reset the anonymous ID and stop. */ + forget(): Promise; +} + +/** Debug builds and a missing key never create the SDK. */ +export function analyticsBuildAllowed(): boolean { + return !__DEV__ && POSTHOG_API_KEY.length > 0; +} + +function appProperties(props: T): T { + return { ...props, $timezone: null, $locale: languageOnly(props.$locale ?? undefined) }; +} + +export function createAnalyticsClient(options: AnalyticsClientOptions): AnalyticsClient { + const posthog = new OffGridPostHog(POSTHOG_API_KEY, { + host: POSTHOG_HOST, + persistence: 'file', + captureAppLifecycleEvents: true, + enableSessionReplay: false, + preloadFeatureFlags: false, + disableRemoteFeatureFlags: true, + disableSurveys: true, + disableGeoip: true, + personProfiles: 'never', + setDefaultPersonProperties: false, + capturePushNotificationSubscriptions: false, + capturePushNotificationOpened: false, + customAppProperties: appProperties, + errorTracking: { + autocapture: options.crashes + ? { uncaughtExceptions: true, unhandledRejections: true, console: false, nativeCrashes: false } + : false, + exceptionSteps: { enabled: false }, + }, + before_send: event => options.beforeSend(event as CaptureEvent) as typeof event | null, + }); + + return { + capture: (event, properties) => posthog.capture(event, properties as never), + screen: (name, properties) => { + posthog.screen(name, properties as never).catch(() => undefined); + }, + register: properties => { + posthog.register(properties as never).catch(() => undefined); + }, + close: () => posthog.shutdown(), + forget: async () => { + await posthog.optOut(); + posthog.setPersistedProperty(PostHogPersistedProperty.Queue, null); + posthog.reset(); + await posthog.shutdown(); + }, + }; +} diff --git a/src/services/analytics/consentPrompt.ts b/src/services/analytics/consentPrompt.ts new file mode 100644 index 000000000..9dc6da60c --- /dev/null +++ b/src/services/analytics/consentPrompt.ts @@ -0,0 +1,46 @@ +/** + * When to ask for analytics consent outside Auto Setup (docs/ANALYTICS.md, Consent): + * + * - After a model download, model load or generation fails, when the person has not opted in and + * was not asked in the last 30 days. + * - Once ever, after the first successful reply, for a person who installed before this version + * and was never asked. + * + * This module owns only WHICH prompt is open. The prompt component renders it and records the + * answer through the analytics owner. + */ +import { create } from 'zustand'; +import { useAppStore } from '../../stores/appStore'; + +export type ConsentPromptKind = 'failure' | 'first_chat'; + +const PROMPT_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000; + +export const useConsentPromptStore = create<{ kind: ConsentPromptKind | null }>(() => ({ kind: null })); + +function optedIn(): boolean { + const { analyticsUsage, analyticsCrashes } = useAppStore.getState().settings; + return !!analyticsUsage || !!analyticsCrashes; +} + +function show(kind: ConsentPromptKind): void { + if (useConsentPromptStore.getState().kind) return; + useAppStore.getState().updateSettings({ analyticsAsked: true, analyticsLastPromptAt: Date.now() }); + useConsentPromptStore.setState({ kind }); +} + +export function maybePromptAfterFailure(): void { + if (optedIn()) return; + const last = useAppStore.getState().settings.analyticsLastPromptAt; + if (last && Date.now() - last < PROMPT_INTERVAL_MS) return; + show('failure'); +} + +export function maybePromptAfterFirstChat(): void { + if (optedIn() || useAppStore.getState().settings.analyticsAsked) return; + show('first_chat'); +} + +export function closeConsentPrompt(): void { + useConsentPromptStore.setState({ kind: null }); +} diff --git a/src/services/analytics/featureSnapshot.ts b/src/services/analytics/featureSnapshot.ts new file mode 100644 index 000000000..cffc20fec --- /dev/null +++ b/src/services/analytics/featureSnapshot.ts @@ -0,0 +1,26 @@ +/** + * `features_snapshot` (docs/ANALYTICS.md): one true/false per feature that is switched on. + * Core reads its own stores; Pro adds its features through HOOKS.analyticsProFeatures. + */ +import { useAppStore } from '../../stores/appStore'; +import { useRemoteServerStore } from '../../stores/remoteServerStore'; +import { useWhisperStore } from '../../stores/whisperStore'; +import { callHook, HOOKS } from '../../bootstrap/hookRegistry'; + +export function featureSnapshot(): Record { + const app = useAppStore.getState(); + const tools = app.settings.enabledTools ?? []; + const active = app.downloadedModels.find(model => model.id === app.activeModelId); + const vision = active + ? active.engine === 'litert' ? !!active.liteRTVision : !!active.isVisionModel + : false; + return { + voice_input: !!useWhisperStore.getState().downloadedModelId, + image_generation: !!app.activeImageModelId, + vision, + web_search: tools.includes('web_search'), + tools: tools.length > 0, + remote_server: useRemoteServerStore.getState().servers.length > 0, + ...(callHook>(HOOKS.analyticsProFeatures) ?? {}), + }; +} diff --git a/src/services/analytics/scrub.ts b/src/services/analytics/scrub.ts new file mode 100644 index 000000000..6a5259ecf --- /dev/null +++ b/src/services/analytics/scrub.ts @@ -0,0 +1,69 @@ +/** + * Crash report scrubbing (docs/ANALYTICS.md, "Crashes"). Runs on every `$exception` before the + * SDK sends it: a path becomes its file name, quoted text and URLs are dropped, and anything after + * a colon in a message is dropped because it may echo what the person typed. Type, file name, + * line and function are kept. + */ + +type Frame = Record; +type ExceptionEntry = Record & { + stacktrace?: { frames?: Frame[]; type?: string }; +}; + +const URL_PATTERN = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi; +const IPV4_PATTERN = /\b\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?\b/g; +const QUOTED_PATTERN = /(["'`])(?:(?!\1).)*\1/g; +const PATH_PATTERN = /(?:[A-Za-z]:)?(?:[\\/][^\\/\s:"'`]+)+[\\/]([^\\/\s:"'`]+)/g; + +export function fileName(path: unknown): string | undefined { + if (typeof path !== 'string' || !path) return undefined; + const clean = path.replace(/[?#].*$/, ''); + const parts = clean.split(/[\\/]/); + return parts[parts.length - 1] || undefined; +} + +export function scrubMessage(message: unknown): string | undefined { + if (typeof message !== 'string') return undefined; + const scrubbed = message + .replace(URL_PATTERN, '') + .replace(IPV4_PATTERN, '') + .replace(QUOTED_PATTERN, '') + .replace(PATH_PATTERN, '$1'); + const colon = scrubbed.indexOf(':'); + return (colon >= 0 ? scrubbed.slice(0, colon) : scrubbed).replace(/\s+/g, ' ').trim(); +} + +function scrubFrame(frame: Frame): Frame { + return { + platform: frame.platform, + filename: fileName(frame.filename ?? frame.abs_path), + function: frame.function, + lineno: frame.lineno, + colno: frame.colno, + in_app: frame.in_app, + }; +} + +function scrubException(entry: ExceptionEntry): ExceptionEntry { + const frames = entry.stacktrace?.frames; + return { + type: entry.type, + value: scrubMessage(entry.value), + mechanism: entry.mechanism, + ...(frames ? { stacktrace: { type: entry.stacktrace?.type ?? 'raw', frames: frames.map(scrubFrame) } } : {}), + }; +} + +/** Keep only the scrubbed exception list and its level from the SDK's `$exception_*` properties. */ +export function scrubExceptionProperties( + properties: Record, +): Record { + const next: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (!key.startsWith('$exception')) next[key] = value; + } + const list = properties.$exception_list; + if (Array.isArray(list)) next.$exception_list = list.map(e => scrubException(e as ExceptionEntry)); + if (properties.$exception_level !== undefined) next.$exception_level = properties.$exception_level; + return next; +} diff --git a/src/services/analytics/uncleanExit.ts b/src/services/analytics/uncleanExit.ts new file mode 100644 index 000000000..40e6e8319 --- /dev/null +++ b/src/services/analytics/uncleanExit.ts @@ -0,0 +1,63 @@ +/** + * Unclean-exit marker (docs/ANALYTICS.md, `unclean_exit`). + * + * A model load or a generation writes a small marker when it starts and clears it when it ends. + * Going to the background marks it not-foreground. If the next launch of the SAME app version finds + * a foreground marker, the app died in the foreground mid-load or mid-generation. + */ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const KEY = 'offgrid.analytics.uncleanExit'; + +export interface UncleanExitMarker { + stage: 'load' | 'generate'; + model: string; + engine: string; + backend: string; + free_ram_gb?: number; + startedAt: number; + appVersion: string; + foreground: boolean; + launch: string; +} + +/** This launch. A marker written by this launch is never reported as the previous one's death. */ +const LAUNCH = `${Date.now()}-${Math.random().toString(36).slice(2)}`; +let current: UncleanExitMarker | null = null; + +function write(): void { + const op = current ? AsyncStorage.setItem(KEY, JSON.stringify(current)) : AsyncStorage.removeItem(KEY); + op.catch(() => undefined); +} + +export function markStageStarted(marker: Omit): void { + current = { ...marker, foreground: true, launch: LAUNCH }; + write(); +} + +export function markStageEnded(stage: UncleanExitMarker['stage']): void { + if (current?.stage !== stage) return; + current = null; + write(); +} + +export function markForeground(foreground: boolean): void { + if (!current || current.foreground === foreground) return; + current = { ...current, foreground }; + write(); +} + +/** Read and clear the previous launch's marker. Returns it only when it counts as an unclean exit. */ +export async function takeUncleanExit(appVersion: string): Promise { + let stored: UncleanExitMarker | null = null; + try { + const raw = await AsyncStorage.getItem(KEY); + stored = raw ? (JSON.parse(raw) as UncleanExitMarker) : null; + } catch { + stored = null; + } + if (!stored || stored.launch === LAUNCH) return null; + if (!current) await AsyncStorage.removeItem(KEY).catch(() => undefined); + if (!stored.foreground || stored.appVersion !== appVersion) return null; + return stored; +} diff --git a/src/services/analytics/values.ts b/src/services/analytics/values.ts new file mode 100644 index 000000000..b4223fec0 --- /dev/null +++ b/src/services/analytics/values.ts @@ -0,0 +1,108 @@ +/** + * Pure value rules for analytics properties (docs/ANALYTICS.md, "Shared value rules"). + * No I/O here, so every rule is checked by plain unit tests. + */ + +const GB = 1024 * 1024 * 1024; + +/** Model ids that hold a file name the person chose (imports, recovered files). */ +const CUSTOM_ID = /^(local|recovered)_/; +const HF_REPO_PREFIX = /^([\w.-]+)\/([\w.-]+)(?:\/|$)/; +const CATALOG_ID = /^[\w.-]+$/; + +/** + * Catalog ID, public Hugging Face `owner/repo`, or `custom`. Anything that may carry a file + * name the person chose is `custom`. + */ +export function modelRef(id: string | null | undefined, repoId?: string): string { + if (repoId && HF_REPO_PREFIX.test(repoId)) return repoId.split('/').slice(0, 2).join('/'); + if (!id || CUSTOM_ID.test(id)) return 'custom'; + const repo = HF_REPO_PREFIX.exec(id); + if (repo) return `${repo[1]}/${repo[2]}`; + return CATALOG_ID.test(id) ? id : 'custom'; +} + +export function sizeBucket(bytes: number | null | undefined): string | undefined { + if (!bytes || bytes <= 0) return undefined; + const gb = bytes / GB; + if (gb < 1) return '<1GB'; + if (gb < 2) return '1-2GB'; + if (gb < 4) return '2-4GB'; + if (gb < 8) return '4-8GB'; + return '>8GB'; +} + +export function contextBucket(tokens: number | null | undefined): string | undefined { + if (!tokens || tokens <= 0) return undefined; + if (tokens < 2048) return '<2k'; + if (tokens < 8192) return '2-8k'; + if (tokens < 32768) return '8-32k'; + return '>32k'; +} + +/** Durations: whole milliseconds. */ +export function ms(value: number | null | undefined): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined; +} + +/** Rates and sizes: one decimal. */ +export function oneDecimal(value: number | null | undefined): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? Math.round(value * 10) / 10 : undefined; +} + +export function bytesToGb(bytes: number | null | undefined): number | undefined { + return typeof bytes === 'number' && Number.isFinite(bytes) ? oneDecimal(bytes / GB) : undefined; +} + +/** Battery level (0..1) as a percentage rounded to 10. */ +export function batteryPct(level: number | null | undefined): number | undefined { + if (typeof level !== 'number' || level < 0) return undefined; + return Math.round(level * 10) * 10; +} + +const SAFE_CODE = /^[A-Za-z][\w.-]{0,47}$/; + +/** + * A fixed code from our own error classes, never a raw message: the error's `code`, else its + * class name, else `error`. + */ +export function errorCode(error: unknown): string { + if (error && typeof error === 'object') { + const code = (error as { code?: unknown }).code; + if (typeof code === 'string' && SAFE_CODE.test(code)) return code; + const name = (error as { name?: unknown }).name; + if (typeof name === 'string' && name !== 'Error' && SAFE_CODE.test(name)) return name; + } + return 'error'; +} + +/** `en-GB` → `en`. */ +export function languageOnly(locale: string | undefined): string { + return (locale ?? '').split(/[-_]/)[0].toLowerCase() || 'unknown'; +} + +/** Store builds call themselves `x.y.z`; anything with a suffix is a beta. */ +export function releaseChannel(version: string): 'stable' | 'nightly' { + return /-/.test(version) ? 'nightly' : 'stable'; +} + +/** `.PDF` / `notes.pdf` → `pdf`. Never the name. */ +export function fileExtension(name: string | undefined): string | undefined { + const match = /\.([A-Za-z0-9]{1,8})$/.exec(name ?? ''); + return match ? match[1].toLowerCase() : undefined; +} + +/** Which `feature_used` value a built-in tool counts as. */ +export function toolFeature(toolName: string): 'web_search' | 'clipboard' | 'browser_action' | 'tools' { + if (toolName === 'web_search' || toolName === 'read_url') return 'web_search'; + if (/clipboard/i.test(toolName)) return 'clipboard'; + if (/^browser/i.test(toolName)) return 'browser_action'; + return 'tools'; +} + +/** llama.cpp/LiteRT backend settings → `cpu` / `gpu` / `npu`. */ +export function computeBackend(setting: string | undefined): 'cpu' | 'gpu' | 'npu' { + if (setting === 'htp' || setting === 'npu' || setting === 'qnn') return 'npu'; + if (setting === 'metal' || setting === 'opencl' || setting === 'gpu' || setting === 'coreml') return 'gpu'; + return 'cpu'; +} diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 252d34ca4..94c50f936 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -126,6 +126,13 @@ export type AppSettings = { autoDiscoverRemoteModels?: boolean; /** LAN server types to probe when Scan network is used. */ remoteScanKinds?: Array<'gateway' | 'ollama' | 'lmstudio'>; + /** Opt-in analytics consent. Per device: never part of the synced model settings. */ + analyticsUsage: boolean; + analyticsCrashes: boolean; + /** The person has seen the consent choice (Auto Setup or a prompt). */ + analyticsAsked: boolean; + /** When a consent prompt was last shown, so a declined prompt waits 30 days. */ + analyticsLastPromptAt: number | null; }; type ThemeMode = 'system' | 'light' | 'dark'; @@ -280,6 +287,10 @@ export const DEFAULT_SETTINGS: AppSettings = { liteRTTemperature: 0.7, liteRTTopP: 0.9, liteRTMaxTokens: 4096, + analyticsUsage: false, + analyticsCrashes: false, + analyticsAsked: false, + analyticsLastPromptAt: null, }; export const selectIsLiteRT = (state: AppState): boolean => From e873c4f1b5bd5df636c05792050f4ee8183e6bfa Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Fri, 23 Oct 2026 14:15:39 +0530 Subject: [PATCH 03/47] Opted-in sessions report app opens, backgrounding and screens, and a failure can ask for consent --- App.tsx | 23 ++++++++++++- src/components/AnalyticsConsentPrompt.tsx | 39 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 src/components/AnalyticsConsentPrompt.tsx diff --git a/App.tsx b/App.tsx index f3ace64ec..3e441e49a 100644 --- a/App.tsx +++ b/App.tsx @@ -39,10 +39,19 @@ 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 { + appBackgrounded, + appForegrounded, + startAnalytics, + trackScreen, +} from './src/services/analytics/analytics'; +import { featureSnapshot } from './src/services/analytics/featureSnapshot'; LogBox.ignoreAllLogs(); // Suppress all logs let stopStartupProbe: (() => void) | null = null; +let stopAnalytics: (() => 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__) { @@ -183,6 +192,7 @@ function App() { // Handle app state changes for auto-lock useAppState({ onBackground: useCallback(() => { + appBackgrounded(); saveChatStateOnBackground(); if (authEnabled) { setLastBackgroundTime(Date.now()); @@ -190,6 +200,7 @@ function App() { } }, [authEnabled, setLastBackgroundTime, setLocked]), onForeground: useCallback(() => { + appForegrounded(); // Rebuild the unified store before reattaching JS listeners so restored // progress events map onto current download entries instead of racing hydration. // NOTE: restoreQueuedDownloads() is intentionally NOT called here — on a foreground @@ -373,6 +384,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({ features: 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 +420,8 @@ function App() { return () => { startupGeneration.current += 1; stopNetworkReconnectWatcher(); + stopAnalytics?.(); + stopAnalytics = null; }; }, [initializeApp]); @@ -447,7 +463,11 @@ function App() { {AppRoot ? : null} { + applyPendingProRedirect(); + trackScreen(appNavigationRef.getCurrentRoute()?.name); + }} + onStateChange={() => trackScreen(appNavigationRef.getCurrentRoute()?.name)} theme={{ dark: isDark, colors: { @@ -480,6 +500,7 @@ function App() { > + {__DEV__ && showSyncStatusBar ? : null} diff --git a/src/components/AnalyticsConsentPrompt.tsx b/src/components/AnalyticsConsentPrompt.tsx new file mode 100644 index 000000000..78603d866 --- /dev/null +++ b/src/components/AnalyticsConsentPrompt.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { CenteredAlert } from './CenteredAlert'; +import { + closeConsentPrompt, + useConsentPromptStore, +} from '../services/analytics/consentPrompt'; +import { setAnalyticsConsent } from '../services/analytics/analytics'; + +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(() => undefined); + }, + }, + ]} + /> + ); +}; From 46497899e1cef1ed3efc9c76eb441da17e2299e8 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Fri, 23 Oct 2026 14:15:40 +0530 Subject: [PATCH 04/47] Auto Setup offers an unchecked share-analytics row above every way out, with a What we share page --- src/navigation/AppNavigator.tsx | 2 + src/navigation/types.ts | 1 + src/screens/AnalyticsInfoScreen.tsx | 103 ++++++++++++++++++++++++++++ src/screens/AutoSetupScreen.tsx | 83 ++++++++++++++++++++-- src/screens/index.ts | 1 + src/services/autoSetupService.ts | 10 ++- 6 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 src/screens/AnalyticsInfoScreen.tsx 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/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..2feae0061 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,8 @@ import { createAutoSetupSession, type AutoSetupSession, } from '../services/autoSetupService'; +import { flowFinished, flowStarted, setAnalyticsConsent } from '../services/analytics/analytics'; +import { useAppStore } from '../stores/appStore'; import { useTheme, useThemedStyles } from '../theme'; import type { ThemeColors, ThemeShadows } from '../theme'; @@ -51,6 +54,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(() => undefined); + }; + // 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 +128,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 => ( = ({ : `Download ${formatBytes(plan.totalBytes)}` } onPress={() => { + markConsentAsked(); session.start().catch(() => undefined); }} loading={starting} @@ -206,13 +260,20 @@ export const AutoSetupScreen: React.FC = ({