Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
22c7af8
The app can send opt-in analytics through the PostHog React Native SDK
alichherawalla Oct 23, 2026
bacf608
Analytics stays off until the person opts in, and opting out resets t…
alichherawalla Oct 23, 2026
e873c4f
Opted-in sessions report app opens, backgrounding and screens, and a …
alichherawalla Oct 23, 2026
4649789
Auto Setup offers an unchecked share-analytics row above every way ou…
alichherawalla Oct 23, 2026
c50c674
Report a problem shows the debug log, then shares it with support
alichherawalla Oct 23, 2026
b43a9fd
Settings has an Analytics section, and Reset All Settings turns analy…
alichherawalla Oct 23, 2026
9dde0a3
Opted-in sessions report how each text, vision, image and voice gener…
alichherawalla Oct 23, 2026
8f4214b
Opted-in sessions report model loads and load failures with the devic…
alichherawalla Oct 23, 2026
13fded2
Opted-in sessions report model download starts, finishes and failures
alichherawalla Oct 23, 2026
b08eea4
Opted-in sessions count chats, messages, regenerations, projects, doc…
alichherawalla Oct 23, 2026
d0be4a7
Remote server, image and voice setup report their funnel steps
alichherawalla Oct 23, 2026
6d797b5
The Pro upgrade funnel reports gates, the Pro page and activation
alichherawalla Oct 23, 2026
8fc1213
Release builds send opt-in analytics to the Off Grid AI PostHog EU pr…
alichherawalla Oct 23, 2026
9aeae0d
The debug log no longer holds prompts, replies, reasoning, transcript…
alichherawalla Oct 23, 2026
543e1c8
Privacy copy says chats and files never leave the device, and analyti…
alichherawalla Oct 23, 2026
65192b4
The iOS privacy manifest declares the opt-in analytics data, not link…
alichherawalla Oct 23, 2026
003c54e
Store listings and docs describe opt-in analytics instead of claiming…
alichherawalla Oct 23, 2026
936b66a
Message routing and stream parse logs keep lengths, never the typed t…
alichherawalla Oct 23, 2026
8e13832
Analytics persists through the app's AsyncStorage, and every event ca…
alichherawalla Oct 23, 2026
b56073c
Closing the app sends what analytics has queued and stops its timers
alichherawalla Oct 23, 2026
93b1e8e
Analytics journeys are proven on the real App: default sends nothing,…
alichherawalla Oct 23, 2026
05ce60c
Analytics drops failed boundary reads in one place, and a corrupt cra…
alichherawalla Oct 23, 2026
781823b
Every new analytics module is covered end to end and held to 100% cov…
alichherawalla Oct 23, 2026
7fc15bf
Downloads are not measured at all before opt-in, and a same-milliseco…
alichherawalla Oct 23, 2026
22959d0
Test settings fixtures carry the analytics consent fields, and the ST…
alichherawalla Oct 23, 2026
5f300d0
Core uses the Pro commits that report Pro funnels and keep tool conte…
alichherawalla Oct 23, 2026
bad3d80
A crash report keeps a file name only for the app's own code; a perso…
alichherawalla Oct 23, 2026
5b680e2
The SDK's own lifecycle events stay off, so no deep link URL reaches …
alichherawalla Oct 23, 2026
764dc36
Store builds keep the size-capped debug log, so Report a problem carr…
alichherawalla Oct 23, 2026
ba4a773
The analytics spec matches OGAD's review: document file types, and ho…
alichherawalla Oct 23, 2026
97e6b68
llama.cpp's per-token log lines stay out of the debug log, and quoted…
alichherawalla Oct 23, 2026
c2770c7
llama.cpp's per-node graph lines stay out of the debug log, so Report…
alichherawalla Oct 23, 2026
30b8f78
PR screenshots: every screen opt-in analytics changes, before and aft…
alichherawalla Oct 23, 2026
93ec4e8
Crash scrubbing checks file types with sets, not long regular express…
alichherawalla Oct 23, 2026
d3cfeb3
A consent change never rejects, and every screen that makes one handl…
alichherawalla Oct 23, 2026
8232294
The fallback loop keeps its name; the turn-level analytics wrapper ge…
alichherawalla Oct 23, 2026
a8f7197
Small readability fixes SonarCloud flagged in the analytics feature s…
alichherawalla Oct 23, 2026
bbbe5bb
The remote server funnel starts only when the editor opens, not when …
alichherawalla Oct 23, 2026
e9c7556
A regenerate is counted once, where it really runs
alichherawalla Oct 23, 2026
aeb2130
The PDF log line keeps sizes only, never the path that names the docu…
alichherawalla Oct 23, 2026
a4a5abc
A refused microphone ends voice input as stopped, not failed, and ask…
alichherawalla Oct 23, 2026
2120e37
Image model downloads report their catalog ID, not custom
alichherawalla Oct 23, 2026
5cdf181
Sending the debug log waits for a flush already writing
alichherawalla Oct 23, 2026
5113b01
Report a problem hides Clear, which would empty the view but not the …
alichherawalla Oct 23, 2026
b8a61a7
The offline claim names the features that use the network after download
alichherawalla Oct 23, 2026
afe1b38
Only a real trip to the background counts as leaving the app, so a co…
alichherawalla Oct 23, 2026
e8a5ee9
App open and background handling is internal to analytics; tests driv…
alichherawalla Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,21 @@
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;
Expand All @@ -68,7 +76,7 @@
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();
}

Expand Down Expand Up @@ -129,7 +137,7 @@
});
}

function App() {

Check warning on line 140 in App.tsx

View workflow job for this annotation

GitHub Actions / ci

Function 'App' has too many lines (366). Maximum allowed is 350
useEffect(() => () => {
stopStartupProbe?.();
stopStartupProbe = null;
Expand Down Expand Up @@ -373,6 +381,9 @@
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
Expand Down Expand Up @@ -406,6 +417,8 @@
return () => {
startupGeneration.current += 1;
stopNetworkReconnectWatcher();
stopAnalytics?.();
stopAnalytics = null;
};
}, [initializeApp]);

Expand Down Expand Up @@ -447,7 +460,11 @@
{AppRoot ? <AppRoot /> : null}
<NavigationContainer
ref={appNavigationRef}
onReady={applyPendingProRedirect}
onReady={() => {
applyPendingProRedirect();
trackScreen(appNavigationRef.getCurrentRoute()?.name);
}}
onStateChange={() => trackScreen(appNavigationRef.getCurrentRoute()?.name)}
theme={{
dark: isDark,
colors: {
Expand Down Expand Up @@ -480,6 +497,7 @@
>
<AppNavigator />
</NavigationContainer>
<AnalyticsConsentPrompt />
{__DEV__ && showSyncStatusBar ? <DevSyncStrip /> : null}
</SafeAreaProvider>
</GestureHandlerRootView>
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
13 changes: 8 additions & 5 deletions __tests__/hardening/batch9-diagnostics-debuglog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
});
139 changes: 139 additions & 0 deletions __tests__/harness/analyticsApp.ts
Original file line number Diff line number Diff line change
@@ -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<typeof requireRTL>;
view: ReturnType<ReturnType<typeof requireRTL>['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<AnalyticsApp> {
(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<string, unknown> };
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<void> {
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<void> {
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<void> {
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<void> {
await app.rtl.act(async () => {
app.boundary.emitAppStateChange('background');
});
}

export async function bringToForeground(app: AnalyticsApp): Promise<void> {
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<void> {
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');
}
19 changes: 19 additions & 0 deletions __tests__/harness/nativeBoundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -736,6 +748,13 @@ function makeLlamaFake(

const module: Record<string, jest.Mock> = {
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
Expand Down
75 changes: 75 additions & 0 deletions __tests__/harness/postHogEndpoint.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

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<Buffer> {
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<ArrayBuffer> };
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<string> {
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;
},
};
}
Loading
Loading