Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
9f3de0e
test(mcp): cover the "Connect desktop tools" row + bump pro pointer
siddWednesday Aug 19, 2026
d48de0f
feat(mcp): QR pairing scanner deps + tests + pro bump
siddWednesday Aug 20, 2026
4b1c450
test(mcp): drop the vision-camera scanner mock + scan-button test
siddWednesday Aug 20, 2026
c256d0f
test(sync): cover scan-to-pair + restore the vision-camera mock
siddWednesday Aug 20, 2026
ce0b1ee
test(sync): guard that opening the scanner hides the pairing sheet
siddWednesday Aug 20, 2026
81da658
chore(pro): bump mobile-pro - QR scanner overlay fix
siddWednesday Aug 20, 2026
0445d6f
test(mcp): cover the Pro tools desktop-tools grant + bump mobile-pro
siddWednesday Aug 20, 2026
c61a8e8
chore(pro): bump mobile-pro - offline-forget fix
siddWednesday Aug 20, 2026
b1fb265
test(mcp): drop the desktop-section sheet tests + bump mobile-pro
siddWednesday Aug 21, 2026
ca2b0e2
chore(pro): bump mobile-pro - revert offline-forget (conflicts with i…
siddWednesday Aug 21, 2026
61f6a28
test(mcp): stub CompanionToolsSection in the McpServersScreen suite
siddWednesday Aug 21, 2026
87adccb
feat(chat): chatOverlay slot for the pending computer-use approval card
siddWednesday Aug 21, 2026
8bd93e5
test(sync): send Whisper from mobile to Mac
alichherawalla Aug 22, 2026
4df5485
test(chat): render clean mobile previews
alichherawalla Aug 22, 2026
37bd5d7
test(sync): allow real Whisper transfer to settle
alichherawalla Aug 22, 2026
9b90bb3
fix(pro): remove device licence shortcut
alichherawalla Aug 22, 2026
f294ca6
test(pro): prove Home to Pro journey
alichherawalla Aug 22, 2026
6d04b00
test(sync): prove licence startup timer cleanup
alichherawalla Aug 22, 2026
b822c75
fix(app): release startup diagnostics on teardown
alichherawalla Aug 22, 2026
098cb48
fix(app): keep probe stop private
alichherawalla Aug 22, 2026
668fe3c
fix(home): retry cancelled LAN discovery
alichherawalla Aug 22, 2026
921489f
ci(test): allow the full Jest suite heap
alichherawalla Aug 22, 2026
b6110f8
test(sync): allow the full transfer journey
alichherawalla Aug 22, 2026
c543467
test(ci): serialize related rendered journeys
alichherawalla Aug 22, 2026
e261317
fix(chat): show stored messages immediately
alichherawalla Aug 22, 2026
229394f
Merge remote-tracking branch 'origin/main' into feat/companion-deskto…
siddWednesday Aug 23, 2026
85290b0
feat(speech): add selectable multilingual voices
alichherawalla Aug 24, 2026
6de04bc
fix(chat): open links and enable back gestures
alichherawalla Aug 24, 2026
ab062a5
test(sync): cover unified model transfer progress
alichherawalla Aug 24, 2026
ca9cce9
test(audio): await recording cleanup
alichherawalla Aug 24, 2026
c255b9b
chore(ui): keep settings selector private
alichherawalla Aug 24, 2026
b3e5a97
fix(ci): finish passing related test gate
alichherawalla Aug 24, 2026
540e568
fix(audio): await realtime reset teardown
alichherawalla Aug 24, 2026
49d6ae9
test(chat): prove immediate stored message display
alichherawalla Aug 24, 2026
3edb596
fix(markdown): validate every rendered link
alichherawalla Aug 24, 2026
942d521
fix(home): wait for remote settings hydration
alichherawalla Aug 24, 2026
c4a081e
refactor(voice): default decode language at boundary
alichherawalla Aug 24, 2026
2791203
fix(audio): keep fallback teardown in reset barrier
alichherawalla Aug 24, 2026
102ce93
feat(sync): add private mesh runtime controls
alichherawalla Aug 24, 2026
d92caee
fix(stt): align capture with the selected model
alichherawalla Aug 24, 2026
5613980
feat(chat): support tap or hold mic recording
alichherawalla Aug 24, 2026
439c5e4
fix(remote): allow private Tailscale endpoints
alichherawalla Aug 24, 2026
8aa9178
refactor(chat): place voice mode with header actions
alichherawalla Aug 24, 2026
e132688
refactor(pro): use the shared activation sheet
alichherawalla Aug 24, 2026
5082298
chore(pro): update voice and private mesh features
alichherawalla Aug 24, 2026
dc96757
chore: bump mobile-pro (desktop tools controlled entirely by device s…
siddWednesday Aug 24, 2026
bdcac8d
refactor(mobile): clear release quality findings
alichherawalla Aug 24, 2026
0646bb3
test(voice): align release expectations
alichherawalla Aug 24, 2026
f3cea4d
test(chat): await generation cleanup
alichherawalla Aug 24, 2026
d917a9b
test(chat): budget heavy coverage journeys
alichherawalla Aug 24, 2026
9b01076
test(chat): centralize the heavy journey budget
alichherawalla Aug 24, 2026
b1da03a
fix(ios): separate proximity advertising lifecycle
alichherawalla Aug 24, 2026
e2c4a5e
fix(sync): honor hidden state on iOS
alichherawalla Aug 24, 2026
39382ea
test(integration): budget native UI journeys
alichherawalla Aug 24, 2026
5f0abfd
docs: explain Personal Mesh connection controls
alichherawalla Aug 24, 2026
b4c2857
chore: update Pro CI policy pointer
alichherawalla Aug 24, 2026
a25e116
feat(audio): surface voice downloads
alichherawalla Aug 24, 2026
c7a681d
test(audio): verify runtime voice selection
alichherawalla Aug 24, 2026
d5402b9
test(audio): verify cached language reuse
alichherawalla Aug 24, 2026
abba61a
Merge remote-tracking branch 'origin/feat/companion-desktop-mcp-tools…
alichherawalla Aug 25, 2026
31a3c5d
fix(sync): fall back when native upload stalls
alichherawalla Aug 25, 2026
c54ee6a
chore(pro): include transfer cancellation state
alichherawalla Aug 25, 2026
38293d4
fix(chat): honor new chat model and context
alichherawalla Aug 25, 2026
5c711e2
feat(sync): receive action approvals
alichherawalla Aug 25, 2026
d060279
refactor(actions): use one approval sync path
alichherawalla Aug 25, 2026
d628c3a
test(actions): prove chat-only remote approval
alichherawalla Aug 25, 2026
e7b0e09
feat(downloads): measure canonical mobile progress
alichherawalla Aug 25, 2026
6c88515
feat(downloads): show progress across mobile flows
alichherawalla Aug 25, 2026
51d6e16
fix(pro): revoke mobile runtime on expiry
alichherawalla Aug 26, 2026
055bd61
fix(runtime): harden mobile pro service boundaries
alichherawalla Aug 26, 2026
b14d71c
test(sync): cover mobile pro device journeys
alichherawalla Aug 26, 2026
80c63a9
build(pro): update mobile pro dependency
alichherawalla Aug 26, 2026
396f601
test(models): keep explicit remote selection authoritative
alichherawalla Aug 26, 2026
96f076d
test(pro): cover live entitlement teardown
alichherawalla Aug 26, 2026
f7a2ce1
build(pro): update mobile pro dependency
alichherawalla Aug 26, 2026
8a44159
test(pro): seed authoritative access state
alichherawalla Aug 26, 2026
bbc0d14
test(sync): balance uploader deadline signal
alichherawalla Aug 26, 2026
d32f67b
ci(shared): build mobile ui dependency
alichherawalla Aug 26, 2026
b2cc88c
test(pro): seed email calendar entitlement
alichherawalla Aug 26, 2026
0574297
ci(pro): build shared ui in private verification
alichherawalla Aug 26, 2026
4ee42a2
ci: allow manual verification runs
alichherawalla Aug 26, 2026
783e12d
fix(sync): use connected-device mesh service
alichherawalla Aug 26, 2026
bf0bde0
test(sync): verify compacted mobile startup
alichherawalla Aug 26, 2026
234de43
fix(quality): address mobile analysis findings
alichherawalla Aug 26, 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
14 changes: 9 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: CI

on:
workflow_dispatch:
push:
branches:
- main
Expand Down Expand Up @@ -130,14 +131,12 @@ jobs:
# against dependencies nobody committed. The drift it papered over was real (five packages committed to
# shared without regenerating its lock) and is fixed at the source; if it recurs, this should stop.
npm --prefix ../shared ci
# Both packages this app consumes, because npm resolves each file: dep to its dist/ (jest
# reads src/, but tsc and metro read dist/). rag was never built at all before.
# Build every shared package this app consumes, because npm resolves each file: dependency
# to its dist/ (Jest reads src/, but TypeScript and Metro read dist/).
npm --prefix ../shared/packages/sync run build
npm --prefix ../shared/packages/rag run build
# speech too. It is a file: dep like the other two and six src files import it, but it was
# never built here - so every gate failed on "Cannot find module '@offgrid/speech'" (1966
# errors in one run) long before it reached anything real.
npm --prefix ../shared/packages/speech run build
npm --prefix ../shared/packages/ui run build

- name: Install dependencies
id: install
Expand Down Expand Up @@ -183,6 +182,11 @@ jobs:

- name: Run Jest tests
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
env:
# The serial RN + jsdom suite retains more than Node's default 2 GB heap before
# coverage is written. The macOS runner has 14 GB; keep enough headroom for native
# tooling while allowing the complete suite to finish without weakening the gate.
NODE_OPTIONS: --max-old-space-size=8192
run: npx jest --coverage --forceExit --runInBand

- name: Install Android NDK
Expand Down
8 changes: 7 additions & 1 deletion .husky/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@ if [ -n "$PUSHED_JS" ]; then
npx tsc --noEmit

echo "▶ JS/TS tests (related to changed files)..."
echo "$PUSHED_JS" | tr '\n' '\0' | xargs -0 npx jest --findRelatedTests --passWithNoTests
# Rendered RN journeys share native-boundary state and are memory-heavy. Parallel workers
# starve one another and turn normal 10-second journeys into false multi-minute timeouts.
# CI runs this same graph serially; keep the local merge gate deterministic too.
# Some React Native boundary shims keep native-style handles alive after Jest has completed every
# assertion. The repository's full test command already force-closes those handles. Apply the same
# completion policy here so a fully passing related suite does not return a false non-zero status.
echo "$PUSHED_JS" | tr '\n' '\0' | xargs -0 npx jest --findRelatedTests --passWithNoTests --runInBand --forceExit

echo "▶ Architecture gate (dependency-cruiser)..."
npm run depcruise
Expand Down
24 changes: 21 additions & 3 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { NavigationContainer } from '@react-navigation/native';
import { AppNavigator } from './src/navigation';
import {
appNavigationRef,
useProExpiryRedirect,
} from './src/navigation/useProExpiryRedirect';
import { useTheme } from './src/theme';
import { hardwareService, modelManager, authService, ragService, remoteServerManager } from './src/services';
import logger from './src/utils/logger';
import { useAppStore, useAuthStore, useRemoteServerStore, useWhisperStore } from './src/stores';
import { useDebugLogsStore } from './src/stores/debugLogsStore';
import { initDebugLogFile, appendDebugLine } from './src/utils/debugLogFile';
import { initDebugLogFile, appendDebugLine, stopDebugLogFile } from './src/utils/debugLogFile';
import { startStartupMemoryProbe } from './src/services/startupMemoryProbe';
import { loadProFeatures } from './src/bootstrap/loadProFeatures';
import { hydrateDownloadStore } from './src/services/downloadHydration';
Expand All @@ -34,6 +38,7 @@ import { ErrorBoundary } from './src/components/ErrorBoundary';

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__) {
Expand All @@ -60,7 +65,7 @@ if (__DEV__) {
// 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.
startStartupMemoryProbe();
stopStartupProbe = startStartupMemoryProbe();
}

const ensureRemoteServerStoreHydrated = async () => {
Expand All @@ -72,11 +77,18 @@ const ensureRemoteServerStoreHydrated = async () => {
};

function App() {
useEffect(() => () => {
stopStartupProbe?.();
stopStartupProbe = null;
stopDebugLogFile();
}, []);

useDownloadListeners();
// Reactive: when Pro is activated at runtime (license key → loadProFeatures),
// the appRoot slot (TTS engine bridge) registers and this re-renders to mount
// it live — no restart needed.
const AppRoot = useSlot(SLOTS.appRoot);
const applyPendingProRedirect = useProExpiryRedirect();
const [isInitializing, setIsInitializing] = useState(true);
const setDeviceInfo = useAppStore((s) => s.setDeviceInfo);
const setModelRecommendation = useAppStore((s) => s.setModelRecommendation);
Expand Down Expand Up @@ -205,7 +217,11 @@ function App() {
})().catch((error) => {
logger.error('[App] Download-state recovery failed:', error);
});
}, [setDownloadedModels, setDownloadedImageModels]);
}, [
reattachTextDownloadRecovery,
setDownloadedModels,
setDownloadedImageModels,
]);

const initializeApp = useCallback(async () => {
try {
Expand Down Expand Up @@ -353,6 +369,8 @@ function App() {
<SystemBars style={isDark ? 'light' : 'dark'} />
{AppRoot ? <AppRoot /> : null}
<NavigationContainer
ref={appNavigationRef}
onReady={applyPendingProRedirect}
theme={{
dark: isDark,
colors: {
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Pro is an optional, additive tier. It gives the assistant a voice that talks bac
- **Voice mode** - the free app transcribes your speech; Pro adds on-device Kokoro text-to-speech, so it talks back and you run the whole thing hands-free. The voice runs in your phone's RAM.
- **Custom personas** - give each assistant its own system prompt, voice, and persistent memory, so it stays in character across conversations.
- **Draft, then approve** - connect Calendar, email, and MCP servers like Linear, Notion, and GitHub. It drafts the reply or files the ticket and waits. Nothing sends without your tap.
- **Sync, landing through July** - your phone and your Mac merge into one picture over your own network, never a relay. Your license includes it the day it ships.
- **Personal Mesh sync** - your phone and your Mac share chats, files, and settings over your own network. There is no relay. See the [Personal Mesh guide](docs/PERSONAL_MESH.md).

**[→ Get Pro access](https://offgridmobileai.co/pay/)** - $69 once and it is yours forever (the price climbs as more people join, never down), or $49/year.

Expand Down Expand Up @@ -95,7 +95,7 @@ Most "local LLM" apps give you a text chatbot and call it a day. Off Grid AI is

**Vision AI** — Point your camera at anything and ask questions. SmolVLM, Qwen3-VL, Gemma 3n — analyze documents, describe scenes, read receipts. ~7s on flagship devices.

**Voice Input** On-device Whisper speech-to-text. Hold to record, auto-transcribe. No audio ever leaves your phone.
**Voice Input** - On-device Whisper speech-to-text. Tap the microphone to start and stop, or use Auto and Hands-free modes. No audio ever leaves your phone.

**Document Analysis** — Attach PDFs, code files, CSVs, and more to your conversations. Native PDF text extraction on both platforms.

Expand Down
82 changes: 70 additions & 12 deletions __tests__/hardening/batch5-kokoroDownloadError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,35 @@
* is exercised nowhere — the ttsStore setVoice tests mock engine.setVoice entirely.
*/
import { BareResourceFetcher } from 'react-native-executorch-bare-resource-fetcher';
import { KokoroEngine } from '../../pro/audio/engine/tts/engines/kokoro/KokoroEngine';
import {
KokoroEngine,
type KokoroBridgeHandle,
} from '../../pro/audio/engine/tts/engines/kokoro/KokoroEngine';
import type { KokoroVoiceId } from '../../pro/audio/engine/tts/engines/kokoro/voices';

const fetchResources = (BareResourceFetcher as any).fetch as jest.Mock;
const listDownloadedFiles = BareResourceFetcher.listDownloadedFiles as jest.Mock;
const listDownloadedFiles =
BareResourceFetcher.listDownloadedFiles as jest.Mock;

beforeEach(() => {
fetchResources?.mockReset().mockResolvedValue(undefined);
listDownloadedFiles?.mockReset().mockResolvedValue([]);
});

function attachSelectedVoiceBridge(engine: KokoroEngine): void {
const bridge: KokoroBridgeHandle = {
speak: async () => undefined,
stop: () => undefined,
pause: () => undefined,
resume: () => undefined,
setSpeed: () => undefined,
setKeepAlive: () => undefined,
};
engine._setMountRequester(() => {
engine._setBridge(bridge, engine.getActiveVoice()!.id as KokoroVoiceId);
});
}

describe('KokoroEngine — download failure (offline / interrupted fetch)', () => {
it('a REAL fetch rejection lands the engine in the error phase, records the message, and rethrows', async () => {
// The offline case: BareResourceFetcher.fetch rejects with a genuine error (NOT the
Expand All @@ -35,7 +54,9 @@ describe('KokoroEngine — download failure (offline / interrupted fetch)', () =
const engine = new KokoroEngine();
fetchResources.mockRejectedValueOnce(new Error('Network is unreachable'));

await expect(engine.downloadAssets()).rejects.toThrow(/network is unreachable/i);
await expect(engine.downloadAssets()).rejects.toThrow(
/network is unreachable/i,
);

expect(engine.getPhase()).toBe('error');
expect(engine.getLastDownloadError()).toMatch(/network is unreachable/i);
Expand All @@ -54,7 +75,11 @@ describe('KokoroEngine — download failure (offline / interrupted fetch)', () =

expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ code: 'KOKORO_DOWNLOAD', recoverable: true, message: expect.stringMatching(/interrupted/i) }),
expect.objectContaining({
code: 'KOKORO_DOWNLOAD',
recoverable: true,
message: expect.stringMatching(/interrupted/i),
}),
);
});

Expand Down Expand Up @@ -88,8 +113,38 @@ describe('KokoroEngine — download failure (offline / interrupted fetch)', () =
});

describe('KokoroEngine.setVoice — active voice + completeness + events', () => {
it('serializes a voice-pack fetch behind an active base-model download', async () => {
const engine = new KokoroEngine();
attachSelectedVoiceBridge(engine);
let finishBase!: () => void;
fetchResources
.mockImplementationOnce(
() =>
new Promise<void>(resolve => {
finishBase = resolve;
}),
)
.mockResolvedValueOnce(undefined);

const baseDownload = engine.downloadAssets();
const voiceSwitch = engine.setVoice('hf_alpha');

await Promise.resolve();
await Promise.resolve();
expect(fetchResources).toHaveBeenCalledTimes(1);

finishBase();
await baseDownload;
await voiceSwitch;

expect(fetchResources).toHaveBeenCalledTimes(2);
expect(engine.getActiveVoice()?.id).toBe('hf_alpha');
expect(engine.isFullyDownloaded()).toBe(true);
});

it('updates the active voice and reflects it via getActiveVoice()', async () => {
const engine = new KokoroEngine();
attachSelectedVoiceBridge(engine);
// Default active voice is af_heart.
expect(engine.getActiveVoice()?.id).toBe('af_heart');

Expand All @@ -101,6 +156,7 @@ describe('KokoroEngine.setVoice — active voice + completeness + events', () =>

it('emits voiceChanged with the new voice id', async () => {
const engine = new KokoroEngine();
attachSelectedVoiceBridge(engine);
const onVoiceChanged = jest.fn();
engine.on('voiceChanged', onVoiceChanged);
fetchResources.mockResolvedValueOnce(undefined);
Expand All @@ -112,6 +168,7 @@ describe('KokoroEngine.setVoice — active voice + completeness + events', () =>

it('records genuine completion once the new voice fetch resolves (reads downloaded)', async () => {
const engine = new KokoroEngine();
attachSelectedVoiceBridge(engine);
fetchResources.mockResolvedValueOnce(undefined);

await engine.setVoice('am_michael');
Expand All @@ -122,23 +179,24 @@ describe('KokoroEngine.setVoice — active voice + completeness + events', () =>

it('rejects an unknown voice id without touching the active voice', async () => {
const engine = new KokoroEngine();
await expect(engine.setVoice('not_a_real_voice')).rejects.toThrow(/unknown kokoro voice/i);
await expect(engine.setVoice('not_a_real_voice')).rejects.toThrow(
/unknown kokoro voice/i,
);
expect(engine.getActiveVoice()?.id).toBe('af_heart'); // unchanged
expect(fetchResources).not.toHaveBeenCalled();
});

it('a failed voice-asset fetch is tolerated: active voice still switches, voiceChanged still emits', async () => {
// setVoice reflects the new voice immediately (the picker reads active voice) and the
// asset prefetch is best-effort — a fetch failure is logged, not thrown, so the picker
// never wedges. The store layer owns the switching-flag/spinner lifecycle.
it('a failed voice-asset fetch rejects and keeps the last usable voice active', async () => {
const engine = new KokoroEngine();
const onVoiceChanged = jest.fn();
engine.on('voiceChanged', onVoiceChanged);
fetchResources.mockRejectedValueOnce(new Error('voice fetch offline'));

await expect(engine.setVoice('am_santa')).resolves.toBeUndefined();
await expect(engine.setVoice('am_santa')).rejects.toThrow(
'voice fetch offline',
);

expect(engine.getActiveVoice()?.id).toBe('am_santa');
expect(onVoiceChanged).toHaveBeenCalledWith('am_santa');
expect(engine.getActiveVoice()?.id).toBe('af_heart');
expect(onVoiceChanged).not.toHaveBeenCalled();
});
});
Loading
Loading