diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5f0f2d6d4..3afa09cab 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,7 @@
name: CI
on:
+ workflow_dispatch:
push:
branches:
- main
@@ -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
@@ -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
diff --git a/.husky/pre-push b/.husky/pre-push
index 3b091cc74..dcb6dec08 100755
--- a/.husky/pre-push
+++ b/.husky/pre-push
@@ -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
diff --git a/App.tsx b/App.tsx
index 844089f45..75e548d68 100644
--- a/App.tsx
+++ b/App.tsx
@@ -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';
@@ -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__) {
@@ -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 () => {
@@ -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);
@@ -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 {
@@ -353,6 +369,8 @@ function App() {
{AppRoot ? : null}
{
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
@@ -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);
@@ -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),
+ }),
);
});
@@ -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(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');
@@ -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);
@@ -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');
@@ -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();
});
});
diff --git a/__tests__/harness/chatHarness.ts b/__tests__/harness/chatHarness.ts
index 4be2bc5aa..54a2ab862 100644
--- a/__tests__/harness/chatHarness.ts
+++ b/__tests__/harness/chatHarness.ts
@@ -20,7 +20,13 @@
* await h.send('what is the capital of France', { text: 'Paris.' }); // types, presses send, awaits reply
* expect(h.view.queryByText(/Paris\./)).not.toBeNull();
*/
-import { installNativeBoundary, requireRTL, GB, type RamProfile, type CompletionMeta } from './nativeBoundary';
+import {
+ installNativeBoundary,
+ requireRTL,
+ GB,
+ type RamProfile,
+ type CompletionMeta,
+} from './nativeBoundary';
import { createDownloadedModel } from '../utils/factories';
/** Shared route params the test's navigation mock reads (set by setupChatScreen). */
@@ -64,31 +70,47 @@ export interface ChatHarnessOptions {
export async function setupChatScreen(opts: ChatHarnessOptions) {
const platform = opts.platform ?? 'android';
const ram = opts.ram ?? { platform, totalBytes: 12 * GB, availBytes: 8 * GB };
- const boundary = installNativeBoundary({ llama: opts.engine === 'llama', llamaChatTemplate: opts.chatTemplate, fs: true, ram, whisper: opts.whisper, download: opts.download });
+ const boundary = installNativeBoundary({
+ llama: opts.engine === 'llama',
+ llamaChatTemplate: opts.chatTemplate,
+ fs: true,
+ ram,
+ whisper: opts.whisper,
+ download: opts.download,
+ });
// Global boundary polyfill: React 19's error reporter calls window.dispatchEvent; in the node test
// env there is no window, so an unrelated crash would mask real errors. This is a jsdom/global shim,
// NOT app logic.
const g = globalThis as unknown as { window?: Record };
- if (!g.window) g.window = { dispatchEvent: () => true, addEventListener: () => {}, removeEventListener: () => {} };
+ if (!g.window)
+ g.window = {
+ dispatchEvent: () => true,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ };
-
const React = require('react');
const rtl = requireRTL();
const { hardwareService } = require('../../src/services/hardware');
const { useAppStore, useChatStore } = require('../../src/stores');
-
// BOUNDARY (not a gesture): a downloaded model = a persisted record (@local_llm/downloaded_models) + the
// file on disk — exactly what a real download leaves. Downloading is native and can't be gestured in jest,
// so we pre-place ONLY this. Everything above it (hydration, the picker, selection, load) runs for real.
-
- const AsyncStorage = require('@react-native-async-storage/async-storage').default ?? require('@react-native-async-storage/async-storage');
- const { activeModelService } = require('../../src/services/activeModelService');
+
+ const AsyncStorage =
+ require('@react-native-async-storage/async-storage').default ??
+ require('@react-native-async-storage/async-storage');
+ const {
+ activeModelService,
+ } = require('../../src/services/activeModelService');
const { HomeScreen } = require('../../src/screens/HomeScreen');
-
+
const docs = boundary.fs!.DocumentDirectoryPath;
- const fileName = opts.modelFileName ?? (opts.engine === 'llama' ? 'ggml-small.gguf' : 'gemma.litertlm');
+ const fileName =
+ opts.modelFileName ??
+ (opts.engine === 'llama' ? 'ggml-small.gguf' : 'gemma.litertlm');
const modelPath = `${docs}/models/${fileName}`;
boundary.fs!.seedFile(modelPath, 500 * 1024 * 1024);
// fileSize drives the residency budget. The factory default is 4GB, which under the GPU-aware text
@@ -97,32 +119,77 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
// A realistic small model (2GB) is device-faithful and loads under the budget; memory/OOM tests set
// their own explicit sizes + RAM profiles and are unaffected.
const fileSize = opts.modelFileSizeBytes ?? 2 * 1024 * 1024 * 1024;
- const model = createDownloadedModel({ id: 'm', name: opts.modelName ?? 'Test Model', engine: opts.engine, filePath: modelPath, fileName, fileSize, liteRTVision: opts.vision, liteRTAudio: opts.audio });
- await AsyncStorage.setItem('@local_llm/downloaded_models', JSON.stringify([model]));
+ const model = createDownloadedModel({
+ id: 'm',
+ name: opts.modelName ?? 'Test Model',
+ engine: opts.engine,
+ filePath: modelPath,
+ fileName,
+ fileSize,
+ liteRTVision: opts.vision,
+ liteRTAudio: opts.audio,
+ });
+ await AsyncStorage.setItem(
+ '@local_llm/downloaded_models',
+ JSON.stringify([model]),
+ );
await hardwareService.refreshMemoryInfo();
// Boundary: dismiss the onboarding spotlight tour. When a whisper model is present the voice-hint
// spotlight (step 12) fires and wraps the send button in an AttachStep, which intercepts the composer
// gesture in tests. The tour is unrelated to any behavior under test, so mark it done up front.
-
+
useAppStore.setState({ checklistDismissed: true });
// Activate PRO (audio/voice mode header toggle, audio layout, TTS, MCP) via the real bootstrap BEFORE any
// screen mounts, so pro slots render in Home + ChatScreen. Reusable seam (proHarness.installPro).
- if (opts.pro) { const { installPro } = require('./proHarness'); await installPro(); }
+ if (opts.pro) {
+ const { installPro } = require('./proHarness');
+ await installPro();
+ }
// GESTURE: mount the real Home screen — its REAL hydration loads the record — then open the picker and TAP
// the model row. The real handleSelectTextModel sets it active (no setState activeModelId shortcut).
- const home = rtl.render(React.createElement(HomeScreen, { navigation: { navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} } }));
- await rtl.waitFor(() => { expect(useAppStore.getState().downloadedModels.length).toBeGreaterThan(0); }, { timeout: 4000 });
- rtl.fireEvent.press(await rtl.waitFor(() => home.getByTestId('browse-models-button')));
- const rows = await rtl.waitFor(() => { const r = home.queryAllByTestId('model-item'); expect(r.length).toBeGreaterThan(0); return r; }, { timeout: 4000 });
+ const home = rtl.render(
+ React.createElement(HomeScreen, {
+ navigation: {
+ navigate: () => {},
+ goBack: () => {},
+ setOptions: () => {},
+ addListener: () => () => {},
+ },
+ }),
+ );
+ await rtl.waitFor(
+ () => {
+ expect(useAppStore.getState().downloadedModels.length).toBeGreaterThan(0);
+ },
+ { timeout: 4000 },
+ );
+ rtl.fireEvent.press(
+ await rtl.waitFor(() => home.getByTestId('browse-models-button')),
+ );
+ const rows = await rtl.waitFor(
+ () => {
+ const r = home.queryAllByTestId('model-item');
+ expect(r.length).toBeGreaterThan(0);
+ return r;
+ },
+ { timeout: 4000 },
+ );
rtl.fireEvent.press(rows[0]);
- await rtl.waitFor(() => { expect(useAppStore.getState().activeModelId).toBe('m'); }, { timeout: 4000 });
+ await rtl.waitFor(
+ () => {
+ expect(useAppStore.getState().activeModelId).toBe('m');
+ },
+ { timeout: 4000 },
+ );
// GESTURE: with the model now selected, tap "New Chat" on Home — the real way a user starts a chat. A new
// chat has NO conversation yet; it is created on the first message (real app behavior). No createConversation.
- rtl.fireEvent.press(await rtl.waitFor(() => home.getByTestId('new-chat-button')));
+ rtl.fireEvent.press(
+ await rtl.waitFor(() => home.getByTestId('new-chat-button')),
+ );
home.unmount();
// Load via the REAL load path (the app loads lazily on the first send; we trigger the same path so the
@@ -138,19 +205,24 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
// token-flush timer that fires inside the NEXT suite and fails it, which is why exactly one rendered
// suite failed per run with a different name every time.
{
-
const { generationService } = require('../../src/services');
- (globalThis as unknown as { __GEN_CLEANUP__?: () => void }).__GEN_CLEANUP__ = () => {
- generationService.stopGeneration().catch(() => { });
- };
+ (
+ globalThis as unknown as { __GEN_CLEANUP__?: () => Promise }
+ ).__GEN_CLEANUP__ = () => generationService.stopGeneration();
}
routeHolder.params = {}; // new chat — the first send() creates the conversation
return {
- boundary, React, rtl, useAppStore, useChatStore,
+ boundary,
+ React,
+ rtl,
+ useAppStore,
+ useChatStore,
/** The active conversation id — a NEW chat has none until the first send() creates it. */
- get conversationId(): string | null { return useChatStore.getState().activeConversationId; },
+ get conversationId(): string | null {
+ return useChatStore.getState().activeConversationId;
+ },
view: null as ReturnType | null,
/**
@@ -159,14 +231,17 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
* live when we return to chat. NOT settings.updateSettings seeding.
*/
enableToolViaUI(toolId: string, value: boolean = true) {
-
const { ToolsScreen } = require('../../src/screens/ToolsScreen');
const { Switch } = require('react-native');
const tools = rtl.render(React.createElement(ToolsScreen, {}));
const row = tools.getByTestId(`tool-picker-row-${toolId}`);
// The RN Switch toggles via onValueChange (not press) — locate it in the row and flip it.
- rtl.fireEvent(rtl.within(row).UNSAFE_getByType(Switch), 'valueChange', value);
+ rtl.fireEvent(
+ rtl.within(row).UNSAFE_getByType(Switch),
+ 'valueChange',
+ value,
+ );
tools.unmount();
},
@@ -175,8 +250,9 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
* value into the real numeric input on the real TextGenerationSection — NOT updateSettings seeding.
*/
setTextSettingViaUI(key: string, value: number) {
-
- const { TextGenerationSection } = require('../../src/components/GenerationSettingsModal/TextGenerationSection');
+ const {
+ TextGenerationSection,
+ } = require('../../src/components/GenerationSettingsModal/TextGenerationSection');
const s = rtl.render(React.createElement(TextGenerationSection, {}));
rtl.fireEvent.press(s.getByTestId(`setting-${key}-value-button`));
const input = s.getByTestId(`setting-${key}-input`);
@@ -187,7 +263,7 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
/** Let async work (tool loop → tool-result bubble render) settle before asserting. */
async settle(ms = 300) {
- await new Promise((r) => setTimeout(r, ms));
+ await new Promise(r => setTimeout(r, ms));
},
/**
@@ -197,8 +273,12 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
*/
async cycleImageMode() {
const view = this.view!;
- rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('quick-settings-button')));
- rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('quick-image-mode')));
+ rtl.fireEvent.press(
+ await rtl.waitFor(() => view.getByTestId('quick-settings-button')),
+ );
+ rtl.fireEvent.press(
+ await rtl.waitFor(() => view.getByTestId('quick-image-mode')),
+ );
},
/**
@@ -206,18 +286,45 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
* is NOT activated here: activation is a real gesture (cycleImageMode's toggle sets activeImageModelId
* when an image model is downloaded). Settles first so the mount's hydration has cleared the empty disk.
*/
- async placeImageModel(imgOpts: { id?: string; modelPath?: string; backend?: 'mnn' | 'qnn' | 'coreml'; size?: number } = {}) {
- const { id = 'sd', modelPath: imgModelPath = '/models/sd', backend = 'coreml', size } = imgOpts;
-
+ async placeImageModel(
+ imgOpts: {
+ id?: string;
+ modelPath?: string;
+ backend?: 'mnn' | 'qnn' | 'coreml';
+ size?: number;
+ } = {},
+ ) {
+ const {
+ id = 'sd',
+ modelPath: imgModelPath = '/models/sd',
+ backend = 'coreml',
+ size,
+ } = imgOpts;
+
const { createONNXImageModel } = require('../utils/factories');
- const imgModel = createONNXImageModel({ id, name: 'SD', modelPath: imgModelPath, backend, ...(size != null ? { size } : {}) });
+ const imgModel = createONNXImageModel({
+ id,
+ name: 'SD',
+ modelPath: imgModelPath,
+ backend,
+ ...(size != null ? { size } : {}),
+ });
// A downloaded+extracted image model IS its file set on disk (the boundary) — seed the exact files the
// real integrity gate + native load require, so the REAL load path runs (mnn/qnn validate the dir;
// coreml doesn't). No pre-marking-loaded shortcut.
- const seedFile = (name: string) => boundary.fs!.seedFile(`${imgModelPath}/${name}`, 8 * 1024 * 1024);
+ const seedFile = (name: string) =>
+ boundary.fs!.seedFile(`${imgModelPath}/${name}`, 8 * 1024 * 1024);
if (backend === 'mnn' || backend === 'qnn') {
['pos_emb.bin', 'token_emb.bin', 'tokenizer.json'].forEach(seedFile);
- if (backend === 'mnn') ['unet.mnn', 'unet.mnn.weight', 'vae_decoder.mnn', 'vae_decoder.mnn.weight', 'clip_v2.mnn', 'clip_v2.mnn.weight'].forEach(seedFile);
+ if (backend === 'mnn')
+ [
+ 'unet.mnn',
+ 'unet.mnn.weight',
+ 'vae_decoder.mnn',
+ 'vae_decoder.mnn.weight',
+ 'clip_v2.mnn',
+ 'clip_v2.mnn.weight',
+ ].forEach(seedFile);
else ['unet.bin', 'vae_decoder.bin', 'clip_v2.mnn'].forEach(seedFile);
} else {
seedFile('model.mlmodelc'); // coreml: a non-empty dir
@@ -241,25 +348,41 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
* cancelling, which is what native does.
*/
async generateImageViaUI(
- imgOpts: { prompt?: string; backend?: 'mnn' | 'qnn' | 'coreml'; hold?: boolean } = {},
+ imgOpts: {
+ prompt?: string;
+ backend?: 'mnn' | 'qnn' | 'coreml';
+ hold?: boolean;
+ } = {},
) {
- const { prompt = 'a fox in the snow', backend = 'coreml', hold = false } = imgOpts;
+ const {
+ prompt = 'a fox in the snow',
+ backend = 'coreml',
+ hold = false,
+ } = imgOpts;
if (!this.view) this.render();
await this.placeImageModel({ backend });
await this.cycleImageMode(); // auto -> ON(force); also activates the downloaded image model
await rtl.waitFor(() => {
- expect(this.view!.queryByTestId('image-mode-force-badge')).not.toBeNull();
+ expect(
+ this.view!.queryByTestId('image-mode-force-badge'),
+ ).not.toBeNull();
});
if (hold) boundary.diffusion.holdNextGeneration();
await this.tapSend(prompt);
// Native has been entered either way; only the waiting differs.
- await rtl.waitFor(() => { expect(boundary.diffusion.calls.generateImage.length).toBe(1); });
+ await rtl.waitFor(() => {
+ expect(boundary.diffusion.calls.generateImage.length).toBe(1);
+ });
if (hold) {
- await rtl.waitFor(() => { expect(boundary.diffusion.generationHeld()).toBe(true); });
+ await rtl.waitFor(() => {
+ expect(boundary.diffusion.generationHeld()).toBe(true);
+ });
return;
}
- await rtl.waitFor(() => { expect(this.view!.queryByTestId('generated-image')).not.toBeNull(); });
+ await rtl.waitFor(() => {
+ expect(this.view!.queryByTestId('generated-image')).not.toBeNull();
+ });
},
/**
@@ -271,19 +394,29 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
* A testID on that control would delete this helper.
*/
async pressImageCardStop() {
- type PressNode = { type?: unknown; props?: Record; parent?: PressNode | null };
+ type PressNode = {
+ type?: unknown;
+ props?: Record;
+ parent?: PressNode | null;
+ };
await rtl.act(async () => {
const xIcons = this.view!.root.findAll(
- (n: PressNode) => n.type === 'Icon' && (n.props as { name?: string })?.name === 'x',
+ (n: PressNode) =>
+ n.type === 'Icon' && (n.props as { name?: string })?.name === 'x',
);
expect(xIcons).toHaveLength(1);
let node: PressNode | null = xIcons[0] as unknown as PressNode;
for (let depth = 0; node && depth < 12; depth++) {
const onPress = node.props?.onPress;
- if (typeof onPress === 'function') { (onPress as () => void)(); return; }
+ if (typeof onPress === 'function') {
+ (onPress as () => void)();
+ return;
+ }
node = node.parent ?? null;
}
- throw new Error('the image progress card\'s "x" has no pressable ancestor - the stop control is dead');
+ throw new Error(
+ 'the image progress card\'s "x" has no pressable ancestor - the stop control is dead',
+ );
});
},
@@ -299,23 +432,37 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
// fireEvent.changeText here because once a whisper/STT model is present it silently no-ops on this
// TextInput (a real ChatInput coupling: the composer subtree reshapes with voice availability), which
// would leave the send button unrendered. Invoking the bound handler is faithful and robust either way.
- await rtl.act(async () => { (input as unknown as { props: { onChangeText: (t: string) => void } }).props.onChangeText(text); });
+ await rtl.act(async () => {
+ (
+ input as unknown as { props: { onChangeText: (t: string) => void } }
+ ).props.onChangeText(text);
+ });
// waitFor the send button (it appears once the text lands), then invoke its TouchableOpacity onPress.
// We resolve the handler off the node instead of rtl.fireEvent.press because, once a whisper/STT model
// is present, RTL's press traversal does not reach this button's onPress (the composer subtree reshapes
// with voice availability) — invoking the bound handler is the same thing a tap does and is robust.
await rtl.waitFor(() => view.getByTestId('send-button'));
- type PressNode = { props?: Record; parent?: PressNode | null } | null;
+ type PressNode = {
+ props?: Record;
+ parent?: PressNode | null;
+ } | null;
const pressSend = () => {
- let n: PressNode = view.getByTestId('send-button') as unknown as PressNode;
+ let n: PressNode = view.getByTestId(
+ 'send-button',
+ ) as unknown as PressNode;
for (let d = 0; n && d < 12; d++) {
const op = n.props?.onPress;
- if (typeof op === 'function') { (op as () => void)(); return; }
+ if (typeof op === 'function') {
+ (op as () => void)();
+ return;
+ }
n = n.parent ?? null;
}
rtl.fireEvent.press(view.getByTestId('send-button')); // fallback
};
- await rtl.act(async () => { pressSend(); });
+ await rtl.act(async () => {
+ pressSend();
+ });
},
/**
@@ -325,18 +472,26 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
*/
async attachImageViaUI(source: 'library' | 'camera' = 'library') {
const view = this.view!;
- rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('attach-button')));
- rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('attach-photo')));
+ rtl.fireEvent.press(
+ await rtl.waitFor(() => view.getByTestId('attach-button')),
+ );
+ rtl.fireEvent.press(
+ await rtl.waitFor(() => view.getByTestId('attach-photo')),
+ );
// Android: attach-photo opens a "Choose image source" alert — tap "Photo Library" or "Camera" (both
// real gestures), which (after a short delay) launches the faked picker and adds the attachment.
// The two sources matter for a MULTI-image turn: the faked library returns one fixed uri every time,
// so two library picks are indistinguishable from one image arriving twice. The camera returns a
// different uri, which is what makes "both images reached the engine" an assertion rather than a hope.
rtl.fireEvent.press(
- await rtl.waitFor(() => view.getByText(source === 'camera' ? 'Camera' : 'Photo Library')),
+ await rtl.waitFor(() =>
+ view.getByText(source === 'camera' ? 'Camera' : 'Photo Library'),
+ ),
);
await this.settle(400); // the handler defers pickFromLibrary via setTimeout(300)
- await rtl.waitFor(() => { expect(view.queryByTestId('attachments-container')).not.toBeNull(); });
+ await rtl.waitFor(() => {
+ expect(view.queryByTestId('attachments-container')).not.toBeNull();
+ });
},
/**
@@ -345,9 +500,12 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
* sent). NOT settings.updateSettings seeding.
*/
enableGenerationDetailsViaUI() {
-
- const { ShowGenerationDetailsToggle } = require('../../src/components/settings/textGenAdvancedSections');
- const s = rtl.render(React.createElement(ShowGenerationDetailsToggle, {}));
+ const {
+ ShowGenerationDetailsToggle,
+ } = require('../../src/components/settings/textGenAdvancedSections');
+ const s = rtl.render(
+ React.createElement(ShowGenerationDetailsToggle, {}),
+ );
rtl.fireEvent.press(s.getByTestId('show-gen-details-on-button'));
s.unmount();
},
@@ -359,42 +517,114 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
* TranscriptionModelsTab → the real selectModel sets it active + loads it resident. Requires whisper:true.
*/
async setupWhisperModel(modelId = 'tiny.en') {
-
- const { TranscriptionModelsTab } = require('../../src/screens/ModelsScreen/TranscriptionModelsTab');
+ const {
+ TranscriptionModelsTab,
+ } = require('../../src/screens/ModelsScreen/TranscriptionModelsTab');
const { useWhisperStore } = require('../../src/stores/whisperStore');
-
- boundary.fs!.seedFile(`${docs}/whisper-models/ggml-${modelId}.bin`, 75 * 1024 * 1024);
+
+ boundary.fs!.seedFile(
+ `${docs}/whisper-models/ggml-${modelId}.bin`,
+ 75 * 1024 * 1024,
+ );
await useWhisperStore.getState().refreshPresentModels(); // real disk scan → present
const t = rtl.render(React.createElement(TranscriptionModelsTab, {}));
- await rtl.waitFor(() => { expect(useWhisperStore.getState().presentModelIds).toContain(modelId); }, { timeout: 4000 });
- rtl.fireEvent.press(await rtl.waitFor(() => t.getByTestId('transcription-model-card-0')));
- await rtl.waitFor(() => { expect(useWhisperStore.getState().downloadedModelId).toBe(modelId); }, { timeout: 4000 });
+ await rtl.waitFor(
+ () => {
+ expect(useWhisperStore.getState().presentModelIds).toContain(modelId);
+ },
+ { timeout: 4000 },
+ );
+ rtl.fireEvent.press(
+ await rtl.waitFor(() => t.getByTestId('transcription-model-card-0')),
+ );
+ await rtl.waitFor(
+ () => {
+ expect(useWhisperStore.getState().downloadedModelId).toBe(modelId);
+ },
+ { timeout: 4000 },
+ );
t.unmount();
},
- /** REAL chat-mode mic gesture: fire the PanResponder grant on the hold-to-talk button (empty input →
- * the send button IS the mic in asSendButton mode). onPanResponderGrant → onStartRecording. */
+ /** Start a real chat-mode mic gesture. Tests can release it as a hold or keep it pressed. */
async tapMic() {
const view = this.view!;
- const btn = await rtl.waitFor(() => view.getByTestId('voice-record-button'));
+ const btn = await rtl.waitFor(() =>
+ view.getByTestId('voice-record-button'),
+ );
// PanResponder wires onResponderGrant → onPanResponderGrant(evt, gestureState); RNTL fireEvent invokes
// the prop directly, so pass a synthetic event carrying a valid touchHistory (PanResponder reads it to
// build gestureState). indexOfSingleActiveTouch:-1 = no active bank entry (a fresh grant).
const evt = {
- nativeEvent: { touches: [], changedTouches: [], identifier: 1, pageX: 0, pageY: 0, timestamp: 0 },
- touchHistory: { touchBank: [], numberActiveTouches: 0, indexOfSingleActiveTouch: -1, mostRecentTimeStamp: 0 },
+ nativeEvent: {
+ touches: [],
+ changedTouches: [],
+ identifier: 1,
+ pageX: 0,
+ pageY: 0,
+ timestamp: 0,
+ },
+ touchHistory: {
+ touchBank: [],
+ numberActiveTouches: 0,
+ indexOfSingleActiveTouch: -1,
+ mostRecentTimeStamp: 0,
+ },
};
rtl.fireEvent(btn, 'responderGrant', evt);
},
- /** REAL hold-to-talk RELEASE: fire the PanResponder release on the mic → onPanResponderRelease →
- * onStopRecording (the direct-audio path transcribes the recorded file, the whisper path finalizes). */
+ /** One short tap: start recording and lock it until the next tap. */
+ async tapMicOnce() {
+ const view = this.view!;
+ const btn = await rtl.waitFor(() =>
+ view.getByTestId('voice-record-button'),
+ );
+ const grant = {
+ nativeEvent: {
+ touches: [],
+ changedTouches: [],
+ identifier: 1,
+ pageX: 0,
+ pageY: 0,
+ timestamp: 0,
+ },
+ touchHistory: {
+ touchBank: [],
+ numberActiveTouches: 0,
+ indexOfSingleActiveTouch: -1,
+ mostRecentTimeStamp: 0,
+ },
+ };
+ const release = {
+ ...grant,
+ nativeEvent: { ...grant.nativeEvent, timestamp: 100 },
+ };
+ rtl.fireEvent(btn, 'responderGrant', grant);
+ rtl.fireEvent(btn, 'responderRelease', release);
+ },
+
+ /** Release after a long press, which stops the recording. */
async releaseMic() {
const view = this.view!;
- const btn = await rtl.waitFor(() => view.getByTestId('voice-record-button'));
+ const btn = await rtl.waitFor(() =>
+ view.getByTestId('voice-record-button'),
+ );
const evt = {
- nativeEvent: { touches: [], changedTouches: [], identifier: 1, pageX: 0, pageY: 0, timestamp: 0 },
- touchHistory: { touchBank: [], numberActiveTouches: 0, indexOfSingleActiveTouch: -1, mostRecentTimeStamp: 0 },
+ nativeEvent: {
+ touches: [],
+ changedTouches: [],
+ identifier: 1,
+ pageX: 0,
+ pageY: 0,
+ timestamp: 500,
+ },
+ touchHistory: {
+ touchBank: [],
+ numberActiveTouches: 0,
+ indexOfSingleActiveTouch: -1,
+ mostRecentTimeStamp: 500,
+ },
};
rtl.fireEvent(btn, 'responderRelease', evt);
},
@@ -408,22 +638,43 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
*/
async enterVoiceMode() {
const view = this.view!;
-
+
const { useTTSStore } = require('@offgrid/pro/audio/ttsStore');
const engineId = useTTSStore.getState().settings.engineId;
// BOUNDARY: the persisted artifact a completed voice-model download leaves — drives shouldLoad in the
// REAL KokoroTTSBridge. Set via the real store action (like the LLM's @local_llm/downloaded_models
// record). NOT a phase/isReady poke: readiness below is EMERGENT from the real engine + executorch fake.
- await useTTSStore.getState().updateSettings({ modelDownloaded: { ...(useTTSStore.getState().settings.modelDownloaded ?? {}), [engineId]: true } });
+ await useTTSStore
+ .getState()
+ .updateSettings({
+ modelDownloaded: {
+ ...(useTTSStore.getState().settings.modelDownloaded ?? {}),
+ [engineId]: true,
+ },
+ });
// The real EngineBridge (mounted in render()) now mounts KokoroTTSBridge → the executorch fake reports
// isReady → KokoroEngine._setBridge → phase 'ready'. Wait for that emergent readiness (the same signal
// the real Voice toggle gates on) — never set by the test.
- await rtl.waitFor(() => { expect(useTTSStore.getState().isReady).toBe(true); }, { timeout: 4000 });
+ await rtl.waitFor(
+ () => {
+ expect(useTTSStore.getState().isReady).toBe(true);
+ },
+ { timeout: 4000 },
+ );
// GESTURE: open the chat-input quick-settings popover and tap the Voice row (the alternate real entry
// to voice mode, per the header dropdown). initializeEngine + interfaceMode='audio' run for real.
- rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('quick-settings-button')));
- rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('quick-tts-mode')));
- await rtl.waitFor(() => { expect(view.getByTestId('voice-record-button-audio')).toBeTruthy(); }, { timeout: 4000 });
+ rtl.fireEvent.press(
+ await rtl.waitFor(() => view.getByTestId('quick-settings-button')),
+ );
+ rtl.fireEvent.press(
+ await rtl.waitFor(() => view.getByTestId('quick-tts-mode')),
+ );
+ await rtl.waitFor(
+ () => {
+ expect(view.getByTestId('voice-record-button-audio')).toBeTruthy();
+ },
+ { timeout: 4000 },
+ );
},
/**
@@ -432,11 +683,28 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
* driving the real transcribeFile → onTranscript → send path (the working voice-mode STT pipeline). Pass
* `scripted` for a text reply; omit it for an image request (the diffusion boundary renders the image).
*/
- async voiceSend(transcript: string, scripted?: { text?: string; content?: string; toolCalls?: Array<{ name: string; arguments: Record }> }) {
+ async voiceSend(
+ transcript: string,
+ scripted?: {
+ text?: string;
+ content?: string;
+ toolCalls?: Array<{ name: string; arguments: Record }>;
+ },
+ ) {
const view = this.view!;
if (scripted) {
- if (opts.engine === 'llama') boundary.llama!.scriptCompletion(scripted as { text?: string });
- else boundary.litert.scriptTurn(scripted as { content?: string; toolCalls?: Array<{ name: string; arguments: Record }> });
+ if (opts.engine === 'llama')
+ boundary.llama!.scriptCompletion(scripted as { text?: string });
+ else
+ boundary.litert.scriptTurn(
+ scripted as {
+ content?: string;
+ toolCalls?: Array<{
+ name: string;
+ arguments: Record;
+ }>;
+ },
+ );
}
// BOUNDARY: the whisper model transcribes the recorded audio file to this text.
boundary.whisper!.setFileTranscript(transcript);
@@ -449,13 +717,17 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
/** Mount the real ChatScreen (plus the real app.root slot when pro is active, so the TTS EngineBridge
* mounts and the voice engine can load over the executorch fake — the same slot App.tsx renders). */
render() {
-
const { ChatScreen } = require('../../src/screens/ChatScreen');
const { getSlot, SLOTS } = require('../../src/bootstrap/slotRegistry');
-
+
const AppRoot = opts.pro ? getSlot(SLOTS.appRoot) : undefined;
const tree = AppRoot
- ? React.createElement(React.Fragment, null, React.createElement(AppRoot, {}), React.createElement(ChatScreen, {}))
+ ? React.createElement(
+ React.Fragment,
+ null,
+ React.createElement(AppRoot, {}),
+ React.createElement(ChatScreen, {}),
+ )
: React.createElement(ChatScreen, {});
this.view = rtl.render(tree);
return this.view;
@@ -466,9 +738,26 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
* send button, and await the assistant reply rendering. `scripted` is what the (faked) native engine
* returns — the real generation pipeline turns it into the rendered bubble.
*/
- async send(text: string, scripted: { text?: string; content?: string; reasoning?: string; thinkingText?: string; toolCalls?: unknown[]; completionMeta?: CompletionMeta }) {
- if (opts.engine === 'llama') boundary.llama!.scriptCompletion(scripted as { text?: string });
- else boundary.litert.scriptTurn(scripted as { content?: string; toolCalls?: { name: string; arguments: Record }[] });
+ async send(
+ text: string,
+ scripted: {
+ text?: string;
+ content?: string;
+ reasoning?: string;
+ thinkingText?: string;
+ toolCalls?: unknown[];
+ completionMeta?: CompletionMeta;
+ },
+ ) {
+ if (opts.engine === 'llama')
+ boundary.llama!.scriptCompletion(scripted as { text?: string });
+ else
+ boundary.litert.scriptTurn(
+ scripted as {
+ content?: string;
+ toolCalls?: { name: string; arguments: Record }[];
+ },
+ );
const view = this.view!;
const input = await rtl.waitFor(() => view.getByTestId('chat-input'));
@@ -476,23 +765,37 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
// fireEvent.changeText here because once a whisper/STT model is present it silently no-ops on this
// TextInput (a real ChatInput coupling: the composer subtree reshapes with voice availability), which
// would leave the send button unrendered. Invoking the bound handler is faithful and robust either way.
- await rtl.act(async () => { (input as unknown as { props: { onChangeText: (t: string) => void } }).props.onChangeText(text); });
+ await rtl.act(async () => {
+ (
+ input as unknown as { props: { onChangeText: (t: string) => void } }
+ ).props.onChangeText(text);
+ });
// waitFor the send button (it appears once the text lands), then invoke its TouchableOpacity onPress.
// We resolve the handler off the node instead of rtl.fireEvent.press because, once a whisper/STT model
// is present, RTL's press traversal does not reach this button's onPress (the composer subtree reshapes
// with voice availability) — invoking the bound handler is the same thing a tap does and is robust.
await rtl.waitFor(() => view.getByTestId('send-button'));
- type PressNode = { props?: Record; parent?: PressNode | null } | null;
+ type PressNode = {
+ props?: Record;
+ parent?: PressNode | null;
+ } | null;
const pressSend = () => {
- let n: PressNode = view.getByTestId('send-button') as unknown as PressNode;
+ let n: PressNode = view.getByTestId(
+ 'send-button',
+ ) as unknown as PressNode;
for (let d = 0; n && d < 12; d++) {
const op = n.props?.onPress;
- if (typeof op === 'function') { (op as () => void)(); return; }
+ if (typeof op === 'function') {
+ (op as () => void)();
+ return;
+ }
n = n.parent ?? null;
}
rtl.fireEvent.press(view.getByTestId('send-button')); // fallback
};
- await rtl.act(async () => { pressSend(); });
+ await rtl.act(async () => {
+ pressSend();
+ });
},
/**
@@ -501,27 +804,47 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
* - 'dots' → tap the 3-dots '•••' button in the message meta row
* BOTH are real user entry points and must both be exercised (they wire the same setShowActionMenu).
*/
- async openActionMenu(role: 'user' | 'assistant', via: 'longpress' | 'dots') {
+ async openActionMenu(
+ role: 'user' | 'assistant',
+ via: 'longpress' | 'dots',
+ ) {
const view = this.view!;
const testId = role === 'user' ? 'user-message' : 'assistant-message';
- const bubbles = await rtl.waitFor(() => { const b = view.queryAllByTestId(testId); expect(b.length).toBeGreaterThan(0); return b; });
+ const bubbles = await rtl.waitFor(() => {
+ const b = view.queryAllByTestId(testId);
+ expect(b.length).toBeGreaterThan(0);
+ return b;
+ });
const target = bubbles[bubbles.length - 1];
if (via === 'longpress') {
rtl.fireEvent(target, 'longPress');
} else {
// The 3-dots '•••' lives inside THIS message's element — scope to it (not the global-last dots,
// which would be a different message's button).
- const dots = await rtl.waitFor(() => rtl.within(target).getByText('•••'));
+ const dots = await rtl.waitFor(() =>
+ rtl.within(target).getByText('•••'),
+ );
rtl.fireEvent.press(dots);
}
- await rtl.waitFor(() => { expect(view.getByTestId('action-menu')).toBeTruthy(); });
+ await rtl.waitFor(() => {
+ expect(view.getByTestId('action-menu')).toBeTruthy();
+ });
},
/**
* REAL regenerate gesture: open the action menu (via long-press OR 3-dots) and press "Retry".
*/
- async regenerateLast(scripted: { text?: string; content?: string; reasoning?: string; toolCalls?: unknown[] }, via: 'longpress' | 'dots' = 'longpress') {
- if (opts.engine === 'llama') boundary.llama!.scriptCompletion(scripted as { text?: string });
+ async regenerateLast(
+ scripted: {
+ text?: string;
+ content?: string;
+ reasoning?: string;
+ toolCalls?: unknown[];
+ },
+ via: 'longpress' | 'dots' = 'longpress',
+ ) {
+ if (opts.engine === 'llama')
+ boundary.llama!.scriptCompletion(scripted as { text?: string });
else boundary.litert.scriptTurn(scripted as { content?: string });
await this.openActionMenu('assistant', via);
rtl.fireEvent.press(this.view!.getByTestId('action-retry'));
@@ -531,13 +854,20 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
* REAL edit gesture: open the action menu (via long-press OR 3-dots) → "Edit" → change text →
* "SAVE & RESEND". The real edit handler rewrites history and re-runs generation.
*/
- async editLastUserMessage(newText: string, scripted: { text?: string; content?: string }, via: 'longpress' | 'dots' = 'longpress') {
- if (opts.engine === 'llama') boundary.llama!.scriptCompletion(scripted as { text?: string });
+ async editLastUserMessage(
+ newText: string,
+ scripted: { text?: string; content?: string },
+ via: 'longpress' | 'dots' = 'longpress',
+ ) {
+ if (opts.engine === 'llama')
+ boundary.llama!.scriptCompletion(scripted as { text?: string });
else boundary.litert.scriptTurn(scripted as { content?: string });
await this.openActionMenu('user', via);
const view = this.view!;
rtl.fireEvent.press(view.getByTestId('action-edit'));
- const input = await rtl.waitFor(() => view.getByPlaceholderText('Enter message...'));
+ const input = await rtl.waitFor(() =>
+ view.getByPlaceholderText('Enter message...'),
+ );
rtl.fireEvent.changeText(input, newText);
rtl.fireEvent.press(view.getByText('SAVE & RESEND'));
},
diff --git a/__tests__/harness/nativeBoundary.ts b/__tests__/harness/nativeBoundary.ts
index 0d0c7581e..2b2cb890e 100644
--- a/__tests__/harness/nativeBoundary.ts
+++ b/__tests__/harness/nativeBoundary.ts
@@ -30,6 +30,12 @@ import {
type NativeFileSystemBoundary,
} from './nativeFileSystem';
+// The serial coverage gate loads more than 600 suites into one process. Near the end of that run,
+// module setup and garbage collection can delay a real native-boundary journey beyond Jest's
+// 10-second default even when the same journey finishes in under two seconds alone. Keep the
+// user-visible RNTL assertions strict; only these integration journeys get a load-tolerant ceiling.
+jest.setTimeout(30_000);
+
// ---------------------------------------------------------------------------
// Fake: LiteRTModule (Android litert engine). Destructured at import in src/services/litert.ts.
// A driveable event emitter + arg-recording methods. Native events: litert_token/thinking/complete/
@@ -359,14 +365,14 @@ export interface LlamaFake {
multimodalHoldActive(): boolean;
/** react-native module object to inject for 'llama.rn'. */
module: Record;
- calls: { completion: unknown[][] };
+ calls: { completion: unknown[][]; clearCache: boolean[] };
}
function makeLlamaFake(
onRelease?: () => void,
chatTemplate?: string,
): LlamaFake {
- const calls: LlamaFake['calls'] = { completion: [] };
+ const calls: LlamaFake['calls'] = { completion: [], clearCache: [] };
type PreparedCompletion = Omit & {
text: string;
};
@@ -530,6 +536,9 @@ function makeLlamaFake(
releaseFn = null;
f?.(); // release a held mid-stream pause so the abort lands
}),
+ clearCache: jest.fn(async (clearData: boolean = false) => {
+ calls.clearCache.push(clearData);
+ }),
// Releasing the native context frees its memory — but the OS reclaims it SHORTLY AFTER release()
// returns (device-faithful), not synchronously. Defer the free so the reclaim barrier captures the
// still-high footprint as its baseline and then observes the drop on a later poll (as on device).
diff --git a/__tests__/integration/app/bootNotBlockedByDownloadDb.rendered.test.tsx b/__tests__/integration/app/bootNotBlockedByDownloadDb.rendered.test.tsx
index 85c71fa26..690818a7d 100644
--- a/__tests__/integration/app/bootNotBlockedByDownloadDb.rendered.test.tsx
+++ b/__tests__/integration/app/bootNotBlockedByDownloadDb.rendered.test.tsx
@@ -12,26 +12,37 @@
* artifact: the boot loader ('app-loading') CLEARS anyway. RED on HEAD: the loader stays
* forever because initializeApp awaits hydrateDownloadStore/reattach before first paint.
*/
-import { installNativeBoundary, requireRTL } from '../../harness/nativeBoundary';
+import {
+ installNativeBoundary,
+ requireRTL,
+} from '../../harness/nativeBoundary';
-jest.mock('react-native-bootsplash', () => ({ hide: jest.fn(async () => {}) }), { virtual: true });
+jest.mock(
+ 'react-native-bootsplash',
+ () => ({ hide: jest.fn(async () => {}) }),
+ { virtual: true },
+);
describe('app boot is not blocked by the download DB (rendered)', () => {
- it('clears the boot loader while getActiveDownloads never resolves (wedged download DB)', async () => {
- const boundary = installNativeBoundary();
+ it('clears the boot loader while getActiveDownloads remains wedged', async () => {
+ installNativeBoundary();
-
const React = require('react');
const rtl = requireRTL();
const { NativeModules } = require('react-native');
- // WEDGE the download DB: the native read never resolves (the device's 9-writer contention,
- // taken to the limit). Everything else on the boundary behaves normally.
+ const AsyncStorage = require('@react-native-async-storage/async-storage');
+ await AsyncStorage.clear();
+ // WEDGE the download DB through first paint. The boundary is released only after the visible
+ // boot outcome is proved, so the test remains faithful without leaking an immortal Promise.
+ let releaseDownloadDb!: (rows: unknown[]) => void;
+ const pendingDownloadDb = new Promise(resolve => {
+ releaseDownloadDb = resolve;
+ });
NativeModules.DownloadManagerModule = {
...NativeModules.DownloadManagerModule,
- getActiveDownloads: jest.fn(() => new Promise(() => {})),
+ getActiveDownloads: jest.fn(() => pendingDownloadDb),
};
const App = require('../../../App').default;
-
const view = rtl.render(React.createElement(App));
@@ -39,9 +50,23 @@ describe('app boot is not blocked by the download DB (rendered)', () => {
expect(view.queryByTestId('app-loading')).not.toBeNull();
// Terminal artifact: the loader clears even though the download DB never answered.
- await rtl.waitFor(() => { expect(view.queryByTestId('app-loading')).toBeNull(); }, { timeout: 8000 });
+ await rtl.waitFor(
+ () => {
+ expect(view.queryByTestId('app-loading')).toBeNull();
+ },
+ { timeout: 8000 },
+ );
+
+ await rtl.act(async () => {
+ releaseDownloadDb([]);
+ await pendingDownloadDb;
+ });
+ await rtl.waitFor(() => {
+ expect(
+ NativeModules.DownloadManagerModule.getActiveDownloads,
+ ).toHaveBeenCalledTimes(2);
+ });
view.unmount();
- void boundary;
}, 20000);
});
diff --git a/__tests__/integration/audio/chatMicTapToggle.rendered.redflow.test.tsx b/__tests__/integration/audio/chatMicTapToggle.rendered.redflow.test.tsx
new file mode 100644
index 000000000..838a987aa
--- /dev/null
+++ b/__tests__/integration/audio/chatMicTapToggle.rendered.redflow.test.tsx
@@ -0,0 +1,56 @@
+/**
+ * A chat mic supports both paths from the same control: one short tap keeps the
+ * recording open, and the next short tap stops it. This mounts the real chat,
+ * recorder controller, Whisper service, and composer. Only native device leaves
+ * are faked by the shared harness.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({
+ navigate: () => {},
+ goBack: () => {},
+ setOptions: () => {},
+ addListener: () => () => {},
+ }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {},
+ useIsFocused: () => true,
+}));
+
+describe('chat mic tap-to-record', () => {
+ it('keeps recording after one tap and stops on the next tap', async () => {
+ const h = await setupChatScreen({
+ engine: 'llama',
+ platform: 'android',
+ whisper: true,
+ });
+ await h.setupWhisperModel('tiny.en');
+ h.render();
+
+ await h.tapMicOnce();
+ await h.rtl.waitFor(() => {
+ expect(h.boundary.whisper!.realtimeActive()).toBe(true);
+ expect(h.view!.getByText('Tap mic to stop')).toBeTruthy();
+ });
+
+ await h.tapMicOnce();
+ await h.rtl.waitFor(
+ () => {
+ expect(h.boundary.whisper!.realtimeActive()).toBe(false);
+ expect(h.view!.queryByText('Tap mic to stop')).toBeNull();
+ },
+ { timeout: 4000 },
+ );
+
+ h.boundary.whisper!.emitRealtime({
+ text: 'tap recording works',
+ isCapturing: false,
+ });
+ await h.rtl.waitFor(() => {
+ expect(h.view!.getByTestId('chat-input').props.value).toContain(
+ 'tap recording works',
+ );
+ });
+ }, 30000);
+});
diff --git a/__tests__/integration/audio/selectedWhisperModelLoadsBeforeTranscription.rendered.redflow.test.tsx b/__tests__/integration/audio/selectedWhisperModelLoadsBeforeTranscription.rendered.redflow.test.tsx
new file mode 100644
index 000000000..892bdb20c
--- /dev/null
+++ b/__tests__/integration/audio/selectedWhisperModelLoadsBeforeTranscription.rendered.redflow.test.tsx
@@ -0,0 +1,74 @@
+/**
+ * Device regression: downloading a new transcription model selects it, but an older Whisper
+ * context can still be resident. Starting dictation must replace that context before capture;
+ * "some Whisper model is loaded" is not enough.
+ *
+ * Real TranscriptionModelsTab + ChatScreen + stores + residency + Whisper service. Only the
+ * filesystem, download manager, and whisper.rn runtime are device-boundary fakes.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {},
+ useIsFocused: () => true,
+}));
+
+describe('selected Whisper model identity', () => {
+ it('loads the newly downloaded model before the next transcription', async () => {
+ const h = await setupChatScreen({ engine: 'llama', whisper: true, download: true });
+ await h.setupWhisperModel('tiny.en');
+
+ const React = require('react');
+ const { TranscriptionModelsTab } = require('../../../src/screens/ModelsScreen/TranscriptionModelsTab');
+ const { useWhisperStore } = require('../../../src/stores/whisperStore');
+ const { whisperService } = require('../../../src/services/whisperService');
+ const modelTab = h.rtl.render(React.createElement(TranscriptionModelsTab));
+
+ // Large v3 Turbo is catalogue index 8. Download it through the real model-card action.
+ await h.rtl.act(async () => {
+ h.rtl.fireEvent.press(modelTab.getByTestId('transcription-model-card-8-download'));
+ await Promise.resolve();
+ });
+ await h.rtl.waitFor(() => { expect(h.boundary.download!.active()).toHaveLength(1); });
+ const row = h.boundary.download!.active()[0];
+ await h.rtl.act(async () => { await Promise.resolve(); });
+ await h.rtl.act(async () => {
+ h.boundary.fs!.seedFile(
+ '/docs/whisper-models/ggml-large-v3-turbo.bin',
+ 809 * 1024 * 1024,
+ );
+ h.boundary.download!.events.emit('DownloadComplete', {
+ downloadId: row.downloadId,
+ fileName: row.fileName,
+ modelId: row.modelId,
+ bytesDownloaded: row.totalBytes ?? 1,
+ totalBytes: row.totalBytes ?? 1,
+ status: 'completed',
+ localUri: '/docs/whisper-models/ggml-large-v3-turbo.bin',
+ });
+ });
+ await h.rtl.waitFor(() => {
+ expect(useWhisperStore.getState().downloadedModelId).toBe('large-v3-turbo');
+ });
+ modelTab.unmount();
+
+ // The old tiny.en context is still resident. A real mic gesture must replace it with
+ // Large v3 Turbo before whisper.rn starts capturing.
+ h.render();
+ await h.tapMic();
+ await h.rtl.waitFor(() => {
+ expect(h.boundary.whisper!.hasRealtimeSubscriber()).toBe(true);
+ }, { timeout: 4000 });
+
+ const initCalls = h.boundary.whisper!.module.initWhisper.mock.calls;
+ const lastLoadedPath = initCalls[initCalls.length - 1]?.[0]?.filePath;
+ await h.rtl.act(async () => {
+ h.boundary.whisper!.emitRealtime({ text: 'test', isCapturing: false });
+ await whisperService.stopTranscription();
+ });
+
+ expect(lastLoadedPath).toBe('/docs/whisper-models/ggml-large-v3-turbo.bin');
+ }, 30000);
+});
diff --git a/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx b/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx
index 1effa4f4b..84538f577 100644
--- a/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx
+++ b/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx
@@ -77,5 +77,13 @@ describe('realtime hold-to-talk dictation recovers when whisper load is blocked
await h.rtl.waitFor(() => {
expect(view.getByTestId('chat-input').props.value ?? '').toContain('take a note');
}, { timeout: 4000 });
+
+ // End the native recording session before Jest tears down the React Native module graph.
+ // The screen cleanup is intentionally fire-and-forget in production, but this integration
+ // journey must wait for the boundary cleanup so no native promise crosses test environments.
+ const { whisperService } = require('../../../src/services/whisperService');
+ await h.rtl.act(async () => {
+ await whisperService.forceReset();
+ });
});
});
diff --git a/__tests__/integration/chat/existingConversationVisibleImmediately.rendered.redflow.test.tsx b/__tests__/integration/chat/existingConversationVisibleImmediately.rendered.redflow.test.tsx
new file mode 100644
index 000000000..84d0f068b
--- /dev/null
+++ b/__tests__/integration/chat/existingConversationVisibleImmediately.rendered.redflow.test.tsx
@@ -0,0 +1,53 @@
+import { setupChatScreen } from '../../harness/chatHarness';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({
+ navigate: () => {},
+ goBack: () => {},
+ setOptions: () => {},
+ addListener: () => () => {},
+ }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {},
+ useIsFocused: () => true,
+}));
+
+describe('opening an existing Mobile chat', () => {
+ it('shows stored messages before the list completes its first measurement', async () => {
+ const h = await setupChatScreen({ engine: 'llama', platform: 'ios' });
+ h.render();
+ await h.send('Show this chat again', { text: 'The stored reply is ready.' });
+ await h.rtl.waitFor(() => {
+ expect(h.view!.getByText('The stored reply is ready.')).toBeVisible();
+ });
+
+ const conversationId = h.conversationId;
+ expect(conversationId).not.toBeNull();
+ h.view!.unmount();
+
+ require('../../harness/chatHarness').routeHolder.params = {
+ conversationId,
+ };
+ // Hold layout work until after the assertion. This proves that stored content
+ // is visible on the first render and does not depend on a deferred frame.
+ const deferredFrames: Array<(time: number) => void> = [];
+ const originalRequestAnimationFrame = (globalThis as any).requestAnimationFrame;
+ (globalThis as any).requestAnimationFrame = (callback: (time: number) => void) => {
+ deferredFrames.push(callback);
+ return deferredFrames.length;
+ };
+
+ try {
+ const reopened = h.render();
+ await h.rtl.waitFor(() => {
+ expect(reopened.getByText('The stored reply is ready.')).toBeVisible();
+ });
+ expect(reopened.getByTestId('chat-message-list')).toBeVisible();
+ } finally {
+ (globalThis as any).requestAnimationFrame = originalRequestAnimationFrame;
+ await h.rtl.act(async () => {
+ deferredFrames.forEach(callback => callback(Date.now()));
+ });
+ }
+ });
+});
diff --git a/__tests__/integration/chat/voiceNoteChatModeEmptyTurn.redflow.test.ts b/__tests__/integration/chat/voiceNoteChatModeEmptyTurn.redflow.test.ts
index 7dce768a2..eac4062ba 100644
--- a/__tests__/integration/chat/voiceNoteChatModeEmptyTurn.redflow.test.ts
+++ b/__tests__/integration/chat/voiceNoteChatModeEmptyTurn.redflow.test.ts
@@ -48,6 +48,7 @@ describe('chat-mode STT is dictation-to-the-input-box on every engine (LiteRT to
const transcriptArgs: string[] = [];
const { result } = renderHook(() => useVoiceInput({
conversationId: 'c1',
+ interfaceMode: 'chat',
onTranscript: (t: string) => { transcriptArgs.push(t); },
onAutoSend: (...a: unknown[]) => { autoSendArgs.push(a); },
onAudioAttachment: (p: Record) => { attachmentArgs.push(p); },
diff --git a/__tests__/integration/happy/firstMessage.happy.test.tsx b/__tests__/integration/happy/firstMessage.happy.test.tsx
index 88c2233a1..87d3a3ab1 100644
--- a/__tests__/integration/happy/firstMessage.happy.test.tsx
+++ b/__tests__/integration/happy/firstMessage.happy.test.tsx
@@ -22,6 +22,7 @@ describe('happy — first message renders the answer (heavy entry point)', () =>
h.render();
await h.send('what is the capital of France', { text: 'The capital of France is Paris.' });
await h.rtl.waitFor(() => { expect(h.view!.queryByText(/The capital of France is Paris\./)).not.toBeNull(); });
+ expect(h.boundary.llama!.calls.clearCache).toContain(true);
});
it('LiteRT: typing + send renders the reply', async () => {
@@ -39,5 +40,58 @@ describe('happy — first message renders the answer (heavy entry point)', () =>
h.render();
await h.send('what is the capital of France', { text: 'The capital of France is Paris.' });
await h.rtl.waitFor(() => { expect(h.view!.queryByText(/The capital of France is Paris\./)).not.toBeNull(); });
+ expect(h.boundary.llama!.calls.clearCache).toContain(true);
+ });
+
+ it('new chat starts the selected model load and shows the real loading state', async () => {
+ const h = await setupChatScreen({ engine: 'llama', platform: 'ios', deferInitialLoad: true });
+ h.boundary.llama!.scriptMultimodalHold();
+ h.render();
+
+ await h.rtl.waitFor(() => {
+ expect(h.boundary.llama!.multimodalHoldActive()).toBe(true);
+ expect(h.view!.queryByText(/Loading Test Model/)).not.toBeNull();
+ });
+
+ h.boundary.llama!.releaseMultimodalHold();
+ await h.rtl.waitFor(() => {
+ expect(h.view!.queryByText(/Loading Test Model/)).toBeNull();
+ }, { timeout: 5000 });
+ });
+
+ it('new chat keeps a remote model choice while discovery metadata refreshes', async () => {
+ const h = await setupChatScreen({ engine: 'llama', platform: 'ios', deferInitialLoad: true });
+ const { useRemoteServerStore } = require('../../../src/stores');
+ const { setActiveRemoteTextModelImpl } = require('../../../src/services/remoteServerManagerUtils');
+
+ const remoteStore = useRemoteServerStore.getState();
+ const serverId = remoteStore.addServer({
+ name: 'Off Grid Desktop',
+ endpoint: 'http://192.168.5.219:7878',
+ providerType: 'openai-compatible',
+ });
+ remoteStore.setDiscoveredModels(serverId, [{
+ id: 'gemma-4-e4b',
+ name: 'Gemma 4 E4B',
+ capabilities: {
+ supportsVision: false,
+ supportsToolCalling: true,
+ supportsThinking: false,
+ acceptsThinkingKwarg: false,
+ },
+ }]);
+ await setActiveRemoteTextModelImpl(serverId, 'gemma-4-e4b');
+
+ // Device-shaped race: provider selection is complete, but the background
+ // discovery refresh temporarily has no metadata for the chosen model.
+ useRemoteServerStore.getState().clearDiscoveredModels(serverId);
+ h.render();
+
+ await h.rtl.waitFor(() => {
+ expect(useRemoteServerStore.getState().activeServerId).toBe(serverId);
+ expect(useRemoteServerStore.getState().activeRemoteTextModelId).toBe('gemma-4-e4b');
+ });
+ expect(h.boundary.llama!.multimodalHoldActive()).toBe(false);
+ expect(h.view!.queryByText(/Loading Test Model/)).toBeNull();
});
});
diff --git a/__tests__/integration/happy/transcription.happy.test.ts b/__tests__/integration/happy/transcription.happy.test.ts
index bf1a9bdee..43b9ebaa8 100644
--- a/__tests__/integration/happy/transcription.happy.test.ts
+++ b/__tests__/integration/happy/transcription.happy.test.ts
@@ -35,6 +35,7 @@ describe('happy — audio-mode transcription auto-sends the spoken text', () =>
const autoSendArgs: unknown[][] = [];
const { result } = renderHook(() => useVoiceInput({
conversationId: 'c1', onTranscript: () => {},
+ interfaceMode: 'audio',
onAutoSend: (...a: unknown[]) => { autoSendArgs.push(a); },
onAudioAttachment: () => {},
}));
diff --git a/__tests__/integration/licensing/proRuntimeExpiry.test.ts b/__tests__/integration/licensing/proRuntimeExpiry.test.ts
new file mode 100644
index 000000000..878d41934
--- /dev/null
+++ b/__tests__/integration/licensing/proRuntimeExpiry.test.ts
@@ -0,0 +1,77 @@
+import { callHook, _clearHooksForTesting } from '../../../src/bootstrap/hookRegistry';
+import { getSlot, SLOTS, _clearSlotsForTesting } from '../../../src/bootstrap/slotRegistry';
+import {
+ getRegisteredScreens,
+ registerScreen,
+ _clearScreensForTesting,
+} from '../../../src/navigation/screenRegistry';
+import {
+ getToolExtensions,
+ registerToolExtension,
+ _clearExtensionsForTesting,
+} from '../../../src/services/tools/extensions';
+import { registerSettingsSection } from '../../../src/components/settings/sectionRegistry';
+import { registerHook } from '../../../src/bootstrap/hookRegistry';
+import { registerSlot } from '../../../src/bootstrap/slotRegistry';
+import { activate, deactivate } from '../../../pro';
+import { syncService } from '../../../pro/sync/syncService';
+
+jest.mock('react-native-tcp-socket', () => {
+ const {
+ createNativeTcpBoundary,
+ } = require('../../utils/nativeSyncBoundaries');
+ return { __esModule: true, default: createNativeTcpBoundary() };
+});
+
+jest.mock('react-native-zeroconf', () => {
+ const {
+ createNativeDiscoveryBoundary,
+ } = require('../../utils/nativeSyncBoundaries');
+ return { __esModule: true, default: createNativeDiscoveryBoundary() };
+});
+
+describe('the Pro runtime when access expires', () => {
+ beforeEach(() => {
+ _clearHooksForTesting();
+ _clearSlotsForTesting();
+ _clearScreensForTesting();
+ _clearExtensionsForTesting();
+ });
+
+ afterEach(async () => {
+ await deactivate();
+ _clearHooksForTesting();
+ _clearSlotsForTesting();
+ _clearScreensForTesting();
+ _clearExtensionsForTesting();
+ });
+
+ it('removes every paid surface and stops Sync without an app restart', async () => {
+ activate({
+ registerToolExtension,
+ registerScreen,
+ registerSettingsSection,
+ registerSlot,
+ registerHook,
+ });
+
+ expect(getRegisteredScreens().map(screen => screen.name)).toEqual(
+ expect.arrayContaining(['Sync', 'Clipboard', 'McpServers']),
+ );
+ expect(getToolExtensions().map(extension => extension.id)).toEqual(
+ expect.arrayContaining(['mcp', 'email-calendar']),
+ );
+ expect(getSlot(SLOTS.appRoot)).toBeDefined();
+ expect(callHook('audio.canSpeak')).toBeDefined();
+
+ await deactivate();
+
+ expect(getRegisteredScreens().map(screen => screen.name)).not.toEqual(
+ expect.arrayContaining(['Sync', 'Clipboard', 'McpServers']),
+ );
+ expect(getToolExtensions().map(extension => extension.id)).toEqual([]);
+ expect(getSlot(SLOTS.appRoot)).toBeUndefined();
+ expect(callHook('audio.canSpeak')).toBeUndefined();
+ expect(syncService.isRunning()).toBe(false);
+ });
+});
diff --git a/__tests__/integration/models/sttResidency.test.ts b/__tests__/integration/models/sttResidency.test.ts
index 9b78ace50..152327c6e 100644
--- a/__tests__/integration/models/sttResidency.test.ts
+++ b/__tests__/integration/models/sttResidency.test.ts
@@ -24,11 +24,19 @@ import { hardwareService } from '../../../src/services/hardware';
// Native boundary: the whisper native model. A dumb stub that just flips a flag
// so the REAL residency bookkeeping and the REAL store logic run on top of it.
let mockWhisperNativeLoaded = false;
+let mockWhisperModelPath: string | null = null;
jest.mock('../../../src/services/whisperService', () => ({
whisperService: {
getModelPath: (id: string) => `/models/ggml-${id}.bin`,
- loadModel: jest.fn(async () => { mockWhisperNativeLoaded = true; }),
- unloadModel: jest.fn(async () => { mockWhisperNativeLoaded = false; }),
+ getLoadedModelPath: () => mockWhisperModelPath,
+ loadModel: jest.fn(async (path: string) => {
+ mockWhisperNativeLoaded = true;
+ mockWhisperModelPath = path;
+ }),
+ unloadModel: jest.fn(async () => {
+ mockWhisperNativeLoaded = false;
+ mockWhisperModelPath = null;
+ }),
isModelLoaded: () => mockWhisperNativeLoaded,
isModelDownloaded: jest.fn(async () => true),
deleteModel: jest.fn(async () => {}),
@@ -57,6 +65,7 @@ describe('STT residency — single-model invariant', () => {
beforeEach(() => {
jest.clearAllMocks();
mockWhisperNativeLoaded = false;
+ mockWhisperModelPath = null;
modelResidencyManager._reset();
useWhisperStore.setState({ downloadedModelId: 'base', isModelLoaded: false, isModelLoading: false, error: null });
diff --git a/__tests__/integration/onboarding/proBootFlow.test.ts b/__tests__/integration/onboarding/proBootFlow.test.ts
index c072c7f63..dd8e412c4 100644
--- a/__tests__/integration/onboarding/proBootFlow.test.ts
+++ b/__tests__/integration/onboarding/proBootFlow.test.ts
@@ -134,6 +134,30 @@ describe('opening the app as a Pro user, and as a free one', () => {
expect(storeState().isProActive).toBe(false);
});
+ it('does not boot Pro from an expired saved credential', async () => {
+ vault.set(
+ 'off-grid-pro-license',
+ JSON.stringify({
+ isPro: true,
+ key: LICENCE_KEY,
+ licenseId: licenceId,
+ expiry: new Date(Date.now() - 1).toISOString(),
+ verifiedAt: Date.now() - 10_000,
+ }),
+ );
+
+ const active = await launch();
+
+ expect(active).toBe(false);
+ expect(activate).not.toHaveBeenCalled();
+ expect(storeState()).toMatchObject({
+ isProActive: false,
+ hasRegisteredPro: false,
+ hasSavedProCredential: true,
+ hasExpiredProCredential: true,
+ });
+ });
+
it('switches the licensed half on for a phone that already holds one', async () => {
// A Pro phone holds a seat on the licence as well as a key in its keychain. Without the seat the
// launch-time check correctly withdraws Pro - a key that no longer has a device registered against
diff --git a/__tests__/integration/pro/proExpiryRedirect.integration.test.tsx b/__tests__/integration/pro/proExpiryRedirect.integration.test.tsx
new file mode 100644
index 000000000..45836480d
--- /dev/null
+++ b/__tests__/integration/pro/proExpiryRedirect.integration.test.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import { act, render } from '@testing-library/react-native';
+import type { NavigationContainerRef } from '@react-navigation/native';
+import { useProExpiryRedirect } from '../../../src/navigation/useProExpiryRedirect';
+import type { RootStackParamList } from '../../../src/navigation/types';
+import { useAppStore } from '../../../src/stores/appStore';
+
+type TestNavigation = Pick<
+ NavigationContainerRef,
+ 'isReady' | 'resetRoot'
+>;
+
+function Probe({ navigation }: { navigation: TestNavigation }): null {
+ useProExpiryRedirect(navigation);
+ return null;
+}
+
+describe('the live Pro expiry redirect', () => {
+ const resetRoot = jest.fn();
+ let ready = true;
+ const navigation: TestNavigation = {
+ isReady: () => ready,
+ resetRoot,
+ };
+
+ beforeEach(() => {
+ ready = true;
+ resetRoot.mockClear();
+ useAppStore.setState({
+ hasRegisteredPro: false,
+ hasSavedProCredential: false,
+ isProActive: false,
+ proDeviceAdmission: 'unknown',
+ });
+ });
+
+ it('does not redirect a normal free launch', () => {
+ render();
+ expect(resetRoot).not.toHaveBeenCalled();
+ });
+
+ it('replaces the current route with the purchase screen when access is lost', () => {
+ useAppStore.setState({
+ hasRegisteredPro: true,
+ hasSavedProCredential: true,
+ isProActive: true,
+ proDeviceAdmission: 'active',
+ });
+ render();
+
+ act(() => {
+ useAppStore.setState({
+ hasRegisteredPro: false,
+ hasSavedProCredential: false,
+ isProActive: false,
+ proDeviceAdmission: 'unknown',
+ });
+ });
+
+ expect(resetRoot).toHaveBeenCalledTimes(1);
+ expect(resetRoot).toHaveBeenCalledWith({
+ index: 0,
+ routes: [{ name: 'ProDetail' }],
+ });
+ });
+});
diff --git a/__tests__/integration/pro/proScreenNoDeviceLicenceShortcut.rendered.test.tsx b/__tests__/integration/pro/proScreenNoDeviceLicenceShortcut.rendered.test.tsx
new file mode 100644
index 000000000..c72799503
--- /dev/null
+++ b/__tests__/integration/pro/proScreenNoDeviceLicenceShortcut.rendered.test.tsx
@@ -0,0 +1,51 @@
+/**
+ * The Pro pitch has one purchase path and one key-entry path. A second action that offered to use
+ * another device's licence duplicated the Sync journey and made the purchase screen ambiguous.
+ *
+ * This test enters through the real Home screen and real app navigation. Native rendering remains
+ * supplied by the Jest environment, but every Off Grid screen, store action, and route is real.
+ */
+import React from 'react';
+import { fireEvent, render, waitFor } from '@testing-library/react-native';
+import { NavigationContainer } from '@react-navigation/native';
+import { AppNavigator } from '../../../src/navigation/AppNavigator';
+import { useAppStore } from '../../../src/stores/appStore';
+import { createDeviceInfo, createDownloadedModel } from '../../utils/factories';
+
+describe('Pro entry from Home', () => {
+ beforeEach(() => {
+ const model = createDownloadedModel();
+ const app = useAppStore.getState();
+
+ // Use the production store actions to restore a returning user's local app state. The behavior
+ // under test starts with their real Home-screen gesture below.
+ app.setOnboardingComplete(true);
+ app.setDeviceInfo(createDeviceInfo());
+ app.setDownloadedModels([model]);
+ app.setActiveModelId(model.id);
+ });
+
+ afterEach(() => {
+ const app = useAppStore.getState();
+ app.setActiveModelId(null);
+ app.setDownloadedModels([]);
+ app.setOnboardingComplete(false);
+ });
+
+ it('opens the Pro screen with its two valid actions and no device-licence shortcut', async () => {
+ const ui = render(
+
+
+ ,
+ );
+
+ fireEvent.press(await ui.findByLabelText('Open Off Grid AI Pro'));
+
+ await waitFor(() => expect(ui.getByText('Off Grid AI Pro')).toBeTruthy());
+ expect(ui.getAllByText('Get Pro').length).toBeGreaterThan(0);
+ expect(ui.getByText('I have a license key')).toBeTruthy();
+ expect(ui.queryByText('Use Pro from another device')).toBeNull();
+
+ ui.unmount();
+ });
+});
diff --git a/__tests__/integration/settings/modelSettingsSurfaceParity.test.tsx b/__tests__/integration/settings/modelSettingsSurfaceParity.test.tsx
index 4f978cdb9..b199accff 100644
--- a/__tests__/integration/settings/modelSettingsSurfaceParity.test.tsx
+++ b/__tests__/integration/settings/modelSettingsSurfaceParity.test.tsx
@@ -120,6 +120,23 @@ describe('model settings surface parity', () => {
expect(modelSettings.getByText('Base')).toBeTruthy();
});
+ it('uses one STT language setting in chat settings and the Models screen', () => {
+ useWhisperStore.setState({ downloadedModelId: 'base', transcriptionLanguage: 'auto' });
+ const chatSettings = render(
+ {}} />,
+ );
+ fireEvent.press(chatSettings.getByTestId('modal-transcription-accordion'));
+ fireEvent.press(chatSettings.getByTestId('chat-transcription-language'));
+ fireEvent.press(chatSettings.getByTestId('chat-transcription-language-fr'));
+ expect(useWhisperStore.getState().transcriptionLanguage).toBe('fr');
+ chatSettings.unmount();
+
+ const { TranscriptionModelsTab } = require('../../../src/screens/ModelsScreen/TranscriptionModelsTab');
+ const models = render();
+ expect(models.getByTestId('models-transcription-language').props.accessibilityLabel)
+ .toBe('Language: French');
+ });
+
it('renders the same TTS settings owner in both UI containers', () => {
const SharedTtsSettings = () => (
Shared TTS settings
diff --git a/__tests__/pro/audio/engines/KokoroEngine.extra.test.ts b/__tests__/pro/audio/engines/KokoroEngine.extra.test.ts
index 1be9ea555..45d2f2766 100644
--- a/__tests__/pro/audio/engines/KokoroEngine.extra.test.ts
+++ b/__tests__/pro/audio/engines/KokoroEngine.extra.test.ts
@@ -24,9 +24,11 @@ import {
type KokoroBridgeHandle,
} from '@offgrid/pro/audio/engine/tts/engines/kokoro/KokoroEngine';
-const fetchResources = (BareResourceFetcher as unknown as { fetch: jest.Mock }).fetch;
+const fetchResources = (BareResourceFetcher as unknown as { fetch: jest.Mock })
+ .fetch;
const deleteResources = BareResourceFetcher.deleteResources as jest.Mock;
-const listDownloadedFiles = BareResourceFetcher.listDownloadedFiles as jest.Mock;
+const listDownloadedFiles =
+ BareResourceFetcher.listDownloadedFiles as jest.Mock;
function makeHandle(): jest.Mocked {
return {
@@ -50,7 +52,7 @@ describe('KokoroEngine.extra — uncovered branches', () => {
afterEach(() => {
// No pollution: restore every Platform/console spy this file installed.
- spies.forEach((s) => s.mockRestore());
+ spies.forEach(s => s.mockRestore());
spies.length = 0;
jest.restoreAllMocks(); // restores any jest.replaceProperty (Platform / executorch exports)
jest.useRealTimers();
@@ -67,7 +69,9 @@ describe('KokoroEngine.extra — uncovered branches', () => {
isFinal: true,
};
let received: typeof chunk | null = null;
- engine.on('audioChunk', (d) => { received = d; });
+ engine.on('audioChunk', d => {
+ received = d;
+ });
engine._onAudioChunk(chunk);
@@ -77,7 +81,9 @@ describe('KokoroEngine.extra — uncovered branches', () => {
it('_onPlaybackTick forwards the elapsed seconds to playbackTick listeners', () => {
const engine = new KokoroEngine();
let secs = -1;
- engine.on('playbackTick', (s) => { secs = s; });
+ engine.on('playbackTick', s => {
+ secs = s;
+ });
engine._onPlaybackTick(4.5);
@@ -88,14 +94,22 @@ describe('KokoroEngine.extra — uncovered branches', () => {
const engine = new KokoroEngine();
engine._setBridge(makeHandle(), 'af_heart');
expect(engine.getPhase()).toBe('ready');
- const errors: Array<{ code: string; message: string; recoverable: boolean }> = [];
- engine.on('error', (e) => errors.push(e));
+ const errors: Array<{
+ code: string;
+ message: string;
+ recoverable: boolean;
+ }> = [];
+ engine.on('error', e => errors.push(e));
engine._onBridgeError('runtime exploded');
expect(engine.getPhase()).toBe('error');
expect(errors).toEqual([
- { code: 'KOKORO_RUNTIME', message: 'runtime exploded', recoverable: false },
+ {
+ code: 'KOKORO_RUNTIME',
+ message: 'runtime exploded',
+ recoverable: false,
+ },
]);
// Bridge was cleared: stop() from now on can't reach a handle and phase stays 'error'.
engine.stop();
@@ -105,7 +119,7 @@ describe('KokoroEngine.extra — uncovered branches', () => {
it('_setDownloadProgress emits downloadProgress AND flips idle→downloading only on a fractional tick', () => {
const engine = new KokoroEngine();
const events: number[] = [];
- engine.on('downloadProgress', (d) => events.push(d.progress));
+ engine.on('downloadProgress', d => events.push(d.progress));
// Fractional from idle → downloading (guard TRUE side).
engine._setDownloadProgress(0.5);
@@ -150,7 +164,9 @@ describe('KokoroEngine.extra — uncovered branches', () => {
it('speak() rejects with a timeout when the requested mount never attaches', async () => {
jest.useFakeTimers();
const engine = new KokoroEngine();
- engine._setMountRequester(() => {/* never calls _setBridge */});
+ engine._setMountRequester(() => {
+ /* never calls _setBridge */
+ });
const p = engine.speak('hello').catch((e: Error) => e);
await jest.advanceTimersByTimeAsync(15000);
@@ -204,9 +220,11 @@ describe('KokoroEngine.extra — uncovered branches', () => {
it('setVoice on an unknown id throws and does not emit voiceChanged', async () => {
const engine = new KokoroEngine();
const changed: string[] = [];
- engine.on('voiceChanged', (id) => changed.push(id));
+ engine.on('voiceChanged', id => changed.push(id));
- await expect(engine.setVoice('no_such_voice')).rejects.toThrow(/Unknown Kokoro voice/);
+ await expect(engine.setVoice('no_such_voice')).rejects.toThrow(
+ /Unknown Kokoro voice/,
+ );
expect(changed).toEqual([]);
expect(engine.getActiveVoice()?.id).toBe('af_heart'); // unchanged
});
@@ -214,7 +232,10 @@ describe('KokoroEngine.extra — uncovered branches', () => {
it('setVoice on a valid id fetches its assets, records completion and emits voiceChanged', async () => {
const engine = new KokoroEngine();
const changed: string[] = [];
- engine.on('voiceChanged', (id) => changed.push(id));
+ engine.on('voiceChanged', id => changed.push(id));
+ engine._setMountRequester(() => {
+ engine._setBridge(makeHandle(), 'bm_daniel');
+ });
await engine.setVoice('bm_daniel');
@@ -223,17 +244,17 @@ describe('KokoroEngine.extra — uncovered branches', () => {
expect(changed).toEqual(['bm_daniel']);
});
- it('setVoice still emits voiceChanged (and switches) when the asset fetch fails', async () => {
+ it('setVoice rejects and keeps the usable voice when the asset fetch fails', async () => {
const engine = new KokoroEngine();
fetchResources.mockRejectedValueOnce(new Error('net down'));
const changed: string[] = [];
- engine.on('voiceChanged', (id) => changed.push(id));
+ engine.on('voiceChanged', id => changed.push(id));
- await engine.setVoice('am_adam'); // must not reject; failure is warn-and-continue
+ await expect(engine.setVoice('am_adam')).rejects.toThrow('net down');
- expect(engine.getActiveVoice()?.id).toBe('am_adam');
+ expect(engine.getActiveVoice()?.id).toBe('af_heart');
expect(engine.isFullyDownloaded()).toBe(false); // no completion recorded on failure
- expect(changed).toEqual(['am_adam']);
+ expect(changed).toEqual([]);
});
// ── speak retry / error / session ownership ───────────────────────────────
@@ -262,7 +283,7 @@ describe('KokoroEngine.extra — uncovered branches', () => {
handle.speak.mockRejectedValue(new Error('engine on fire'));
engine._setBridge(handle, 'af_heart');
const errors: Array<{ code: string; recoverable: boolean }> = [];
- engine.on('error', (e) => errors.push(e));
+ engine.on('error', e => errors.push(e));
await expect(engine.speak('boom')).rejects.toThrow('engine on fire');
@@ -293,12 +314,18 @@ describe('KokoroEngine.extra — uncovered branches', () => {
const handle = makeHandle();
let releaseFirst!: () => void;
handle.speak
- .mockImplementationOnce(() => new Promise((r) => { releaseFirst = r; }))
+ .mockImplementationOnce(
+ () =>
+ new Promise(r => {
+ releaseFirst = r;
+ }),
+ )
.mockResolvedValueOnce(undefined);
engine._setBridge(handle, 'af_heart');
- const first = engine.speak('one'); // opens session 1 (pending)
- const second = await engine.speak('two') // session 2 runs and completes
+ const first = engine.speak('one'); // opens session 1 (pending)
+ const second = await engine
+ .speak('two') // session 2 runs and completes
.then(() => 'second-done');
expect(second).toBe('second-done');
expect(engine.getPhase()).toBe('ready');
@@ -314,7 +341,7 @@ describe('KokoroEngine.extra — uncovered branches', () => {
const engine = new KokoroEngine();
fetchResources.mockRejectedValueOnce(new Error('disk full'));
const errors: Array<{ code: string; recoverable: boolean }> = [];
- engine.on('error', (e) => errors.push(e));
+ engine.on('error', e => errors.push(e));
await expect(engine.downloadAssets()).rejects.toThrow('disk full');
@@ -354,7 +381,9 @@ describe('KokoroEngine.extra — uncovered branches', () => {
fetchResources.mockImplementationOnce(async () => {
// The concurrent fetch has finished writing every byte to the shared cache; the
// engine learns this and latches completion, then the losing fetch throws.
- (engine as unknown as { _genuineCompletion: boolean })._genuineCompletion = true;
+ (
+ engine as unknown as { _genuineCompletion: boolean }
+ )._genuineCompletion = true;
throw new Error('Resource already downloading');
});
@@ -381,7 +410,9 @@ describe('KokoroEngine.extra — uncovered branches', () => {
it('generateAndSave rejects — Kokoro has no generate-and-save capability', async () => {
const engine = new KokoroEngine();
expect(engine.capabilities.generateAndSave).toBe(false);
- await expect(engine.generateAndSave()).rejects.toThrow(/does not support generateAndSave/);
+ await expect(engine.generateAndSave()).rejects.toThrow(
+ /does not support generateAndSave/,
+ );
});
it('pause/resume drive processing↔paused only from the matching phase', () => {
@@ -395,7 +426,9 @@ describe('KokoroEngine.extra — uncovered branches', () => {
expect(engine.getPhase()).toBe('ready');
// put it into processing via the private setter path (speak), then pause↔resume.
- (engine as unknown as { _setPhase: (p: string) => void })._setPhase('processing');
+ (engine as unknown as { _setPhase: (p: string) => void })._setPhase(
+ 'processing',
+ );
engine.pause();
expect(engine.getPhase()).toBe('paused');
engine.resume();
@@ -433,7 +466,9 @@ describe('KokoroEngine.extra — uncovered branches', () => {
it('stop from paused returns to ready when the bridge is still mounted', () => {
const engine = new KokoroEngine();
engine._setBridge(makeHandle(), 'af_heart');
- (engine as unknown as { _setPhase: (p: string) => void })._setPhase('paused');
+ (engine as unknown as { _setPhase: (p: string) => void })._setPhase(
+ 'paused',
+ );
engine.stop();
expect(engine.getPhase()).toBe('ready');
diff --git a/__tests__/pro/audio/ttsEngineSubscription.extra.test.ts b/__tests__/pro/audio/ttsEngineSubscription.extra.test.ts
index 91cf4fcf3..0144b5ff6 100644
--- a/__tests__/pro/audio/ttsEngineSubscription.extra.test.ts
+++ b/__tests__/pro/audio/ttsEngineSubscription.extra.test.ts
@@ -64,6 +64,8 @@ beforeEach(() => {
playbackDuration: 0,
currentAmplitude: 0,
overallDownloadProgress: 0,
+ voiceSwitchProgress: 0,
+ isSwitchingVoice: false,
activeVoiceId: null,
playSessionId: 7,
error: null,
@@ -82,6 +84,35 @@ describe('downloadProgress → store projection', () => {
engine.emit('downloadProgress');
expect(state.overallDownloadProgress).toBe(0.42);
});
+
+ it('projects progress into the pending voice switch', () => {
+ state.isSwitchingVoice = true;
+ const engine = makeEngine(0.42);
+ subscribeToEngine(engine as any, deps());
+ engine.emit('downloadProgress');
+ expect(state.voiceSwitchProgress).toBe(0.42);
+ });
+
+ it('measures one aggregate byte rate for every voice download view', () => {
+ const now = jest.spyOn(Date, 'now')
+ .mockReturnValueOnce(1_000)
+ .mockReturnValueOnce(2_000);
+ const engine = makeEngine(0.5);
+ subscribeToEngine(engine as any, deps());
+
+ engine.emit('downloadProgress', {
+ assetId: 'voice', progress: 0.25, bytesWritten: 250, totalBytes: 1_000,
+ });
+ expect(state.downloadBytesPerSecond).toBeUndefined();
+
+ engine.emit('downloadProgress', {
+ assetId: 'voice', progress: 0.5, bytesWritten: 500, totalBytes: 1_000,
+ });
+ expect(state.downloadCurrentBytes).toBe(500);
+ expect(state.downloadTotalBytes).toBe(1_000);
+ expect(state.downloadBytesPerSecond).toBe(250);
+ now.mockRestore();
+ });
});
describe('amplitudeChange → store projection', () => {
@@ -142,6 +173,15 @@ describe('voiceChanged → store projection', () => {
engine.emit('voiceChanged', 'af_heart');
expect(state.activeVoiceId).toBe('af_heart');
});
+
+ it('does not mark a voice active before its pending switch is ready', () => {
+ state.isSwitchingVoice = true;
+ state.activeVoiceId = 'af_heart';
+ const engine = makeEngine();
+ subscribeToEngine(engine as any, deps());
+ engine.emit('voiceChanged', 'bf_emma');
+ expect(state.activeVoiceId).toBe('af_heart');
+ });
});
describe('phaseChange error branch preserves the existing error', () => {
diff --git a/__tests__/pro/audio/ttsStore.extra.test.ts b/__tests__/pro/audio/ttsStore.extra.test.ts
index e8c5bbfea..92aeb493c 100644
--- a/__tests__/pro/audio/ttsStore.extra.test.ts
+++ b/__tests__/pro/audio/ttsStore.extra.test.ts
@@ -103,6 +103,7 @@ const baseSettings = {
engineId: 'mock-tts',
voiceByEngine: {} as Record,
modelDownloaded: {} as Record,
+ voiceAssetsDownloaded: {} as Record,
};
describe('ttsStore — extra branch coverage', () => {
@@ -360,6 +361,7 @@ describe('ttsStore — extra branch coverage', () => {
expect(mockCurrentEngine.downloadAssets).toHaveBeenCalledTimes(1);
expect(getState().settings.modelDownloaded?.['mock-tts']).toBe(true);
+ expect(getState().settings.voiceAssetsDownloaded?.['mock-tts']).toEqual(['default']);
expect(getState().error).toBeNull();
});
@@ -513,10 +515,11 @@ describe('ttsStore persist migration (onRehydrateStorage)', () => {
expect(() => opts.onRehydrateStorage()(undefined)).not.toThrow();
});
- it('backfills voiceByEngine and modelDownloaded when missing', () => {
+ it('backfills voice and download records when missing', () => {
const s = runMigration({ engineId: 'kokoro' });
expect(s.voiceByEngine).toEqual({});
expect(s.modelDownloaded).toEqual({});
+ expect(s.voiceAssetsDownloaded).toEqual({});
});
it('migrates flat kokoroVoiceId and voiceId into voiceByEngine', () => {
diff --git a/__tests__/pro/audio/ui/EngineBridge.test.tsx b/__tests__/pro/audio/ui/EngineBridge.test.tsx
index 658ce1879..a45d1ab2c 100644
--- a/__tests__/pro/audio/ui/EngineBridge.test.tsx
+++ b/__tests__/pro/audio/ui/EngineBridge.test.tsx
@@ -19,6 +19,18 @@ import { render, screen } from '@testing-library/react-native';
jest.mock('react-native-executorch', () => ({
initExecutorch: jest.fn(),
+ models: {
+ text_to_speech: {
+ kokoro: {
+ en_us: {
+ heart: () => ({
+ voiceSource: 'https://example.test/af_heart.bin',
+ phonemizerConfig: { lang: 'en-us' },
+ }),
+ },
+ },
+ },
+ },
useTextToSpeech: jest.fn(() => ({
isReady: true,
downloadProgress: 1,
diff --git a/__tests__/pro/audio/ui/MessageAudioMode.test.tsx b/__tests__/pro/audio/ui/MessageAudioMode.test.tsx
index d9022e2a6..ef1e4ba02 100644
--- a/__tests__/pro/audio/ui/MessageAudioMode.test.tsx
+++ b/__tests__/pro/audio/ui/MessageAudioMode.test.tsx
@@ -20,6 +20,7 @@ import { render, fireEvent } from '@testing-library/react-native';
import { MessageAudioMode } from '@offgrid/pro/audio/ui/MessageAudioMode';
import type { MessageAudioModeProps } from '@offgrid/pro/audio/ui/MessageAudioMode';
import { useTTSStore } from '@offgrid/pro/audio/ttsStore';
+import { useChatStore } from '@offgrid/core/stores';
import type { Message } from '@offgrid/core/types';
import {
createUserMessage,
@@ -48,10 +49,12 @@ const renderMode = (msg: Message, overrides: Partial = {}
render();
const initialTTSState = useTTSStore.getState();
+const initialChatState = useChatStore.getState();
afterEach(() => {
jest.clearAllMocks();
useTTSStore.setState(initialTTSState, true);
+ useChatStore.setState(initialChatState, true);
});
describe('MessageAudioMode', () => {
@@ -95,6 +98,19 @@ describe('MessageAudioMode', () => {
expect(getByText('•••')).toBeTruthy();
});
+ it('keeps the newest assistant voice answer transcript open by default', () => {
+ const conversationId = useChatStore.getState().createConversation('model-1');
+ const msg = useChatStore.getState().addMessage(conversationId, {
+ role: 'assistant',
+ content: 'The newest answer stays readable.',
+ });
+
+ const { getByText } = renderMode(msg);
+
+ expect(getByText('Hide transcript')).toBeTruthy();
+ expect(getByText('The newest answer stays readable.')).toBeTruthy();
+ });
+
it('pressing copy on a completed assistant bubble passes the transcript to onCopy', () => {
const onCopy = jest.fn();
const msg = createAssistantMessage('Speak this answer.');
diff --git a/__tests__/pro/audio/ui/TTSSection.test.tsx b/__tests__/pro/audio/ui/TTSSection.test.tsx
index 6e1f11c20..79b562cc2 100644
--- a/__tests__/pro/audio/ui/TTSSection.test.tsx
+++ b/__tests__/pro/audio/ui/TTSSection.test.tsx
@@ -14,7 +14,7 @@
* The store under assertion is NEVER mocked.
*/
import React from 'react';
-import { render, fireEvent, act } from '@testing-library/react-native';
+import { render, fireEvent, act, waitFor } from '@testing-library/react-native';
// Render each Feather icon as a Text carrying its name, so a test can assert
// which glyph shows (check / check-circle / external-link) without reaching into
@@ -33,24 +33,25 @@ jest.mock('react-native-vector-icons/Feather', () => {
// A minimal fake engine so the store's setVoice action proceeds (it bails when
// there is no active engine). setVoice does the real optimistic state update
// (activeVoiceId + voiceByEngine) before awaiting this boundary.
-jest.mock('../../../../pro/audio/engine', () => {
- const engine = {
- id: 'kokoro',
- displayName: 'Kokoro TTS',
- capabilities: { peakRamMB: 82 },
- setVoice: jest.fn(async () => {}),
- stop: jest.fn(),
- getPhase: () => 'ready',
- getRequiredAssets: () => [{ id: 'a', sizeBytes: 82 * 1024 * 1024 }],
- isFullyDownloaded: () => true,
- initialize: jest.fn(async () => {}),
- release: jest.fn(async () => {}),
- };
- return {
- ttsRegistry: { getActiveEngine: () => engine, getRegisteredIds: () => ['kokoro'] },
- OuteTTSEngine: class {},
- };
-});
+const mockSetVoice = jest.fn(async () => {});
+const mockTTSEngine = {
+ id: 'kokoro',
+ displayName: 'Kokoro TTS',
+ capabilities: { peakRamMB: 82 },
+ setVoice: mockSetVoice,
+ stop: jest.fn(),
+ getPhase: () => 'ready',
+ getRequiredAssets: () => [{ id: 'a', sizeBytes: 82 * 1024 * 1024 }],
+ checkAssetStatus: jest.fn(async () => []),
+ getOverallDownloadProgress: () => 1,
+ isFullyDownloaded: () => true,
+ initialize: jest.fn(async () => {}),
+ release: jest.fn(async () => {}),
+};
+jest.mock('../../../../pro/audio/engine', () => ({
+ ttsRegistry: { getActiveEngine: () => mockTTSEngine, getRegisteredIds: () => ['kokoro'] },
+ OuteTTSEngine: class {},
+}));
// The residency lock/hardware are native boundaries; the mode switch's
// initializeEngine side-effect routes through them. Grant room so it proceeds
@@ -77,8 +78,8 @@ import { TTSSection } from '@offgrid/pro/audio/ui/TTSSection';
import { useTTSStore } from '@offgrid/pro/audio/ttsStore';
const VOICES = [
- { id: 'af_heart', label: 'Warm', metadata: { accent: 'US', gender: 'Female', persona: 'Friendly' } },
- { id: 'bf_emma', label: 'Gentle', metadata: { accent: '', gender: '', persona: '' } },
+ { id: 'af_heart', label: 'Warm', metadata: { accent: 'English (US)', languageCode: 'en-US', gender: 'Female', persona: 'Friendly' } },
+ { id: 'bf_emma', label: 'Gentle', metadata: { accent: 'English (UK)', languageCode: 'en-GB', gender: '', persona: '' } },
] as any;
// Snapshot the pristine store so every test starts from the real defaults and
@@ -122,6 +123,28 @@ describe('TTSSection', () => {
});
});
+ describe('when the voice model is downloaded but the engine is cold', () => {
+ it('shows the voice controls instead of the download empty state', () => {
+ setStore({
+ ...INITIAL,
+ isReady: false,
+ voices: VOICES,
+ activeVoiceId: 'af_heart',
+ settings: {
+ ...INITIAL.settings,
+ interfaceMode: 'chat',
+ modelDownloaded: { ...INITIAL.settings.modelDownloaded, kokoro: true },
+ },
+ });
+
+ const { getByText, getByTestId, queryByText } = render();
+
+ expect(queryByText(/No voice models downloaded/)).toBeNull();
+ expect(getByText('Interface Mode')).toBeTruthy();
+ expect(getByTestId('tts-speed-slider')).toBeTruthy();
+ });
+ });
+
// ── Ready branch ─────────────────────────────────────────────────────────
describe('when a voice model is ready', () => {
beforeEach(() =>
@@ -141,7 +164,8 @@ describe('TTSSection', () => {
expect(getByText('Chat')).toBeTruthy();
expect(getByText('Audio')).toBeTruthy();
expect(getByText('Warm')).toBeTruthy();
- expect(getByText('Gentle')).toBeTruthy();
+ expect(queryByText('Gentle')).toBeNull();
+ expect(getByTestId('chat-tts-language')).toBeTruthy();
expect(getByTestId('tts-speed-slider')).toBeTruthy();
});
@@ -165,16 +189,89 @@ describe('TTSSection', () => {
const { getByText, getByTestId } = render();
// Active voice (af_heart) shows the check glyph; its metadata joins accent + gender.
expect(getByTestId('icon-check')).toBeTruthy();
- expect(getByText('US · Female')).toBeTruthy();
+ expect(getByText('English (US) · Female')).toBeTruthy();
});
- it('tapping a voice dispatches setVoice → activeVoiceId changes in the REAL store', async () => {
- const { getByText } = render();
+ it('shows the language download until the requested voice is ready', async () => {
+ let finishSwitch!: () => void;
+ const pendingSwitch = new Promise((resolve) => { finishSwitch = resolve; });
+ mockSetVoice.mockReturnValueOnce(pendingSwitch);
+ const realSetVoice = useTTSStore.getState().setVoice;
+ const setVoiceFromUI = jest.fn(realSetVoice);
+ setStore({ setVoice: setVoiceFromUI });
+ const { getByText, getByTestId, queryByTestId } = render();
expect(useTTSStore.getState().activeVoiceId).toBe('af_heart');
- await act(async () => { fireEvent.press(getByText('Gentle')); });
- // The real setVoice action updates activeVoiceId immediately (optimistic).
+ fireEvent.press(getByTestId('chat-tts-language'));
+ await act(async () => { fireEvent.press(getByTestId('chat-tts-language-en-GB')); });
+
+ expect(setVoiceFromUI).toHaveBeenCalledWith('bf_emma');
+ expect(mockSetVoice).toHaveBeenCalledWith('bf_emma');
+ expect(mockSetVoice.mock.results[0]?.value).toBe(pendingSwitch);
+ expect(useTTSStore.getState().activeVoiceId).toBe('af_heart');
+ await waitFor(() => expect(useTTSStore.getState().pendingVoiceId).toBe('bf_emma'));
+ expect(getByTestId('chat-tts-language-download-status')).toBeTruthy();
+ expect(getByText(/Downloading English \(UK\) voice/)).toBeTruthy();
+ expect(queryByTestId('icon-check-circle')).toBeNull();
+
+ // A second selection cannot start while one voice is still being prepared.
+ fireEvent.press(getByTestId('chat-tts-language'));
+ expect(queryByTestId('chat-tts-language-en-US')).toBeNull();
+
+ act(() => { useTTSStore.setState({ voiceSwitchProgress: 0.42 }); });
+ expect(getByText('Downloading English (UK) voice - 42% · Rate unavailable')).toBeTruthy();
+
+ act(() => {
+ useTTSStore.setState({
+ downloadCurrentBytes: 21 * 1024 * 1024,
+ downloadTotalBytes: 50 * 1024 * 1024,
+ downloadBytesPerSecond: 2 * 1024 * 1024,
+ });
+ });
+ expect(getByText('Downloading English (UK) voice - 42% · 21 MB / 50 MB · 2.0 MB/s')).toBeTruthy();
+
+ await act(async () => { finishSwitch(); await Promise.resolve(); });
+ await waitFor(() => expect(getByTestId('chat-tts-language-ready-status')).toBeTruthy());
expect(useTTSStore.getState().activeVoiceId).toBe('bf_emma');
expect(useTTSStore.getState().settings.voiceByEngine[useTTSStore.getState().settings.engineId]).toBe('bf_emma');
+ expect(getByText('Gentle')).toBeTruthy();
+ });
+
+ it('prepares a voice from cache when that voice completed before', async () => {
+ let finishSwitch!: () => void;
+ mockSetVoice.mockReturnValueOnce(new Promise((resolve) => { finishSwitch = resolve; }));
+ setStore({
+ settings: {
+ ...useTTSStore.getState().settings,
+ voiceAssetsDownloaded: { kokoro: ['bf_emma'] },
+ },
+ });
+ const { getByText, getByTestId, queryByText } = render();
+
+ fireEvent.press(getByTestId('chat-tts-language'));
+ await act(async () => { fireEvent.press(getByTestId('chat-tts-language-en-GB')); });
+
+ expect(getByText('Preparing English (UK) voice')).toBeTruthy();
+ expect(queryByText(/Downloading English \(UK\) voice/)).toBeNull();
+
+ await act(async () => { finishSwitch(); await Promise.resolve(); });
+ await waitFor(() => expect(getByTestId('chat-tts-language-ready-status')).toBeTruthy());
+ });
+
+ it('shows a failed language download and retries it', async () => {
+ mockSetVoice
+ .mockRejectedValueOnce(new Error('network down'))
+ .mockResolvedValueOnce(undefined);
+ const { getByText, getByTestId } = render();
+
+ fireEvent.press(getByTestId('chat-tts-language'));
+ await act(async () => { fireEvent.press(getByTestId('chat-tts-language-en-GB')); });
+ await waitFor(() => expect(getByTestId('chat-tts-language-download-error')).toBeTruthy());
+ expect(getByText('Could not download the English (UK) voice. Check your connection and retry.')).toBeTruthy();
+ expect(useTTSStore.getState().activeVoiceId).toBe('af_heart');
+
+ await act(async () => { fireEvent.press(getByTestId('chat-tts-language-download-retry')); });
+ await waitFor(() => expect(useTTSStore.getState().activeVoiceId).toBe('bf_emma'));
+ expect(mockSetVoice).toHaveBeenCalledTimes(2);
});
// ── Mode picker interaction (real store action) ────────────────────────
diff --git a/__tests__/pro/runtimeDeactivation.integration.test.ts b/__tests__/pro/runtimeDeactivation.integration.test.ts
new file mode 100644
index 000000000..6608cc448
--- /dev/null
+++ b/__tests__/pro/runtimeDeactivation.integration.test.ts
@@ -0,0 +1,200 @@
+const mockClipboardEntitlement = jest.fn();
+const mockEmailCalendarEntitlement = jest.fn();
+const mockDisconnectServer = jest.fn();
+const mockAudioCleanup = jest.fn();
+const mockGrantCleanup = jest.fn();
+const mockReconcileCleanup = jest.fn();
+let mockLicenseInfoListener: ((info: { isPro: boolean }) => void) | undefined;
+
+jest.mock('@offgrid/core/bootstrap/slotRegistry', () => ({
+ SLOTS: {
+ appRoot: 'app.root',
+ homeSyncCard: 'home.syncCard',
+ homeNotificationsButton: 'home.notificationsButton',
+ chatOverlay: 'chat.overlay',
+ },
+}));
+jest.mock('@offgrid/core/bootstrap/hookRegistry', () => ({
+ HOOKS: {
+ onboardingAdditionalSlides: 'onboarding.additionalSlides',
+ clipboardRecordLocalText: 'clipboard.recordLocalText',
+ },
+}));
+jest.mock('@offgrid/core/utils/logger', () => ({
+ __esModule: true,
+ default: { log: jest.fn(), warn: jest.fn() },
+}));
+
+jest.mock('../../pro/mcp/McpToolExtension', () => ({
+ McpToolExtension: { id: 'mcp' },
+}));
+jest.mock('../../pro/tools/EmailCalendarExtension', () => ({
+ EmailCalendarExtension: { id: 'email-calendar' },
+ setEmailCalendarEntitlementActive: mockEmailCalendarEntitlement,
+}));
+jest.mock('../../pro/audio', () => ({
+ activateAudio: (options: {
+ registerScreen: (screen: { name: string; component: () => null }) => () => void;
+ registerSlot: (name: string, component: () => null) => () => void;
+ registerHook: (name: string, hook: () => void) => () => void;
+ }) => {
+ const disposeScreen = options.registerScreen({
+ name: 'AudioSettings',
+ component: () => null,
+ });
+ const disposeSlot = options.registerSlot('audio.slot', () => null);
+ const disposeHook = options.registerHook('audio.hook', () => undefined);
+ return () => {
+ disposeHook();
+ disposeSlot();
+ disposeScreen();
+ mockAudioCleanup();
+ };
+ },
+}));
+
+for (const modulePath of [
+ '../../pro/ui/ComputerApprovalCard',
+ '../../pro/ui/McpServersScreen',
+ '../../pro/ui/McpToolsScreen',
+ '../../pro/ui/McpGuideScreen',
+ '../../pro/ui/SyncScreen',
+ '../../pro/ui/SyncScreen/SyncSharingSettingsScreen',
+ '../../pro/ui/SyncScreen/SyncActivityScreen',
+ '../../pro/ui/SyncScreen/SyncFilesScreen',
+ '../../pro/ui/ClipboardScreen',
+ '../../pro/ui/SyncHomeCard',
+ '../../pro/ui/HomeNotificationsButton',
+ '../../pro/ui/SyncNotificationsScreen',
+ '../../pro/ui/ProRoot',
+]) {
+ jest.mock(modulePath, () => new Proxy({}, { get: () => () => null }));
+}
+
+jest.mock('../../pro/mcp/mcpStore', () => ({
+ useMcpStore: {
+ getState: () => ({ servers: [{ id: 'server-1' }] }),
+ persist: { hasHydrated: () => true, onFinishHydration: jest.fn() },
+ },
+}));
+jest.mock('../../pro/mcp/mcpService', () => ({
+ disconnectServer: mockDisconnectServer,
+ reconnectSavedServers: jest.fn(async () => undefined),
+}));
+jest.mock('../../pro/mcp/mcpToolGrantService', () => ({
+ initMcpToolGrants: () => mockGrantCleanup,
+ initToolGrantReconcile: () => mockReconcileCleanup,
+}));
+
+jest.mock('../../pro/sync/syncService', () => ({
+ syncService: { start: jest.fn(async () => undefined) },
+}));
+jest.mock('../../pro/sync/modelTransferService', () => ({
+ modelTransferService: { start: jest.fn() },
+}));
+jest.mock('../../pro/sync/stateSyncService', () => ({
+ stateSyncService: {
+ start: jest.fn(async () => undefined),
+ recordMutation: jest.fn(),
+ stageMutation: jest.fn(),
+ sendSharedFileRecord: jest.fn(),
+ },
+}));
+jest.mock('../../pro/sync/clipboardSyncService', () => ({
+ clipboardSyncService: {
+ start: jest.fn(async () => undefined),
+ setEntitlementActive: mockClipboardEntitlement,
+ recordLocalText: jest.fn(async () => undefined),
+ },
+}));
+jest.mock('../../pro/sync/chatStreamService', () => ({
+ chatStreamService: {
+ start: jest.fn(async () => undefined),
+ discardConversation: jest.fn(),
+ },
+}));
+jest.mock('../../pro/sync/knowledgeDocumentSyncService', () => ({
+ knowledgeDocumentSyncService: {
+ start: jest.fn(),
+ handleLocalMutation: jest.fn(async () => undefined),
+ },
+}));
+jest.mock('../../pro/sync/sharedFileSyncService', () => ({
+ sharedFileSyncService: { start: jest.fn(async () => undefined) },
+}));
+jest.mock('../../pro/sync/fileTransferService', () => ({
+ fileTransferService: { loadHistory: jest.fn(async () => undefined) },
+}));
+jest.mock('../../pro/sync/fileCompletionNotificationService', () => ({
+ fileCompletionNotificationService: { start: jest.fn(async () => undefined) },
+}));
+jest.mock('../../pro/sync/entitlementActivation', () => ({
+ setEntitlementImportedHandler: jest.fn(),
+}));
+jest.mock('../../pro/licensing/proLicenseProvider', () => ({
+ proLicenseProvider: {},
+ onProLicenseInfoChanged: jest.fn((listener: (info: { isPro: boolean }) => void) => {
+ mockLicenseInfoListener = listener;
+ return jest.fn();
+ }),
+}));
+
+describe('the paid mobile runtime after live entitlement loss', () => {
+ it('removes paid surfaces and stops paid work in the same process', async () => {
+ const disposeByScreen = new Map();
+ const disposeBySlot = new Map();
+ const disposeByHook = new Map();
+ const toolDisposers: jest.Mock[] = [];
+ const options = {
+ registerToolExtension: jest.fn(() => {
+ const dispose = jest.fn();
+ toolDisposers.push(dispose);
+ return dispose;
+ }),
+ registerScreen: jest.fn((screen: { name: string }) => {
+ const dispose = jest.fn();
+ disposeByScreen.set(screen.name, dispose);
+ return dispose;
+ }),
+ registerSettingsSection: jest.fn(() => jest.fn()),
+ registerSlot: jest.fn((name: string) => {
+ const dispose = jest.fn();
+ disposeBySlot.set(name, dispose);
+ return dispose;
+ }),
+ registerHook: jest.fn((name: string) => {
+ const dispose = jest.fn();
+ disposeByHook.set(name, dispose);
+ return dispose;
+ }),
+ };
+ const pro = require('../../pro') as typeof import('../../pro');
+
+ pro.configureProEntitlementProvider(jest.fn());
+ pro.activate(options as Parameters[0]);
+ expect(mockClipboardEntitlement).toHaveBeenLastCalledWith(true);
+ expect(mockEmailCalendarEntitlement).toHaveBeenLastCalledWith(true);
+
+ mockLicenseInfoListener?.({ isPro: false });
+ for (let index = 0; index < 20; index += 1) await Promise.resolve();
+
+ expect(mockClipboardEntitlement).toHaveBeenLastCalledWith(false);
+ expect(mockEmailCalendarEntitlement).toHaveBeenLastCalledWith(false);
+ expect(toolDisposers).toHaveLength(2);
+ expect(toolDisposers.every(dispose => dispose.mock.calls.length === 1)).toBe(true);
+ expect(disposeByScreen.get('McpServers')).toHaveBeenCalledTimes(1);
+ expect(disposeByScreen.get('McpTools')).toHaveBeenCalledTimes(1);
+ expect(disposeByScreen.get('McpGuide')).toHaveBeenCalledTimes(1);
+ expect(disposeByScreen.get('AudioSettings')).toHaveBeenCalledTimes(1);
+ expect(disposeBySlot.get('app.root')).toHaveBeenCalledTimes(1);
+ expect(disposeBySlot.get('audio.slot')).toHaveBeenCalledTimes(1);
+ expect(disposeByHook.get('audio.hook')).toHaveBeenCalledTimes(1);
+ expect(mockAudioCleanup).toHaveBeenCalledTimes(1);
+ expect(mockGrantCleanup).toHaveBeenCalledTimes(1);
+ expect(mockReconcileCleanup).toHaveBeenCalledTimes(1);
+ expect(mockDisconnectServer).toHaveBeenCalledWith('server-1');
+
+ // Entitlement-recovery Sync stays registered so the user can reactivate.
+ expect(disposeByScreen.get('Sync')).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/pro/sync/KnownDevicesSection.integration.test.tsx b/__tests__/pro/sync/KnownDevicesSection.integration.test.tsx
new file mode 100644
index 000000000..59bbd87a0
--- /dev/null
+++ b/__tests__/pro/sync/KnownDevicesSection.integration.test.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { fireEvent, render, waitFor } from '@testing-library/react-native';
+import type { SyncControlAction, SyncManagedDevice } from '@offgrid/sync';
+import { KnownDevicesSection } from '../../../pro/ui/SyncScreen/KnownDevicesSection';
+
+const enabled: SyncControlAction = { visible: true, enabled: true };
+const hidden: SyncControlAction = { visible: false, enabled: false };
+
+function savedDevice(id: string, name: string): SyncManagedDevice {
+ return {
+ id,
+ name,
+ platform: 'macos',
+ version: '1',
+ host: '127.0.0.1',
+ port: 37878,
+ saved: true,
+ state: 'offline',
+ onNetwork: false,
+ route: { kind: 'unknown', label: 'Unknown' },
+ availableRoutes: [],
+ actions: {
+ pair: hidden,
+ pairAgain: hidden,
+ reconnect: enabled,
+ disconnect: hidden,
+ rename: hidden,
+ evict: enabled,
+ retryEviction: hidden,
+ dismissEviction: hidden,
+ sendModel: hidden,
+ },
+ evictionConfirmation: {
+ title: `Forget ${name}?`,
+ description: 'This removes the saved device.',
+ confirmLabel: 'Forget',
+ },
+ };
+}
+
+describe(' action ownership', () => {
+ it('shows reconnect progress only on the device being reconnected', async () => {
+ let finishReconnect!: () => void;
+ const onReconnect = jest.fn(
+ () =>
+ new Promise(resolve => {
+ finishReconnect = resolve;
+ }),
+ );
+ const devices = [
+ savedDevice('mac-debug', 'OGAD: Mac (Debug)'),
+ savedDevice('iphone-debug', 'iPhone (Debug)'),
+ ];
+ const ui = render(
+ 'reconnected')}
+ onDisconnect={jest.fn(() => true)}
+ onReconnect={onReconnect}
+ onSetManualEndpoint={jest.fn()}
+ manualEndpointDeviceIds={[]}
+ onSendModel={jest.fn()}
+ onForget={jest.fn(async () => undefined)}
+ />,
+ );
+
+ fireEvent.press(ui.getByTestId('sync-reconnect-mac-debug'));
+
+ expect(
+ await ui.findByTestId('sync-reconnect-loader-mac-debug'),
+ ).toBeTruthy();
+ expect(ui.queryByTestId('sync-reconnect-loader-iphone-debug')).toBeNull();
+ expect(ui.getByTestId('sync-reconnect-iphone-debug')).toBeTruthy();
+
+ finishReconnect();
+ await waitFor(() =>
+ expect(ui.queryByTestId('sync-reconnect-loader-mac-debug')).toBeNull(),
+ );
+ });
+});
diff --git a/__tests__/pro/sync/actionApproval.integration.test.ts b/__tests__/pro/sync/actionApproval.integration.test.ts
new file mode 100644
index 000000000..ef5a545e0
--- /dev/null
+++ b/__tests__/pro/sync/actionApproval.integration.test.ts
@@ -0,0 +1,103 @@
+import { useActionApprovalStore } from '../../../pro/mcp/actionApprovalStore';
+import { projectNotificationCenter } from '../../../pro/sync/notificationCenter';
+import { MobileStateMaterializer } from '../../../pro/sync/mobileStateMaterializer';
+import { ComputerApprovalCard } from '../../../pro/ui/ComputerApprovalCard';
+import { useChatStore } from '../../../src/stores/chatStore';
+import React from 'react';
+import { act, render } from '@testing-library/react-native';
+
+const pending = {
+ version: 1 as const,
+ actionId: 'action-1',
+ executionChatId: 'chat-action-1',
+ title: 'Generate the proposal deck',
+ detail: 'Use the selected source folder.',
+ args: { sourceFolder: '/private/source' },
+ risk: 'mutate',
+ status: 'pending' as const,
+ createdAt: 10,
+ updatedAt: 10,
+};
+
+const origin = {
+ originDeviceId: 'desktop-origin',
+ originDeviceName: 'Off Grid AI Desktop',
+};
+
+describe('durable Action approval projection', () => {
+ beforeEach(() => {
+ useChatStore.getState().clearAllConversations();
+ useActionApprovalStore
+ .getState()
+ .applySynced(
+ { ...pending, status: 'executed', updatedAt: 1 },
+ 'desktop-origin',
+ );
+ });
+
+ it('keeps one request in its exact synced chat with no separate notification or badge', () => {
+ const materializer = new MobileStateMaterializer();
+ materializer.put(
+ 'conversation',
+ pending.executionChatId,
+ {
+ title: pending.title,
+ created_at: new Date(10).toISOString(),
+ updated_at: new Date(10).toISOString(),
+ project_id: null,
+ },
+ origin,
+ );
+ materializer.put('action_approval', pending.actionId, pending, origin);
+ materializer.put(
+ 'action_approval',
+ pending.actionId,
+ { ...pending, title: 'Generate the final proposal deck' },
+ origin,
+ );
+
+ expect(useActionApprovalStore.getState().pending).toEqual([
+ expect.objectContaining({
+ actionId: 'action-1',
+ title: 'Generate the final proposal deck',
+ deviceId: 'desktop-origin',
+ synced: expect.objectContaining({ executionChatId: 'chat-action-1' }),
+ }),
+ ]);
+ const notifications = projectNotificationCenter([], 'all');
+ expect(notifications.badgeCount).toBe(0);
+ expect(notifications.items).not.toContainEqual(
+ expect.objectContaining({ type: 'action-approval' }),
+ );
+
+ act(() => useChatStore.getState().setActiveConversation('another-chat'));
+ const card = render(React.createElement(ComputerApprovalCard));
+ expect(card.queryByTestId('computer-approval-card')).toBeNull();
+
+ act(() =>
+ useChatStore.getState().setActiveConversation(pending.executionChatId),
+ );
+ expect(card.getByTestId('computer-approval-card')).toBeTruthy();
+ expect(card.getByText('Generate the final proposal deck')).toBeTruthy();
+ expect(card.getByText('/private/source')).toBeTruthy();
+ expect(card.getByText('Approve')).toBeTruthy();
+ expect(card.getByText('Decline')).toBeTruthy();
+ expect(card.queryByText('Open chat')).toBeNull();
+
+ act(() =>
+ materializer.put(
+ 'action_approval',
+ pending.actionId,
+ {
+ ...pending,
+ status: 'executed',
+ result: 'Deck created',
+ updatedAt: 30,
+ },
+ origin,
+ ),
+ );
+ expect(useActionApprovalStore.getState().pending).toEqual([]);
+ expect(card.queryByTestId('computer-approval-card')).toBeNull();
+ });
+});
diff --git a/__tests__/pro/sync/clipboardSync.integration.test.tsx b/__tests__/pro/sync/clipboardSync.integration.test.tsx
index fa2f31b0a..73b99b0ed 100644
--- a/__tests__/pro/sync/clipboardSync.integration.test.tsx
+++ b/__tests__/pro/sync/clipboardSync.integration.test.tsx
@@ -203,6 +203,53 @@ describe('mobile clipboard Sync journey', () => {
jest.restoreAllMocks();
});
+ it('rejects remote clipboard text as soon as the entitlement closes', async () => {
+ const nativeClipboard = new ClipboardBoundary();
+ let receiveRemote:
+ | ((deviceId: string, channel: string, data: unknown) => void)
+ | undefined;
+ const service = new MobileClipboardSyncService({
+ nativeClipboard,
+ preferences: new ClipboardPreferences(),
+ localDevice: async () => device('this-phone', 'ios'),
+ transport: {
+ sendApp: () => true,
+ connectedDeviceIds: () => ['paired-mac'],
+ thisDeviceName: () => 'This phone',
+ deviceName: () => 'Paired Mac',
+ onAppMessage: listener => {
+ receiveRemote = listener;
+ return () => {
+ receiveRemote = undefined;
+ };
+ },
+ },
+ now: () => BASE_TIME,
+ });
+ service.setEntitlementActive(true);
+ await service.setEnabled(true);
+ receiveRemote?.('paired-mac', CLIPBOARD_CHANNEL, {
+ t: 'text',
+ text: 'arrived before expiry',
+ ts: BASE_TIME,
+ });
+ await waitFor(() =>
+ expect(nativeClipboard.writes).toEqual(['arrived before expiry']),
+ );
+
+ service.setEntitlementActive(false);
+ receiveRemote?.('paired-mac', CLIPBOARD_CHANNEL, {
+ t: 'text',
+ text: 'arrived after expiry',
+ ts: BASE_TIME + 1,
+ });
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(nativeClipboard.writes).toEqual(['arrived before expiry']);
+ expect(service.enabled()).toBe(false);
+ await service.stop();
+ });
+
it('syncs opted-in native clipboard text once over the encrypted app channel', async () => {
const tcpModule = createNativeTcpBoundary() as RnTcpModule;
const mobileDevice = device('mobile-clipboard', 'ios');
diff --git a/__tests__/pro/sync/deviceManagement.integration.test.tsx b/__tests__/pro/sync/deviceManagement.integration.test.tsx
index e4ff14307..dd887991a 100644
--- a/__tests__/pro/sync/deviceManagement.integration.test.tsx
+++ b/__tests__/pro/sync/deviceManagement.integration.test.tsx
@@ -9,7 +9,7 @@ import {
} from '@testing-library/react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import TcpSocket from 'react-native-tcp-socket';
-import type { DeviceInfo } from '@offgrid/sync';
+import { OFFGRID_SYNC_PORT, type DeviceInfo } from '@offgrid/sync';
import type { RnTcpModule } from '@offgrid/sync/rn';
import { AppNavigator } from '../../../src/navigation/AppNavigator';
import {
@@ -31,7 +31,11 @@ import { SyncHomeCard } from '../../../pro/ui/SyncHomeCard';
import { ProRoot } from '../../../pro/ui/ProRoot';
import {
getDiscoveryBoundaries,
+ getTcpDials,
resetDiscoveryBoundaries,
+ resetTcpDials,
+ resetTcpPortRoutes,
+ routeTcpPort,
} from '../../utils/nativeSyncBoundaries';
import {
pairingCodeOnScreen,
@@ -97,6 +101,7 @@ describe('Pro mobile saved-device management journey', () => {
mesh.reset();
await AsyncStorage.clear();
resetDiscoveryBoundaries();
+ resetTcpPortRoutes();
_clearScreensForTesting();
_clearSlotsForTesting();
_clearSectionsForTesting();
@@ -209,7 +214,9 @@ describe('Pro mobile saved-device management journey', () => {
expect(within(connectedRow).getByText(/Connected · WiFi/)).toBeTruthy();
expect(within(connectedRow).queryByLabelText(/Rename/)).toBeNull();
fireEvent.press(ui.getByTestId('sync-rename-this-device'));
- expect(await waitFor(() => ui!.getByText('Rename this device'))).toBeTruthy();
+ expect(
+ await waitFor(() => ui!.getByText('Rename this device')),
+ ).toBeTruthy();
fireEvent.changeText(
ui.getByTestId('sync-rename-this-device-input'),
'Travel Phone',
@@ -420,6 +427,159 @@ describe('Pro mobile saved-device management journey', () => {
).toEqual([remoteDevice.id]);
});
+ it('saves one private endpoint and reconnects only to that address after restart', async () => {
+ mesh.register({
+ id: 'desktop-private-peer',
+ name: 'Travel Desktop',
+ platform: 'macos',
+ });
+ const remoteDevice: DeviceInfo = {
+ id: 'desktop-private-peer',
+ name: 'Travel Desktop',
+ platform: 'macos',
+ version: '1',
+ host: '127.0.0.1',
+ port: 0,
+ };
+ const remotePersistence = new MembershipPersistenceBoundary();
+ remote = buildSyncEngine({
+ pairingEntitlement: mesh.peer(),
+ localDevice: remoteDevice,
+ tcpModule: nativeTcpBoundary,
+ getPassphrase: async () => TYPED_PAIRING_CODE,
+ getSharedSecret: deviceId =>
+ remotePersistence.getActive(deviceId)?.sharedSecret,
+ pairingPersistence: remotePersistence,
+ membershipPersistence: remotePersistence,
+ });
+ await remote.engine.start(0);
+ remoteDevice.port = remote.transport.boundPort ?? 0;
+ await syncService.start();
+
+ ui = render(
+ <>
+
+
+
+
+ >,
+ );
+ await waitFor(() => expect(ui!.getByTestId('sync-home-card')).toBeTruthy());
+ fireEvent.press(ui.getByTestId('open-sync-from-home'));
+
+ const mobile = useSyncStore.getState().thisDevice;
+ const discovery = getDiscoveryBoundaries().at(-1);
+ if (!mobile || !discovery?.publishedPort) {
+ throw new Error('Sync did not publish the mobile device');
+ }
+ await remote.engine.pair(
+ { ...mobile, host: '127.0.0.1', port: discovery.publishedPort },
+ await pairingCodeOnScreen(ui),
+ );
+ await waitFor(() =>
+ expect(
+ within(ui!.getByTestId(`sync-paired-${remoteDevice.id}`)).getByText(
+ /Connected/,
+ ),
+ ).toBeTruthy(),
+ );
+
+ fireEvent.press(ui.getByTestId(`sync-disconnect-${remoteDevice.id}`));
+ await waitFor(() =>
+ expect(
+ within(ui!.getByTestId(`sync-paired-${remoteDevice.id}`)).getByText(
+ /Offline/,
+ ),
+ ).toBeTruthy(),
+ );
+
+ fireEvent.press(ui.getByTestId(`sync-manual-endpoint-${remoteDevice.id}`));
+ expect(
+ await waitFor(() => ui!.getByText('Connect by address')),
+ ).toBeTruthy();
+ expect(ui.getByText(/Only your devices can read it/)).toBeTruthy();
+ fireEvent.changeText(
+ ui.getByTestId('sync-manual-address-input'),
+ '100.100.20.30',
+ );
+ expect(ui.queryByTestId('sync-manual-port-input')).toBeNull();
+ routeTcpPort(OFFGRID_SYNC_PORT, remoteDevice.port);
+ const scansBeforeConnect = discovery.scanCount;
+ resetTcpDials();
+ fireEvent.press(ui.getByTestId('sync-manual-endpoint-connect'));
+
+ await waitFor(() =>
+ expect(
+ within(ui!.getByTestId(`sync-paired-${remoteDevice.id}`)).getByText(
+ /Connected/,
+ ),
+ ).toBeTruthy(),
+ );
+ expect(getTcpDials()).toContainEqual({
+ host: '100.100.20.30',
+ port: OFFGRID_SYNC_PORT,
+ });
+ expect(discovery.scanCount).toBe(scansBeforeConnect);
+
+ await syncService.stop();
+ ui.unmount();
+ ui = undefined;
+ await syncService.start();
+ const restartedDiscovery = getDiscoveryBoundaries().at(-1);
+ if (!restartedDiscovery) throw new Error('Sync discovery did not restart');
+ expect(syncService.manualEndpoint(remoteDevice.id)).toEqual({
+ deviceId: remoteDevice.id,
+ host: '100.100.20.30',
+ });
+ resetTcpDials();
+ await syncService.reconnectDevice(remoteDevice.id);
+
+ expect(getTcpDials()).toContainEqual({
+ host: '100.100.20.30',
+ port: OFFGRID_SYNC_PORT,
+ });
+ });
+
+ it('stops nearby browsing and keeps the saved Sync port after restart', async () => {
+ await syncService.start();
+ ui = render(
+ <>
+
+
+
+
+ >,
+ );
+ await waitFor(() => expect(ui!.getByTestId('sync-home-card')).toBeTruthy());
+ fireEvent.press(ui.getByTestId('open-sync-from-home'));
+
+ const discovery = getDiscoveryBoundaries().at(-1);
+ if (!discovery) throw new Error('Sync discovery did not start');
+ const stopsBefore = discovery.stopCount;
+ fireEvent(ui.getByTestId('sync-toggle-browsing'), 'valueChange', false);
+ await waitFor(() => {
+ expect(discovery.stopCount).toBeGreaterThan(stopsBefore);
+ expect(ui!.getByTestId('sync-browsing-off')).toBeTruthy();
+ });
+
+ fireEvent.press(ui.getByTestId('sync-open-connection-settings'));
+ expect(await waitFor(() => ui!.getByTestId('sync-port-input'))).toBeTruthy();
+ fireEvent.changeText(ui.getByTestId('sync-port-input'), '40123');
+ fireEvent.press(ui.getByTestId('sync-port-save'));
+ await waitFor(() => {
+ expect(ui!.queryByTestId('sync-port-input')).toBeNull();
+ expect(useSyncStore.getState().syncPort).toBe(40123);
+ });
+
+ await syncService.stop();
+ ui.unmount();
+ ui = undefined;
+ await syncService.start();
+
+ expect(useSyncStore.getState().browsing).toBe(false);
+ expect(useSyncStore.getState().syncPort).toBe(40123);
+ });
+
it('shows Mobile-initiated cancel, code, and persistence failures before a clean retry', async () => {
// This desktop holds an installation, as any licensed Mac does. Reconciliation RETIRES a device it
// finds locally trusted but absent from the licence, so an unregistered peer is un-pairable by
diff --git a/__tests__/pro/sync/discoverabilityControl.integration.test.ts b/__tests__/pro/sync/discoverabilityControl.integration.test.ts
new file mode 100644
index 000000000..e11d2719c
--- /dev/null
+++ b/__tests__/pro/sync/discoverabilityControl.integration.test.ts
@@ -0,0 +1,93 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import type { NativeSync } from '../../../src/services/sync/nativeSync';
+import { discoverabilityControl } from '../../../pro/sync/discoverabilityControl';
+import { DiscoverabilityPreference } from '../../../pro/sync/discoverabilityPreference';
+import { useSyncStore } from '../../../pro/sync/syncStore';
+
+class DiscoverabilityRuntimeBoundary {
+ readonly calls: boolean[] = [];
+ current = true;
+ failure: Error | undefined;
+
+ async setDiscoverable(next: boolean): Promise {
+ this.calls.push(next);
+ if (this.failure) throw this.failure;
+ this.current = next;
+ return this.current;
+ }
+
+ isDiscoverable(): boolean {
+ return this.current;
+ }
+}
+
+describe('discoverability state follows the native result', () => {
+ beforeEach(async () => {
+ await AsyncStorage.clear();
+ await discoverabilityControl.hydrate(true);
+ discoverabilityControl.bind(() => null);
+ });
+
+ afterEach(() => jest.restoreAllMocks());
+
+ it('keeps UI and storage on the last true state when native stop fails, then retries', async () => {
+ const runtime = new DiscoverabilityRuntimeBoundary();
+ await new DiscoverabilityPreference().set(true);
+ await discoverabilityControl.hydrate(true);
+ discoverabilityControl.bind(() => runtime as unknown as NativeSync);
+ runtime.failure = new Error('iOS is still advertising.');
+
+ await expect(discoverabilityControl.set(false)).rejects.toThrow(
+ 'iOS is still advertising.',
+ );
+
+ expect(runtime.current).toBe(true);
+ expect(useSyncStore.getState()).toMatchObject({
+ discoverable: true,
+ discoverablePending: false,
+ });
+ await expect(
+ new DiscoverabilityPreference().load(),
+ ).resolves.toBe(true);
+
+ runtime.failure = undefined;
+ await expect(discoverabilityControl.set(false)).resolves.toBe(false);
+ expect(runtime.current).toBe(false);
+ expect(useSyncStore.getState()).toMatchObject({
+ discoverable: false,
+ discoverablePending: false,
+ });
+ await expect(
+ new DiscoverabilityPreference().load(),
+ ).resolves.toBe(false);
+ expect(runtime.calls).toEqual([false, false]);
+ });
+
+ it('rolls native back when persistence fails, then applies the next retry', async () => {
+ const runtime = new DiscoverabilityRuntimeBoundary();
+ await new DiscoverabilityPreference().set(true);
+ await discoverabilityControl.hydrate(true);
+ discoverabilityControl.bind(() => runtime as unknown as NativeSync);
+ jest
+ .spyOn(AsyncStorage, 'setItem')
+ .mockRejectedValueOnce(new Error('Storage is unavailable.'));
+
+ await expect(discoverabilityControl.set(false)).rejects.toThrow(
+ 'Storage is unavailable.',
+ );
+
+ expect(runtime.current).toBe(true);
+ expect(runtime.calls).toEqual([false, true]);
+ expect(useSyncStore.getState()).toMatchObject({
+ discoverable: true,
+ discoverablePending: false,
+ });
+ await expect(
+ new DiscoverabilityPreference().load(),
+ ).resolves.toBe(true);
+
+ await expect(discoverabilityControl.set(false)).resolves.toBe(false);
+ expect(runtime.current).toBe(false);
+ expect(runtime.calls).toEqual([false, true, false]);
+ });
+});
diff --git a/__tests__/pro/sync/modelTransfer.integration.test.tsx b/__tests__/pro/sync/modelTransfer.integration.test.tsx
index d143aa0ce..282fd3156 100644
--- a/__tests__/pro/sync/modelTransfer.integration.test.tsx
+++ b/__tests__/pro/sync/modelTransfer.integration.test.tsx
@@ -372,6 +372,13 @@ describe('Pro mobile model transfer journey', () => {
).resolves.toBe(false);
fireEvent.press(ui.getByLabelText('Back'));
+ const whisperPath = `${modelTransferFsBoundary.DocumentDirectoryPath}/whisper-models/ggml-base.bin`;
+ const whisperBytes = Buffer.alloc(11 * 1024 * 1024);
+ await modelTransferFsBoundary.module.writeFile(
+ whisperPath,
+ whisperBytes.toString('base64'),
+ 'base64',
+ );
fireEvent.press(ui.getByTestId(`sync-send-model-${remoteDevice.id}`));
await waitFor(() =>
expect(
@@ -402,14 +409,34 @@ describe('Pro mobile model transfer journey', () => {
),
).toBeTruthy(),
);
+ // The same aggregated picker resolves Whisper from its own disk registry and sends the real
+ // package to a Mac. This is the Android/iOS -> macOS route that was absent when transfer queried
+ // only the text-model registry.
+ returnedModel = undefined;
+ returnedFileName = undefined;
+ fireEvent.press(
+ ui.getByTestId('transfer-model-ggerganov/whisper.cpp/base'),
+ );
+ fireEvent.press(ui.getByTestId('send-selected-model'));
+ await waitFor(
+ () =>
+ expect(
+ ui!.getByText(`Whisper Base is available on ${remoteDevice.name}.`),
+ ).toBeTruthy(),
+ // This moves a real 11 MB model through the transport. The full pre-push suite runs other
+ // integration tests at the same time, so allow the transfer to finish under that load.
+ { timeout: 60000 },
+ );
+ expect(returnedFileName).toBe('ggml-base.bin');
+ expect(returnedModel).toEqual(whisperBytes);
// Not asserted: the sheet's "Sent " progress line. The completion state replaces it, so
// matching it means catching a moment that has already passed - and the outcome is covered twice
// over, by the sentence the user reads and by the peer holding the exact bytes.
- });
+ }, 90_000);
// A phone whose every model is vision-capable used to be told it had nothing to send: the send side
// refused any model with an mmproj, while the receiving side had installed those packages all along.
- it('offers a vision package to a paired device and withholds a runtime that device cannot run', async () => {
+ it('offers vision and Whisper packages to a Mac and withholds a runtime it cannot run', async () => {
const vision = createVisionModel({
id: 'google/gemma-4-E2B/gemma-4-E2B-it-Q4_K_M.gguf',
name: 'Gemma 4 E2B',
@@ -447,10 +474,14 @@ describe('Pro mobile model transfer journey', () => {
{ ...liteRT, filePath: `${modelsDir}/${liteRT.fileName}` },
]),
);
- const iPhone: DeviceInfo = {
- id: 'paired-iphone',
- name: 'iPhone',
- platform: 'ios',
+ modelTransferFsBoundary.seedFile(
+ `${modelTransferFsBoundary.DocumentDirectoryPath}/whisper-models/ggml-base.bin`,
+ 142 * 1024 * 1024,
+ );
+ const mac: DeviceInfo = {
+ id: 'paired-mac',
+ name: 'Mac',
+ platform: 'macos',
version: '1.0.0',
host: '192.168.1.20',
port: 51000,
@@ -458,7 +489,7 @@ describe('Pro mobile model transfer journey', () => {
ui = render(
- {}} />
+ {}} />
,
);
@@ -468,6 +499,11 @@ describe('Pro mobile model transfer journey', () => {
);
// LiteRT exists only on Android, so an iPhone is never offered one.
expect(ui.queryByTestId(`transfer-model-${liteRT.id}`)).toBeNull();
+ // Download Manager and model transfer both discover Whisper from its real disk registry.
+ expect(
+ ui.getByTestId('transfer-model-ggerganov/whisper.cpp/base'),
+ ).toBeTruthy();
+ expect(ui.getByText('Whisper Base')).toBeTruthy();
// Its size is the whole package, not just the primary file.
expect(ui.getByText(/4\.5 GB|4\.49 GB/)).toBeTruthy();
});
@@ -523,7 +559,8 @@ describe('Pro mobile model transfer journey', () => {
direction: 'send',
peerDeviceId: target.id,
peerPlatform: target.platform,
- modelId: moving.id,
+ modelId: 'model-package-v1:exact-transcription-variant',
+ requestedModelId: moving.id,
modelName: moving.name,
fileCount: 1,
bytesTotal: moving.fileSize,
diff --git a/__tests__/pro/sync/stateOpStore.integration.test.ts b/__tests__/pro/sync/stateOpStore.integration.test.ts
new file mode 100644
index 000000000..aa57600ce
--- /dev/null
+++ b/__tests__/pro/sync/stateOpStore.integration.test.ts
@@ -0,0 +1,49 @@
+import { installRealSqlite } from '../../harness/sqliteFake';
+
+/**
+ * Mobile startup must compact the durable op log before whole-record snapshots enter the JS heap.
+ *
+ * This uses the real Pro store, the real shared sync store, and a real in-memory SQLite engine. Only
+ * the native op-sqlite binding is replaced. Repeated large snapshots are the production failure:
+ * loading all of them before the normal in-memory compaction can exhaust a phone's process.
+ */
+describe('Pro state op store startup', () => {
+ it('deletes superseded snapshots before returning the startup log', async () => {
+ installRealSqlite();
+
+ const { StateOpStore } = require('../../../pro/sync/stateOpStore');
+ const { countOps } = require('@offgrid/sync');
+ const { opStoreDriver } = require('../../../pro/sync/opStoreDriver');
+ const store = new StateOpStore();
+
+ await store.load();
+ store.append({
+ opId: 'old-task',
+ entity: 'task',
+ entityId: 'task-1',
+ kind: 'put',
+ fields: { text: 'old '.repeat(100_000) },
+ lamport: 1,
+ deviceId: 'phone',
+ ts: 1,
+ });
+ store.append({
+ opId: 'current-task',
+ entity: 'task',
+ entityId: 'task-1',
+ kind: 'put',
+ fields: { text: 'current' },
+ lamport: 2,
+ deviceId: 'phone',
+ ts: 2,
+ });
+ expect(countOps(opStoreDriver)).toBe(2);
+
+ const loaded = await new StateOpStore().load();
+
+ expect(loaded.map((op: { opId: string }) => op.opId)).toEqual([
+ 'current-task',
+ ]);
+ expect(countOps(opStoreDriver)).toBe(1);
+ });
+});
diff --git a/__tests__/pro/ui/modelTransferStatus.test.tsx b/__tests__/pro/ui/modelTransferStatus.test.tsx
index 1133224cf..f2caeb9d3 100644
--- a/__tests__/pro/ui/modelTransferStatus.test.tsx
+++ b/__tests__/pro/ui/modelTransferStatus.test.tsx
@@ -161,7 +161,7 @@ describePro('the model transfer card', () => {
expect(ui.queryByText('25%')).not.toBeNull();
});
- it('shows 0% rather than dividing by a total nobody has sent yet', () => {
+ it('shows indeterminate progress rather than dividing by a total nobody has sent yet', () => {
// A queued transfer has no total until the offer is answered. NaN% is what an unguarded division renders.
const ui = render(
{
/>,
);
- expect(ui.queryByText('0%')).not.toBeNull();
+ expect(ui.queryByText('In progress')).not.toBeNull();
+ expect(ui.queryByText('Rate unavailable')).not.toBeNull();
expect(ui.queryByText('NaN%')).toBeNull();
});
diff --git a/__tests__/pro/ui/transferActivitySection.test.tsx b/__tests__/pro/ui/transferActivitySection.test.tsx
index 231e125c0..685cb5369 100644
--- a/__tests__/pro/ui/transferActivitySection.test.tsx
+++ b/__tests__/pro/ui/transferActivitySection.test.tsx
@@ -305,6 +305,31 @@ describePro('the Activity list', () => {
expect(ui.getByText(/25%/)).toBeTruthy();
expect(ui.getByText(/MB \/ /)).toBeTruthy();
expect(ui.queryByText(/MB\/s/)).toBeNull();
+ expect(ui.getByText(/Rate unavailable/)).toBeTruthy();
+ });
+
+ it('shows the live rate for an ordinary file transfer', () => {
+ if (!guard()) return;
+ const acts = handlers();
+ const ui = render(
+ ,
+ );
+
+ expect(ui.getByText('4 MB / 8 MB · 1.5 MB/s')).toBeTruthy();
});
it('shows one model job instead of one raw row for every file in a vision package', async () => {
diff --git a/__tests__/rntl/components/ChatInputModeToggle.test.tsx b/__tests__/rntl/components/ChatInputModeToggle.test.tsx
index 902121b87..9b81627f2 100644
--- a/__tests__/rntl/components/ChatInputModeToggle.test.tsx
+++ b/__tests__/rntl/components/ChatInputModeToggle.test.tsx
@@ -1,11 +1,10 @@
/**
* ChatInputModeToggle tests
*
- * The pro-only Chat→Voice interface toggle in the chat-input pill row. It's a chip
- * that opens a dropdown; choosing "Voice":
+ * The pro-only Text/Voice interface control is one direct icon toggle:
* - when the voice model is NOT downloaded → routes to the Models Voice tab
* - when downloaded → flips interfaceMode inline (chat→audio)
- * - when the chip is disabled → does nothing (menu never opens)
+ * - when the control is disabled → does nothing
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
@@ -22,15 +21,12 @@ jest.mock('@offgrid/core/utils/haptics', () => ({
import { ChatInputModeToggle } from '../../../pro/audio/ui/ChatInputModeToggle';
import { useTTSStore } from '../../../pro/audio/ttsStore';
-// The chip opens its dropdown via chipRef.measureInWindow(...) → setOpen(true).
-// Host instances don't implement it under jest, so shim it to fire the callback.
-beforeAll(() => {
- (require('react-native').View.prototype as any).measureInWindow = (cb: (x: number, y: number, w: number, h: number) => void) => cb(0, 0, 100, 40);
-});
-
// isReady drives the `downloaded` gate (modelDownloaded ?? isReady) the component uses.
-const setDownloaded = (downloaded: boolean, mode: 'chat' | 'audio' = 'chat') => {
- useTTSStore.setState((s) => ({
+const setDownloaded = (
+ downloaded: boolean,
+ mode: 'chat' | 'audio' = 'chat',
+) => {
+ useTTSStore.setState(s => ({
isReady: downloaded,
settings: { ...s.settings, interfaceMode: mode },
}));
@@ -54,7 +50,6 @@ describe('ChatInputModeToggle', () => {
const { getByTestId, getByText } = render();
fireEvent.press(getByTestId('chat-mode-toggle'));
- fireEvent.press(getByTestId('mode-option-audio'));
// No silent switch — a prompt appears and the mode stays on chat.
expect(mockNavigate).not.toHaveBeenCalled();
@@ -62,7 +57,10 @@ describe('ChatInputModeToggle', () => {
// Tapping "Get voice model" routes to the nested Models Voice tab.
fireEvent.press(getByText('Get voice model'));
- expect(mockNavigate).toHaveBeenCalledWith('Main', { screen: 'ModelsTab', params: { initialTab: 'voice' } });
+ expect(mockNavigate).toHaveBeenCalledWith('Main', {
+ screen: 'ModelsTab',
+ params: { initialTab: 'voice' },
+ });
expect(useTTSStore.getState().settings.interfaceMode).toBe('chat');
} finally {
Platform.OS = prevOS;
@@ -74,19 +72,17 @@ describe('ChatInputModeToggle', () => {
const { getByTestId } = render();
fireEvent.press(getByTestId('chat-mode-toggle'));
- fireEvent.press(getByTestId('mode-option-audio'));
expect(mockNavigate).not.toHaveBeenCalled();
expect(useTTSStore.getState().settings.interfaceMode).toBe('audio');
});
- it('does not open the menu when disabled', () => {
+ it('does not change mode when disabled', () => {
setDownloaded(true, 'chat');
- const { getByTestId, queryByTestId } = render();
+ const { getByTestId } = render();
fireEvent.press(getByTestId('chat-mode-toggle'));
- expect(queryByTestId('mode-option-audio')).toBeNull();
expect(useTTSStore.getState().settings.interfaceMode).toBe('chat');
});
});
diff --git a/__tests__/rntl/components/MarkdownText.test.tsx b/__tests__/rntl/components/MarkdownText.test.tsx
index e7483d648..96e9c80b2 100644
--- a/__tests__/rntl/components/MarkdownText.test.tsx
+++ b/__tests__/rntl/components/MarkdownText.test.tsx
@@ -10,10 +10,15 @@
*/
import React from 'react';
-import { render } from '@testing-library/react-native';
+import { fireEvent, render } from '@testing-library/react-native';
+import { Linking } from 'react-native';
import { MarkdownText, preprocessMarkdown } from '../../../src/components/MarkdownText';
describe('MarkdownText', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
it('renders plain text', () => {
const { getByText } = render(Hello world);
expect(getByText(/Hello world/)).toBeTruthy();
@@ -112,6 +117,29 @@ describe('MarkdownText', () => {
const { toJSON } = render({longUrl});
expect(toJSON()).toBeTruthy();
});
+
+ it('opens a Markdown link and refuses an unsafe destination', () => {
+ const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined);
+ const rendered = render(
+ {'[Docs](https://example.com/docs) [Unsafe](ftp://example.com/file)'},
+ );
+
+ fireEvent.press(rendered.getByText('Docs'));
+ fireEvent.press(rendered.getByText('Unsafe'));
+ expect(openURL).toHaveBeenCalledTimes(1);
+ expect(openURL).toHaveBeenCalledWith('https://example.com/docs');
+ expect(rendered.getAllByRole('link')).toHaveLength(2);
+ });
+
+ it('turns a plain web address into a safe clickable link', () => {
+ const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined);
+ const rendered = render(
+ {'Main page: https://github.com/off-grid-ai'},
+ );
+
+ fireEvent.press(rendered.getByRole('link'));
+ expect(openURL).toHaveBeenCalledWith('https://github.com/off-grid-ai');
+ });
});
describe('preprocessMarkdown', () => {
diff --git a/__tests__/rntl/components/McpAddServerSheet.test.tsx b/__tests__/rntl/components/McpAddServerSheet.test.tsx
index de0d59295..ce29aca23 100644
--- a/__tests__/rntl/components/McpAddServerSheet.test.tsx
+++ b/__tests__/rntl/components/McpAddServerSheet.test.tsx
@@ -78,6 +78,13 @@ maybe('McpAddServerSheet', () => {
expect(props.onAddCustom).toHaveBeenCalledTimes(1);
});
+ it('has no "Scan a desktop QR" button - a paired desktop grants tools over the mesh', () => {
+ // The QR-scan pairing was removed: a paired desktop now hands its tools over
+ // the sync mesh, so there is nothing to scan from the add sheet.
+ const { queryByTestId } = render();
+ expect(queryByTestId('scan-desktop-qr')).toBeNull();
+ });
+
it('lists the preset rows', () => {
const props = baseProps();
const { getByTestId } = render();
diff --git a/__tests__/rntl/components/McpServersScreen.test.tsx b/__tests__/rntl/components/McpServersScreen.test.tsx
index e936b0a30..7252655b5 100644
--- a/__tests__/rntl/components/McpServersScreen.test.tsx
+++ b/__tests__/rntl/components/McpServersScreen.test.tsx
@@ -45,7 +45,7 @@ jest.mock('@react-navigation/native', () => {
jest.mock('../../../src/services/tools/extensions', () => ({ getToolExtensions: () => [] }));
const mockAppState = { settings: { enabledTools: [] as string[] }, updateSettings: jest.fn(), activeModelId: undefined, downloadedModels: [] as any[] };
-const mockRemoteState = { activeRemoteTextModelId: 'remote-1' };
+const mockRemoteState = { activeRemoteTextModelId: 'remote-1', servers: [] as any[] };
jest.mock('../../../src/stores', () => ({
useAppStore: (selector?: any) => (selector ? selector(mockAppState) : mockAppState),
useRemoteServerStore: (selector?: any) => (selector ? selector(mockRemoteState) : mockRemoteState),
@@ -55,6 +55,10 @@ jest.mock('../../../pro/mcp/mcpService', () => ({
connectServer: jest.fn(), disconnectServer: jest.fn(), signOutServer: jest.fn(),
}));
+// The paired-desktops tools section pulls in the sync store + grant service (→ syncService,
+// which does not load under jest). This suite is about the MCP server cards, so stub it out.
+jest.mock('../../../pro/ui/CompanionToolsSection', () => ({ CompanionToolsSection: () => null }));
+
type ScreenModule = typeof import('../../../pro/ui/McpServersScreen');
type StoreModule = typeof import('../../../pro/mcp/mcpStore');
diff --git a/__tests__/rntl/components/ModelCard.test.tsx b/__tests__/rntl/components/ModelCard.test.tsx
index 62ee0aaca..75d8dc3bf 100644
--- a/__tests__/rntl/components/ModelCard.test.tsx
+++ b/__tests__/rntl/components/ModelCard.test.tsx
@@ -77,10 +77,26 @@ describe('ModelCard', () => {
/>
);
// Both the size caption and the percent render (full-width bar + left/right row).
- expect(getByText('2.0 GB / 4.0 GB')).toBeTruthy();
+ expect(getByText('2.0 GB / 4.0 GB · Rate unavailable')).toBeTruthy();
expect(getByText('50%')).toBeTruthy();
});
+ it('shows the canonical live rate while downloading', () => {
+ const { getByText } = render(
+
+ );
+ expect(getByText('2.0 GB / 4.0 GB · 2.5 MB/s')).toBeTruthy();
+ });
+
it('shows bytes alongside the Queued label (queued reads "0 B / size")', () => {
const { getByText, getByLabelText } = render(
{
downloadCount={2}
/>
);
- expect(getByText('1.5 GB / 10.0 GB · 2 downloads')).toBeTruthy();
+ expect(getByText('1.5 GB / 10.0 GB · Rate unavailable · 2 downloads')).toBeTruthy();
});
it('omits the "N downloads" note for a single download', () => {
@@ -332,7 +348,7 @@ describe('ModelCard', () => {
downloadCount={1}
/>
);
- expect(getByText('2.0 GB / 4.0 GB')).toBeTruthy();
+ expect(getByText('2.0 GB / 4.0 GB · Rate unavailable')).toBeTruthy();
expect(queryByText(/downloads/)).toBeNull();
});
@@ -1045,14 +1061,16 @@ describe('ModelCard', () => {
expect(getByText('50%')).toBeTruthy();
});
- it('shows 0% when totalBytes is 0 (unknown size)', () => {
- const { getByText } = render(
+ it('does not invent a percentage when the failed download size is unknown', () => {
+ const { getByText, queryByText } = render(
,
);
- expect(getByText('0%')).toBeTruthy();
+ expect(getByText('Stopped')).toBeTruthy();
+ expect(queryByText(/NaN/)).toBeNull();
+ expect(queryByText('0%')).toBeNull();
});
it('hides ModelCardActions when failedState is set', () => {
diff --git a/__tests__/rntl/components/PlaybackControls.test.tsx b/__tests__/rntl/components/PlaybackControls.test.tsx
index 85b1bd080..7d9bed78b 100644
--- a/__tests__/rntl/components/PlaybackControls.test.tsx
+++ b/__tests__/rntl/components/PlaybackControls.test.tsx
@@ -7,7 +7,7 @@
* resumed ("play not clickable") — the tap reached nothing.
*/
import React from 'react';
-import { TouchableOpacity } from 'react-native';
+import { StyleSheet, TouchableOpacity } from 'react-native';
import { render, fireEvent, renderHook } from '@testing-library/react-native';
// Render the icon as a Text carrying its Feather name, so tests can assert which glyph
@@ -22,7 +22,11 @@ import { PlayButton, usePlaybackState } from '../../../pro/audio/ui/AudioMessage
import { useTTSStore } from '../../../pro/audio/ttsStore';
const colors = { primary: '#0f0' } as any;
-const styles = { playButton: {}, playButtonDisabled: {} } as any;
+const styles = {
+ playButton: {},
+ playButtonDisabled: {},
+ playButtonLoader: { paddingHorizontal: 4 },
+} as any;
function renderButton(props: Partial>) {
const onPlayPause = jest.fn();
@@ -75,8 +79,11 @@ describe('PlayButton — touchability (always controllable when this is the acti
});
it('renders a spinner (non-touchable) while THIS is preparing but not yet playing/synth', () => {
- const { UNSAFE_queryAllByType } = renderButton({ isThisLoading: true });
+ const { UNSAFE_queryAllByType, getByTestId } = renderButton({ isThisLoading: true });
expect(UNSAFE_queryAllByType(TouchableOpacity)).toHaveLength(0);
+ expect(StyleSheet.flatten(getByTestId('voice-note-play-loader').props.style)).toEqual(
+ expect.objectContaining({ paddingHorizontal: 4 }),
+ );
});
it('is touchable in the normal idle state', () => {
diff --git a/__tests__/rntl/components/VoiceModelsPanel.test.tsx b/__tests__/rntl/components/VoiceModelsPanel.test.tsx
index b09ddc6fa..96e7ad69e 100644
--- a/__tests__/rntl/components/VoiceModelsPanel.test.tsx
+++ b/__tests__/rntl/components/VoiceModelsPanel.test.tsx
@@ -60,9 +60,11 @@ const actions = {
clearError: jest.fn(),
};
let mockStoreState: any;
-jest.mock('../../../pro/audio/ttsStore', () => ({ useTTSStore: () => mockStoreState }));
+jest.mock('../../../pro/audio/ttsStore', () => ({
+ useTTSStore: (selector?: (state: any) => unknown) =>
+ selector ? selector(mockStoreState) : mockStoreState,
+}));
-import { useFocusEffect } from '@react-navigation/native';
import { VoiceModelsPanel } from '../../../pro/audio/ui/VoiceModelsPanel';
const VOICES = [
@@ -95,12 +97,13 @@ describe('VoiceModelsPanel', () => {
expect(getByText(/nothing is sent anywhere/)).toBeTruthy();
});
- it('lists voices when the model is downloaded and selects one on tap', async () => {
+ it('filters voices by language and selects the first voice when language changes', async () => {
const { getByTestId } = await renderPanel();
expect(getByTestId('voice-af_heart')).toBeTruthy();
- expect(getByTestId('voice-bf_emma')).toBeTruthy();
+ expect(() => getByTestId('voice-bf_emma')).toThrow();
- await act(async () => { fireEvent.press(getByTestId('voice-bf_emma')); });
+ fireEvent.press(getByTestId('models-tts-language'));
+ await act(async () => { fireEvent.press(getByTestId('models-tts-language-en-GB')); });
expect(actions.setVoice).toHaveBeenCalledWith('bf_emma');
});
@@ -131,8 +134,21 @@ describe('VoiceModelsPanel', () => {
it('shows live progress while the service reports downloading', async () => {
mockDownloads = [ttsDl('downloading', 0.4)];
mockStoreState.isReady = false;
- const { getByText } = await renderPanel();
+ const { getByText, queryByText } = await renderPanel();
expect(getByText('40%')).toBeTruthy();
+ expect(getByText('Rate unavailable')).toBeTruthy();
+ expect(queryByText(/NaN/)).toBeNull();
+ });
+
+ it('shows bytes and rate when the engine reports them', async () => {
+ mockDownloads = [ttsDl('downloading', 0.5)];
+ mockStoreState.isReady = false;
+ mockStoreState.downloadCurrentBytes = 25 * 1024 * 1024;
+ mockStoreState.downloadTotalBytes = 50 * 1024 * 1024;
+ mockStoreState.downloadBytesPerSecond = 2 * 1024 * 1024;
+ const { getByText } = await renderPanel();
+ expect(getByText('50%')).toBeTruthy();
+ expect(getByText('25 MB / 50 MB · 2.0 MB/s')).toBeTruthy();
});
it('shows progress (not the idle CTA) for queued and paused too — the shared in-progress predicate', async () => {
@@ -147,12 +163,9 @@ describe('VoiceModelsPanel', () => {
}
});
- it('backfills the persisted-downloaded flag from disk on focus', async () => {
- let focusCb: (() => void) | undefined;
- (useFocusEffect as jest.Mock).mockImplementation((cb: () => void) => { focusCb = cb; });
+ it('backfills the persisted-downloaded flag from disk when the panel opens', async () => {
+ actions.checkDownloadStatus.mockClear();
await renderPanel();
- actions.checkDownloadStatus.mockClear(); // drop the mount-effect call
- await act(async () => { focusCb?.(); await Promise.resolve(); });
expect(actions.checkDownloadStatus).toHaveBeenCalled();
});
});
diff --git a/__tests__/rntl/components/VoiceRecordButton.test.tsx b/__tests__/rntl/components/VoiceRecordButton.test.tsx
index 9885bcd15..d53ca9412 100644
--- a/__tests__/rntl/components/VoiceRecordButton.test.tsx
+++ b/__tests__/rntl/components/VoiceRecordButton.test.tsx
@@ -344,8 +344,8 @@ describe('VoiceRecordButton', () => {
expect(toJSON()).toBeTruthy();
});
- it('shows mic icon in loading state when asSendButton', () => {
- const { toJSON } = render(
+ it('shows loading progress in loading state when asSendButton', () => {
+ const { getByLabelText, getByTestId } = render(
{
/>
);
- const treeStr = JSON.stringify(toJSON());
- // asSendButton + loading shows mic icon
- expect(treeStr).toContain('mic');
+ expect(getByTestId('voice-loading')).toBeTruthy();
+ expect(getByLabelText('Working')).toBeTruthy();
});
it('shows transcribing state without text when asSendButton and transcribing', () => {
@@ -372,8 +371,8 @@ describe('VoiceRecordButton', () => {
expect(toJSON()).toBeTruthy();
});
- it('shows mic icon in transcribing state when asSendButton', () => {
- const { toJSON } = render(
+ it('shows loading progress in transcribing state when asSendButton', () => {
+ const { getByLabelText } = render(
{
/>
);
- const treeStr = JSON.stringify(toJSON());
- // asSendButton + transcribing shows mic icon
- expect(treeStr).toContain('mic');
+ expect(getByLabelText('Working')).toBeTruthy();
});
});
diff --git a/__tests__/rntl/components/companionToolsSection.test.tsx b/__tests__/rntl/components/companionToolsSection.test.tsx
new file mode 100644
index 000000000..6a083d199
--- /dev/null
+++ b/__tests__/rntl/components/companionToolsSection.test.tsx
@@ -0,0 +1,84 @@
+/**
+ * Integration (RNTL): CompanionToolsSection - the single home for desktop tools.
+ *
+ * Proves the moved grant: paired desktops (only desktops) appear in Pro tools with a
+ * switch that reflects whether their tools are connected here (grantedByDeviceId), and
+ * flipping one calls requestTools(deviceId, next) - the same mesh request the old
+ * Devices-row toggle sent. Loaded via a computed path so it skips where pro/ is absent.
+ */
+
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+
+jest.mock('react-native-vector-icons/Feather', () => {
+ const { Text } = require('react-native');
+ return ({ name, ...props }: any) => {name};
+});
+
+jest.mock('../../../src/theme', () => ({
+ useTheme: () => ({
+ colors: {
+ text: '#000', textMuted: '#999', primary: '#1DB954', surface: '#F5F5F5', border: '#E0E0E0',
+ },
+ }),
+}));
+
+const mockState: { knownDevices: unknown[]; servers: unknown[] } = { knownDevices: [], servers: [] };
+const mockRequestTools = jest.fn();
+
+jest.mock('../../../pro/sync/syncStore', () => ({
+ useSyncStore: (selector: (s: unknown) => unknown) =>
+ selector({ knownDevices: mockState.knownDevices }),
+}));
+jest.mock('../../../pro/mcp/mcpStore', () => ({
+ useMcpStore: (selector: (s: unknown) => unknown) => selector({ servers: mockState.servers }),
+}));
+jest.mock('../../../pro/mcp/mcpToolGrantService', () => ({
+ requestTools: (...args: unknown[]) => mockRequestTools(...args),
+}));
+
+type Mod = typeof import('../../../pro/ui/CompanionToolsSection');
+function load(): Mod | null {
+ try {
+ return require(['..', '..', '..', 'pro', 'ui', 'CompanionToolsSection'].join('/'));
+ } catch {
+ return null;
+ }
+}
+
+const mod = load();
+const maybe = mod ? describe : describe.skip;
+
+maybe('CompanionToolsSection', () => {
+ const { CompanionToolsSection } = mod!;
+
+ beforeEach(() => {
+ mockState.knownDevices = [];
+ mockState.servers = [];
+ mockRequestTools.mockClear();
+ });
+
+ it('lists only desktop peers, reflects the grant, and toggles via requestTools', () => {
+ mockState.knownDevices = [
+ { id: 'mac1', name: 'My Mac', platform: 'macos' },
+ { id: 'phone1', name: 'My Phone', platform: 'ios' },
+ ];
+ mockState.servers = [{ id: 's1', grantedByDeviceId: 'mac1' }];
+
+ const { getByTestId, queryByTestId } = render();
+ // Desktop shows; a phone peer (serves no tools) is filtered out.
+ expect(getByTestId('companion-tools-mac1')).toBeTruthy();
+ expect(queryByTestId('companion-tools-phone1')).toBeNull();
+
+ const sw = getByTestId('companion-tools-switch-mac1');
+ expect(sw.props.value).toBe(true); // granted -> on
+ fireEvent(sw, 'valueChange', false);
+ expect(mockRequestTools).toHaveBeenCalledWith('mac1', false);
+ });
+
+ it('renders nothing when there are no paired desktops', () => {
+ mockState.knownDevices = [{ id: 'phone1', name: 'Phone', platform: 'android' }];
+ const { toJSON } = render();
+ expect(toJSON()).toBeNull();
+ });
+});
diff --git a/__tests__/rntl/components/pairingCodeSheet.test.tsx b/__tests__/rntl/components/pairingCodeSheet.test.tsx
new file mode 100644
index 000000000..1ce4dbcf9
--- /dev/null
+++ b/__tests__/rntl/components/pairingCodeSheet.test.tsx
@@ -0,0 +1,120 @@
+/**
+ * Integration (RNTL): PairingCodeSheet scan-to-pair.
+ *
+ * Guards the approved behavior change: a paired-code sheet can be filled by scanning
+ * the other device's QR, not just by typing. A decoded QR carrying a valid pairing
+ * code lands on the SAME onPair (syncService.pair) as the typed path, and a QR that
+ * is not a pairing code is ignored so the scanner keeps looking.
+ *
+ * Lives in the private pro/ submodule, loaded via a computed path so the suite skips
+ * in open-core CI where pro/ is absent.
+ */
+
+import React from 'react';
+import { render, fireEvent, act } from '@testing-library/react-native';
+
+jest.mock('react-native-vector-icons/Feather', () => {
+ const { Text } = require('react-native');
+ return ({ name, ...props }: any) => {name};
+});
+
+// The sheet is a modal wrapper; render its children inline (respecting `visible`, as
+// the real one does) so the test can drive the content and observe it hiding while
+// the scanner is open.
+jest.mock('@offgrid/core/components/AppSheet', () => ({
+ AppSheet: ({ visible, children }: { visible: boolean; children: React.ReactNode }) =>
+ visible ? children : null,
+}));
+
+jest.mock('../../../src/theme', () => {
+ const colors = {
+ text: '#000', textMuted: '#999', primary: '#1DB954', error: '#F00',
+ background: '#FFF', surface: '#F5F5F5', border: '#E0E0E0',
+ };
+ const shadows = { small: {}, medium: {}, large: {} };
+ return {
+ useTheme: () => ({ colors, shadows, isDark: false }),
+ useThemedStyles: (fn: any) => fn(colors, shadows),
+ };
+});
+
+// vision-camera is globally stubbed in jest.setup; capture the scan config here so
+// the test can simulate a decoded QR frame.
+const visionCamera = require('react-native-vision-camera');
+let scanConfig: { onCodeScanned: (codes: { value?: string }[]) => void } | null = null;
+
+type SheetModule = typeof import('../../../pro/ui/SyncScreen/PairingCodeSheet');
+
+function load(): SheetModule | null {
+ try {
+ return require(['..', '..', '..', 'pro', 'ui', 'SyncScreen', 'PairingCodeSheet'].join('/'));
+ } catch {
+ return null;
+ }
+}
+
+const mod = load();
+const maybe = mod ? describe : describe.skip;
+
+// A valid code: every character is in the pairing alphabet.
+const VALID_QR = 'ABCD2345';
+
+maybe('PairingCodeSheet scan-to-pair', () => {
+ const { PairingCodeSheet } = mod!;
+
+ const baseProps = () => ({
+ visible: true,
+ deviceName: 'Studio Mac',
+ confirmLabel: 'Pair',
+ testIDPrefix: 'sync-test',
+ onClose: jest.fn(),
+ onPair: jest.fn().mockResolvedValue(undefined),
+ });
+
+ beforeEach(() => {
+ scanConfig = null;
+ jest.spyOn(visionCamera, 'useCodeScanner').mockImplementation((cfg: any) => {
+ scanConfig = cfg;
+ return cfg;
+ });
+ });
+
+ it('offers a Scan button that opens the camera scanner', () => {
+ const { getByTestId, queryByText, getByText } = render(
+ ,
+ );
+ expect(queryByText('Camera access needed')).toBeNull();
+ fireEvent.press(getByTestId('sync-test-scan'));
+ // Global vision-camera mock reports no permission, so the scanner asks for it -
+ // proof the scanner surface mounted.
+ expect(getByText('Camera access needed')).toBeTruthy();
+ });
+
+ it('hides the pairing sheet while the scanner is open (one modal at a time)', () => {
+ // iOS presents one modal at a time; the sheet must yield so the scanner can show.
+ const { getByTestId, queryByTestId } = render();
+ expect(queryByTestId('sync-test-input')).toBeTruthy();
+ fireEvent.press(getByTestId('sync-test-scan'));
+ expect(queryByTestId('sync-test-input')).toBeNull();
+ });
+
+ it('pairs from a scanned QR via the same onPair as typing', async () => {
+ const props = baseProps();
+ const { getByTestId } = render();
+ fireEvent.press(getByTestId('sync-test-scan'));
+ await act(async () => {
+ scanConfig!.onCodeScanned([{ value: VALID_QR }]);
+ });
+ expect(props.onPair).toHaveBeenCalledWith(VALID_QR);
+ });
+
+ it('ignores a QR that is not a pairing code', async () => {
+ const props = baseProps();
+ const { getByTestId } = render();
+ fireEvent.press(getByTestId('sync-test-scan'));
+ await act(async () => {
+ scanConfig!.onCodeScanned([{ value: 'https://example.com/not-a-code' }]);
+ });
+ expect(props.onPair).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/rntl/screens/DownloadManagerScreen.test.tsx b/__tests__/rntl/screens/DownloadManagerScreen.test.tsx
index 8e2af66e6..dd330340d 100644
--- a/__tests__/rntl/screens/DownloadManagerScreen.test.tsx
+++ b/__tests__/rntl/screens/DownloadManagerScreen.test.tsx
@@ -546,8 +546,31 @@ describe('DownloadManagerScreen', () => {
};
const { getByText } = render();
- // Progress bar is shown but no status text for running downloads
- expect(getByText('256 B / 1 KB')).toBeTruthy();
+ expect(getByText('25% · 256 B / 1 KB · Rate unavailable')).toBeTruthy();
+ });
+
+ it('shows the measured rate for an active download', () => {
+ mockDownloadStoreDownloads = {
+ 'author/model-id/active-model.gguf': {
+ modelKey: 'author/model-id/active-model.gguf',
+ downloadId: 'dl-rate',
+ modelId: 'author/model-id',
+ fileName: 'active-model.gguf',
+ quantization: 'Q4_K_M',
+ modelType: 'text',
+ status: 'running',
+ bytesDownloaded: 512 * 1024,
+ totalBytes: 1024 * 1024,
+ combinedTotalBytes: 1024 * 1024,
+ bytesPerSecond: 128 * 1024,
+ progress: 0.5,
+ createdAt: Date.now(),
+ lastProgressAt: Date.now(),
+ },
+ };
+
+ const { getByText } = render();
+ expect(getByText('50% · 512 KB / 1 MB · 128.0 KB/s')).toBeTruthy();
});
it('does not show storage section when no completed models', () => {
diff --git a/__tests__/rntl/screens/HomeScreen.test.tsx b/__tests__/rntl/screens/HomeScreen.test.tsx
index 3bc2073e0..f3a97f93e 100644
--- a/__tests__/rntl/screens/HomeScreen.test.tsx
+++ b/__tests__/rntl/screens/HomeScreen.test.tsx
@@ -22,6 +22,7 @@ import { render, fireEvent, act, waitFor } from '@testing-library/react-native';
import { NavigationContainer } from '@react-navigation/native';
import { useAppStore } from '../../../src/stores/appStore';
import { useChatStore } from '../../../src/stores/chatStore';
+import { useRemoteServerStore } from '../../../src/stores/remoteServerStore';
import { resetStores, createMultipleConversations } from '../../utils/testHelpers';
import {
createDownloadedModel,
@@ -34,6 +35,7 @@ import {
import { Linking, Clipboard } from 'react-native';
import { OFF_GRID_DESKTOP_URL } from '../../../src/constants';
import { withUtm } from '../../../src/utils/utm';
+import * as networkDiscovery from '../../../src/services/networkDiscovery';
// Mock requestAnimationFrame
(globalThis as any).requestAnimationFrame = (cb: () => void) => {
@@ -322,6 +324,66 @@ describe('HomeScreen', () => {
}
});
+ describe('LAN discovery lifecycle', () => {
+ it('waits for saved remote settings and recovers from a pre-scan remount', async () => {
+ jest.useFakeTimers();
+ const discoverySpy = jest.spyOn(networkDiscovery, 'discoverLANServers').mockResolvedValue([]);
+ let finishHydration: ((state: ReturnType) => void) | undefined;
+ const unsubscribe = jest.fn();
+ const persistApi = useRemoteServerStore.persist;
+ const hydratedSpy = jest.spyOn(persistApi, 'hasHydrated').mockReturnValue(false);
+ const hydrationSpy = jest.spyOn(persistApi, 'onFinishHydration').mockImplementation(callback => {
+ finishHydration = callback;
+ return unsubscribe;
+ });
+ useAppStore.getState().updateSettings({ autoDiscoverRemoteModels: undefined });
+ let firstMount: ReturnType | undefined;
+ let secondMount: ReturnType | undefined;
+
+ try {
+ firstMount = renderHomeScreen();
+ await act(async () => undefined);
+ await act(async () => {
+ jest.advanceTimersByTime(3000);
+ await Promise.resolve();
+ });
+ expect(discoverySpy).not.toHaveBeenCalled();
+
+ useRemoteServerStore.setState({
+ servers: [{ id: 'saved', name: 'Saved gateway', endpoint: 'http://saved.test', providerType: 'ollama' }],
+ } as any);
+ await act(async () => { finishHydration?.(useRemoteServerStore.getState()); });
+ expect(useAppStore.getState().settings.autoDiscoverRemoteModels).toBe(true);
+
+ // Unmount after hydration but before the deferred scan. A later mount
+ // must be able to schedule the scan again from the hydrated store.
+ await act(async () => {
+ jest.advanceTimersByTime(1000);
+ await Promise.resolve();
+ });
+ firstMount.unmount();
+ firstMount = undefined;
+ expect(discoverySpy).not.toHaveBeenCalled();
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
+
+ hydratedSpy.mockReturnValue(true);
+ secondMount = renderHomeScreen();
+ await act(async () => {
+ jest.advanceTimersByTime(3000);
+ await Promise.resolve();
+ });
+ expect(discoverySpy).toHaveBeenCalledTimes(1);
+ } finally {
+ firstMount?.unmount();
+ secondMount?.unmount();
+ hydrationSpy.mockRestore();
+ hydratedSpy.mockRestore();
+ discoverySpy.mockRestore();
+ jest.useRealTimers();
+ }
+ });
+ });
+
// ============================================================================
// Off Grid AI Desktop promo card
// ============================================================================
@@ -607,6 +669,27 @@ describe('HomeScreen', () => {
expect(getByText(/Hi there, how can I help/)).toBeTruthy();
});
+ it('shows a clean enhanced-prompt preview without model protocol', () => {
+ const conv = createConversation({
+ title: 'Draw a dog',
+ messages: [
+ createMessage({ role: 'user', content: 'Draw a dog' }),
+ createMessage({
+ role: 'assistant',
+ content:
+ '__LABEL:Enhanced prompt__\nA sleek black dog in soft morning light.',
+ }),
+ ],
+ });
+ useChatStore.setState({ conversations: [conv] });
+
+ const { getByText, queryByText } = renderHomeScreen();
+ expect(
+ getByText('A sleek black dog in soft morning light.'),
+ ).toBeTruthy();
+ expect(queryByText(/|__LABEL:/)).toBeNull();
+ });
+
it('shows "You: " prefix for last user message', () => {
const conv = createConversation({
title: 'User Preview Test',
diff --git a/__tests__/rntl/screens/ProDetailScreen.test.tsx b/__tests__/rntl/screens/ProDetailScreen.test.tsx
index dd2ed6e01..115492464 100644
--- a/__tests__/rntl/screens/ProDetailScreen.test.tsx
+++ b/__tests__/rntl/screens/ProDetailScreen.test.tsx
@@ -76,8 +76,9 @@ describe('ProDetailScreen', () => {
});
it('renders the Get Pro call-to-action when the user is not Pro', () => {
- const { queryAllByText } = render();
+ const { queryAllByText, queryByText } = render();
expect(queryAllByText('Get Pro').length).toBeGreaterThan(0);
+ expect(queryByText('Use Pro from another device')).toBeNull();
});
it('Get Pro opens the web pay page directly without a modal', () => {
diff --git a/__tests__/rntl/screens/ToolsScreen.test.tsx b/__tests__/rntl/screens/ToolsScreen.test.tsx
index f2680c2ef..757d0858b 100644
--- a/__tests__/rntl/screens/ToolsScreen.test.tsx
+++ b/__tests__/rntl/screens/ToolsScreen.test.tsx
@@ -15,6 +15,7 @@ import { ToolsScreen } from '../../../src/screens/ToolsScreen';
import { AVAILABLE_TOOLS } from '../../../src/services/tools/registry';
import { registerScreen, _clearScreensForTesting } from '../../../src/navigation/screenRegistry';
import { PRO_TOOLS_SCREEN } from '../../../src/hooks/useIsProActive';
+import { useAppStore } from '../../../src/stores/appStore';
const mockNavigate = jest.fn();
const mockGoBack = jest.fn();
@@ -69,6 +70,12 @@ describe('ToolsScreen', () => {
jest.clearAllMocks();
_clearScreensForTesting();
mockEnabledTools = ['web_search', 'calculator'];
+ useAppStore.setState({
+ hasRegisteredPro: false,
+ hasSavedProCredential: false,
+ isProActive: false,
+ proDeviceAdmission: 'unknown',
+ });
});
afterEach(() => {
_clearScreensForTesting();
@@ -95,6 +102,12 @@ describe('ToolsScreen', () => {
});
it('routes a pro user straight to the Pro Tools screen', () => {
+ useAppStore.setState({
+ hasRegisteredPro: true,
+ hasSavedProCredential: true,
+ isProActive: true,
+ proDeviceAdmission: 'active',
+ });
registerScreen({ name: PRO_TOOLS_SCREEN, component: () => null });
const { getByTestId } = render();
fireEvent.press(getByTestId('tools-pro-tools'));
diff --git a/__tests__/unit/components/ensureWhisperForTranscription.test.ts b/__tests__/unit/components/ensureWhisperForTranscription.test.ts
index 38e44db46..d8d30b730 100644
--- a/__tests__/unit/components/ensureWhisperForTranscription.test.ts
+++ b/__tests__/unit/components/ensureWhisperForTranscription.test.ts
@@ -11,7 +11,7 @@ const makeDeps = (over: Partial
const freeGenerationModels = jest.fn(async () => {});
const loadWhisper = jest.fn(async () => 'loaded' as const);
const deps = {
- isLoaded: () => false,
+ isSelectedModelLoaded: () => false,
hasDownloadedModel: () => true,
loadWhisper,
freeGenerationModels,
@@ -22,7 +22,7 @@ const makeDeps = (over: Partial
describe('ensureWhisperForTranscription', () => {
it('returns true immediately when whisper is already loaded (no load, no eviction)', async () => {
- const { deps, freeGenerationModels, loadWhisper } = makeDeps({ isLoaded: () => true });
+ const { deps, freeGenerationModels, loadWhisper } = makeDeps({ isSelectedModelLoaded: () => true });
await expect(ensureWhisperForTranscription(deps)).resolves.toBe(true);
expect(loadWhisper).not.toHaveBeenCalled();
expect(freeGenerationModels).not.toHaveBeenCalled();
diff --git a/__tests__/unit/engine/kokoroLiveState.test.ts b/__tests__/unit/engine/kokoroLiveState.test.ts
index 8d992e9c2..093c1c44d 100644
--- a/__tests__/unit/engine/kokoroLiveState.test.ts
+++ b/__tests__/unit/engine/kokoroLiveState.test.ts
@@ -100,6 +100,7 @@ describe('KokoroEngine — live download lifecycle is the source of truth', () =
);
const p = engine.downloadAssets();
+ await Promise.resolve(); // the engine serializes asset fetches through one microtask queue
// MID-FETCH: the stale flag was reset, phase is 'downloading' → NOT complete.
// FAILS on the old early-return (stale _genuineCompletion faked progress=1 + done).
diff --git a/__tests__/unit/engine/kokoroVoiceCatalog.test.ts b/__tests__/unit/engine/kokoroVoiceCatalog.test.ts
new file mode 100644
index 000000000..b29e48733
--- /dev/null
+++ b/__tests__/unit/engine/kokoroVoiceCatalog.test.ts
@@ -0,0 +1,45 @@
+/**
+ * The real Mobile Kokoro catalog uses the shared customer-facing names and
+ * language metadata, so Desktop and Mobile do not rename the same voice.
+ */
+import {
+ getKokoroAssetSources,
+ getKokoroTTSVoices,
+} from '../../../pro/audio/engine/tts/engines/kokoro/voices';
+import { models } from 'react-native-executorch';
+
+describe('Kokoro voice catalog', () => {
+ it('uses shared voice names and language labels', () => {
+ const voices = getKokoroTTSVoices();
+
+ const runtimeVoiceCount = Object.values(models.text_to_speech.kokoro)
+ .reduce((count, language) => count + Object.keys(language).length, 0);
+ expect(voices).toHaveLength(runtimeVoiceCount);
+
+ expect(voices.find(voice => voice.id === 'af_heart')).toMatchObject({
+ label: 'Heart',
+ metadata: { language: 'English (US)' },
+ });
+ expect(voices.find(voice => voice.id === 'bf_emma')).toMatchObject({
+ label: 'Emma',
+ metadata: { language: 'English (UK)' },
+ });
+ expect(voices.find(voice => voice.id === 'hf_alpha')).toMatchObject({
+ label: 'Alpha',
+ metadata: { language: 'Hindi' },
+ });
+ expect(voices.find(voice => voice.id === 'df_anna')).toMatchObject({
+ label: 'Anna',
+ metadata: { language: 'German' },
+ });
+ });
+
+ it('resolves a complete downloadable asset package for every voice', () => {
+ for (const voice of getKokoroTTSVoices()) {
+ const sources = getKokoroAssetSources(voice.id as Parameters[0]);
+ expect(sources.length).toBeGreaterThanOrEqual(3);
+ expect(sources.every(source => source.startsWith('https://'))).toBe(true);
+ expect(sources.some(source => source.includes(`/voices/${voice.id}.bin`))).toBe(true);
+ }
+ });
+});
diff --git a/__tests__/unit/hooks/useIsProActive.test.tsx b/__tests__/unit/hooks/useIsProActive.test.tsx
index d5763eb87..de8343366 100644
--- a/__tests__/unit/hooks/useIsProActive.test.tsx
+++ b/__tests__/unit/hooks/useIsProActive.test.tsx
@@ -22,14 +22,18 @@ describe('useIsProActive / useHasRegisteredScreen', () => {
// Registration alone no longer means Pro: the device must still be entitled, or a device the owner
// removed from the licence would keep every Pro entry point until the app restarted.
useAppStore.setState({
+ hasRegisteredPro: true,
hasSavedProCredential: true,
+ isProActive: true,
proDeviceAdmission: 'active',
});
});
afterEach(() => {
_clearScreensForTesting();
useAppStore.setState({
+ hasRegisteredPro: false,
hasSavedProCredential: false,
+ isProActive: false,
proDeviceAdmission: 'unknown',
});
});
diff --git a/__tests__/unit/hooks/useOpenProTools.test.tsx b/__tests__/unit/hooks/useOpenProTools.test.tsx
index 153c64d40..388371c6a 100644
--- a/__tests__/unit/hooks/useOpenProTools.test.tsx
+++ b/__tests__/unit/hooks/useOpenProTools.test.tsx
@@ -12,6 +12,7 @@ import { render, fireEvent } from '@testing-library/react-native';
import { useOpenProTools } from '../../../src/hooks/useOpenProTools';
import { registerScreen, _clearScreensForTesting } from '../../../src/navigation/screenRegistry';
import { PRO_TOOLS_SCREEN } from '../../../src/hooks/useIsProActive';
+import { useAppStore } from '../../../src/stores/appStore';
const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => {
@@ -35,6 +36,12 @@ describe('useOpenProTools', () => {
beforeEach(() => {
jest.clearAllMocks();
_clearScreensForTesting();
+ useAppStore.setState({
+ hasRegisteredPro: false,
+ hasSavedProCredential: false,
+ isProActive: false,
+ proDeviceAdmission: 'unknown',
+ });
});
afterEach(() => {
_clearScreensForTesting();
@@ -47,9 +54,30 @@ describe('useOpenProTools', () => {
});
it('routes a pro user to the Pro Tools screen once it is registered', () => {
+ useAppStore.setState({
+ hasRegisteredPro: true,
+ hasSavedProCredential: true,
+ isProActive: true,
+ proDeviceAdmission: 'active',
+ });
registerScreen({ name: PRO_TOOLS_SCREEN, component: () => null });
const { getByTestId } = render();
fireEvent.press(getByTestId('open'));
expect(mockNavigate).toHaveBeenCalledWith(PRO_TOOLS_SCREEN);
});
+
+ it('routes to the purchase screen as soon as live access is removed', () => {
+ registerScreen({ name: PRO_TOOLS_SCREEN, component: () => null });
+ useAppStore.setState({
+ hasRegisteredPro: false,
+ hasSavedProCredential: false,
+ isProActive: false,
+ proDeviceAdmission: 'unknown',
+ });
+
+ const { getByTestId } = render();
+ fireEvent.press(getByTestId('open'));
+
+ expect(mockNavigate).toHaveBeenCalledWith('ProDetail');
+ });
});
diff --git a/__tests__/unit/licensing/proLicenseProvider.test.ts b/__tests__/unit/licensing/proLicenseProvider.test.ts
index cd7ed69b9..4420d1fff 100644
--- a/__tests__/unit/licensing/proLicenseProvider.test.ts
+++ b/__tests__/unit/licensing/proLicenseProvider.test.ts
@@ -115,6 +115,7 @@ describe('the licence this phone holds', () => {
});
afterEach(() => {
+ jest.useRealTimers();
keygen.restore();
jest.restoreAllMocks();
});
@@ -454,7 +455,7 @@ describe('the licence this phone holds', () => {
});
});
- it('calls a licence with an expiry a yearly one, and shows the date', async () => {
+ it('shows a legacy timed licence as a neutral subscription', async () => {
keygen.reset();
keygen.addLicence({
key: LICENCE_KEY,
@@ -469,11 +470,29 @@ describe('the licence this phone holds', () => {
provider.proLicenseProvider.getInfo(),
).resolves.toMatchObject({
isPro: true,
- tier: 'yearly',
+ tier: 'subscription',
expiry: '2030-01-01T00:00:00.000Z',
});
});
+ it('shows the new RevenueCat key as a monthly plan', async () => {
+ keygen.reset();
+ keygen.addLicence({
+ key: LICENCE_KEY,
+ seats: 3,
+ expiry: '2030-01-01T00:00:00.000Z',
+ metadata: { tier: 'monthly' },
+ });
+ const provider = load();
+ provider.setDirectEntitlementActivationOwner(activationOwner().owner);
+ await provider.proLicenseProvider.activate!(LICENCE_KEY);
+
+ await expect(provider.proLicenseProvider.getInfo()).resolves.toMatchObject({
+ isPro: true,
+ tier: 'monthly',
+ });
+ });
+
it('survives a keychain that cannot be read at all', async () => {
const provider = load();
keychain().getGenericPassword.mockRejectedValue(
@@ -511,6 +530,29 @@ describe('the licence this phone holds', () => {
);
});
+ it('removes access at the exact cached expiry without a restart', async () => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2030-01-01T00:00:00.000Z'));
+ keygen.reset();
+ keygen.addLicence({
+ key: LICENCE_KEY,
+ seats: 3,
+ expiry: '2030-01-01T00:00:05.000Z',
+ });
+ const provider = load();
+ provider.setDirectEntitlementActivationOwner(activationOwner().owner);
+ await provider.proLicenseProvider.activate!(LICENCE_KEY);
+
+ await expect(provider.proLicenseProvider.readActive()).resolves.toBe(true);
+ await jest.advanceTimersByTimeAsync(5_001);
+
+ await expect(provider.proLicenseProvider.readActive()).resolves.toBe(false);
+ await expect(provider.proLicenseProvider.getInfo()).resolves.toMatchObject({
+ isPro: false,
+ credentialSaved: true,
+ });
+ });
+
it('locks the app when the licence has been revoked', async () => {
const provider = await licensed();
// Refunded, charged back, or revoked by an admin: the provider stops recognising the key.
@@ -537,6 +579,95 @@ describe('the licence this phone holds', () => {
);
});
+ it('closes a saved credential locally before a throttled foreground check', async () => {
+ const start = Date.UTC(2026, 7, 26, 9, 0, 0);
+ let now = start;
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
+ keygen.reset();
+ keygen.addLicence({
+ key: LICENCE_KEY,
+ seats: 3,
+ expiry: new Date(start + 5_000).toISOString(),
+ });
+ const provider = await licensed();
+ await provider.proLicenseProvider.revalidate!('peer_connected');
+ const providerCalls = keygen.calls.length;
+
+ now = start + 5_000;
+ await provider.proLicenseProvider.revalidate!('foreground');
+
+ await expect(provider.proLicenseProvider.getInfo()).resolves.toMatchObject({
+ isPro: false,
+ credentialSaved: true,
+ expired: true,
+ });
+ expect(keygen.calls).toHaveLength(providerCalls);
+ });
+
+ it('reports an expired saved credential as inactive on cold start', async () => {
+ secrets.set(
+ 'off-grid-pro-license',
+ JSON.stringify({
+ isPro: true,
+ key: LICENCE_KEY,
+ licenseId: 'licence-1',
+ expiry: new Date(Date.now() - 1).toISOString(),
+ verifiedAt: Date.now() - 10_000,
+ }),
+ );
+ const provider = load();
+
+ await expect(provider.proLicenseProvider.getInfo()).resolves.toMatchObject({
+ isPro: false,
+ credentialSaved: true,
+ expired: true,
+ });
+ await expect(provider.proLicenseProvider.readActive()).resolves.toBe(false);
+ });
+
+ it('closes access at the exact saved deadline without a network event', async () => {
+ jest.useFakeTimers();
+ const start = Date.UTC(2026, 7, 26, 9, 0, 0);
+ jest.setSystemTime(start);
+ keygen.reset();
+ keygen.addLicence({
+ key: LICENCE_KEY,
+ seats: 3,
+ expiry: new Date(start + 5_000).toISOString(),
+ });
+ const provider = await licensed();
+ const decisions: boolean[] = [];
+ provider.onProLicenseInfoChanged(info => decisions.push(info.isPro));
+ const providerCalls = keygen.calls.length;
+
+ await jest.advanceTimersByTimeAsync(5_000);
+
+ await expect(provider.proLicenseProvider.getInfo()).resolves.toMatchObject({
+ isPro: false,
+ expired: true,
+ });
+ expect(decisions).toContain(false);
+ expect(keygen.calls).toHaveLength(providerCalls);
+ jest.useRealTimers();
+ });
+
+ it('keeps lifetime access active when time advances', async () => {
+ jest.useFakeTimers();
+ const start = Date.UTC(2026, 7, 26, 9, 0, 0);
+ jest.setSystemTime(start);
+ const provider = await licensed();
+
+ await jest.advanceTimersByTimeAsync(10 * 365 * 24 * 60 * 60 * 1_000);
+
+ await expect(provider.proLicenseProvider.getInfo()).resolves.toMatchObject({
+ isPro: true,
+ tier: 'lifetime',
+ expiry: null,
+ expired: false,
+ });
+ jest.useRealTimers();
+ });
+
it('stops active access but keeps the credential when the seat is gone', async () => {
const provider = await licensed();
// The seat was freed from another device: the key is still valid, this installation is not on it.
diff --git a/__tests__/unit/navigation/useProExpiryRedirect.test.tsx b/__tests__/unit/navigation/useProExpiryRedirect.test.tsx
new file mode 100644
index 000000000..a4ac3e815
--- /dev/null
+++ b/__tests__/unit/navigation/useProExpiryRedirect.test.tsx
@@ -0,0 +1,52 @@
+import { act, renderHook, waitFor } from '@testing-library/react-native';
+import type { NavigationContainerRefWithCurrent } from '@react-navigation/native';
+import { useProExpiryRedirect } from '../../../src/navigation/useProExpiryRedirect';
+import type { RootStackParamList } from '../../../src/navigation/types';
+import { useAppStore } from '../../../src/stores/appStore';
+
+describe('the expired Pro purchase route', () => {
+ const navigation = {
+ isReady: jest.fn(() => true),
+ getCurrentRoute: jest.fn(() => ({ name: 'Main', key: 'main' })),
+ resetRoot: jest.fn(),
+ } as unknown as NavigationContainerRefWithCurrent;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ useAppStore.getState().setHasExpiredProCredential(false);
+ });
+
+ afterEach(() => {
+ useAppStore.getState().setHasExpiredProCredential(false);
+ });
+
+ it('routes a cold-start expired credential when navigation becomes ready', async () => {
+ useAppStore.getState().setHasExpiredProCredential(true);
+ const { result } = renderHook(() => useProExpiryRedirect(navigation));
+
+ act(() => result.current());
+
+ await waitFor(() =>
+ expect(navigation.resetRoot).toHaveBeenCalledWith({
+ index: 0,
+ routes: [{ name: 'ProDetail' }],
+ }),
+ );
+ });
+
+ it('routes an active screen when the saved deadline passes', async () => {
+ renderHook(() => useProExpiryRedirect(navigation));
+ expect(navigation.resetRoot).not.toHaveBeenCalled();
+
+ act(() => {
+ useAppStore.getState().setHasExpiredProCredential(true);
+ });
+
+ await waitFor(() =>
+ expect(navigation.resetRoot).toHaveBeenCalledWith({
+ index: 0,
+ routes: [{ name: 'ProDetail' }],
+ }),
+ );
+ });
+});
diff --git a/__tests__/unit/services/cleanTranscription.test.ts b/__tests__/unit/services/cleanTranscription.test.ts
index 9981e4a94..62450840b 100644
--- a/__tests__/unit/services/cleanTranscription.test.ts
+++ b/__tests__/unit/services/cleanTranscription.test.ts
@@ -22,6 +22,7 @@ describe('cleanTranscription', () => {
it('keeps real speech', () => {
expect(cleanTranscription('hello world')).toBe('hello world');
expect(cleanTranscription(' draw a horse ')).toBe('draw a horse');
+ expect(cleanTranscription('नमस्ते, कैसे हो भाई')).toBe('नमस्ते, कैसे हो भाई');
});
it('strips a leading marker but keeps the speech after it', () => {
diff --git a/__tests__/unit/services/generationService.test.ts b/__tests__/unit/services/generationService.test.ts
index 5d8e0eaae..8f4709d26 100644
--- a/__tests__/unit/services/generationService.test.ts
+++ b/__tests__/unit/services/generationService.test.ts
@@ -1562,11 +1562,12 @@ describe('generationService', () => {
});
// ============================================================================
- // isUsingRemoteProvider — prefers local model when loaded
+ // isUsingRemoteProvider — explicit remote selection is authoritative
// ============================================================================
- describe('isUsingRemoteProvider — local model wins when loaded', () => {
+ describe('isUsingRemoteProvider — selected remote model wins', () => {
const mockRemoteProvider4 = {
id: 'remote-srv',
+ capabilities: { supportsThinking: true },
isReady: jest.fn().mockResolvedValue(true),
generate: jest.fn(),
stopGeneration: jest.fn().mockResolvedValue(undefined),
@@ -1581,7 +1582,7 @@ describe('generationService', () => {
});
(mockedProviderRegistry as any).hasProvider = jest.fn(() => true);
mockedProviderRegistry.getProvider.mockReturnValue(mockRemoteProvider4 as any);
- // Local model IS loaded — service should prefer local
+ // A resident local model must not replace the explicit remote selection.
mockedLlmService.isModelLoaded.mockReturnValue(true);
});
@@ -1590,7 +1591,7 @@ describe('generationService', () => {
(mockedProviderRegistry as any).hasProvider = jest.fn(() => false);
});
- it('uses local LLM when local model is loaded even if remote server is configured', async () => {
+ it('uses the selected remote model even when a local model is resident', async () => {
const convId = setupWithConversation();
mockedLlmService.generateResponse.mockImplementation(async (_msgs, { onStream: cb }: any = {}) => {
cb?.({ content: 'hello' });
@@ -1601,9 +1602,8 @@ describe('generationService', () => {
createMessage({ role: 'user', content: 'Hi' }),
]);
- // Local generateResponse should have been called, not remote provider
- expect(mockedLlmService.generateResponse).toHaveBeenCalled();
- expect(mockRemoteProvider4.generate).not.toHaveBeenCalled();
+ expect(mockRemoteProvider4.generate).toHaveBeenCalled();
+ expect(mockedLlmService.generateResponse).not.toHaveBeenCalled();
});
});
diff --git a/__tests__/unit/services/httpClient.test.ts b/__tests__/unit/services/httpClient.test.ts
index 9c55a37a0..fc5f04808 100644
--- a/__tests__/unit/services/httpClient.test.ts
+++ b/__tests__/unit/services/httpClient.test.ts
@@ -271,6 +271,14 @@ describe('httpClient', () => {
expect(isPrivateNetworkEndpoint('http://169.254.0.1:11434')).toBe(true);
});
+ it('accepts only the Tailscale CGNAT range as private', () => {
+ expect(isPrivateNetworkEndpoint('http://100.64.0.0:7878')).toBe(true);
+ expect(isPrivateNetworkEndpoint('http://100.116.255.25:7878')).toBe(true);
+ expect(isPrivateNetworkEndpoint('http://100.127.255.255:7878')).toBe(true);
+ expect(isPrivateNetworkEndpoint('http://100.63.255.255:7878')).toBe(false);
+ expect(isPrivateNetworkEndpoint('http://100.128.0.0:7878')).toBe(false);
+ });
+
it('should detect .local (mDNS) as private', () => {
expect(isPrivateNetworkEndpoint('http://myserver.local:11434')).toBe(true);
});
@@ -1240,4 +1248,4 @@ describe('httpClient', () => {
});
});
-});
\ No newline at end of file
+});
diff --git a/__tests__/unit/services/llm.test.ts b/__tests__/unit/services/llm.test.ts
index 14971cd40..d6276e69c 100644
--- a/__tests__/unit/services/llm.test.ts
+++ b/__tests__/unit/services/llm.test.ts
@@ -65,6 +65,7 @@ describe('LLMService', () => {
// Reset singleton state
(llmService as any).context = null;
(llmService as any).currentModelPath = null;
+ (llmService as any).nativeConversationId = null;
(llmService as any).isGenerating = false;
(llmService as any).multimodalSupport = null;
(llmService as any).multimodalInitialized = false;
@@ -848,6 +849,21 @@ describe('LLMService', () => {
it('is safe without context', async () => {
await llmService.clearKVCache(); // Should not throw
});
+
+ it('fully clears once when the native context moves to another chat', async () => {
+ mockedRNFS.exists.mockResolvedValue(true);
+ const ctx = createMockLlamaContext();
+ mockedInitLlama.mockResolvedValue(ctx as any);
+ await llmService.loadModel('/models/test.gguf');
+
+ await llmService.prepareConversationBoundary('chat-a');
+ await llmService.prepareConversationBoundary('chat-a');
+ await llmService.prepareConversationBoundary('chat-b');
+
+ expect(ctx.clearCache).toHaveBeenCalledTimes(2);
+ expect(ctx.clearCache).toHaveBeenNthCalledWith(1, true);
+ expect(ctx.clearCache).toHaveBeenNthCalledWith(2, true);
+ });
});
// ========================================================================
diff --git a/__tests__/unit/services/tools/EmailCalendarExtension.test.ts b/__tests__/unit/services/tools/EmailCalendarExtension.test.ts
index b8ed71830..d6b915831 100644
--- a/__tests__/unit/services/tools/EmailCalendarExtension.test.ts
+++ b/__tests__/unit/services/tools/EmailCalendarExtension.test.ts
@@ -32,19 +32,30 @@ jest.mock('react-native-calendar-events', () => ({
// out in the open-core CI. Load it dynamically via a computed path (so tsc does
// not try to resolve the absent module) and skip the suite when it is missing.
// jest hoists the jest.mock calls above this, so the mocks are already registered.
-function loadProExtension(): ToolExtension | null {
+interface LoadedProExtension {
+ extension: ToolExtension;
+ setEntitlementActive(active: boolean): void;
+}
+
+function loadProExtension(): LoadedProExtension | null {
const proPath = ['..', '..', '..', '..', 'pro', 'tools', 'EmailCalendarExtension'].join('/');
try {
-
- return require(proPath).EmailCalendarExtension as ToolExtension;
+ const module = require(proPath) as {
+ EmailCalendarExtension: ToolExtension;
+ setEmailCalendarEntitlementActive(active: boolean): void;
+ };
+ return {
+ extension: module.EmailCalendarExtension,
+ setEntitlementActive: module.setEmailCalendarEntitlementActive,
+ };
} catch {
return null;
}
}
-const proExtension = loadProExtension();
-const EmailCalendarExtension = proExtension ?? ({} as ToolExtension);
-const describeIfPro = proExtension ? describe : describe.skip;
+const loadedProExtension = loadProExtension();
+const EmailCalendarExtension = loadedProExtension?.extension ?? ({} as ToolExtension);
+const describeIfPro = loadedProExtension ? describe : describe.skip;
const mockOpenURL = jest.spyOn(Linking, 'openURL');
@@ -54,6 +65,7 @@ function call(name: string, args: Record = {}): ToolCall {
describeIfPro('EmailCalendarExtension', () => {
beforeEach(() => {
+ loadedProExtension?.setEntitlementActive(true);
mockEnabledTools = [];
mockOpenURL.mockReset().mockResolvedValue(undefined as never);
mockSaveEvent.mockReset().mockResolvedValue('evt-1');
@@ -61,6 +73,10 @@ describeIfPro('EmailCalendarExtension', () => {
mockFetchAllEvents.mockReset().mockResolvedValue([]);
});
+ afterAll(() => {
+ loadedProExtension?.setEntitlementActive(false);
+ });
+
describe('definitions and gating', () => {
it('advertises the three tools to the main picker', () => {
const ids = EmailCalendarExtension.getToolDefinitions!().map(t => t.id);
diff --git a/__tests__/unit/services/whisperService.test.ts b/__tests__/unit/services/whisperService.test.ts
index 9e7197968..68e11fbce 100644
--- a/__tests__/unit/services/whisperService.test.ts
+++ b/__tests__/unit/services/whisperService.test.ts
@@ -8,14 +8,20 @@
import { initWhisper } from 'whisper.rn';
import { Platform, PermissionsAndroid } from 'react-native';
import RNFS from 'react-native-fs';
-import { whisperService, WHISPER_MODELS } from '../../../src/services/whisperService';
+import {
+ whisperService,
+ WHISPER_MODELS,
+} from '../../../src/services/whisperService';
import { backgroundDownloadService } from '../../../src/services/backgroundDownloadService';
import { audioSessionManager } from '../../../src/services/audioSessionManager';
+import { audioRecorderService } from '../../../src/services/audioRecorderService';
import { AudioManager } from 'react-native-audio-api';
// The realtime permission path drives audioSessionManager, which calls these.
-const mockSetAudioSessionOptions = AudioManager.setAudioSessionOptions as jest.Mock;
-const mockSetAudioSessionActivity = AudioManager.setAudioSessionActivity as jest.Mock;
+const mockSetAudioSessionOptions =
+ AudioManager.setAudioSessionOptions as jest.Mock;
+const mockSetAudioSessionActivity =
+ AudioManager.setAudioSessionActivity as jest.Mock;
jest.mock('../../../src/services/backgroundDownloadService', () => ({
backgroundDownloadService: {
@@ -39,15 +45,22 @@ jest.mock('../../../src/stores/downloadStore', () => ({
},
}));
-const mockedBDS = backgroundDownloadService as jest.Mocked;
+const mockedBDS = backgroundDownloadService as jest.Mocked<
+ typeof backgroundDownloadService
+>;
const mockedRNFS = RNFS as jest.Mocked;
-const mockedInitWhisper = initWhisper as jest.MockedFunction;
+const mockedInitWhisper = initWhisper as jest.MockedFunction<
+ typeof initWhisper
+>;
- /** Mock RNFS to report a valid model file (exists + large enough) */
+/** Mock RNFS to report a valid model file (exists + large enough) */
const mockValidModelFile = () => {
mockedRNFS.exists.mockResolvedValue(true);
- mockedRNFS.stat.mockResolvedValue({ size: 75 * 1024 * 1024, isFile: () => true } as any);
+ mockedRNFS.stat.mockResolvedValue({
+ size: 75 * 1024 * 1024,
+ isFile: () => true,
+ } as any);
};
describe('WhisperService', () => {
@@ -82,14 +95,16 @@ describe('WhisperService', () => {
// ========================================================================
describe('getModelsDir', () => {
it('returns path under DocumentDirectoryPath', () => {
- expect(whisperService.getModelsDir()).toBe('/mock/documents/whisper-models');
+ expect(whisperService.getModelsDir()).toBe(
+ '/mock/documents/whisper-models',
+ );
});
});
describe('getModelPath', () => {
it('returns correct path for a model ID', () => {
expect(whisperService.getModelPath('tiny.en')).toBe(
- '/mock/documents/whisper-models/ggml-tiny.en.bin'
+ '/mock/documents/whisper-models/ggml-tiny.en.bin',
);
});
});
@@ -114,7 +129,9 @@ describe('WhisperService', () => {
// ========================================================================
describe('downloadModel', () => {
it('throws for unknown model ID', async () => {
- await expect(whisperService.downloadModel('nonexistent')).rejects.toThrow('Unknown model');
+ await expect(whisperService.downloadModel('nonexistent')).rejects.toThrow(
+ 'Unknown model',
+ );
});
it('returns existing path if already downloaded', async () => {
@@ -128,10 +145,13 @@ describe('WhisperService', () => {
it('downloads via backgroundDownloadService when not present', async () => {
mockedRNFS.exists
- .mockResolvedValueOnce(true) // dir exists
+ .mockResolvedValueOnce(true) // dir exists
.mockResolvedValueOnce(false) // model not yet downloaded
.mockResolvedValueOnce(true); // validateModelFile: file exists
- mockedRNFS.stat.mockResolvedValueOnce({ size: 75 * 1024 * 1024, isFile: () => true } as any);
+ mockedRNFS.stat.mockResolvedValueOnce({
+ size: 75 * 1024 * 1024,
+ isFile: () => true,
+ } as any);
mockedBDS.downloadFileTo.mockReturnValue({
downloadId: 1,
@@ -141,21 +161,29 @@ describe('WhisperService', () => {
const result = await whisperService.downloadModel('tiny.en');
- expect(mockedBDS.downloadFileTo).toHaveBeenCalledWith(expect.objectContaining({
- // modelType 'stt' files the in-progress download under Voice in the
- // Download Manager (without it the entry defaulted to 'text').
- params: expect.objectContaining({ url: WHISPER_MODELS[0].url, modelType: 'stt' }),
- destPath: '/mock/documents/whisper-models/ggml-tiny.en.bin',
- }));
+ expect(mockedBDS.downloadFileTo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ // modelType 'stt' files the in-progress download under Voice in the
+ // Download Manager (without it the entry defaulted to 'text').
+ params: expect.objectContaining({
+ url: WHISPER_MODELS[0].url,
+ modelType: 'stt',
+ }),
+ destPath: '/mock/documents/whisper-models/ggml-tiny.en.bin',
+ }),
+ );
expect(result).toBe('/mock/documents/whisper-models/ggml-tiny.en.bin');
});
it('calls progress callback', async () => {
mockedRNFS.exists
- .mockResolvedValueOnce(true) // dir exists
+ .mockResolvedValueOnce(true) // dir exists
.mockResolvedValueOnce(false) // model doesn't exist
.mockResolvedValueOnce(true); // validateModelFile: file exists
- mockedRNFS.stat.mockResolvedValueOnce({ size: 75 * 1024 * 1024, isFile: () => true } as any);
+ mockedRNFS.stat.mockResolvedValueOnce({
+ size: 75 * 1024 * 1024,
+ isFile: () => true,
+ } as any);
let capturedOnProgress: ((b: number, t: number) => void) | undefined;
mockedBDS.downloadFileTo.mockImplementation((opts: any) => {
@@ -178,7 +206,7 @@ describe('WhisperService', () => {
it('cleans up partial file and rethrows when download fails', async () => {
mockedRNFS.exists
- .mockResolvedValueOnce(true) // dir exists
+ .mockResolvedValueOnce(true) // dir exists
.mockResolvedValueOnce(false); // model not yet downloaded
mockedRNFS.unlink.mockResolvedValue(undefined as any);
@@ -188,16 +216,23 @@ describe('WhisperService', () => {
promise: Promise.reject(new Error('network_lost')),
} as any);
- await expect(whisperService.downloadModel('tiny.en')).rejects.toThrow('network_lost');
- expect(RNFS.unlink).toHaveBeenCalledWith('/mock/documents/whisper-models/ggml-tiny.en.bin');
+ await expect(whisperService.downloadModel('tiny.en')).rejects.toThrow(
+ 'network_lost',
+ );
+ expect(RNFS.unlink).toHaveBeenCalledWith(
+ '/mock/documents/whisper-models/ggml-tiny.en.bin',
+ );
});
it('registers the in-flight download in the download store so it shows live, then clears it on completion', async () => {
mockedRNFS.exists
- .mockResolvedValueOnce(true) // dir exists
+ .mockResolvedValueOnce(true) // dir exists
.mockResolvedValueOnce(false) // model not yet downloaded
.mockResolvedValueOnce(true); // validateModelFile: file exists
- mockedRNFS.stat.mockResolvedValueOnce({ size: 75 * 1024 * 1024, isFile: () => true } as any);
+ mockedRNFS.stat.mockResolvedValueOnce({
+ size: 75 * 1024 * 1024,
+ isFile: () => true,
+ } as any);
mockedBDS.downloadFileTo.mockReturnValue({
downloadId: 7,
downloadIdPromise: Promise.resolve(7),
@@ -210,24 +245,31 @@ describe('WhisperService', () => {
// keyed by whisper-/ and filed under Voice via modelType 'stt',
// so a queued STT download shows as "Queued" in the same canonical store the
// Text/Image cards read (not "0%"). This is the single-source-of-truth path.
- expect(mockDownloadStoreAdd).toHaveBeenCalledWith(expect.objectContaining({
- modelKey: 'whisper-tiny.en/ggml-tiny.en.bin',
- downloadId: 'queued:whisper-tiny.en/ggml-tiny.en.bin',
- modelId: 'whisper-tiny.en',
- fileName: 'ggml-tiny.en.bin',
- modelType: 'stt',
- status: 'pending',
- }));
+ expect(mockDownloadStoreAdd).toHaveBeenCalledWith(
+ expect.objectContaining({
+ modelKey: 'whisper-tiny.en/ggml-tiny.en.bin',
+ downloadId: 'queued:whisper-tiny.en/ggml-tiny.en.bin',
+ modelId: 'whisper-tiny.en',
+ fileName: 'ggml-tiny.en.bin',
+ modelType: 'stt',
+ status: 'pending',
+ }),
+ );
// Once a slot opens and the native download starts, the placeholder is
// reconciled to the real downloadId so progress events route to it.
- expect(mockDownloadStoreRetryEntry).toHaveBeenCalledWith('whisper-tiny.en/ggml-tiny.en.bin', 7);
+ expect(mockDownloadStoreRetryEntry).toHaveBeenCalledWith(
+ 'whisper-tiny.en/ggml-tiny.en.bin',
+ 7,
+ );
// Cleared on success — completed STT models are listed from disk instead.
- expect(mockDownloadStoreRemove).toHaveBeenCalledWith('whisper-tiny.en/ggml-tiny.en.bin');
+ expect(mockDownloadStoreRemove).toHaveBeenCalledWith(
+ 'whisper-tiny.en/ggml-tiny.en.bin',
+ );
});
it('clears the download store entry even when the download fails', async () => {
mockedRNFS.exists
- .mockResolvedValueOnce(true) // dir exists
+ .mockResolvedValueOnce(true) // dir exists
.mockResolvedValueOnce(false); // model not yet downloaded
mockedRNFS.unlink.mockResolvedValue(undefined as any);
mockedBDS.downloadFileTo.mockReturnValue({
@@ -236,10 +278,14 @@ describe('WhisperService', () => {
promise: Promise.reject(new Error('network_lost')),
} as any);
- await expect(whisperService.downloadModel('tiny.en')).rejects.toThrow('network_lost');
+ await expect(whisperService.downloadModel('tiny.en')).rejects.toThrow(
+ 'network_lost',
+ );
expect(mockDownloadStoreAdd).toHaveBeenCalled();
- expect(mockDownloadStoreRemove).toHaveBeenCalledWith('whisper-tiny.en/ggml-tiny.en.bin');
+ expect(mockDownloadStoreRemove).toHaveBeenCalledWith(
+ 'whisper-tiny.en/ggml-tiny.en.bin',
+ );
});
});
@@ -252,7 +298,9 @@ describe('WhisperService', () => {
await whisperService.deleteModel('tiny.en');
- expect(RNFS.unlink).toHaveBeenCalledWith('/mock/documents/whisper-models/ggml-tiny.en.bin');
+ expect(RNFS.unlink).toHaveBeenCalledWith(
+ '/mock/documents/whisper-models/ggml-tiny.en.bin',
+ );
});
it('does nothing when file does not exist', async () => {
@@ -269,29 +317,43 @@ describe('WhisperService', () => {
// ========================================================================
describe('validateModelFile', () => {
it('throws when path is empty', async () => {
- await expect(whisperService.validateModelFile('')).rejects.toThrow('empty or undefined');
+ await expect(whisperService.validateModelFile('')).rejects.toThrow(
+ 'empty or undefined',
+ );
});
it('throws when file does not exist', async () => {
mockedRNFS.exists.mockResolvedValue(false);
- await expect(whisperService.validateModelFile('/missing/model.bin')).rejects.toThrow('not found');
+ await expect(
+ whisperService.validateModelFile('/missing/model.bin'),
+ ).rejects.toThrow('not found');
});
it('throws and deletes file when file is too small (corrupted)', async () => {
mockedRNFS.exists.mockResolvedValue(true);
- mockedRNFS.stat.mockResolvedValue({ size: 1000, isFile: () => true } as any);
+ mockedRNFS.stat.mockResolvedValue({
+ size: 1000,
+ isFile: () => true,
+ } as any);
mockedRNFS.unlink.mockResolvedValue(undefined as any);
- await expect(whisperService.validateModelFile('/path/model.bin')).rejects.toThrow('too small');
+ await expect(
+ whisperService.validateModelFile('/path/model.bin'),
+ ).rejects.toThrow('too small');
expect(RNFS.unlink).toHaveBeenCalledWith('/path/model.bin');
});
it('passes for valid file with sufficient size', async () => {
mockedRNFS.exists.mockResolvedValue(true);
- mockedRNFS.stat.mockResolvedValue({ size: 75 * 1024 * 1024, isFile: () => true } as any);
+ mockedRNFS.stat.mockResolvedValue({
+ size: 75 * 1024 * 1024,
+ isFile: () => true,
+ } as any);
- await expect(whisperService.validateModelFile('/path/model.bin')).resolves.toBeUndefined();
+ await expect(
+ whisperService.validateModelFile('/path/model.bin'),
+ ).resolves.toBeUndefined();
});
});
@@ -311,7 +373,9 @@ describe('WhisperService', () => {
await whisperService.loadModel('/path/to/model.bin');
- expect(initWhisper).toHaveBeenCalledWith({ filePath: '/path/to/model.bin' });
+ expect(initWhisper).toHaveBeenCalledWith({
+ filePath: '/path/to/model.bin',
+ });
expect(whisperService.isModelLoaded()).toBe(true);
expect(whisperService.getLoadedModelPath()).toBe('/path/to/model.bin');
});
@@ -361,7 +425,9 @@ describe('WhisperService', () => {
mockValidModelFile();
mockedInitWhisper.mockRejectedValue(new Error('Load failed'));
- await expect(whisperService.loadModel('/bad/model.bin')).rejects.toThrow('Load failed');
+ await expect(whisperService.loadModel('/bad/model.bin')).rejects.toThrow(
+ 'Load failed',
+ );
expect(whisperService.isModelLoaded()).toBe(false);
expect(whisperService.getLoadedModelPath()).toBeNull();
});
@@ -369,16 +435,23 @@ describe('WhisperService', () => {
it('throws when model file is missing (prevents native crash)', async () => {
mockedRNFS.exists.mockResolvedValue(false);
- await expect(whisperService.loadModel('/missing/model.bin')).rejects.toThrow('not found');
+ await expect(
+ whisperService.loadModel('/missing/model.bin'),
+ ).rejects.toThrow('not found');
expect(initWhisper).not.toHaveBeenCalled();
});
it('throws when model file is corrupted/too small (prevents native crash)', async () => {
mockedRNFS.exists.mockResolvedValue(true);
- mockedRNFS.stat.mockResolvedValue({ size: 500, isFile: () => true } as any);
+ mockedRNFS.stat.mockResolvedValue({
+ size: 500,
+ isFile: () => true,
+ } as any);
mockedRNFS.unlink.mockResolvedValue(undefined as any);
- await expect(whisperService.loadModel('/corrupted/model.bin')).rejects.toThrow('too small');
+ await expect(
+ whisperService.loadModel('/corrupted/model.bin'),
+ ).rejects.toThrow('too small');
expect(initWhisper).not.toHaveBeenCalled();
});
});
@@ -427,31 +500,33 @@ describe('WhisperService', () => {
});
it('returns true when granted', async () => {
- jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue(
- PermissionsAndroid.RESULTS.GRANTED
- );
+ jest
+ .spyOn(PermissionsAndroid, 'request')
+ .mockResolvedValue(PermissionsAndroid.RESULTS.GRANTED);
expect(await whisperService.requestPermissions()).toBe(true);
});
it('returns false when denied', async () => {
- jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue(
- PermissionsAndroid.RESULTS.DENIED
- );
+ jest
+ .spyOn(PermissionsAndroid, 'request')
+ .mockResolvedValue(PermissionsAndroid.RESULTS.DENIED);
expect(await whisperService.requestPermissions()).toBe(false);
});
it('returns false on permission error', async () => {
- jest.spyOn(PermissionsAndroid, 'request').mockRejectedValue(new Error('Permission error'));
+ jest
+ .spyOn(PermissionsAndroid, 'request')
+ .mockRejectedValue(new Error('Permission error'));
expect(await whisperService.requestPermissions()).toBe(false);
});
it('does not touch the iOS audio session (manager mode stays null)', async () => {
- jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue(
- PermissionsAndroid.RESULTS.GRANTED
- );
+ jest
+ .spyOn(PermissionsAndroid, 'request')
+ .mockResolvedValue(PermissionsAndroid.RESULTS.GRANTED);
await whisperService.requestPermissions();
@@ -462,9 +537,9 @@ describe('WhisperService', () => {
});
it('requests RECORD_AUDIO permission with correct message', async () => {
- const requestSpy = jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue(
- PermissionsAndroid.RESULTS.GRANTED
- );
+ const requestSpy = jest
+ .spyOn(PermissionsAndroid, 'request')
+ .mockResolvedValue(PermissionsAndroid.RESULTS.GRANTED);
await whisperService.requestPermissions();
@@ -473,7 +548,7 @@ describe('WhisperService', () => {
expect.objectContaining({
title: 'Microphone Permission',
buttonPositive: 'OK',
- })
+ }),
);
});
});
@@ -492,7 +567,7 @@ describe('WhisperService', () => {
// old direct AudioSessionIos path left mode stale → silent TTS after STT).
expect(audioSessionManager.getMode()).toBe('record');
expect(mockSetAudioSessionOptions).toHaveBeenCalledWith(
- expect.objectContaining({ iosCategory: 'playAndRecord' })
+ expect.objectContaining({ iosCategory: 'playAndRecord' }),
);
// Behaviour-neutral: the session is re-activated (not skipped) on the call.
expect(mockSetAudioSessionActivity).toHaveBeenCalledWith(true);
@@ -500,7 +575,9 @@ describe('WhisperService', () => {
it('returns false when audio session activation fails (permission denied)', async () => {
// A throw on activation is how iOS surfaces a denied mic permission.
- mockSetAudioSessionActivity.mockRejectedValueOnce(new Error('Microphone permission denied'));
+ mockSetAudioSessionActivity.mockRejectedValueOnce(
+ new Error('Microphone permission denied'),
+ );
expect(await whisperService.requestPermissions()).toBe(false);
// Activation failed → mode must not advance to record.
@@ -556,7 +633,7 @@ describe('WhisperService', () => {
it('throws when no model loaded', async () => {
await expect(
- whisperService.startRealtimeTranscription(jest.fn())
+ whisperService.startRealtimeTranscription(jest.fn()),
).rejects.toThrow('No Whisper model loaded');
});
@@ -566,10 +643,12 @@ describe('WhisperService', () => {
const mockContext = {
id: 'ctx',
release: jest.fn(),
- transcribeRealtime: jest.fn(() => Promise.resolve({
- stop: mockStop,
- subscribe: jest.fn(),
- })),
+ transcribeRealtime: jest.fn(() =>
+ Promise.resolve({
+ stop: mockStop,
+ subscribe: jest.fn(),
+ }),
+ ),
transcribe: jest.fn(),
};
mockedInitWhisper.mockResolvedValueOnce(mockContext as any);
@@ -598,21 +677,22 @@ describe('WhisperService', () => {
await whisperService.loadModel('/path/model.bin');
Object.defineProperty(Platform, 'OS', { get: () => 'android' });
- jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue(
- PermissionsAndroid.RESULTS.DENIED
- );
+ jest
+ .spyOn(PermissionsAndroid, 'request')
+ .mockResolvedValue(PermissionsAndroid.RESULTS.DENIED);
await expect(
- whisperService.startRealtimeTranscription(jest.fn())
+ whisperService.startRealtimeTranscription(jest.fn()),
).rejects.toThrow('Microphone permission denied');
});
- it('calls transcribeRealtime with correct options', async () => {
+ it('stops the exact session when stop arrives while permission is pending', async () => {
+ const nativeStop = jest.fn(async () => undefined);
const mockContext = {
id: 'ctx',
release: jest.fn(),
- transcribeRealtime: jest.fn(() => Promise.resolve({
- stop: jest.fn(),
+ transcribeRealtime: jest.fn(async () => ({
+ stop: nativeStop,
subscribe: jest.fn(),
})),
transcribe: jest.fn(),
@@ -620,15 +700,62 @@ describe('WhisperService', () => {
mockedInitWhisper.mockResolvedValueOnce(mockContext as any);
await whisperService.loadModel('/path/model.bin');
+ Object.defineProperty(Platform, 'OS', { get: () => 'android' });
+ let answerPermission!: (result: string) => void;
+ let permissionRequests = 0;
+ jest.spyOn(PermissionsAndroid, 'request').mockImplementation(() => {
+ permissionRequests += 1;
+ if (permissionRequests > 1) {
+ return Promise.resolve(PermissionsAndroid.RESULTS.GRANTED);
+ }
+ return new Promise(resolve => {
+ answerPermission = resolve;
+ }) as Promise;
+ });
+
+ const start = whisperService.startRealtimeTranscription(jest.fn());
+ await Promise.resolve();
+ const stop = whisperService.stopTranscription();
+ await Promise.resolve();
+ expect(mockContext.transcribeRealtime).not.toHaveBeenCalled();
+
+ answerPermission(PermissionsAndroid.RESULTS.GRANTED);
+ await Promise.all([start, stop]);
+
+ expect(mockContext.transcribeRealtime).toHaveBeenCalledTimes(1);
+ expect(nativeStop).toHaveBeenCalledTimes(1);
+ expect(whisperService.isCurrentlyTranscribing()).toBe(false);
+ });
+
+ it('calls transcribeRealtime with correct options', async () => {
+ const mockContext = {
+ id: 'ctx',
+ release: jest.fn(),
+ transcribeRealtime: jest.fn(() =>
+ Promise.resolve({
+ stop: jest.fn(),
+ subscribe: jest.fn(),
+ }),
+ ),
+ transcribe: jest.fn(),
+ };
+ mockedInitWhisper.mockResolvedValueOnce(mockContext as any);
+ await whisperService.loadModel('/path/model.bin');
+
Object.defineProperty(Platform, 'OS', { get: () => 'ios' });
- await whisperService.startRealtimeTranscription(jest.fn(), { language: 'fr', maxLen: 100 });
+ await whisperService.startRealtimeTranscription(jest.fn(), {
+ language: 'fr',
+ maxLen: 100,
+ });
expect(mockContext.transcribeRealtime).toHaveBeenCalledWith(
expect.objectContaining({
language: 'fr',
+ translate: false,
+ beamSize: 5,
maxLen: 100,
- })
+ }),
);
});
@@ -636,10 +763,12 @@ describe('WhisperService', () => {
const mockContext = {
id: 'ctx',
release: jest.fn(),
- transcribeRealtime: jest.fn(() => Promise.resolve({
- stop: jest.fn(),
- subscribe: jest.fn(),
- })),
+ transcribeRealtime: jest.fn(() =>
+ Promise.resolve({
+ stop: jest.fn(),
+ subscribe: jest.fn(),
+ }),
+ ),
transcribe: jest.fn(),
};
mockedInitWhisper.mockResolvedValueOnce(mockContext as any);
@@ -657,7 +786,7 @@ describe('WhisperService', () => {
mode: 'Default',
}),
audioSessionOnStopIos: 'restore',
- })
+ }),
);
});
@@ -665,19 +794,21 @@ describe('WhisperService', () => {
const mockContext = {
id: 'ctx',
release: jest.fn(),
- transcribeRealtime: jest.fn((..._args: any[]) => Promise.resolve({
- stop: jest.fn(),
- subscribe: jest.fn(),
- })),
+ transcribeRealtime: jest.fn((..._args: any[]) =>
+ Promise.resolve({
+ stop: jest.fn(),
+ subscribe: jest.fn(),
+ }),
+ ),
transcribe: jest.fn(),
};
mockedInitWhisper.mockResolvedValueOnce(mockContext as any);
await whisperService.loadModel('/path/model.bin');
Object.defineProperty(Platform, 'OS', { get: () => 'android' });
- jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue(
- PermissionsAndroid.RESULTS.GRANTED
- );
+ jest
+ .spyOn(PermissionsAndroid, 'request')
+ .mockResolvedValue(PermissionsAndroid.RESULTS.GRANTED);
await whisperService.startRealtimeTranscription(jest.fn());
@@ -691,10 +822,14 @@ describe('WhisperService', () => {
const mockContext = {
id: 'ctx',
release: jest.fn(),
- transcribeRealtime: jest.fn(() => Promise.resolve({
- stop: jest.fn(),
- subscribe: (fn: any) => { subscribeFn = fn; },
- })),
+ transcribeRealtime: jest.fn(() =>
+ Promise.resolve({
+ stop: jest.fn(),
+ subscribe: (fn: any) => {
+ subscribeFn = fn;
+ },
+ }),
+ ),
transcribe: jest.fn(),
};
mockedInitWhisper.mockResolvedValueOnce(mockContext as any);
@@ -720,6 +855,63 @@ describe('WhisperService', () => {
recordingTime: 200,
});
});
+
+ it('keeps the selected language when the realtime result falls back to the recorded file', async () => {
+ let subscribeFn: any;
+ const mockContext = {
+ id: 'ctx',
+ release: jest.fn(),
+ transcribeRealtime: jest.fn(() =>
+ Promise.resolve({
+ stop: jest.fn(),
+ subscribe: (fn: any) => {
+ subscribeFn = fn;
+ },
+ }),
+ ),
+ transcribe: jest.fn(() => ({
+ stop: jest.fn(),
+ promise: Promise.resolve({ result: 'नमस्ते दुनिया' }),
+ })),
+ };
+ mockedInitWhisper.mockResolvedValueOnce(mockContext as any);
+ await whisperService.loadModel('/path/model.bin');
+ Object.defineProperty(Platform, 'OS', { get: () => 'ios' });
+ jest.spyOn(audioRecorderService, 'startRecording').mockResolvedValue();
+ jest.spyOn(audioRecorderService, 'stopRecording').mockResolvedValue({
+ path: '/recorded-hindi.wav',
+ durationSeconds: 1,
+ });
+
+ const resultCb = jest.fn();
+ await whisperService.startRealtimeTranscription(resultCb, {
+ language: 'hi',
+ });
+ subscribeFn({ isCapturing: false, data: { result: '' } });
+ await (whisperService as any).transcriptionFullyStopped;
+
+ expect(mockContext.transcribe).toHaveBeenCalledWith(
+ '/recorded-hindi.wav',
+ expect.objectContaining({
+ language: 'hi',
+ translate: false,
+ temperature: 0,
+ beamSize: 5,
+ }),
+ );
+ const decodeOptions = (
+ mockContext.transcribe.mock.calls as unknown as Array<
+ [string, Record]
+ >
+ )[0][1];
+ expect(decodeOptions).not.toHaveProperty('prompt');
+ expect(resultCb).toHaveBeenCalledWith(
+ expect.objectContaining({
+ text: 'नमस्ते दुनिया',
+ isCapturing: false,
+ }),
+ );
+ });
});
// ========================================================================
@@ -752,7 +944,9 @@ describe('WhisperService', () => {
});
it('handles error in stop function gracefully', async () => {
- (whisperService as any).stopFn = () => { throw new Error('stop error'); };
+ (whisperService as any).stopFn = () => {
+ throw new Error('stop error');
+ };
(whisperService as any).isTranscribing = true;
(whisperService as any).context = { release: jest.fn() };
@@ -773,7 +967,7 @@ describe('WhisperService', () => {
describe('transcribeFile', () => {
it('throws when no model loaded', async () => {
await expect(
- whisperService.transcribeFile('/path/to/audio.wav')
+ whisperService.transcribeFile('/path/to/audio.wav'),
).rejects.toThrow('No Whisper model loaded');
});
@@ -793,9 +987,12 @@ describe('WhisperService', () => {
const result = await whisperService.transcribeFile('/audio.wav');
expect(result).toBe('transcribed text');
- expect(mockContext.transcribe).toHaveBeenCalledWith('/audio.wav', expect.objectContaining({
- language: 'en',
- }));
+ expect(mockContext.transcribe).toHaveBeenCalledWith(
+ '/audio.wav',
+ expect.objectContaining({
+ language: 'en',
+ }),
+ );
});
});
@@ -803,49 +1000,72 @@ describe('WhisperService', () => {
// forceReset
// ========================================================================
describe('forceReset', () => {
- it('resets transcription state', () => {
+ it('resets transcription state', async () => {
(whisperService as any).isTranscribing = true;
(whisperService as any).stopFn = jest.fn();
- whisperService.forceReset();
+ await whisperService.forceReset();
expect(whisperService.isCurrentlyTranscribing()).toBe(false);
});
- it('calls native stopFn when context exists (prevents SIGSEGV)', () => {
+ it('calls native stopFn when context exists (prevents SIGSEGV)', async () => {
const mockStopFn = jest.fn();
(whisperService as any).isTranscribing = true;
(whisperService as any).stopFn = mockStopFn;
(whisperService as any).context = { release: jest.fn() };
- whisperService.forceReset();
+ await whisperService.forceReset();
expect(mockStopFn).toHaveBeenCalled();
expect(whisperService.isCurrentlyTranscribing()).toBe(false);
});
- it('does not call stopFn when context is null (prevents SIGSEGV on freed context)', () => {
+ it('does not call stopFn when context is null (prevents SIGSEGV on freed context)', async () => {
const mockStopFn = jest.fn();
(whisperService as any).isTranscribing = true;
(whisperService as any).stopFn = mockStopFn;
(whisperService as any).context = null;
- whisperService.forceReset();
+ await whisperService.forceReset();
expect(mockStopFn).not.toHaveBeenCalled();
expect(whisperService.isCurrentlyTranscribing()).toBe(false);
});
- it('handles stopFn error gracefully during forceReset', () => {
+ it('handles stopFn error gracefully during forceReset', async () => {
(whisperService as any).isTranscribing = true;
- (whisperService as any).stopFn = () => { throw new Error('stop error'); };
+ (whisperService as any).stopFn = () => {
+ throw new Error('stop error');
+ };
(whisperService as any).context = { release: jest.fn() };
// Should not throw
- whisperService.forceReset();
+ await whisperService.forceReset();
expect(whisperService.isCurrentlyTranscribing()).toBe(false);
});
+
+ it('does not finish until the native realtime job has stopped', async () => {
+ let finishNativeStop: (() => void) | undefined;
+ const nativeStop = new Promise(resolve => {
+ finishNativeStop = resolve;
+ });
+ (whisperService as any).isTranscribing = true;
+ (whisperService as any).stopFn = jest.fn(() => nativeStop);
+ (whisperService as any).context = { release: jest.fn() };
+
+ let resetFinished = false;
+ const reset = whisperService.forceReset().then(() => {
+ resetFinished = true;
+ });
+ await Promise.resolve();
+
+ expect(resetFinished).toBe(false);
+ finishNativeStop?.();
+ await reset;
+ expect(resetFinished).toBe(true);
+ });
});
// ========================================================================
diff --git a/__tests__/unit/stores/downloadStore.test.ts b/__tests__/unit/stores/downloadStore.test.ts
index 5f1da6c01..e432dd6a2 100644
--- a/__tests__/unit/stores/downloadStore.test.ts
+++ b/__tests__/unit/stores/downloadStore.test.ts
@@ -172,6 +172,21 @@ describe('updateProgress', () => {
useDownloadStore.getState().updateProgress('dl-1', 1000, 1000); // (1000+400)/1000 = 1.4 → clamp
expect(useDownloadStore.getState().downloads['author/model/model.gguf'].progress).toBe(1);
});
+
+ it('measures one live rate in the canonical store for every view', () => {
+ const now = jest.spyOn(Date, 'now');
+ now.mockReturnValueOnce(1_000).mockReturnValueOnce(2_000);
+ useDownloadStore.getState().add(makeEntry());
+
+ useDownloadStore.getState().updateProgress('dl-1', 100, 1000);
+ expect(useDownloadStore.getState().downloads['author/model/model.gguf'].bytesPerSecond).toBeUndefined();
+ useDownloadStore.getState().updateProgress('dl-1', 600, 1000);
+
+ const entry = useDownloadStore.getState().downloads['author/model/model.gguf'];
+ expect(entry.bytesPerSecond).toBe(500);
+ expect(entry.rateSample).toEqual({ currentBytes: 600, sampledAtMs: 2_000 });
+ now.mockRestore();
+ });
});
describe('updateMmProjProgress', () => {
@@ -220,6 +235,25 @@ describe('setStatus', () => {
useDownloadStore.getState().setStatus('unknown', 'failed');
expect(useDownloadStore.getState().downloads).toBe(before);
});
+
+ it('keeps the aggregate rate while the sidecar still transfers', () => {
+ useDownloadStore.getState().add(makeEntry({
+ status: 'running',
+ mmProjDownloadId: 'dl-mm',
+ mmProjStatus: 'running',
+ bytesPerSecond: 512,
+ rateSample: { currentBytes: 500, sampledAtMs: 1_000 },
+ }));
+
+ useDownloadStore.getState().setCompleted('dl-1');
+ let entry = useDownloadStore.getState().downloads['author/model/model.gguf'];
+ expect(entry.bytesPerSecond).toBe(512);
+
+ useDownloadStore.getState().setMmProjCompleted('dl-mm', 500);
+ entry = useDownloadStore.getState().downloads['author/model/model.gguf'];
+ expect(entry.bytesPerSecond).toBeUndefined();
+ expect(entry.rateSample).toBeUndefined();
+ });
});
describe('setProcessing / setCompleted', () => {
diff --git a/__tests__/unit/stores/ttsStore.test.ts b/__tests__/unit/stores/ttsStore.test.ts
index 7a625590a..ca4481560 100644
--- a/__tests__/unit/stores/ttsStore.test.ts
+++ b/__tests__/unit/stores/ttsStore.test.ts
@@ -88,6 +88,11 @@ const resetState = () => {
overallDownloadProgress: 1,
voices: [{ id: 'default', label: 'Default', metadata: {} }],
activeVoiceId: 'default',
+ isSwitchingVoice: false,
+ pendingVoiceId: null,
+ failedVoiceId: null,
+ voiceSwitchProgress: 0,
+ voiceSwitchNeedsDownload: false,
audioCacheSizeMB: 0,
settings: {
interfaceMode: 'chat',
@@ -95,6 +100,8 @@ const resetState = () => {
speed: 1.0,
engineId: 'mock-tts',
voiceByEngine: {},
+ modelDownloaded: {},
+ voiceAssetsDownloaded: {},
},
});
};
@@ -164,13 +171,36 @@ describe('ttsStore', () => {
});
describe('setVoice (logged, timeout-guarded switch)', () => {
- it('clears isSwitchingVoice after a successful switch', async () => {
- mockEngine.setVoice.mockResolvedValueOnce(undefined);
- await getState().setVoice('default');
- expect(mockEngine.setVoice).toHaveBeenCalledWith('default');
+ it('keeps the current voice active until the requested voice is ready', async () => {
+ let finishSwitch!: () => void;
+ mockEngine.setVoice.mockReturnValueOnce(new Promise((resolve) => { finishSwitch = resolve; }));
+ const switching = getState().setVoice('next');
+
+ expect(getState().activeVoiceId).toBe('default');
+ expect(getState().pendingVoiceId).toBe('next');
+ expect(getState().isSwitchingVoice).toBe(true);
+
+ finishSwitch();
+ await switching;
+ expect(mockEngine.setVoice).toHaveBeenCalledWith('next');
+ expect(getState().activeVoiceId).toBe('next');
+ expect(getState().settings.voiceByEngine['mock-tts']).toBe('next');
+ expect(getState().settings.voiceAssetsDownloaded?.['mock-tts']).toContain('next');
+ expect(getState().pendingVoiceId).toBeNull();
expect(getState().isSwitchingVoice).toBe(false);
});
+ it('distinguishes a first download from preparing a completed voice', async () => {
+ const firstSwitch = getState().setVoice('next');
+ expect(getState().voiceSwitchNeedsDownload).toBe(true);
+ await firstSwitch;
+
+ useTTSStore.setState({ activeVoiceId: 'default' });
+ const cachedSwitch = getState().setVoice('next');
+ expect(getState().voiceSwitchNeedsDownload).toBe(false);
+ await cachedSwitch;
+ });
+
it('does NOT hang when the engine voice fetch never settles — times out and recovers', async () => {
_setVoiceSwitchTimeoutForTest(20);
mockEngine.setVoice.mockReturnValueOnce(new Promise(() => { /* never resolves (stuck native fetch) */ }));
@@ -178,6 +208,8 @@ describe('ttsStore', () => {
// The spinner must clear and an error surfaces — never a permanent stuck state.
expect(getState().isSwitchingVoice).toBe(false);
expect(getState().error).toMatch(/timed out/i);
+ expect(getState().failedVoiceId).toBe('default');
+ expect(getState().pendingVoiceId).toBeNull();
_setVoiceSwitchTimeoutForTest(45000);
});
@@ -186,6 +218,7 @@ describe('ttsStore', () => {
await getState().setVoice('default');
expect(getState().isSwitchingVoice).toBe(false);
expect(getState().error).toBe('fetch failed');
+ expect(getState().failedVoiceId).toBe('default');
});
it('deleteModels clears a stuck isSwitchingVoice (delete mid-switch must not lock the picker)', async () => {
@@ -226,9 +259,10 @@ describe('ttsStore', () => {
describe('clearError', () => {
it('clears the error field', () => {
- useTTSStore.setState({ error: 'something went wrong' });
+ useTTSStore.setState({ error: 'something went wrong', failedVoiceId: 'next' });
getState().clearError();
expect(getState().error).toBeNull();
+ expect(getState().failedVoiceId).toBeNull();
});
});
diff --git a/__tests__/unit/sync/licenceRevalidationBudget.test.ts b/__tests__/unit/sync/licenceRevalidationBudget.test.ts
new file mode 100644
index 000000000..25bb7981d
--- /dev/null
+++ b/__tests__/unit/sync/licenceRevalidationBudget.test.ts
@@ -0,0 +1,26 @@
+import { settleWithinBudget } from '../../../pro/sync/licenceRevalidationBudget';
+
+describe('licence revalidation startup budget', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('releases the budget timer when revalidation finishes first', async () => {
+ await settleWithinBudget(Promise.resolve(), 3_000);
+
+ expect(jest.getTimerCount()).toBe(0);
+ });
+
+ it('continues when the budget finishes before revalidation', async () => {
+ const pendingOperation = new Promise(() => undefined);
+ const result = settleWithinBudget(pendingOperation, 3_000);
+
+ await jest.advanceTimersByTimeAsync(3_000);
+ await expect(result).resolves.toBeUndefined();
+ expect(jest.getTimerCount()).toBe(0);
+ });
+});
diff --git a/__tests__/unit/sync/nativeProximity.test.ts b/__tests__/unit/sync/nativeProximity.test.ts
index 515dbd892..c02e465d1 100644
--- a/__tests__/unit/sync/nativeProximity.test.ts
+++ b/__tests__/unit/sync/nativeProximity.test.ts
@@ -4,6 +4,7 @@ import type {
DiscoveredDevice,
SyncConnection,
} from '@offgrid/sync';
+import { DiscoveryOrchestrator } from '@offgrid/sync';
import {
CONNECTION_CLOSED_EVENT,
CONNECTION_OPENED_EVENT,
@@ -16,6 +17,8 @@ import {
type ProximityNativeFake,
} from '../../utils/proximityNativeBoundary';
import { IosProximityAdapter } from '../../../src/services/sync/nativeProximity';
+import { buildSyncEngine } from '../../../src/services/sync/engine';
+import { createNativeTcpBoundary } from '../../utils/nativeSyncBoundaries';
jest.mock('react-native', () => {
const boundary = require('../../utils/proximityNativeBoundary');
@@ -74,6 +77,30 @@ describe('two phones talking with no network between them', () => {
const bytes = (text: string) => new Uint8Array(Buffer.from(text, 'utf8'));
const text = (data: Uint8Array) => Buffer.from(data).toString('utf8');
+ const startVisible = async (
+ adapter: IosProximityAdapter,
+ device: DeviceInfo,
+ ) => {
+ await adapter.discovery.start();
+ await adapter.discovery.advertise(device);
+ };
+ const orchestrate = (
+ adapter: IosProximityAdapter,
+ device: DeviceInfo,
+ discoverable: boolean,
+ ) => {
+ const { engine } = buildSyncEngine({
+ localDevice: device,
+ tcpModule: createNativeTcpBoundary(),
+ });
+ return new DiscoveryOrchestrator({
+ engine,
+ discovery: adapter.discovery,
+ localDevice: device,
+ discoverable,
+ getSharedSecret: () => undefined,
+ });
+ };
beforeEach(() => {
platform.OS = 'ios';
@@ -92,8 +119,8 @@ describe('two phones talking with no network between them', () => {
const found: DiscoveredDevice[] = [];
phoneA.discovery.onDeviceFound(device => found.push(device));
- await phoneA.discovery.start();
- await phoneB.discovery.start();
+ await startVisible(phoneA, PHONE_A);
+ await startVisible(phoneB, PHONE_B);
expect(found).toHaveLength(1);
expect(found[0]).toMatchObject({
@@ -108,8 +135,8 @@ describe('two phones talking with no network between them', () => {
});
it('replays the phones it already found to a listener that arrives late', async () => {
- await phoneA.discovery.start();
- await phoneB.discovery.start();
+ await startVisible(phoneA, PHONE_A);
+ await startVisible(phoneB, PHONE_B);
const found: DiscoveredDevice[] = [];
phoneA.discovery.onDeviceFound(device => found.push(device));
@@ -122,7 +149,7 @@ describe('two phones talking with no network between them', () => {
it('never offers the phone itself as a peer', async () => {
const found: DiscoveredDevice[] = [];
phoneA.discovery.onDeviceFound(device => found.push(device));
- await phoneA.discovery.start();
+ await startVisible(phoneA, PHONE_A);
nativeA.emit(PEER_FOUND_EVENT, { device: PHONE_A });
@@ -134,8 +161,8 @@ describe('two phones talking with no network between them', () => {
it('reports a phone that walked out of range', async () => {
const lost: string[] = [];
- await phoneA.discovery.start();
- await phoneB.discovery.start();
+ await startVisible(phoneA, PHONE_A);
+ await startVisible(phoneB, PHONE_B);
phoneA.discovery.onDeviceLost(deviceId => lost.push(deviceId));
expect(phoneA.canConnect(PHONE_B)).toBe(true);
@@ -147,8 +174,8 @@ describe('two phones talking with no network between them', () => {
});
it('counts the phones in range in the health it reports', async () => {
- await phoneA.discovery.start();
- await phoneB.discovery.start();
+ await startVisible(phoneA, PHONE_A);
+ await startVisible(phoneB, PHONE_B);
expect(discoveryRoute()).toMatchObject({ id: 'proximity', peerCount: 1 });
@@ -177,7 +204,7 @@ describe('two phones talking with no network between them', () => {
])('ignores a peer announcement with %s', async (_label, payload) => {
const found: DiscoveredDevice[] = [];
phoneA.discovery.onDeviceFound(device => found.push(device));
- await phoneA.discovery.start();
+ await startVisible(phoneA, PHONE_A);
nativeA.emit(PEER_FOUND_EVENT, payload);
@@ -189,7 +216,7 @@ describe('two phones talking with no network between them', () => {
it('accepts a peer that did not say which version it speaks', async () => {
const found: DiscoveredDevice[] = [];
phoneA.discovery.onDeviceFound(device => found.push(device));
- await phoneA.discovery.start();
+ await startVisible(phoneA, PHONE_A);
nativeA.emit(PEER_FOUND_EVENT, {
device: { id: 'phone-b', name: 'The iPad', platform: 'ios' },
@@ -204,8 +231,8 @@ describe('two phones talking with no network between them', () => {
['no device id', {}],
['a device id that is not text', { deviceId: 7 }],
])('ignores a peer-lost event with %s', async (_label, payload) => {
- await phoneA.discovery.start();
- await phoneB.discovery.start();
+ await startVisible(phoneA, PHONE_A);
+ await startVisible(phoneB, PHONE_B);
const lost: string[] = [];
phoneA.discovery.onDeviceLost(deviceId => lost.push(deviceId));
@@ -219,8 +246,8 @@ describe('two phones talking with no network between them', () => {
it('finds the other phone again after it renames itself', async () => {
const found: DiscoveredDevice[] = [];
- await phoneA.discovery.start();
- await phoneB.discovery.start();
+ await startVisible(phoneA, PHONE_A);
+ await startVisible(phoneB, PHONE_B);
phoneA.discovery.onDeviceFound(device => found.push(device));
found.length = 0;
@@ -236,7 +263,8 @@ describe('two phones talking with no network between them', () => {
const connected = async () => {
const inbound: SyncConnection[] = [];
await phoneB.listen(0, connection => inbound.push(connection));
- await phoneA.discovery.start();
+ await phoneB.discovery.advertise(PHONE_B);
+ await startVisible(phoneA, PHONE_A);
const outbound = await phoneA.connect('', 0, PHONE_B);
return { outbound, inbound };
};
@@ -292,7 +320,7 @@ describe('two phones talking with no network between them', () => {
});
it('keeps frames that arrive before the connection object even exists', async () => {
- await phoneA.discovery.start();
+ await startVisible(phoneA, PHONE_A);
const inbound: SyncConnection[] = [];
// Native reports data on a connection this side has not been told about yet - the open event and the
@@ -362,8 +390,8 @@ describe('two phones talking with no network between them', () => {
it('reuses the session when both phones invite each other at once', async () => {
const inbound: SyncConnection[] = [];
await phoneA.listen(0, connection => inbound.push(connection));
- await phoneA.discovery.start();
- await phoneB.discovery.start();
+ await phoneA.discovery.advertise(PHONE_A);
+ await startVisible(phoneB, PHONE_B);
nativeA.emit(CONNECTION_OPENED_EVENT, {
connectionId: 'proximity-1',
deviceId: 'phone-b',
@@ -402,7 +430,8 @@ describe('two phones talking with no network between them', () => {
const connected = async () => {
const inbound: SyncConnection[] = [];
await phoneB.listen(0, connection => inbound.push(connection));
- await phoneA.discovery.start();
+ await phoneB.discovery.advertise(PHONE_B);
+ await startVisible(phoneA, PHONE_A);
const outbound = await phoneA.connect('', 0, PHONE_B);
return { outbound, inbound };
};
@@ -514,8 +543,8 @@ describe('two phones talking with no network between them', () => {
});
it('refuses to reach a phone that is no longer nearby', async () => {
- await phoneA.discovery.start();
- await phoneB.discovery.start();
+ await startVisible(phoneA, PHONE_A);
+ await startVisible(phoneB, PHONE_B);
air.lose(nativeA, 'phone-b');
await expect(phoneA.connect('', 0, PHONE_B)).rejects.toThrow(
@@ -524,7 +553,7 @@ describe('two phones talking with no network between them', () => {
});
it('refuses when it is not told which phone to reach', async () => {
- await phoneA.discovery.start();
+ await startVisible(phoneA, PHONE_A);
// A LAN host and port mean nothing here, so a call without the device is a caller that thinks this is
// TCP. Failing loudly beats dialling nothing.
@@ -534,8 +563,8 @@ describe('two phones talking with no network between them', () => {
});
it('reports the native failure when the other phone refuses the connection', async () => {
- await phoneA.discovery.start();
- await phoneB.discovery.start();
+ await startVisible(phoneA, PHONE_A);
+ await startVisible(phoneB, PHONE_B);
nativeA.connectFailure = new Error('Peer declined the invitation.');
await expect(phoneA.connect('', 0, PHONE_B)).rejects.toThrow(
@@ -557,7 +586,7 @@ describe('two phones talking with no network between them', () => {
});
});
- it('is ready once the phone is advertising', async () => {
+ it('starts browsing without advertising', async () => {
const starting = phoneA.discovery.start();
// Caught mid-flight: a screen that opened during this shows "starting", not a blank state.
expect(phoneA.getTransportHealthSnapshot().listener.state).toBe(
@@ -566,12 +595,83 @@ describe('two phones talking with no network between them', () => {
await starting;
+ expect(nativeA.calls).toEqual(['start']);
+ expect(nativeA.advertising).toBe(false);
+ expect(discoveryRoute()).toMatchObject({
+ browse: { state: 'ready' },
+ advertise: { state: 'stopped' },
+ });
expect(phoneA.getTransportHealthSnapshot()).toMatchObject({
listener: { state: 'ready' },
routes: [{ id: 'proximity', state: 'ready' }],
});
});
+ it('honours persisted Hidden through the real startup orchestrator', async () => {
+ const startup = orchestrate(phoneA, PHONE_A, false);
+
+ await startup.start();
+
+ expect(startup.isDiscoverable()).toBe(false);
+ expect(nativeA.calls).toEqual(['start']);
+ expect(nativeA.advertising).toBe(false);
+ expect(discoveryRoute()).toMatchObject({
+ browse: { state: 'ready' },
+ advertise: { state: 'stopped' },
+ });
+ });
+
+ it('keeps orchestrator truth visible when native cannot stop, then retries', async () => {
+ const startup = orchestrate(phoneA, PHONE_A, true);
+ await startup.start();
+ nativeA.stopAdvertisingFailure = new Error(
+ 'Multipeer refused to stop advertising.',
+ );
+
+ await expect(startup.setDiscoverable(false)).rejects.toThrow(
+ 'Multipeer refused to stop advertising.',
+ );
+
+ expect(startup.isDiscoverable()).toBe(true);
+ expect(nativeA.advertising).toBe(true);
+ expect(discoveryRoute().advertise.state).toBe('failed');
+
+ nativeA.stopAdvertisingFailure = undefined;
+ await startup.setDiscoverable(false);
+
+ expect(startup.isDiscoverable()).toBe(false);
+ expect(nativeA.advertising).toBe(false);
+ expect(discoveryRoute().advertise.state).toBe('stopped');
+ });
+
+ it('reports an advertising failure and retries without restarting browsing', async () => {
+ await phoneA.discovery.start();
+ nativeA.startAdvertisingFailure = new Error(
+ 'Advertising needs local network permission.',
+ );
+
+ await expect(phoneA.discovery.advertise(PHONE_A)).rejects.toThrow(
+ 'Advertising needs local network permission.',
+ );
+
+ expect(nativeA.advertising).toBe(false);
+ expect(discoveryRoute().browse.state).toBe('ready');
+ expect(discoveryRoute().advertise).toMatchObject({
+ state: 'failed',
+ error: 'Advertising needs local network permission.',
+ });
+
+ nativeA.startAdvertisingFailure = undefined;
+ await phoneA.discovery.advertise(PHONE_A);
+
+ expect(nativeA.advertising).toBe(true);
+ expect(discoveryRoute().advertise.state).toBe('ready');
+ expect(
+ nativeA.calls.filter(call => call === 'startAdvertising'),
+ ).toHaveLength(2);
+ expect(nativeA.calls.filter(call => call === 'start')).toHaveLength(1);
+ });
+
it('says why when the phone cannot advertise at all', async () => {
nativeA.startFailure = new Error(
'Nearby Sync needs local network permission.',
@@ -605,7 +705,7 @@ describe('two phones talking with no network between them', () => {
await expect(phoneA.discovery.start()).rejects.toThrow();
nativeA.startFailure = undefined;
- await phoneA.discovery.start();
+ await startVisible(phoneA, PHONE_A);
// The retry has to actually reach native again - a failed attempt left cached is a phone that never
// recovers without a relaunch.
@@ -626,7 +726,7 @@ describe('two phones talking with no network between them', () => {
});
it('reports a rescan that failed without claiming the phone went down with it', async () => {
- await phoneA.discovery.start();
+ await startVisible(phoneA, PHONE_A);
nativeA.rescanFailure = new Error('Browsing failed to restart.');
await expect(phoneA.discovery.rescan()).rejects.toThrow(
@@ -652,7 +752,7 @@ describe('two phones talking with no network between them', () => {
});
it('finds the phones again on a rescan', async () => {
- await phoneB.discovery.start();
+ await startVisible(phoneB, PHONE_B);
const found: DiscoveredDevice[] = [];
phoneA.discovery.onDeviceFound(device => found.push(device));
await phoneA.discovery.start();
@@ -679,7 +779,8 @@ describe('two phones talking with no network between them', () => {
it('closes the connections it was holding and reports itself stopped', async () => {
const inbound: SyncConnection[] = [];
await phoneB.listen(0, connection => inbound.push(connection));
- await phoneA.discovery.start();
+ await phoneB.discovery.advertise(PHONE_B);
+ await startVisible(phoneA, PHONE_A);
const outbound = await phoneA.connect('', 0, PHONE_B);
let closed = false;
outbound.onClose(() => {
@@ -700,8 +801,8 @@ describe('two phones talking with no network between them', () => {
});
it('forgets the phones it had found', async () => {
- await phoneA.discovery.start();
- await phoneB.discovery.start();
+ await startVisible(phoneA, PHONE_A);
+ await startVisible(phoneB, PHONE_B);
await phoneA.stop();
@@ -755,27 +856,117 @@ describe('two phones talking with no network between them', () => {
});
it('can be switched back on afterwards', async () => {
- await phoneA.discovery.start();
+ await startVisible(phoneA, PHONE_A);
await phoneA.stop();
- await phoneA.discovery.start();
- await phoneB.discovery.start();
+ await startVisible(phoneA, PHONE_A);
+ await startVisible(phoneB, PHONE_B);
// A user toggling Nearby off and on is not a relaunch. It has to find the room again.
expect(phoneA.canConnect(PHONE_B)).toBe(true);
expect(phoneA.getTransportHealthSnapshot().listener.state).toBe('ready');
});
- it('leaves stopping advertising to the whole shutdown', async () => {
- await phoneA.discovery.start();
+ it('stops advertising without stopping nearby browsing or connections', async () => {
+ await startVisible(phoneA, PHONE_A);
await expect(phoneA.discovery.stopAdvertising()).resolves.toBeUndefined();
- // Multipeer has no separate advertise session, so this is deliberately a no-op rather than a
- // teardown - calling it must not make the phone unfindable.
+ expect(nativeA.advertising).toBe(false);
+ expect(nativeA.calls).toContain('stopAdvertising');
expect(phoneA.getTransportHealthSnapshot().listener.state).toBe('ready');
- await expect(phoneA.discovery.stop()).resolves.toBeUndefined();
- expect(nativeA.calls).not.toContain('stop');
+ expect(discoveryRoute().advertise.state).toBe('stopped');
+ });
+
+ it('keeps the real advertising state on a native failure and retries cleanly', async () => {
+ await startVisible(phoneA, PHONE_A);
+ nativeA.stopAdvertisingFailure = new Error(
+ 'Multipeer refused to stop advertising.',
+ );
+
+ await expect(phoneA.discovery.stopAdvertising()).rejects.toThrow(
+ 'Multipeer refused to stop advertising.',
+ );
+
+ expect(nativeA.advertising).toBe(true);
+ expect(discoveryRoute().advertise).toMatchObject({
+ state: 'failed',
+ error: 'Multipeer refused to stop advertising.',
+ });
+
+ nativeA.stopAdvertisingFailure = undefined;
+ await phoneA.discovery.stopAdvertising();
+
+ expect(nativeA.advertising).toBe(false);
+ expect(discoveryRoute().advertise.state).toBe('stopped');
+ expect(
+ nativeA.calls.filter(call => call === 'stopAdvertising'),
+ ).toHaveLength(2);
+ });
+
+ it('starts advertising again without restarting the whole nearby session', async () => {
+ await startVisible(phoneA, PHONE_A);
+ await phoneA.discovery.stopAdvertising();
+
+ await phoneA.discovery.advertise(PHONE_A);
+
+ expect(nativeA.advertising).toBe(true);
+ expect(nativeA.calls.filter(call => call === 'start')).toHaveLength(1);
+ expect(nativeA.calls).toContain('startAdvertising');
+ expect(discoveryRoute().advertise.state).toBe('ready');
+ });
+
+ it('honours a hide request that arrives while native advertising starts', async () => {
+ let releaseAdvertising: () => void = () => {};
+ let markAdvertisingStarted: () => void = () => {};
+ nativeA.startAdvertisingBarrier = new Promise(resolve => {
+ releaseAdvertising = resolve;
+ });
+ const advertisingStarted = new Promise(resolve => {
+ markAdvertisingStarted = resolve;
+ });
+ nativeA.onStartAdvertising = markAdvertisingStarted;
+
+ const show = phoneA.discovery.advertise(PHONE_A);
+ await advertisingStarted;
+ const hide = phoneA.discovery.stopAdvertising();
+ releaseAdvertising();
+
+ await Promise.all([show, hide]);
+
+ expect(nativeA.advertising).toBe(false);
+ expect(nativeA.calls).toEqual([
+ 'start',
+ 'startAdvertising',
+ 'stopAdvertising',
+ ]);
+ expect(discoveryRoute().advertise.state).toBe('stopped');
+ });
+
+ it('honours a full shutdown requested before queued advertising starts', async () => {
+ let releaseAdvertising: () => void = () => {};
+ let markAdvertisingStarted: () => void = () => {};
+ nativeA.startAdvertisingBarrier = new Promise(resolve => {
+ releaseAdvertising = resolve;
+ });
+ const advertisingStarted = new Promise(resolve => {
+ markAdvertisingStarted = resolve;
+ });
+ nativeA.onStartAdvertising = markAdvertisingStarted;
+
+ const show = phoneA.discovery.advertise(PHONE_A);
+ const shutdown = phoneA.stop();
+ await advertisingStarted;
+ releaseAdvertising();
+
+ await Promise.all([show, shutdown]);
+
+ expect(nativeA.started).toBe(false);
+ expect(nativeA.advertising).toBe(false);
+ expect(nativeA.calls).toEqual(['start', 'startAdvertising', 'stop']);
+ expect(phoneA.getTransportHealthSnapshot().listener.state).toBe(
+ 'stopped',
+ );
});
});
diff --git a/__tests__/unit/sync/pairingEntitlementCredentialAdapter.test.ts b/__tests__/unit/sync/pairingEntitlementCredentialAdapter.test.ts
index 1af4a25b5..88791a47f 100644
--- a/__tests__/unit/sync/pairingEntitlementCredentialAdapter.test.ts
+++ b/__tests__/unit/sync/pairingEntitlementCredentialAdapter.test.ts
@@ -163,6 +163,7 @@ describe('two devices agreeing about a shared licence', () => {
key: FULL_LICENCE_KEY,
entitlementId: fullLicenceId,
expiry: null,
+ tier: null,
verifiedAt: 1_700_000_000_000,
});
}
@@ -212,6 +213,7 @@ describe('two devices agreeing about a shared licence', () => {
key: LICENCE_KEY,
entitlementId: licenceId,
expiry: null,
+ tier: null,
verifiedAt: 1_700_000_000_000,
});
}
diff --git a/__tests__/unit/utils/progressPresentation.test.ts b/__tests__/unit/utils/progressPresentation.test.ts
new file mode 100644
index 000000000..97b63e384
--- /dev/null
+++ b/__tests__/unit/utils/progressPresentation.test.ts
@@ -0,0 +1,57 @@
+import {
+ formatByteRate,
+ presentProgress,
+} from '../../../src/utils/progressPresentation';
+
+describe('Mobile progress presentation', () => {
+ it('shows known bytes, live rate, and a finite percentage', () => {
+ const result = presentProgress({
+ bytesDownloaded: 5 * 1024 * 1024,
+ totalBytes: 20 * 1024 * 1024,
+ bytesPerSecond: 2.5 * 1024 * 1024,
+ status: 'running',
+ });
+
+ expect(result.percentageText).toBe('25%');
+ expect(result.bytesText).toBe('5 MB / 20 MB');
+ expect(result.rateText).toBe('2.5 MB/s');
+ expect(result.detailText).toBe('5 MB / 20 MB · 2.5 MB/s');
+ });
+
+ it('keeps an unknown total and rate honest without NaN', () => {
+ const result = presentProgress({
+ bytesDownloaded: 64,
+ totalBytes: 0,
+ bytesPerSecond: Number.NaN,
+ progress: Number.POSITIVE_INFINITY,
+ status: 'running',
+ });
+
+ expect(result.percentageText).toBeUndefined();
+ expect(result.bytesText).toBe('64 B');
+ expect(result.rateText).toBe('Rate unavailable');
+ expect(result.detailText).toBe('64 B · Rate unavailable');
+ expect(JSON.stringify(result)).not.toContain('NaN');
+ expect(JSON.stringify(result)).not.toContain('Infinity');
+ });
+
+ it.each([
+ ['completed', '100%'],
+ ['failed', '30%'],
+ ['cancelled', '30%'],
+ ])('renders terminal %s progress without an active-only value', (status, expected) => {
+ const result = presentProgress({
+ bytesDownloaded: 300,
+ totalBytes: 1_000,
+ status,
+ });
+ expect(result.percentageText).toBe(expected);
+ expect(result.progress.terminal).toBe(true);
+ });
+
+ it('never formats an invalid rate', () => {
+ expect(formatByteRate(Number.NaN)).toBe('Rate unavailable');
+ expect(formatByteRate(Number.POSITIVE_INFINITY)).toBe('Rate unavailable');
+ expect(formatByteRate(-1)).toBe('Rate unavailable');
+ });
+});
diff --git a/__tests__/utils/nativeSyncBoundaries.ts b/__tests__/utils/nativeSyncBoundaries.ts
index b5b3761ba..626a8a299 100644
--- a/__tests__/utils/nativeSyncBoundaries.ts
+++ b/__tests__/utils/nativeSyncBoundaries.ts
@@ -54,6 +54,7 @@ export interface TcpDialRecord {
}
let dials: TcpDialRecord[] = [];
+const routedPorts = new Map();
export function getTcpDials(): readonly TcpDialRecord[] {
return dials;
@@ -63,6 +64,18 @@ export function resetTcpDials(): void {
dials = [];
}
+/** Route one advertised port to the listener that represents a different fake host. */
+export function routeTcpPort(
+ advertisedPort: number,
+ listenerPort: number,
+): void {
+ routedPorts.set(advertisedPort, listenerPort);
+}
+
+export function resetTcpPortRoutes(): void {
+ routedPorts.clear();
+}
+
export function createNativeTcpBoundary(): RnTcpModule {
const servers = new Map void>();
let nextPort = 43000;
@@ -85,7 +98,8 @@ export function createNativeTcpBoundary(): RnTcpModule {
return server;
},
createConnection(options, callback) {
- const onConnection = servers.get(options.port);
+ const listenerPort = routedPorts.get(options.port) ?? options.port;
+ const onConnection = servers.get(listenerPort);
if (!onConnection) {
// Recorded before throwing: a dial to a port nothing is listening on is a real outcome, and a
// test that only sees the throw cannot tell it apart from a dial that never happened.
diff --git a/__tests__/utils/proximityNativeBoundary.ts b/__tests__/utils/proximityNativeBoundary.ts
index 197a64dfe..c7bee7bcc 100644
--- a/__tests__/utils/proximityNativeBoundary.ts
+++ b/__tests__/utils/proximityNativeBoundary.ts
@@ -38,10 +38,15 @@ export { FakeNativeEventEmitter as ProximityEventEmitter } from './nativeEventBu
export class ProximityNativeFake extends NativeEventBus {
/** Set by a test to make the native layer refuse, the way a device with Bluetooth off does. */
startFailure: Error | undefined;
+ startAdvertisingFailure: Error | undefined;
+ startAdvertisingBarrier: Promise | undefined;
+ onStartAdvertising: (() => void) | undefined;
+ stopAdvertisingFailure: Error | undefined;
rescanFailure: Error | undefined;
connectFailure: Error | undefined;
readonly calls: string[] = [];
started = false;
+ advertising = false;
device: Device;
constructor(private readonly air: ProximityAir, device: Device) {
@@ -54,9 +59,26 @@ export class ProximityNativeFake extends NativeEventBus {
if (this.startFailure) throw this.startFailure;
this.device = device;
this.started = true;
+ this.advertising = false;
this.air.announce(this);
}
+ async startAdvertising(): Promise {
+ this.calls.push('startAdvertising');
+ this.onStartAdvertising?.();
+ if (this.startAdvertisingFailure) throw this.startAdvertisingFailure;
+ await this.startAdvertisingBarrier;
+ this.advertising = true;
+ this.air.announce(this);
+ }
+
+ async stopAdvertising(): Promise {
+ this.calls.push('stopAdvertising');
+ if (this.stopAdvertisingFailure) throw this.stopAdvertisingFailure;
+ this.advertising = false;
+ this.air.withdraw(this);
+ }
+
async rescan(): Promise {
this.calls.push('rescan');
if (this.rescanFailure) throw this.rescanFailure;
@@ -66,6 +88,7 @@ export class ProximityNativeFake extends NativeEventBus {
async stop(): Promise {
this.calls.push('stop');
this.started = false;
+ this.advertising = false;
this.air.withdraw(this);
}
@@ -121,8 +144,12 @@ export class ProximityAir {
for (const peer of this.devices) {
if (peer === source || !peer.started) continue;
// Both directions, the way browsing and advertising each surface the other side.
- peer.emit(PEER_FOUND_EVENT, { device: source.device });
- source.emit(PEER_FOUND_EVENT, { device: peer.device });
+ if (source.advertising) {
+ peer.emit(PEER_FOUND_EVENT, { device: source.device });
+ }
+ if (peer.advertising) {
+ source.emit(PEER_FOUND_EVENT, { device: peer.device });
+ }
}
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 204bcfe56..68b6bcf7e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -44,6 +44,8 @@
the app is backgrounded / the screen is off (WorkManager foreground worker). -->
+
+
@@ -132,7 +134,7 @@
android:name="ai.offgridmobile.sync.MeshResidencyService"
android:enabled="true"
android:exported="false"
- android:foregroundServiceType="dataSync" />
+ android:foregroundServiceType="connectedDevice" />
diff --git a/android/app/src/main/java/ai/offgridmobile/sync/MeshResidencyService.kt b/android/app/src/main/java/ai/offgridmobile/sync/MeshResidencyService.kt
index 931a90ace..0f48b0c57 100644
--- a/android/app/src/main/java/ai/offgridmobile/sync/MeshResidencyService.kt
+++ b/android/app/src/main/java/ai/offgridmobile/sync/MeshResidencyService.kt
@@ -15,9 +15,9 @@ import androidx.core.app.NotificationCompat
* Keeps the Personal Mesh reachable while Off Grid is not in the foreground.
*
* Without this, Android suspends the process and mDNS discovery, the TCP listener and any in-flight
- * transfer stop, while the other device still shows this one as connected. A dataSync foreground
- * service is the only way to hold those sockets open, and it comes with a notification the user can
- * see - which is the honest trade: background reachability is visible, never silent.
+ * transfer stop, while the other device still shows this one as connected. Android classifies this
+ * live local-device connection as connectedDevice work. That type is not subject to dataSync's
+ * six-hour budget. The ongoing notification keeps background reachability visible, never silent.
*/
class MeshResidencyService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
@@ -35,7 +35,7 @@ class MeshResidencyService : Service() {
startForeground(
NOTIFICATION_ID,
notification,
- ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
+ FOREGROUND_SERVICE_TYPE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
@@ -45,6 +45,8 @@ class MeshResidencyService : Service() {
companion object {
const val CHANNEL_ID = "offgrid-personal-mesh"
const val NOTIFICATION_ID = 4711
+ const val FOREGROUND_SERVICE_TYPE =
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
/**
* Ensure the channel exists before the first foreground start.
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
index 71797e32a..12ed18b3e 100644
--- a/android/app/src/main/res/xml/network_security_config.xml
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -10,7 +10,8 @@
NOTE: Android network_security_config.xml has no IP-range wildcard support — only
matches exact hostnames or IPs, not CIDR ranges. The base-config is therefore the only
practical mechanism to allow HTTP to user-configured LAN servers with arbitrary IPs
- (192.168.x.x, 10.x.x.x, 172.16-31.x.x). All outbound connections to the public internet
+ (192.168.x.x, 10.x.x.x, 172.16-31.x.x) and Tailscale (100.64.0.0/10).
+ All outbound connections to the public internet
remain HTTPS-only because those servers redirect HTTP → HTTPS; this config only permits
plain HTTP but does not downgrade secure connections. -->
diff --git a/android/app/src/test/java/ai/offgridmobile/sync/MeshResidencyServiceTest.kt b/android/app/src/test/java/ai/offgridmobile/sync/MeshResidencyServiceTest.kt
new file mode 100644
index 000000000..18e8f4847
--- /dev/null
+++ b/android/app/src/test/java/ai/offgridmobile/sync/MeshResidencyServiceTest.kt
@@ -0,0 +1,52 @@
+package ai.offgridmobile.sync
+
+import android.Manifest
+import android.app.Application
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager
+import android.content.pm.ServiceInfo
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34], application = Application::class)
+class MeshResidencyServiceTest {
+ @Test
+ fun personalMeshUsesConnectedDeviceForegroundServiceContract() {
+ val context = ApplicationProvider.getApplicationContext()
+ val serviceInfo =
+ context.packageManager.getServiceInfo(
+ ComponentName(context, MeshResidencyService::class.java),
+ PackageManager.ComponentInfoFlags.of(0),
+ )
+
+ assertEquals(
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
+ MeshResidencyService.FOREGROUND_SERVICE_TYPE,
+ )
+ assertEquals(
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
+ serviceInfo.foregroundServiceType,
+ )
+
+ val requestedPermissions =
+ context.packageManager
+ .getPackageInfo(
+ context.packageName,
+ PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()),
+ ).requestedPermissions.orEmpty()
+
+ assertTrue(
+ requestedPermissions.contains(Manifest.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE),
+ )
+ assertTrue(
+ requestedPermissions.contains(Manifest.permission.CHANGE_WIFI_MULTICAST_STATE),
+ )
+ }
+}
diff --git a/android/build.gradle b/android/build.gradle
index 8fb686abb..35991036b 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -6,6 +6,11 @@ buildscript {
targetSdkVersion = 36
ndkVersion = "27.1.12297006"
kotlinVersion = "2.2.0"
+ // react-native-inappbrowser-reborn otherwise requests dynamic `+`
+ // versions. Pin them so Gradle does not query unrelated repositories
+ // for metadata during an offline/local build.
+ androidXAnnotationVersion = "1.5.0"
+ androidXBrowserVersion = "1.4.0"
}
repositories {
google()
@@ -37,4 +42,3 @@ subprojects { subproject ->
}
}
}
-
diff --git a/docs/GAPS_BACKLOG.md b/docs/GAPS_BACKLOG.md
index 141a71c6f..c3158037e 100644
--- a/docs/GAPS_BACKLOG.md
+++ b/docs/GAPS_BACKLOG.md
@@ -12,6 +12,35 @@ Verdict legend:
---
+## Active Kokoro voice-model download cannot stop at Pro expiry - 2026-08-26
+
+**Verdict: instrument-and-revisit.**
+
+The Pro-expiry teardown stops audio, removes the voice download provider, and releases the TTS
+engine. However, a Kokoro asset fetch that is already active continues inside the
+`react-native-executorch` fetcher because that external API has no abort or cancel operation.
+`pro/audio/ttsDownloadProvider.ts` records this boundary as `cancel: false`; expiry can prevent new
+paid work, but it cannot stop the active native download or its remaining disk writes.
+
+Revisit when the native fetcher exposes cancellation, or put voice-model transfer behind an app-owned
+cancellable downloader. The acceptance case is that exact Pro expiry aborts an active voice-model
+network request and no download progress or file write occurs after access closes.
+
+---
+
+## Android voice session can lose playback or transcription - 2026-08-24
+
+**Verdict: instrument-and-revisit.**
+
+A Pixel 8a user reported that voice replies showed both transcripts but produced no sound. After the
+user stopped and restarted the app, playback worked, but microphone input no longer produced a
+transcript. The currently attached Android device creates and plays a full-volume `AudioTrack`, so
+the failure is not reproduced. Capture Pixel logs for the playback and recorder state machines before
+changing audio-focus or model-lifecycle behavior. Acceptance: repeated voice turns continue to play
+and transcribe before and after app restart, for the user's selected language.
+
+---
+
## Projects screen does not refresh after desktop project sync - 2026-08-20
**Verdict: instrument-and-revisit.**
@@ -1215,3 +1244,23 @@ these fixes are verified against faked native leaves. Three flows to run on a ph
**Do not make `useVoiceSessionDriver` level-triggered again.** `voiceSession.dispatch` notifies on a
phase change so the hero can show "Recording you now"; with a level-triggered driver that same
notification opens a second recording mid-turn. The two belong together and each says so in a comment.
+
+---
+
+## Personal Mesh visibility needs the final physical lifecycle pass
+
+**Status:** automation-backed; manual device verification is open. Filed 2026-08-24.
+
+The Shared, React Native, Pro control, and Swift tests prove that browsing and advertising are
+separate. They also prove that Hidden is applied before startup, a failed advertising stop keeps the
+last true runtime and stored state, overlapping show and hide requests finish in order, and a retry
+can complete the stop.
+
+The remaining boundary is a real iPhone and Mac. Use the exact release builds and complete rows
+43-48 in `docs/PERSONAL_MESH_TEST_MATRIX.csv`. Confirm that Hidden survives a cold start, that each
+visibility control leaves the other function active, that an existing encrypted session stays active,
+and that a second device sees the correct advertisement. Also confirm one private IP or machine-name
+route and one non-default Sync port on every device.
+
+Close this gap only with the device names, OS versions, exact build commits, and the completed matrix
+rows. Simulator and injected-failure results do not close the physical radio boundary.
diff --git a/docs/PERSONAL_MESH.md b/docs/PERSONAL_MESH.md
new file mode 100644
index 000000000..5ffd7dcd2
--- /dev/null
+++ b/docs/PERSONAL_MESH.md
@@ -0,0 +1,59 @@
+# Personal Mesh
+
+Your phone and computer can share data directly. There is no relay between them.
+
+## Requirements
+
+- Off Grid Pro must be active on each device.
+- Pair each device once on the same Wi-Fi network.
+- Use Sync port `37878` on every device, unless you set one different port on every device.
+- Restart each app after you change the Sync port.
+- Android uses the local network. Apple devices can also use Nearby when Wi-Fi is not available.
+
+Your Sync traffic is encrypted between paired devices. A private address does not remove pairing or
+encryption.
+
+## Control who can find you
+
+Open **Settings > Sync**. The two controls have different jobs.
+
+| Control | When it is on | When it is off |
+| --- | --- | --- |
+| **Discoverable to new devices** | Other devices can find this device for pairing. | This device is Hidden. It does not advertise itself. It can still find other devices, and an existing paired connection stays active. |
+| **Find nearby devices** | This device looks for other Off Grid AI devices. | This device stops looking. It can still be discoverable to other devices, and active connections stay active. |
+
+If you save Hidden, the app starts Hidden after a full quit or phone restart. It does not advertise
+first and hide later.
+
+## Use one Sync port
+
+The default Sync port is `37878`.
+
+1. Open **Settings > Sync > Connection settings**.
+2. Enter a port from `1024` to `65535`.
+3. Select **Save**.
+4. Set the same port on every paired device.
+5. Restart every app.
+
+If one device uses a different port, the devices cannot make the direct connection.
+
+## Connect by private address
+
+Use this when discovery cannot reach a paired device across a private network, VPN, or tailnet.
+
+1. Pair the device on the same Wi-Fi network first.
+2. Open **Settings > Sync**.
+3. Select the saved device.
+4. Select **Connect by address**.
+5. Enter the private IP address or machine name, such as `100.116.255.25` or
+ `apples-macbook-pro-2`.
+6. Select **Save and connect**.
+
+Enter only the address or name. Do not enter `http://`, a path, or a port. Off Grid uses the Sync
+port from Connection settings. The saved address belongs to that device only.
+
+## What success looks like
+
+The device row changes to **Connected** and shows the route that is in use. A hidden device does not
+appear to a new unpaired device. It can still show devices that it finds when **Find nearby devices**
+is on.
diff --git a/docs/PERSONAL_MESH_TEST_MATRIX.csv b/docs/PERSONAL_MESH_TEST_MATRIX.csv
index bb7d7c2ca..6ab263584 100644
--- a/docs/PERSONAL_MESH_TEST_MATRIX.csv
+++ b/docs/PERSONAL_MESH_TEST_MATRIX.csv
@@ -12,7 +12,7 @@
11,2 Pairing,Pairing stages are visible,Watch the screen through a successful pair,"Stages appear: connecting, verifying code, checking admission, saving trust, paired",P1,,,,Both devices must agree on the stage
12,2 Pairing,No false paired state,Kill the app mid-pair (airplane mode at the code step),"Never shows paired; shows an actionable recovery state",P0,,,,Trust must not commit before admission
13,3 Discovery,Devices find each other on the same Wi-Fi,Put both on one network; open Sync on both,Each appears in the other's discovered list within ~10s,P0,,,,
-14,3 Discovery,Android advertises itself,From the Mac, look for the Android device without touching Android,Android appears in the Mac's list,P0,,n/a,,Proves NSD registerService works, not just browsing
+14,3 Discovery,Android advertises itself,"From the Mac, look for the Android device without touching Android",Android appears in the Mac's list,P0,,n/a,,"Proves NSD registerService works, not just browsing"
15,3 Discovery,Route is reported honestly,Inspect the connected row on each device,"Says LAN when on Wi-Fi; Android never claims a nearby/proximity route",P1,,,,Android has no Nearby analog
16,3 Discovery,Reconnect after a Wi-Fi drop,Toggle Wi-Fi off then on on one device,Reconnects without re-pairing,P0,,,,
17,3 Discovery,Reconnect after moving networks,Move one device to a different Wi-Fi then back,Reconnects; no duplicate device rows,P1,,,,
@@ -36,8 +36,14 @@
35,8 Clipboard,Apple Universal Clipboard is not claimed as ours,Turn Off Grid clipboard sync OFF; copy on the Mac; check the iPhone,"If the text appears it is labelled a local pasteboard observation, NOT an Off Grid transfer",P0,,,n/a,The exact dishonesty this guards
36,8 Clipboard,Android clipboard semantics are labelled on their own terms,Copy on the Mac with sync on; check Android,"Arrives as an Off Grid transfer; Android has no Universal Clipboard so nothing arrives with sync off",P0,,n/a,,
37,9 Provider,Provider outage keeps verified devices working,Block api.keygen.sh; relaunch,"Pro still works; roster shows it is cached, not authoritative",P0,,,,
-38,9 Provider,Known eviction beats a cached credential,Evict the device, then block the provider and relaunch it,Device does NOT present itself as Pro active,P0,,,,
+38,9 Provider,Known eviction beats a cached credential,"Evict the device, then block the provider and relaunch it",Device does NOT present itself as Pro active,P0,,,,
39,9 Provider,Freshness is visible,Compare a fresh load with an offline load,"Says updated just now vs showing saved roster - provider unavailable",P1,,,,
40,10 Honesty,No ambiguous single number,Read the Devices screen on each platform,"Separate counts: registered / paired / connected now - never one bare ""1/5""",P1,,,,
-41,10 Honesty,Same words on all three platforms,Compare the state labels across macOS, iOS and Android,"Identical vocabulary: registered, paired, connected, offline, registration required",P1,,,,Layout may differ; meaning may not
+41,10 Honesty,Same words on all three platforms,"Compare the state labels across macOS, iOS and Android","Identical vocabulary: registered, paired, connected, offline, registration required",P1,,,,Layout may differ; meaning may not
42,10 Honesty,Debug builds never say Pro Active,Open a debug build without a license,"Says Development Access, never Pro Active",P1,,,,
+43,11 Visibility,Hidden survives a cold start,"Turn Discoverable to new devices off; force-quit the app; relaunch; search from an unpaired device","The phone starts Hidden and never appears to the unpaired device; Find nearby devices keeps its saved state",P0,,,n/a,"Run on a real iPhone; watch the second device during the full launch"
+44,11 Visibility,Hidden does not stop finding or an active session,"Connect two paired devices; turn Discoverable to new devices off on one; rescan and send a small record","The hidden device is not offered for new pairing; it still finds the peer and the active encrypted session carries the record",P0,,,,
+45,11 Visibility,Find nearby off does not hide this device,"Leave Discoverable to new devices on; turn Find nearby devices off; search from the second device","The first device stops adding discovered rows; the second device can still find it; active connections stay active",P0,,,,
+46,11 Connection,Private address reconnects a saved device,"Pair on Wi-Fi; move to a private VPN or tailnet; enter the saved device private IP address or machine name; select Save and connect","The exact saved device reconnects on the configured Sync port; no new device identity or pairing appears",P0,,,,"Enter a host only, with no URL scheme, path, or port"
+47,11 Connection,Custom Sync port is one mesh setting,"Set a non-default port on one device only and restart; then set the same port on every device and restart all apps","The mismatch fails clearly; the matching port reconnects the same paired devices; default port 37878 still works after restore",P0,,,,"Use one disposable port from 1024 to 65535"
+48,11 Failure recovery,Failed advertising stop keeps the true state and retries,"Use a diagnostic native boundary that rejects the first advertising stop; turn Discoverable off; remove the failure; turn it off again","The first action reports failure and the switch and saved value stay on; the second action stops advertising and the switch and saved value turn off",P0,,,n/a,"Failure injection is required because the native stop API has no normal user control"
diff --git a/ios/BlobChannelUploader.swift b/ios/BlobChannelUploader.swift
index 4b6760615..bdc8765b8 100644
--- a/ios/BlobChannelUploader.swift
+++ b/ios/BlobChannelUploader.swift
@@ -80,8 +80,15 @@ final class BlobChannelUploader {
}
}
connection.start(queue: queue)
- _ = ready.wait(timeout: .now() + 15)
+ try waitForSignal(
+ ready,
+ timeout: .seconds(15),
+ message: "the endpoint did not become reachable"
+ )
if let problem { throw problem }
+ guard connection.state == .ready else {
+ throw failure("the endpoint did not become ready")
+ }
live.hold(request.requestId, connection)
defer {
_ = live.take(request.requestId)
@@ -145,7 +152,11 @@ final class BlobChannelUploader {
problem = error
done.signal()
})
- _ = done.wait(timeout: .now() + 60)
+ try waitForSignal(
+ done,
+ timeout: .seconds(60),
+ message: "the endpoint stopped accepting the payload"
+ )
if let problem { throw problem }
}
@@ -156,11 +167,31 @@ final class BlobChannelUploader {
answer = String(data: data ?? Data(), encoding: .utf8) ?? ""
done.signal()
}
- _ = done.wait(timeout: .now() + 60)
+ try waitForSignal(
+ done,
+ timeout: .seconds(60),
+ message: "the endpoint did not confirm the payload"
+ )
guard answer.hasPrefix("HTTP/1.1 200") else {
throw NSError(
domain: "ai.offgridmobile.blob", code: 1,
userInfo: [NSLocalizedDescriptionKey: "the endpoint answered \(answer.prefix(32))"])
}
}
+
+ /// A network deadline is a failure, not a successful empty response.
+ ///
+ /// `DispatchSemaphore.wait` reports a timeout as a return value. Ignoring that value made an
+ /// unreachable endpoint look ready, so a transfer stayed at zero bytes until its manager deadline.
+ /// Throwing here lets the shared transfer manager use its slower fallback route while the peer is
+ /// still connected.
+ static func waitForSignal(
+ _ semaphore: DispatchSemaphore,
+ timeout: DispatchTimeInterval,
+ message: String
+ ) throws {
+ guard semaphore.wait(timeout: .now() + timeout) == .success else {
+ throw failure(message)
+ }
+ }
}
diff --git a/ios/OffgridMobile/Info.plist b/ios/OffgridMobile/Info.plist
index c06edb52a..350fa2054 100644
--- a/ios/OffgridMobile/Info.plist
+++ b/ios/OffgridMobile/Info.plist
@@ -46,6 +46,14 @@
NSAllowsLocalNetworking
+ NSExceptionDomains
+
+ 100.64.0.0/10
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+
+
NSBonjourServices
diff --git a/ios/OffgridMobileTests/OffgridMobileTests.swift b/ios/OffgridMobileTests/OffgridMobileTests.swift
index 5bd1f3a82..b3284b4de 100644
--- a/ios/OffgridMobileTests/OffgridMobileTests.swift
+++ b/ios/OffgridMobileTests/OffgridMobileTests.swift
@@ -44,6 +44,77 @@ final class BlobReceiveWindowTests: XCTestCase {
}
}
+final class BlobChannelUploaderDeadlineTests: XCTestCase {
+ func testAReachedNetworkSignalContinues() {
+ let signal = DispatchSemaphore(value: 0)
+ signal.signal()
+
+ XCTAssertNoThrow(
+ try BlobChannelUploader.waitForSignal(
+ signal, timeout: .milliseconds(1), message: "should not time out"))
+ }
+
+ func testAnUnreachedNetworkSignalFailsInsteadOfPretendingToContinue() {
+ let signal = DispatchSemaphore(value: 0)
+
+ XCTAssertThrowsError(
+ try BlobChannelUploader.waitForSignal(
+ signal, timeout: .milliseconds(0), message: "the endpoint did not become reachable")
+ ) { error in
+ XCTAssertEqual(error.localizedDescription, "the endpoint did not become reachable")
+ }
+ }
+}
+
+final class ProximityAdvertisingControllerTests: XCTestCase {
+ func testStopAndRestartReachTheNativeAdvertiserWithoutRestartingTheSession() {
+ let controller = ProximityAdvertisingController()
+ var starts = 0
+ var stops = 0
+ controller.install(
+ start: { starts += 1 },
+ stop: { stops += 1 }
+ )
+ XCTAssertFalse(controller.isAdvertising)
+ XCTAssertEqual(starts, 0)
+
+ XCTAssertTrue(controller.start())
+ controller.stop()
+ XCTAssertFalse(controller.isAdvertising)
+ XCTAssertEqual(starts, 1)
+ XCTAssertEqual(stops, 1)
+
+ XCTAssertTrue(controller.start())
+ XCTAssertTrue(controller.isAdvertising)
+ XCTAssertEqual(starts, 2)
+ }
+
+ func testReplacingTheAdvertiserPreservesWhetherItWasHidden() {
+ let controller = ProximityAdvertisingController()
+ var firstStarts = 0
+ var firstStops = 0
+ var replacementStarts = 0
+ controller.install(
+ start: { firstStarts += 1 },
+ stop: { firstStops += 1 }
+ )
+ XCTAssertTrue(controller.start())
+
+ controller.install(
+ start: { replacementStarts += 1 },
+ stop: {}
+ )
+ XCTAssertEqual(firstStops, 1)
+ XCTAssertEqual(replacementStarts, 1)
+
+ controller.stop()
+ controller.install(start: { replacementStarts += 1 }, stop: {})
+ XCTAssertFalse(controller.isAdvertising)
+ XCTAssertEqual(replacementStarts, 1)
+ XCTAssertEqual(firstStarts, 1)
+ }
+}
+
final class StreamingFileHasherTests: XCTestCase {
func testProducesTheStandardSHA512DigestAcrossManyChunks() throws {
let url = FileManager.default.temporaryDirectory
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 3ed9ea153..766110f6f 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -2040,7 +2040,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - react-native-executorch (0.8.1):
+ - react-native-executorch (0.9.3):
- boost
- DoubleConversion
- fast_float
@@ -3459,6 +3459,12 @@ PODS:
- SSZipArchive (~> 2.5.5)
- SocketRocket (0.7.1)
- SSZipArchive (2.5.5)
+ - VisionCamera (4.7.3):
+ - VisionCamera/Core (= 4.7.3)
+ - VisionCamera/React (= 4.7.3)
+ - VisionCamera/Core (4.7.3)
+ - VisionCamera/React (4.7.3):
+ - React-Core
- whisper-rn (0.5.5):
- boost
- DoubleConversion
@@ -3599,6 +3605,7 @@ DEPENDENCIES:
- RNWorklets (from `../node_modules/react-native-worklets`)
- RNZipArchive (from `../node_modules/react-native-zip-archive`)
- SocketRocket (~> 0.7.1)
+ - VisionCamera (from `../node_modules/react-native-vision-camera`)
- whisper-rn (from `../node_modules/whisper.rn`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
@@ -3823,6 +3830,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-worklets"
RNZipArchive:
:path: "../node_modules/react-native-zip-archive"
+ VisionCamera:
+ :path: "../node_modules/react-native-vision-camera"
whisper-rn:
:path: "../node_modules/whisper.rn"
Yoga:
@@ -3881,7 +3890,7 @@ SPEC CHECKSUMS:
react-native-background-downloader: b02d12c3961322ce1c85fa0f8b3e4adb5b652106
react-native-document-picker: dc2d83366e47e89e7c51e8a41eab99c1d54e941c
react-native-document-viewer: 8c6ed07e7e27352743fa98e8dd6d288ad925b884
- react-native-executorch: 9a44ee2b18773cbe5ad2e6d7376eb76f347e2935
+ react-native-executorch: 863673cf458ceec5df61012fa30981ffa3ee8fc4
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-image-picker: 0314366753615115fa55c3cc937ac44cb7e75702
react-native-keyboard-controller: 7534b5a39d1e8b2b79f86e8e998ed71c7154f69f
@@ -3940,6 +3949,7 @@ SPEC CHECKSUMS:
RNZipArchive: f2806ba80e24cf1984d6a7cb361d8a07d734997d
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
SSZipArchive: c69881e8ac5521f0e622291387add5f60f30f3c4
+ VisionCamera: 7187b3dac1ff3071234ead959ce311875748e14f
whisper-rn: 7566faf9b7d78e39ab9fc634cb90fdee81177793
Yoga: 5456bb010373068fc92221140921b09d126b116e
diff --git a/ios/SyncProximityModule.m b/ios/SyncProximityModule.m
index 58edac185..0925d65b3 100644
--- a/ios/SyncProximityModule.m
+++ b/ios/SyncProximityModule.m
@@ -10,6 +10,12 @@ @interface RCT_EXTERN_MODULE(SyncProximityModule, RCTEventEmitter)
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(rescan:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
+RCT_EXTERN_METHOD(stopBrowsing:(RCTPromiseResolveBlock)resolve
+ rejecter:(RCTPromiseRejectBlock)reject)
+RCT_EXTERN_METHOD(startAdvertising:(RCTPromiseResolveBlock)resolve
+ rejecter:(RCTPromiseRejectBlock)reject)
+RCT_EXTERN_METHOD(stopAdvertising:(RCTPromiseResolveBlock)resolve
+ rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(updateDevice:(NSDictionary *)device
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
diff --git a/ios/SyncProximityModule.swift b/ios/SyncProximityModule.swift
index 93a48aa77..d548c3dde 100644
--- a/ios/SyncProximityModule.swift
+++ b/ios/SyncProximityModule.swift
@@ -5,6 +5,45 @@ import React
private let proximityServiceType = "offgrid-sync"
private let proximityConnectTimeout: TimeInterval = 12
+/// Owns the advertiser's active state independently from browsing and sessions.
+/// The closures keep MultipeerConnectivity at the native boundary while making
+/// stop, restart, and advertiser replacement deterministic in native tests.
+final class ProximityAdvertisingController {
+ private var startPeer: (() -> Void)?
+ private var stopPeer: (() -> Void)?
+ private(set) var isAdvertising = false
+
+ func install(start: @escaping () -> Void, stop: @escaping () -> Void) {
+ let shouldRestart = isAdvertising
+ if shouldRestart { stopPeer?() }
+ startPeer = start
+ stopPeer = stop
+ if shouldRestart { startPeer?() }
+ }
+
+ @discardableResult
+ func start() -> Bool {
+ guard let startPeer else { return false }
+ if !isAdvertising {
+ startPeer()
+ isAdvertising = true
+ }
+ return true
+ }
+
+ func stop() {
+ guard isAdvertising else { return }
+ stopPeer?()
+ isAdvertising = false
+ }
+
+ func clear() {
+ stop()
+ startPeer = nil
+ stopPeer = nil
+ }
+}
+
private struct ProximityDevice {
let id: String
let name: String
@@ -93,6 +132,7 @@ final class SyncProximityModule: RCTEventEmitter {
private var localDevice: ProximityDevice?
private var localPeer: MCPeerID?
private var advertiser: MCNearbyServiceAdvertiser?
+ private let advertising = ProximityAdvertisingController()
private var browser: MCNearbyServiceBrowser?
private var peersByDeviceId: [String: MCPeerID] = [:]
private var devicesByPeerName: [String: ProximityDevice] = [:]
@@ -145,7 +185,10 @@ final class SyncProximityModule: RCTEventEmitter {
self.browser = browser
advertiser.delegate = self
browser.delegate = self
- advertiser.startAdvertisingPeer()
+ advertising.install(
+ start: { advertiser.startAdvertisingPeer() },
+ stop: { advertiser.stopAdvertisingPeer() }
+ )
browser.startBrowsingForPeers()
resolve(nil)
}
@@ -189,6 +232,46 @@ final class SyncProximityModule: RCTEventEmitter {
}
}
+ @objc
+ func stopBrowsing(
+ _ resolve: @escaping RCTPromiseResolveBlock,
+ rejecter _: @escaping RCTPromiseRejectBlock
+ ) {
+ stateQueue.async { [weak self] in
+ self?.browser?.stopBrowsingForPeers()
+ resolve(nil)
+ }
+ }
+
+ @objc
+ func startAdvertising(
+ _ resolve: @escaping RCTPromiseResolveBlock,
+ rejecter reject: @escaping RCTPromiseRejectBlock
+ ) {
+ stateQueue.async { [weak self] in
+ guard let self, advertising.start() else {
+ reject(
+ "proximity_not_started",
+ "Sync proximity is not running.",
+ nil
+ )
+ return
+ }
+ resolve(nil)
+ }
+ }
+
+ @objc
+ func stopAdvertising(
+ _ resolve: @escaping RCTPromiseResolveBlock,
+ rejecter _: @escaping RCTPromiseRejectBlock
+ ) {
+ stateQueue.async { [weak self] in
+ self?.advertising.stop()
+ resolve(nil)
+ }
+ }
+
@objc
func updateDevice(
_ device: [String: Any],
@@ -208,7 +291,6 @@ final class SyncProximityModule: RCTEventEmitter {
)
return
}
- advertiser?.stopAdvertisingPeer()
advertiser?.delegate = nil
let replacement = MCNearbyServiceAdvertiser(
peer: peer,
@@ -218,7 +300,10 @@ final class SyncProximityModule: RCTEventEmitter {
localDevice = parsed
advertiser = replacement
replacement.delegate = self
- replacement.startAdvertisingPeer()
+ advertising.install(
+ start: { replacement.startAdvertisingPeer() },
+ stop: { replacement.stopAdvertisingPeer() }
+ )
resolve(nil)
}
}
@@ -352,7 +437,7 @@ final class SyncProximityModule: RCTEventEmitter {
}
private func stopInternal(notifyConnections: Bool) {
- advertiser?.stopAdvertisingPeer()
+ advertising.clear()
browser?.stopBrowsingForPeers()
advertiser?.delegate = nil
browser?.delegate = nil
diff --git a/jest.setup.ts b/jest.setup.ts
index 09127be0b..3d0979fb4 100644
--- a/jest.setup.ts
+++ b/jest.setup.ts
@@ -41,6 +41,15 @@ jest.mock('react-native-edge-to-edge', () => ({
NavigationBar: () => null,
}));
+// react-native-vision-camera is a native module (NitroModules); stub it so the
+// QR scanner (mesh Scan-to-pair) can render in jest without the native view.
+jest.mock('react-native-vision-camera', () => ({
+ Camera: () => null,
+ useCameraDevice: () => undefined,
+ useCameraPermission: () => ({ hasPermission: false, requestPermission: jest.fn() }),
+ useCodeScanner: (config: unknown) => config,
+}));
+
// ============================================================================
// AsyncStorage Mock
// ============================================================================
@@ -259,14 +268,19 @@ jest.mock('@react-native-community/slider', () => {
// A voice carries its own assets (embedding + tagger + lexicon) in addition to
// the two shared core .pte models — mirror that so completeness checks
// (_activeVoiceSources) have a realistic full asset set to validate against.
-const mockVoiceConfig = {
- id: 'mock_voice',
- voiceSource: 'https://example.test/kokoro/voices/af_heart.bin',
- extra: {
- taggerSource: 'https://example.test/kokoro/tagger.pt',
- lexiconSource: 'https://example.test/kokoro/lexicon.json',
+const mockKokoroConfig = (voice: string, language: string) => ({
+ model: {
+ durationPredictorSource: `https://example.test/kokoro/${language}/duration_predictor.pte`,
+ synthesizerSource: `https://example.test/kokoro/${language}/synthesizer.pte`,
},
-};
+ voiceSource: `https://example.test/kokoro/voices/${voice}.bin`,
+ phonemizerConfig: {
+ lang: language,
+ taggerSource: `https://example.test/kokoro/${language}/tagger.pt`,
+ lexiconSource: `https://example.test/kokoro/${language}/lexicon.json`,
+ neuralModelSource: `https://example.test/kokoro/${language}/phonemizer.pte`,
+ },
+});
jest.mock('react-native-executorch', () => ({
// Faithful init leaf for the executorch native runtime (a genuine external native boundary):
// initExecutorch registers the resource fetcher so the runtime is ready to load models through
@@ -284,19 +298,64 @@ jest.mock('react-native-executorch', () => ({
stream: jest.fn(() => Promise.resolve()),
streamStop: jest.fn(),
})),
- KOKORO_MEDIUM: {
- modelName: 'kokoro-medium',
- durationPredictorSource: 'https://example.test/kokoro/medium/duration_predictor.pte',
- synthesizerSource: 'https://example.test/kokoro/medium/synthesizer.pte',
+ models: {
+ text_to_speech: {
+ kokoro: {
+ en_us: {
+ heart: () => mockKokoroConfig('af_heart', 'en-us'),
+ river: () => mockKokoroConfig('af_river', 'en-us'),
+ sarah: () => mockKokoroConfig('af_sarah', 'en-us'),
+ adam: () => mockKokoroConfig('am_adam', 'en-us'),
+ michael: () => mockKokoroConfig('am_michael', 'en-us'),
+ santa: () => mockKokoroConfig('am_santa', 'en-us'),
+ },
+ en_gb: {
+ emma: () => mockKokoroConfig('bf_emma', 'en-gb'),
+ daniel: () => mockKokoroConfig('bm_daniel', 'en-gb'),
+ },
+ fr: { siwis: () => mockKokoroConfig('ff_siwis', 'fr') },
+ es: {
+ dora: () => mockKokoroConfig('ef_dora', 'es'),
+ alex: () => mockKokoroConfig('em_alex', 'es'),
+ },
+ it: {
+ sara: () => mockKokoroConfig('if_sara', 'it'),
+ nicola: () => mockKokoroConfig('im_nicola', 'it'),
+ },
+ pt: {
+ dora: () => mockKokoroConfig('pf_dora', 'pt'),
+ santa: () => mockKokoroConfig('pm_santa', 'pt'),
+ },
+ hi: {
+ alpha: () => mockKokoroConfig('hf_alpha', 'hi'),
+ omega: () => mockKokoroConfig('hm_omega', 'hi'),
+ psi: () => mockKokoroConfig('hm_psi', 'hi'),
+ },
+ pl: { mateusz: () => mockKokoroConfig('pm_mateusz', 'pl') },
+ de: { anna: () => mockKokoroConfig('df_anna', 'de') },
+ },
+ },
},
- KOKORO_VOICE_AF_HEART: mockVoiceConfig,
- KOKORO_VOICE_AF_RIVER: mockVoiceConfig,
- KOKORO_VOICE_AF_SARAH: mockVoiceConfig,
- KOKORO_VOICE_AM_ADAM: mockVoiceConfig,
- KOKORO_VOICE_AM_MICHAEL: mockVoiceConfig,
- KOKORO_VOICE_AM_SANTA: mockVoiceConfig,
- KOKORO_VOICE_BF_EMMA: mockVoiceConfig,
- KOKORO_VOICE_BM_DANIEL: mockVoiceConfig,
+ KOKORO_AMERICAN_ENGLISH_FEMALE_HEART: mockKokoroConfig('af_heart', 'en-us'),
+ KOKORO_AMERICAN_ENGLISH_FEMALE_RIVER: mockKokoroConfig('af_river', 'en-us'),
+ KOKORO_AMERICAN_ENGLISH_FEMALE_SARAH: mockKokoroConfig('af_sarah', 'en-us'),
+ KOKORO_AMERICAN_ENGLISH_MALE_ADAM: mockKokoroConfig('am_adam', 'en-us'),
+ KOKORO_AMERICAN_ENGLISH_MALE_MICHAEL: mockKokoroConfig('am_michael', 'en-us'),
+ KOKORO_AMERICAN_ENGLISH_MALE_SANTA: mockKokoroConfig('am_santa', 'en-us'),
+ KOKORO_BRITISH_ENGLISH_FEMALE_EMMA: mockKokoroConfig('bf_emma', 'en-gb'),
+ KOKORO_BRITISH_ENGLISH_MALE_DANIEL: mockKokoroConfig('bm_daniel', 'en-gb'),
+ KOKORO_FRENCH_FEMALE_SIWIS: mockKokoroConfig('ff_siwis', 'fr'),
+ KOKORO_SPANISH_FEMALE_DORA: mockKokoroConfig('ef_dora', 'es'),
+ KOKORO_SPANISH_MALE_ALEX: mockKokoroConfig('em_alex', 'es'),
+ KOKORO_ITALIAN_FEMALE_SARA: mockKokoroConfig('if_sara', 'it'),
+ KOKORO_ITALIAN_MALE_NICOLA: mockKokoroConfig('im_nicola', 'it'),
+ KOKORO_PORTUGUESE_FEMALE_DORA: mockKokoroConfig('pf_dora', 'pt'),
+ KOKORO_PORTUGUESE_MALE_SANTA: mockKokoroConfig('pm_santa', 'pt'),
+ KOKORO_HINDI_FEMALE_ALPHA: mockKokoroConfig('hf_alpha', 'hi'),
+ KOKORO_HINDI_MALE_OMEGA: mockKokoroConfig('hm_omega', 'hi'),
+ KOKORO_HINDI_MALE_PSI: mockKokoroConfig('hm_psi', 'hi'),
+ KOKORO_POLISH_MALE_MATEUSZ: mockKokoroConfig('pm_mateusz', 'pl'),
+ KOKORO_GERMAN_FEMALE_ANNA: mockKokoroConfig('df_anna', 'de'),
}));
// react-native-executorch-bare-resource-fetcher mock.
@@ -678,10 +737,13 @@ beforeEach(() => {
// flakiness, far worse in-band). This afterEach requires RTL AFTER the test's resetModules, so it resolves
// the SAME post-reset instance the test rendered on, and unmounts its tree. It also drops the global
// `window` shim the harness installs for React 19's error reporter, so no true-global leaks across files.
-afterEach(() => {
+afterEach(async () => {
// Only unmount when a test actually rendered via requireRTL (which stashed its own cleanup here). Do NOT
// require RTL fresh — after a test's resetModules that pulls a new module graph and breaks the next test.
- const g = globalThis as unknown as { __RTL_CLEANUP__?: () => void; __GEN_CLEANUP__?: () => void };
+ const g = globalThis as unknown as {
+ __RTL_CLEANUP__?: () => void;
+ __GEN_CLEANUP__?: () => Promise;
+ };
if (g.__RTL_CLEANUP__) { try { g.__RTL_CLEANUP__(); } catch { /* already torn down */ } g.__RTL_CLEANUP__ = undefined; }
// A generation left IN FLIGHT outlives its test. generationServiceHelpers schedules a 50ms token-buffer
// flush; when a suite ends mid-reply that timer fires during the NEXT suite, which has since called
@@ -689,7 +751,7 @@ afterEach(() => {
// "Cannot read properties of undefined (reading 'getState')" — failing whichever suite happened to be
// running. That is why exactly one rendered suite failed per run, with a different name each time, and why
// it always passed in isolation. Whoever started a generation registers the stop here.
- if (g.__GEN_CLEANUP__) { try { g.__GEN_CLEANUP__(); } catch { /* already torn down */ } g.__GEN_CLEANUP__ = undefined; }
+ if (g.__GEN_CLEANUP__) { try { await g.__GEN_CLEANUP__(); } catch { /* already torn down */ } g.__GEN_CLEANUP__ = undefined; }
});
// Global timeout for async operations
diff --git a/metro.config.js b/metro.config.js
index 0734f853e..87c5780f2 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -14,6 +14,7 @@ const proExists = fs.existsSync(path.resolve(proPackagePath, 'package.json'));
// dep and breaks libraries with malformed exports maps). The package ships prebuilt CJS in dist/.
const syncPackagePath = path.resolve(__dirname, '../shared/packages/sync');
const ragPackagePath = path.resolve(__dirname, '../shared/packages/rag');
+const uiPackagePath = path.resolve(__dirname, '../shared/packages/ui');
// @offgrid/speech: voice-turn decisions (when a spoken turn begins and ends) shared with desktop.
// Out-of-root like sync, so Metro must watch it and be pointed at its built entry.
const speechPackagePath = path.resolve(__dirname, '../shared/packages/speech');
@@ -27,7 +28,13 @@ const syncRuntimeModules = {
const config = {
// pro/ is a submodule inside the project root, so Metro already watches it by default. The sync
// package is out-of-root, so Metro must be told to watch it (for its dist) — nothing else needed.
- watchFolders: [syncPackagePath, ragPackagePath, speechPackagePath, sharedNodeModulesPath],
+ watchFolders: [
+ syncPackagePath,
+ ragPackagePath,
+ speechPackagePath,
+ uiPackagePath,
+ sharedNodeModulesPath,
+ ],
resolver: {
// When resolving modules from outside the project root (i.e. @offgrid/pro),
// Metro falls back here so @babel/runtime and all other peer deps are found.
@@ -48,6 +55,7 @@ const config = {
// after the file dependency is added, even though Node can resolve the package.
'@offgrid/rag': path.resolve(ragPackagePath, 'dist/index.js'),
'@offgrid/speech': path.resolve(speechPackagePath, 'dist/index.cjs'),
+ '@offgrid/ui': path.resolve(uiPackagePath, 'dist/index.js'),
// Points to the real pro package when present on disk (store builds),
// falls back to a null stub so free builds bundle cleanly.
'@offgrid/pro': proExists ? proPackagePath : proStubPath,
diff --git a/package-lock.json b/package-lock.json
index b2e07c0c4..ff6931dd6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"@offgrid/rag": "file:../shared/packages/rag",
"@offgrid/speech": "file:../shared/packages/speech",
"@offgrid/sync": "file:../shared/packages/sync",
+ "@offgrid/ui": "file:../shared/packages/ui",
"@op-engineering/op-sqlite": "^15.2.5",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/slider": "^5.1.2",
@@ -40,8 +41,8 @@
"react-native-calendar-events": "^2.2.0",
"react-native-device-info": "^15.0.1",
"react-native-edge-to-edge": "^1.8.1",
- "react-native-executorch": "^0.8.1",
- "react-native-executorch-bare-resource-fetcher": "^0.8.0",
+ "react-native-executorch": "^0.9.3",
+ "react-native-executorch-bare-resource-fetcher": "^0.9.1",
"react-native-gesture-handler": "^2.30.0",
"react-native-get-random-values": "^1.11.0",
"react-native-haptic-feedback": "^2.3.3",
@@ -56,6 +57,7 @@
"react-native-tcp-socket": "^6.4.1",
"react-native-url-polyfill": "^3.0.0",
"react-native-vector-icons": "^10.3.0",
+ "react-native-vision-camera": "^4.7.3",
"react-native-worklets": "^0.7.3",
"react-native-zeroconf": "^0.14.0",
"react-native-zip-archive": "7.1.0",
@@ -129,6 +131,11 @@
"c8": "^12.0.0"
}
},
+ "../shared/packages/ui": {
+ "name": "@offgrid/ui",
+ "version": "0.0.1",
+ "license": "AGPL-3.0-only"
+ },
"node_modules/@babel/code-frame": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@@ -4468,6 +4475,10 @@
"resolved": "../shared/packages/sync",
"link": true
},
+ "node_modules/@offgrid/ui": {
+ "resolved": "../shared/packages/ui",
+ "link": true
+ },
"node_modules/@op-engineering/op-sqlite": {
"version": "15.2.5",
"resolved": "https://registry.npmjs.org/@op-engineering/op-sqlite/-/op-sqlite-15.2.5.tgz",
@@ -4609,9 +4620,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4629,9 +4637,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4649,9 +4654,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4669,9 +4671,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4689,9 +4688,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4709,9 +4705,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4729,9 +4722,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4749,9 +4739,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4964,9 +4951,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4981,9 +4965,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4998,9 +4979,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5015,9 +4993,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5032,9 +5007,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5049,9 +5021,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5066,9 +5035,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5083,9 +5049,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -12667,14 +12630,30 @@
"license": "MIT"
},
"node_modules/linkify-it": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz",
- "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==",
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
+ "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/markdown-it"
+ }
+ ],
"license": "MIT",
"dependencies": {
- "uc.micro": "^1.0.1"
+ "uc.micro": "^2.0.0"
}
},
+ "node_modules/linkify-it/node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "license": "MIT"
+ },
"node_modules/llama.rn": {
"version": "0.13.0-rc.0",
"resolved": "https://registry.npmjs.org/llama.rn/-/llama.rn-0.13.0-rc.0.tgz",
@@ -14364,15 +14343,6 @@
"node": ">=8.0"
}
},
- "node_modules/pngjs": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
- "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
- "license": "MIT",
- "engines": {
- "node": ">=14.19.0"
- }
- },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -14807,9 +14777,9 @@
}
},
"node_modules/react-native-executorch": {
- "version": "0.8.1",
- "resolved": "https://registry.npmjs.org/react-native-executorch/-/react-native-executorch-0.8.1.tgz",
- "integrity": "sha512-DEVWs+Ki7p1C8mEgsHiabZizO/kDM0zELlJ+JFCfNCb2RrraMUXBTZIARWHPUbxpG17nqFswIZmwjUoNK5V36g==",
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/react-native-executorch/-/react-native-executorch-0.9.3.tgz",
+ "integrity": "sha512-eanpDe8sFFxbKfCh0lqrSVyU4/deiF1pZWtZOjZSV+w+qfuNsdrkQkoscxvhMwJOnvXNkBt4BXAr8pAlGOXStA==",
"license": "MIT",
"workspaces": [
"example"
@@ -14818,7 +14788,6 @@
"@huggingface/jinja": "^0.5.0",
"jsonrepair": "^3.12.0",
"jsonschema": "^1.5.0",
- "pngjs": "^7.0.0",
"zod": "^4.3.6"
},
"peerDependencies": {
@@ -14827,9 +14796,9 @@
}
},
"node_modules/react-native-executorch-bare-resource-fetcher": {
- "version": "0.8.0",
- "resolved": "https://registry.npmjs.org/react-native-executorch-bare-resource-fetcher/-/react-native-executorch-bare-resource-fetcher-0.8.0.tgz",
- "integrity": "sha512-PzSzK31qnKmwW06+JCbpQML24u3XiqYcWKQG0Y1cwPmkOqz0VppI0ZOeCZh03/03SMyuvwwEgteJtgO0uSP8sg==",
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/react-native-executorch-bare-resource-fetcher/-/react-native-executorch-bare-resource-fetcher-0.9.1.tgz",
+ "integrity": "sha512-ru5YN/4CQj3YlqW1L908PD7TEeZc5/63IV/xmjjDDz6+rpaR/aELA1Wo9ccRolgxWXTsCgMJ697fPQcC+TdZcw==",
"license": "MIT",
"peerDependencies": {
"@dr.pogodin/react-native-fs": "^2.0.0",
@@ -15121,6 +15090,30 @@
"node": ">=10"
}
},
+ "node_modules/react-native-vision-camera": {
+ "version": "4.7.3",
+ "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz",
+ "integrity": "sha512-g1/neOyjSqn1kaAa2FxI/qp5KzNvPcF0bnQw6NntfbxH6tm0+8WFZszlgb5OV+iYlB6lFUztCbDtyz5IpL47OA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@shopify/react-native-skia": "*",
+ "react": "*",
+ "react-native": "*",
+ "react-native-reanimated": "*",
+ "react-native-worklets-core": "*"
+ },
+ "peerDependenciesMeta": {
+ "@shopify/react-native-skia": {
+ "optional": true
+ },
+ "react-native-reanimated": {
+ "optional": true
+ },
+ "react-native-worklets-core": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-native-worklets": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.3.tgz",
diff --git a/package.json b/package.json
index ca949de98..4c494ed6b 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"@offgrid/rag": "file:../shared/packages/rag",
"@offgrid/speech": "file:../shared/packages/speech",
"@offgrid/sync": "file:../shared/packages/sync",
+ "@offgrid/ui": "file:../shared/packages/ui",
"@op-engineering/op-sqlite": "^15.2.5",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/slider": "^5.1.2",
@@ -62,8 +63,8 @@
"react-native-calendar-events": "^2.2.0",
"react-native-device-info": "^15.0.1",
"react-native-edge-to-edge": "^1.8.1",
- "react-native-executorch": "^0.8.1",
- "react-native-executorch-bare-resource-fetcher": "^0.8.0",
+ "react-native-executorch": "^0.9.3",
+ "react-native-executorch-bare-resource-fetcher": "^0.9.1",
"react-native-gesture-handler": "^2.30.0",
"react-native-get-random-values": "^1.11.0",
"react-native-haptic-feedback": "^2.3.3",
@@ -78,6 +79,7 @@
"react-native-tcp-socket": "^6.4.1",
"react-native-url-polyfill": "^3.0.0",
"react-native-vector-icons": "^10.3.0",
+ "react-native-vision-camera": "^4.7.3",
"react-native-worklets": "^0.7.3",
"react-native-zeroconf": "^0.14.0",
"react-native-zip-archive": "7.1.0",
@@ -126,6 +128,7 @@
},
"op-sqlite": {},
"overrides": {
- "react": "19.2.0"
+ "react": "19.2.0",
+ "linkify-it": "^5.0.2"
}
}
diff --git a/pro b/pro
index 879094707..71d67796a 160000
--- a/pro
+++ b/pro
@@ -1 +1 @@
-Subproject commit 879094707d0007e251cf0c9209ddbf8aaf35a83d
+Subproject commit 71d67796a9ae51c056f0291856f6cda3112f908e
diff --git a/scripts/ios-device.sh b/scripts/ios-device.sh
index 000b21b91..7ff2c7b98 100755
--- a/scripts/ios-device.sh
+++ b/scripts/ios-device.sh
@@ -167,6 +167,24 @@ else
fi
APP="build/device/Build/Products/Debug-iphoneos/OffgridMobile.app"
+
+# A physical iPhone cannot use the Mac's localhost. The React Native build phase
+# writes the first Wi-Fi address it finds to ip.txt, but some networks isolate
+# clients even when both devices are on the same subnet. Prefer an explicit host;
+# otherwise use this Mac's Tailscale address when Metro is reachable there. The
+# app keeps the embedded bundle as its fallback when Metro is not running.
+METRO_HOST="${IOS_METRO_HOST:-}"
+if [ -z "$METRO_HOST" ] && command -v tailscale >/dev/null 2>&1; then
+ TAILSCALE_HOST="$(tailscale ip -4 2>/dev/null | head -1 || true)"
+ if [ -n "$TAILSCALE_HOST" ] && [ "$(curl -fsS --max-time 2 "http://$TAILSCALE_HOST:8081/status" 2>/dev/null || true)" = "packager-status:running" ]; then
+ METRO_HOST="$TAILSCALE_HOST"
+ fi
+fi
+if [ -n "$METRO_HOST" ]; then
+ printf '%s\n' "$METRO_HOST" > "$APP/ip.txt"
+ echo "Debug Metro host: $METRO_HOST:8081"
+fi
+
echo "Installing $APP ..."
xcrun devicectl device install app --device "$DEVICE_ID" "$APP"
diff --git a/src/bootstrap/hookRegistry.ts b/src/bootstrap/hookRegistry.ts
index fdda2d2db..53386e6f4 100644
--- a/src/bootstrap/hookRegistry.ts
+++ b/src/bootstrap/hookRegistry.ts
@@ -12,8 +12,11 @@ type HookFn = (...args: any[]) => any;
const hooks: Record = {};
-export function registerHook(name: string, fn: HookFn): void {
+export function registerHook(name: string, fn: HookFn): () => void {
hooks[name] = fn;
+ return () => {
+ if (hooks[name] === fn) delete hooks[name];
+ };
}
/** Call a hook if registered; returns its result, or undefined when absent. */
diff --git a/src/bootstrap/loadProFeatures.ts b/src/bootstrap/loadProFeatures.ts
index 6e25c05dd..5f6b8bc58 100644
--- a/src/bootstrap/loadProFeatures.ts
+++ b/src/bootstrap/loadProFeatures.ts
@@ -42,17 +42,21 @@ export async function loadProFeatures(isPro?: boolean): Promise {
const credentialActive = isPro ?? licenseInfo.isPro;
const credentialSaved =
isPro === true || (licenseInfo.credentialSaved ?? licenseInfo.isPro);
- const active = credentialActive || DEV_UNLOCK_PRO;
+ const expired = licenseInfo.expired === true;
+ const active = (credentialActive || DEV_UNLOCK_PRO) && !expired;
// Single source of truth for "Pro is unlocked" — every upsell gate reads this, so a
// keychain- or dev-unlocked Pro user never sees the upgrade prompt.
useAppStore.getState().setHasRegisteredPro(credentialActive);
useAppStore.getState().setHasSavedProCredential(credentialSaved);
useAppStore.getState().setProActive(active);
+ useAppStore
+ .getState()
+ .setHasExpiredProCredential(expired);
// A credential is not access. If the roster last told us this device is deactivated, the paid bundle
// must not load at all - loading it and then hiding the entry points leaves every Pro service running.
const admitted =
selectHasProAccess(useAppStore.getState()) || DEV_UNLOCK_PRO;
- if (typeof pro.activateSyncBootstrap === 'function') {
+ if (!expired && typeof pro.activateSyncBootstrap === 'function') {
pro.activateSyncBootstrap({
registerScreen,
registerSlot,
diff --git a/src/bootstrap/slotRegistry.ts b/src/bootstrap/slotRegistry.ts
index fa90db726..edf249293 100644
--- a/src/bootstrap/slotRegistry.ts
+++ b/src/bootstrap/slotRegistry.ts
@@ -26,10 +26,15 @@ function emitChange(): void {
export function registerSlot(
name: string,
component: ComponentType,
-): void {
- if (slots[name] === component) return; // no-op re-register (dev Fast Refresh)
+): () => void {
+ if (slots[name] === component) return () => undefined; // no-op re-register (dev Fast Refresh)
slots[name] = component;
emitChange();
+ return () => {
+ if (slots[name] !== component) return;
+ delete slots[name];
+ emitChange();
+ };
}
export function getSlot(name: string): ComponentType | undefined {
@@ -85,4 +90,8 @@ export const SLOTS = {
* download/management). The tab itself only appears when this is
* registered, so free builds show just Text/Image. */
modelsScreenVoiceTab: 'modelsScreen.voiceTab',
+ /** Full-width overlay pinned above the chat messages (below the header) - e.g. a
+ * pending computer-use approval forwarded from a paired desktop, answered here.
+ * Renders nothing (and takes no space) when there is nothing pending. */
+ chatOverlay: 'chat.overlay',
} as const;
diff --git a/src/components/ChatInput/RecordingHint.tsx b/src/components/ChatInput/RecordingHint.tsx
index 629111459..4eb4fe2c5 100644
--- a/src/components/ChatInput/RecordingHint.tsx
+++ b/src/components/ChatInput/RecordingHint.tsx
@@ -2,10 +2,20 @@ import React from 'react';
import { View, Text } from 'react-native';
import Icon from 'react-native-vector-icons/Feather';
import { useTheme, useThemedStyles } from '../../theme';
+import { LoadingDots } from '../LoadingDots';
import { createStyles } from './styles';
+import type { VoiceRecordInteractionMode } from '../VoiceRecordButton';
+
+const processingLabel = (
+ processing: 'loading' | 'starting' | 'transcribing',
+): string => {
+ if (processing === 'loading') return 'Loading voice model...';
+ if (processing === 'starting') return 'Starting microphone...';
+ return 'Transcribing...';
+};
/**
- * Push-to-talk hint shown INLINE in the composer while holding to record (the WhatsApp pattern):
+ * Voice interaction status shown INLINE in the composer while recording:
* a recording dot on the left and "‹ Slide to cancel" centred. The mic sits to the right, outside
* the pill, where the thumb is. Living in the composer (not as a floating pill over the mic) keeps
* it always visible and never overlapping the mic (device 2026-07-15).
@@ -13,9 +23,35 @@ import { createStyles } from './styles';
export const RecordingHint: React.FC<{
/** Hands-free: the mic is open but nobody has spoken, so nothing is being captured yet. */
awaitingSpeech?: boolean;
-}> = ({ awaitingSpeech = false }) => {
+ interactionMode?: VoiceRecordInteractionMode;
+ processing?: 'loading' | 'starting' | 'transcribing';
+}> = ({ awaitingSpeech = false, interactionMode = 'idle', processing }) => {
const { colors } = useTheme();
const styles = useThemedStyles(createStyles);
+ if (processing) {
+ return (
+
+
+
+ {processingLabel(processing)}
+
+
+ );
+ }
+ if (interactionMode === 'locked') {
+ return (
+
+
+
+ Tap mic to stop
+
+
+ );
+ }
// Hands-free opens the recorder BEFORE the turn begins, so the red dot and "slide to cancel" were
// shown at someone whose words were not being captured yet. Waiting says so instead.
if (awaitingSpeech) {
@@ -28,6 +64,16 @@ export const RecordingHint: React.FC<{
);
}
+ if (interactionMode === 'idle') {
+ return (
+
+
+
+ Recording...
+
+
+ );
+ }
return (
diff --git a/src/components/ChatInput/Voice.ts b/src/components/ChatInput/Voice.ts
index 9d25e1ec0..11b57e593 100644
--- a/src/components/ChatInput/Voice.ts
+++ b/src/components/ChatInput/Voice.ts
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useWhisperTranscription } from '../../hooks/useWhisperTranscription';
-import { useWhisperStore, useUiModeStore, useAppStore } from '../../stores';
+import { useWhisperStore, useAppStore } from '../../stores';
import { activeModelService } from '../../services/activeModelService';
import { audioRecorderService } from '../../services/audioRecorderService';
import { whisperService } from '../../services/whisperService';
@@ -15,6 +15,7 @@ import logger from '../../utils/logger';
interface UseVoiceInputParams {
conversationId?: string | null;
+ interfaceMode: 'chat' | 'audio';
onTranscript: (text: string) => void;
onAudioAttachment?: (audio: { uri: string; format: 'wav' | 'mp3'; durationSeconds?: number; transcription?: string }) => void;
/** Called in Audio Mode to auto-send. Includes audio info so caller can build attachment atomically. */
@@ -31,7 +32,39 @@ async function stopAndFinalise(silence: SilenceEndpoint): Promise
);
}
-export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment, onAutoSend }: UseVoiceInputParams) {
+/** Cancel the active recorder only. Session policy stays with useVoiceInput. */
+function cancelActiveCapture(input: {
+ isDirectRecording: boolean;
+ isAudioModeRecording: boolean;
+ setIsDirectRecording: (value: boolean) => void;
+ setIsAudioModeRecording: (value: boolean) => void;
+ stopWhisperRecording: () => void;
+ clearWhisperResult: () => void;
+ clearConversation: () => void;
+}): void {
+ if (input.isDirectRecording || input.isAudioModeRecording) {
+ audioRecorderService.cancelRecording();
+ if (input.isDirectRecording) input.setIsDirectRecording(false);
+ else input.setIsAudioModeRecording(false);
+ } else {
+ input.stopWhisperRecording();
+ input.clearWhisperResult();
+ }
+ input.clearConversation();
+}
+
+/** Build the one readiness boundary shared by realtime and file transcription. */
+function createWhisperReadiness(downloadedModelId: string | null): () => Promise {
+ return () => ensureWhisperForTranscription({
+ isSelectedModelLoaded: () => !!downloadedModelId &&
+ whisperService.getLoadedModelPath() === whisperService.getModelPath(downloadedModelId),
+ hasDownloadedModel: () => !!downloadedModelId,
+ loadWhisper: () => useWhisperStore.getState().loadModel(),
+ freeGenerationModels: () => activeModelService.unloadAllModels(true).then(() => {}),
+ });
+}
+
+export function useVoiceInput({ conversationId, interfaceMode, onTranscript, onAudioAttachment, onAutoSend }: UseVoiceInputParams) {
const recordingConversationIdRef = useRef(null);
const onTranscriptRef = useRef(onTranscript);
onTranscriptRef.current = onTranscript;
@@ -39,7 +72,7 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment,
onAudioAttachmentRef.current = onAudioAttachment;
const onAutoSendRef = useRef(onAutoSend);
onAutoSendRef.current = onAutoSend;
- const { downloadedModelId } = useWhisperStore();
+ const { downloadedModelId, transcriptionLanguage } = useWhisperStore();
const [isDirectRecording, setIsDirectRecording] = useState(false);
const [isAudioModeRecording, setIsAudioModeRecording] = useState(false);
/** Hands-free: the mic is open but nobody has spoken yet, so the turn has not begun. */
@@ -50,30 +83,20 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment,
const supportsDirectAudio = (): boolean =>
activeModelService.supportsAudioInput() && audioRecorderService.supportsDirectAudioInput();
- const isInAudioInterfaceMode = (): boolean =>
- useUiModeStore.getState().interfaceMode === 'audio';
+ // The rendered composer mode is the recording mode. Passing it in prevents the
+ // Voice layout and recorder from observing two different store snapshots.
+ const isInAudioInterfaceMode = (): boolean => interfaceMode === 'audio';
// Use file-based transcription path when: Audio Mode + Whisper available + not direct audio model
const shouldUseFilePath = (): boolean =>
isInAudioInterfaceMode() && !!downloadedModelId && !supportsDirectAudio();
- // Ensure whisper is resident before transcribing (the decision lives in the pure
- // ensureWhisperForTranscription — it frees a blocking generation model, but never
- // evicts on a hard whisper-load failure). ONE seam for EVERY path: the file paths below
- // AND the realtime hold-to-talk dictation (injected into useWhisperTranscription), so a
- // memory-blocked dictation recovers instead of dead-ending.
- const ensureWhisper = (): Promise => ensureWhisperForTranscription({
- isLoaded: () => whisperService.isModelLoaded(),
- hasDownloadedModel: () => !!downloadedModelId,
- loadWhisper: () => useWhisperStore.getState().loadModel(),
- // keepSelection=true so routing reloads the right generation model after the
- // transcript decides text-vs-image.
- freeGenerationModels: () => activeModelService.unloadAllModels(true).then(() => {}),
- });
+ const ensureWhisper = createWhisperReadiness(downloadedModelId);
const {
isRecording: isWhisperRecording,
isModelLoading,
+ isStartingRecording,
isTranscribing: isWhisperTranscribing,
partialResult,
finalResult,
@@ -165,7 +188,7 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment,
setIsTranscribingFile(true);
try {
whisperReady = await ensureWhisper();
- if (whisperReady) transcript = await whisperService.transcribeFile(path);
+ if (whisperReady) transcript = await whisperService.transcribeFile(path, { language: transcriptionLanguage });
} catch (err) { logger.error(errLabel, err); }
setIsTranscribingFile(false);
}
@@ -198,7 +221,7 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment,
// output. A person tapping the mic resumes it.
voiceSession.dispatch('nothingHeard');
voiceSession.dispatch('nothingHeard');
- setDirectError(outcome.message);
+ setDirectError(outcome.message);
setTimeout(() => setDirectError(null), 3000);
}
} else {
@@ -217,7 +240,7 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment,
// output. A person tapping the mic resumes it.
voiceSession.dispatch('nothingHeard');
voiceSession.dispatch('nothingHeard');
- setDirectError(outcome.message);
+ setDirectError(outcome.message);
setTimeout(() => setDirectError(null), 3000);
}
}
@@ -243,7 +266,7 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment,
let transcript = '';
try {
whisperReady = await ensureWhisper();
- if (whisperReady) transcript = await whisperService.transcribeFile(path);
+ if (whisperReady) transcript = await whisperService.transcribeFile(path, { language: transcriptionLanguage });
} catch (transcribeErr) {
logger.error('[Voice] File transcription error:', transcribeErr);
}
@@ -275,41 +298,49 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment,
// arrives here. A stop that silence did not cause is a deliberate one, and it suspends hands-free
// until the person taps for the floor again.
logger.log('[TURN] stop requested');
+ const isChatDictation = !isInAudioInterfaceMode();
// The person's turn is over and there is audio to work on, so the assistant takes the floor now -
// before any reply exists. That is what keeps the mic shut while it transcribes and thinks.
voiceSession.dispatch('turnCaptured');
// Released on EVERY stop path, so a mic that closed can never keep the floor.
stopListeningForSilence();
- if (isDirectRecording) {
- await stopDirectRecording();
- return;
- }
+ try {
+ if (isDirectRecording) {
+ await stopDirectRecording();
+ return;
+ }
- if (isAudioModeRecording) {
- await stopAudioModeRecording();
- return;
- }
+ if (isAudioModeRecording) {
+ await stopAudioModeRecording();
+ return;
+ }
- await stopWhisperRecording();
+ await stopWhisperRecording();
+ } finally {
+ // Chat dictation only edits the draft. It does not start an assistant turn,
+ // so report its own lifecycle event instead of borrowing Voice-mode reset.
+ if (isChatDictation) voiceSession.dispatch('dictationFinished');
+ }
};
const cancelRecording = () => {
+ const isReplayCancellation = !!voiceSession.current().replayReturnsTo;
stopListeningForSilence();
- if (isDirectRecording) {
- audioRecorderService.cancelRecording();
- setIsDirectRecording(false);
- recordingConversationIdRef.current = null;
- return;
- }
- if (isAudioModeRecording) {
- audioRecorderService.cancelRecording();
- setIsAudioModeRecording(false);
- recordingConversationIdRef.current = null;
- return;
+ try {
+ cancelActiveCapture({
+ isDirectRecording,
+ isAudioModeRecording,
+ setIsDirectRecording,
+ setIsAudioModeRecording,
+ stopWhisperRecording,
+ clearWhisperResult: clearResult,
+ clearConversation: () => { recordingConversationIdRef.current = null; },
+ });
+ } finally {
+ // A user cancellation ends the manual turn. A replay cancellation is
+ // different: replayStarted already owns the floor and replayEnded returns it.
+ if (!isReplayCancellation) voiceSession.dispatch('dictationFinished');
}
- stopWhisperRecording();
- clearResult();
- recordingConversationIdRef.current = null;
};
// Register this recorder's concrete intents with the single recording-controller
@@ -363,6 +394,7 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment,
isRecording,
isAwaitingSpeech: silence.isAwaitingSpeech,
isModelLoading,
+ isStartingRecording,
isTranscribing,
partialResult,
error,
diff --git a/src/components/ChatInput/ensureWhisperForTranscription.ts b/src/components/ChatInput/ensureWhisperForTranscription.ts
index 8b80ba0dc..31f2e9141 100644
--- a/src/components/ChatInput/ensureWhisperForTranscription.ts
+++ b/src/components/ChatInput/ensureWhisperForTranscription.ts
@@ -17,14 +17,17 @@ import type { WhisperLoadResult } from '../../stores/whisperStore';
* user's generation model (that would strand them with nothing loaded).
*/
export interface WhisperReadinessDeps {
- isLoaded: () => boolean;
+ /** True only when the model selected for this turn is the resident context. */
+ isSelectedModelLoaded?: () => boolean;
+ /** Compatibility for callers that do not yet track resident model identity. */
+ isLoaded?: () => boolean;
hasDownloadedModel: () => boolean;
loadWhisper: () => Promise;
freeGenerationModels: () => Promise;
}
export async function ensureWhisperForTranscription(deps: WhisperReadinessDeps): Promise {
- if (deps.isLoaded()) return true;
+ if (deps.isSelectedModelLoaded?.() ?? deps.isLoaded?.() ?? false) return true;
if (!deps.hasDownloadedModel()) return false;
const first = await deps.loadWhisper();
diff --git a/src/components/ChatInput/index.tsx b/src/components/ChatInput/index.tsx
index b5476c44f..3a5165b27 100644
--- a/src/components/ChatInput/index.tsx
+++ b/src/components/ChatInput/index.tsx
@@ -3,7 +3,7 @@ import { View, TextInput, TouchableOpacity, Animated, Platform, ActionSheetIOS }
import Icon from 'react-native-vector-icons/Feather';
import { useTheme, useThemedStyles } from '../../theme';
import { ImageModeState, MediaAttachment } from '../../types';
-import { VoiceRecordButton } from '../VoiceRecordButton';
+import { VoiceRecordButton, type VoiceRecordInteractionMode } from '../VoiceRecordButton';
import { RecordingHint } from './RecordingHint';
import { ComposerIconsRow } from './ComposerIconsRow';
import { triggerHaptic } from '../../utils/haptics';
@@ -65,6 +65,22 @@ const IMAGE_MODE_CYCLE: ImageModeState[] = ['auto', 'force', 'disabled'];
// (collapsing) row — it's rendered persistently above the input instead.
const computePillIconsWidth = (): number => PILL_ICON_SIZE * 2;
+type VoiceProcessingState = 'loading' | 'starting' | 'transcribing' | undefined;
+
+/** Project the recorder lifecycle into one display state. */
+const deriveVoiceProcessingState = (input: {
+ isRecording: boolean;
+ isModelLoading: boolean;
+ isStartingRecording: boolean;
+ isTranscribing: boolean;
+}): VoiceProcessingState => {
+ if (input.isRecording) return undefined;
+ if (input.isModelLoading) return 'loading';
+ if (input.isStartingRecording) return 'starting';
+ if (input.isTranscribing) return 'transcribing';
+ return undefined;
+};
+
/**
* Alert shown when the user attaches an image to a model without vision support.
* Remote (server) models have no local vision-projector file to repair, so the
@@ -137,6 +153,7 @@ export const ChatInput: React.FC = ({
const styles = useThemedStyles(createStyles);
const [message, setMessage] = useState('');
const [imageMode, setImageMode] = useState('auto');
+ const [voiceInteractionMode, setVoiceInteractionMode] = useState('idle');
const [alertState, setAlertState] = useState(initialAlertState);
const quickSettings = useKeyboardAwarePopover();
const attachPicker = useKeyboardAwarePopover();
@@ -178,12 +195,20 @@ export const ChatInput: React.FC = ({
}),
});
- const { isRecording, isModelLoading, isTranscribing, partialResult, error, voiceAvailable, isAwaitingSpeech, startRecording, stopRecording, cancelRecording } = useVoiceInput({
+ const { isRecording, isModelLoading, isStartingRecording, isTranscribing, partialResult, error, voiceAvailable, isAwaitingSpeech, startRecording, stopRecording, cancelRecording } = useVoiceInput({
conversationId,
+ interfaceMode,
onTranscript: voiceHandlers.onTranscript,
onAudioAttachment: voiceHandlers.onAudioAttachment,
onAutoSend: voiceHandlers.onAutoSend,
});
+ const voiceProcessingState = deriveVoiceProcessingState({
+ isRecording,
+ isModelLoading,
+ isStartingRecording,
+ isTranscribing,
+ });
+ const showVoiceStatus = isRecording || voiceInteractionMode !== 'idle' || voiceProcessingState !== undefined;
const { settings: appSettings, updateSettings: updateAppSettings } = useAppStore();
const thinkingEnabled = appSettings.thinkingEnabled;
@@ -349,6 +374,7 @@ export const ChatInput: React.FC = ({
onStartRecording={startRecording}
onStopRecording={stopRecording}
onCancelRecording={cancelRecording}
+ onInteractionModeChange={setVoiceInteractionMode}
/>
);
@@ -362,9 +388,12 @@ export const ChatInput: React.FC = ({
/>
- {isRecording ? (
- // Push-to-talk hint inline in the composer (WhatsApp pattern) — see RecordingHint.
-
+ {showVoiceStatus ? (
+
) : (
<>
= ({
);
};
-
diff --git a/src/components/GenerationSettingsModal/index.tsx b/src/components/GenerationSettingsModal/index.tsx
index c1af5d6b0..a449d7ad7 100644
--- a/src/components/GenerationSettingsModal/index.tsx
+++ b/src/components/GenerationSettingsModal/index.tsx
@@ -11,6 +11,7 @@ import { ConversationActionsSection } from './ConversationActionsSection';
import { ImageGenerationSection } from './ImageGenerationSection';
import { TextGenerationSection } from './TextGenerationSection';
import { WhisperPickerSheet } from '../models/WhisperPickerSheet';
+import { TranscriptionLanguageSelect } from '../TranscriptionLanguageSelect';
import {
NO_TRANSCRIPTION_MODEL_LABEL,
useTranscriptionModelSetting,
@@ -177,6 +178,7 @@ export const GenerationSettingsModal: React.FC = (
+
{/* Voice mode ends a turn on silence. Lives with STT because it is about listening. */}
diff --git a/src/components/MarkdownText.tsx b/src/components/MarkdownText.tsx
index 1e13f8c1f..584899a20 100644
--- a/src/components/MarkdownText.tsx
+++ b/src/components/MarkdownText.tsx
@@ -1,11 +1,15 @@
import React, { useCallback, useMemo } from 'react';
import { Linking, Text } from 'react-native';
-import Markdown from '@ronradtke/react-native-markdown-display';
-import { preprocessChatMarkdown } from '@offgrid/sync';
+import Markdown, {
+ MarkdownIt,
+} from '@ronradtke/react-native-markdown-display';
+import { preprocessChatMarkdown, safeChatExternalUrl } from '@offgrid/sync';
import { useTheme } from '../theme';
import type { ThemeColors } from '../theme';
import { TYPOGRAPHY, SPACING, FONTS } from '../constants';
+const chatMarkdownParser = MarkdownIt({ typographer: true, linkify: true });
+
/**
* Escape asterisks used as multiplication operators (digit*digit) so
* markdown-it doesn't treat them as emphasis markers.
@@ -86,7 +90,8 @@ export function MarkdownText({ children, dimmed }: MarkdownTextProps) {
);
const handleLinkPress = useCallback((url: string) => {
- Linking.openURL(url);
+ const safeUrl = safeChatExternalUrl(url);
+ if (safeUrl) void Linking.openURL(safeUrl);
return false;
}, []);
@@ -99,6 +104,7 @@ export function MarkdownText({ children, dimmed }: MarkdownTextProps) {
return (
diff --git a/src/components/ModelCard.tsx b/src/components/ModelCard.tsx
index 86c951f97..05d668feb 100644
--- a/src/components/ModelCard.tsx
+++ b/src/components/ModelCard.tsx
@@ -16,6 +16,7 @@ import {
} from './ModelCardContent';
import { QUEUED_ICON } from '../utils/downloadStatusIcon';
import { formatBytes } from '../utils/formatBytes';
+import { presentProgress } from '../utils/progressPresentation';
interface ModelCardProps {
model: {
@@ -39,7 +40,7 @@ interface ModelCardProps {
* 0% progress bar, so the user gets clear feedback the tap registered. */
isQueued?: boolean;
downloadProgress?: number;
- downloadBytes?: { downloaded: number; total: number };
+ downloadBytes?: { downloaded: number; total: number; bytesPerSecond?: number };
/** Concurrent downloads behind this card (main+mmproj / grouped) → "N downloads". */
downloadCount?: number;
isActive?: boolean;
@@ -87,23 +88,34 @@ function resolveCredibility(
const DownloadProgressSection: React.FC<{
progress: number;
- bytes?: { downloaded: number; total: number };
+ bytes?: { downloaded: number; total: number; bytesPerSecond?: number };
queued?: boolean;
/** Number of concurrent downloads behind this card (>1 → show "N downloads"). */
count?: number;
}> = ({ progress, bytes, queued, count }) => {
const styles = useThemedStyles(createStyles);
const { colors } = useTheme();
- const bytesLabel = bytes && bytes.total > 0 ? `${formatBytes(bytes.downloaded)} / ${formatBytes(bytes.total)}` : '';
+ const presented = presentProgress({
+ progress,
+ bytesDownloaded: bytes?.downloaded,
+ totalBytes: bytes?.total,
+ bytesPerSecond: bytes?.bytesPerSecond,
+ status: queued ? 'pending' : 'running',
+ });
+ const percentage = presented.progress.percentage ?? 0;
// Cumulative download → note how many files are running so the total reads clearly.
const countLabel = count && count > 1 ? `${count} downloads` : '';
- const caption = [bytesLabel, countLabel].filter(Boolean).join(' · ');
+ const caption = [
+ presented.bytesText,
+ queued ? undefined : presented.rateText,
+ countLabel,
+ ].filter(Boolean).join(' · ');
return (
{/* Full-width bar so it uses the whole card width. Queued shows an EMPTY bar
(0 progress) so it reads as "not started yet". */}
-
+
{/* Caption row under the bar: bytes (+ "N downloads") on the LEFT, status on the
RIGHT. "Queued" while waiting for a slot, otherwise the percent. */}
@@ -115,7 +127,7 @@ const DownloadProgressSection: React.FC<{
Queued
) : (
- {`${Math.round(progress * 100)}%`}
+ {presented.percentageText ?? 'In progress'}
)}
@@ -131,14 +143,19 @@ const FailedSection: React.FC<{
}> = ({ errorMessage, bytesDownloaded, totalBytes, onRetry, onRemove }) => {
const styles = useThemedStyles(createStyles);
const { colors } = useTheme();
- const progress = totalBytes > 0 ? bytesDownloaded / totalBytes : 0;
+ const presented = presentProgress({
+ bytesDownloaded,
+ totalBytes,
+ status: 'failed',
+ });
+ const progress = presented.progress.percentage ?? 0;
return (
-
+
- {Math.round(progress * 100)}%
+ {presented.percentageText ?? 'Stopped'}
{totalBytes > 0 && (
{formatBytes(bytesDownloaded)} / {formatBytes(totalBytes)}
@@ -309,4 +326,3 @@ function formatNumber(num: number): string {
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
}
-
diff --git a/src/components/ModelSelectorModal/index.tsx b/src/components/ModelSelectorModal/index.tsx
index 0d8b7da87..7fcf521b5 100644
--- a/src/components/ModelSelectorModal/index.tsx
+++ b/src/components/ModelSelectorModal/index.tsx
@@ -13,7 +13,7 @@ import { useLoadedTextModelPath } from '../../hooks/useLoadedTextModelPath';
import { useActiveModelStatus } from '../../hooks/useActiveModelStatus';
import { loadingTextRowId } from './rowState';
import { DownloadedModel, ONNXImageModel, RemoteModel } from '../../types';
-import { activeModelService, llmService, remoteServerManager } from '../../services';
+import { activeModelService, remoteServerManager } from '../../services';
import { loadModelWithOverride } from '../../services/loadModelWithOverride';
import { CustomAlert, AlertState, initialAlertState, showAlert } from '../CustomAlert';
import { createAllStyles } from './styles';
@@ -159,10 +159,9 @@ export const ModelSelectorModal: React.FC = ({
// Handle selecting a remote text model
const handleSelectRemoteTextModel = async (model: RemoteModel, serverId: string) => {
try {
- // Unload any active local model first — only one active model at a time
- if (llmService.isModelLoaded()) {
- await activeModelService.unloadTextModel();
- }
+ // Always go through the owner. It also waits for an in-flight local load,
+ // which is not yet visible as a loaded native model.
+ await activeModelService.unloadTextModel();
await remoteServerManager.setActiveRemoteTextModel(serverId, model.id);
onSelectionComplete?.();
} catch (error) {
diff --git a/src/components/SettingsOptionSelect.tsx b/src/components/SettingsOptionSelect.tsx
new file mode 100644
index 000000000..771e598f9
--- /dev/null
+++ b/src/components/SettingsOptionSelect.tsx
@@ -0,0 +1,96 @@
+import React, { useState } from 'react';
+import { ScrollView, Text, TouchableOpacity, View } from 'react-native';
+import Icon from 'react-native-vector-icons/Feather';
+import { AppSheet } from './AppSheet';
+import { SPACING, TYPOGRAPHY } from '../constants';
+import { useTheme, useThemedStyles } from '../theme';
+import type { ThemeColors, ThemeShadows } from '../theme';
+
+interface SettingsOption {
+ value: string;
+ label: string;
+}
+
+interface SettingsOptionSelectProps {
+ label: string;
+ value: string;
+ options: SettingsOption[];
+ onChange: (value: string) => void;
+ description?: string;
+ testID?: string;
+ disabled?: boolean;
+}
+
+/** One settings selector for chat settings and the Models screens. */
+export const SettingsOptionSelect: React.FC = ({
+ label, value, options, onChange, description, testID, disabled = false,
+}) => {
+ const { colors } = useTheme();
+ const styles = useThemedStyles(createStyles);
+ const [open, setOpen] = useState(false);
+ const selected = options.find((option) => option.value === value) ?? options[0];
+
+ return (
+
+ {label}
+ setOpen(true)}
+ >
+ {selected?.label ?? value}
+
+
+ {description ? {description} : null}
+
+ setOpen(false)} title={`Select ${label}`} enableDynamicSizing>
+
+ {options.map((option) => {
+ const active = option.value === value;
+ return (
+ { onChange(option.value); setOpen(false); }}
+ >
+ {option.label}
+ {active ? : null}
+
+ );
+ })}
+
+
+
+ );
+};
+
+const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
+ container: { gap: SPACING.xs as number, marginBottom: SPACING.md },
+ label: { ...TYPOGRAPHY.label, color: colors.textMuted, textTransform: 'uppercase' as const },
+ trigger: {
+ minHeight: 44, borderWidth: 1, borderColor: colors.border, borderRadius: 8,
+ paddingHorizontal: SPACING.md, flexDirection: 'row' as const,
+ alignItems: 'center' as const, justifyContent: 'space-between' as const,
+ backgroundColor: colors.surface,
+ },
+ value: { ...TYPOGRAPHY.body, color: colors.text, flex: 1 },
+ description: { ...TYPOGRAPHY.meta, color: colors.textMuted },
+ disabled: { opacity: 0.6 },
+ options: { padding: SPACING.lg, paddingBottom: SPACING.xxl },
+ option: {
+ minHeight: 48, paddingHorizontal: SPACING.md, borderWidth: 1, borderColor: colors.border,
+ borderRadius: 8, marginBottom: SPACING.sm, flexDirection: 'row' as const,
+ alignItems: 'center' as const, justifyContent: 'space-between' as const,
+ backgroundColor: colors.surface,
+ },
+ optionActive: { borderColor: colors.primary, backgroundColor: `${colors.primary}12` },
+ optionText: { ...TYPOGRAPHY.body, color: colors.text },
+ optionTextActive: { color: colors.primary },
+});
diff --git a/src/components/TranscriptionLanguageSelect.tsx b/src/components/TranscriptionLanguageSelect.tsx
new file mode 100644
index 000000000..9f471181a
--- /dev/null
+++ b/src/components/TranscriptionLanguageSelect.tsx
@@ -0,0 +1,45 @@
+import React, { useEffect, useMemo } from 'react';
+import { transcriptionLanguages } from '@offgrid/speech';
+import { useWhisperStore } from '../stores/whisperStore';
+import { SettingsOptionSelect } from './SettingsOptionSelect';
+
+interface TranscriptionLanguageSelectProps {
+ testID?: string;
+}
+
+/** One language setting for every local speech-to-text entry point. */
+export const TranscriptionLanguageSelect: React.FC = ({
+ testID = 'transcription-language-select',
+}) => {
+ const downloadedModelId = useWhisperStore((state) => state.downloadedModelId);
+ const language = useWhisperStore((state) => state.transcriptionLanguage);
+ const setLanguage = useWhisperStore((state) => state.setTranscriptionLanguage);
+ const languages = useMemo(
+ () => transcriptionLanguages('whisper', downloadedModelId),
+ [downloadedModelId],
+ );
+ const options = useMemo(
+ () => languages.map(({ code, label }) => ({ value: code, label })),
+ [languages],
+ );
+ const supportedValue = options.some((option) => option.value === language)
+ ? language
+ : options[0]?.value ?? 'en';
+
+ useEffect(() => {
+ if (supportedValue !== language) setLanguage(supportedValue);
+ }, [language, setLanguage, supportedValue]);
+
+ return (
+
+ );
+};
diff --git a/src/components/VoiceRecordButton/index.tsx b/src/components/VoiceRecordButton/index.tsx
index 8bbb604c6..8ca2ad861 100644
--- a/src/components/VoiceRecordButton/index.tsx
+++ b/src/components/VoiceRecordButton/index.tsx
@@ -4,9 +4,6 @@ import {
Text,
TouchableOpacity,
Animated,
- PanResponder,
- GestureResponderEvent,
- PanResponderGestureState,
Vibration,
} from 'react-native';
import Icon from 'react-native-vector-icons/Feather';
@@ -17,13 +14,32 @@ import ReanimatedAnimated, {
withTiming,
Easing,
} from 'react-native-reanimated';
-import { useThemedStyles } from '../../theme';
-import { CustomAlert, showAlert, hideAlert, AlertState, initialAlertState } from '../CustomAlert';
+import { useTheme, useThemedStyles } from '../../theme';
+import {
+ CustomAlert,
+ showAlert,
+ hideAlert,
+ AlertState,
+ initialAlertState,
+} from '../CustomAlert';
import { createStyles } from './styles';
-import { LoadingState, TranscribingState, UnavailableButton, DownloadingButton, ButtonIcon } from './states';
+import {
+ LoadingState,
+ TranscribingState,
+ UnavailableButton,
+ DownloadingButton,
+ ButtonIcon,
+} from './states';
import { deriveVoiceButtonState } from './derive';
import { useWhisperStore } from '../../stores';
import logger from '../../utils/logger';
+import {
+ buildVoiceRecordGesture,
+ type VoiceRecordCallbacks,
+ type VoiceRecordInteractionMode,
+} from './voiceRecordGesture';
+
+export type { VoiceRecordInteractionMode } from './voiceRecordGesture';
const DOWNLOAD_MODEL_ID = 'base.en';
const DOWNLOAD_MODEL_SIZE_MB = 142;
@@ -39,62 +55,13 @@ interface VoiceRecordButtonProps {
onStartRecording: () => void;
onStopRecording: () => void;
onCancelRecording: () => void;
+ onInteractionModeChange?: (mode: VoiceRecordInteractionMode) => void;
asSendButton?: boolean;
}
-const CANCEL_DISTANCE = 80;
-
-type CallbacksRef = { onStartRecording: () => void; onStopRecording: () => void; onCancelRecording: () => void };
-
-function buildPanResponder({
- isDraggingToCancel,
- cancelOffsetX,
- callbacksRef,
-}: {
- isDraggingToCancel: React.MutableRefObject;
- cancelOffsetX: Animated.Value;
- callbacksRef: React.MutableRefObject;
-}) {
- return PanResponder.create({
- onStartShouldSetPanResponder: () => true,
- onMoveShouldSetPanResponder: () => true,
- onPanResponderGrant: () => {
- logger.log('[VoiceButton] Press started');
- Vibration.vibrate(50);
- isDraggingToCancel.current = false;
- callbacksRef.current.onStartRecording();
- },
- onPanResponderMove: (_: GestureResponderEvent, gestureState: PanResponderGestureState) => {
- const offsetX = Math.min(0, gestureState.dx);
- cancelOffsetX.setValue(offsetX);
- const wasInCancelZone = isDraggingToCancel.current;
- const isInCancelZone = Math.abs(offsetX) > CANCEL_DISTANCE;
- if (isInCancelZone && !wasInCancelZone) Vibration.vibrate(30);
- isDraggingToCancel.current = isInCancelZone;
- },
- onPanResponderRelease: () => {
- logger.log('[VoiceButton] Press released, cancel:', isDraggingToCancel.current);
- Vibration.vibrate(30);
- if (isDraggingToCancel.current) {
- callbacksRef.current.onCancelRecording();
- } else {
- callbacksRef.current.onStopRecording();
- }
- Animated.spring(cancelOffsetX, { toValue: 0, useNativeDriver: true }).start();
- isDraggingToCancel.current = false;
- },
- onPanResponderTerminate: () => {
- logger.log('[VoiceButton] Press terminated');
- callbacksRef.current.onCancelRecording();
- Animated.spring(cancelOffsetX, { toValue: 0, useNativeDriver: true }).start();
- isDraggingToCancel.current = false;
- },
- });
-}
-
type VoiceButtonStyles = ReturnType;
-/** Chat-mode (hold-to-record) button style stack. Extracted to module scope to
+/** Chat-mode recording button style stack. Extracted to module scope to
* keep the component's cyclomatic complexity under the lint limit. */
const buildChatButtonStyle = (
styles: VoiceButtonStyles,
@@ -109,10 +76,14 @@ const buildChatButtonStyle = (
/** Audio-mode (tap-to-toggle) busy face: the load vs transcribe spinner. Audio mode has no
* hold gesture, so it can safely replace the whole button while busy. Module scope keeps the
* pick out of the component's complexity budget. */
-const AudioBusyFace: React.FC<{ kind: 'loading' | 'transcribing'; loadingAnim: Animated.Value }> = ({ kind, loadingAnim }) =>
- kind === 'loading'
- ?
- : ;
+const AudioBusyFace: React.FC<{ kind: 'loading' | 'transcribing' }> = ({
+ kind,
+}) =>
+ kind === 'loading' ? (
+
+ ) : (
+
+ );
/** The inner face of the chat-mode hold button — a spinner while a cold model load /
* transcription is in flight, the mic otherwise. Extracted to module scope so the
@@ -121,12 +92,11 @@ const AudioBusyFace: React.FC<{ kind: 'loading' | 'transcribing'; loadingAnim: A
* ghost-recording fix). */
const ChatButtonFace: React.FC<{
kind: 'loading' | 'transcribing' | 'ready' | 'downloading' | 'unavailable';
- loadingAnim: Animated.Value;
buttonStyle: ReturnType;
isRecording: boolean;
-}> = ({ kind, loadingAnim, buttonStyle, isRecording }) => {
- if (kind === 'loading') return ;
- if (kind === 'transcribing') return ;
+}> = ({ kind, buttonStyle, isRecording }) => {
+ if (kind === 'loading') return ;
+ if (kind === 'transcribing') return ;
return (
@@ -145,11 +115,13 @@ export const VoiceRecordButton: React.FC = ({
onStartRecording,
onStopRecording,
onCancelRecording,
+ onInteractionModeChange,
asSendButton = false,
}) => {
+ const { colors } = useTheme();
const styles = useThemedStyles(createStyles);
- const downloadModel = useWhisperStore((s) => s.downloadModel);
- const downloadProgressById = useWhisperStore((s) => s.downloadProgressById);
+ const downloadModel = useWhisperStore(s => s.downloadModel);
+ const downloadProgressById = useWhisperStore(s => s.downloadProgressById);
// The ONE derivation of what the mic renders (see derive.ts): a background STT
// download is never the busy spinner — that is reserved for a tap-triggered
// model load and live transcription.
@@ -164,12 +136,24 @@ export const VoiceRecordButton: React.FC = ({
// slide-to-cancel / release-during-load behaviour — when kind flips to 'loading'
// the hold-to-record view (and its PanResponder) is replaced by a gesture-less
// spinner, so the finger that is still down loses its cancel affordance.
- logger.log('[VoiceButton-SM] render kind=', buttonState.kind, 'asSend=', asSendButton, 'recording=', isRecording);
+ logger.log(
+ '[VoiceButton-SM] render kind=',
+ buttonState.kind,
+ 'asSend=',
+ asSendButton,
+ 'recording=',
+ isRecording,
+ );
const pulseAnim = useRef(new Animated.Value(1)).current;
- const loadingAnim = useRef(new Animated.Value(0)).current;
const cancelOffsetX = useRef(new Animated.Value(0)).current;
const isDraggingToCancel = useRef(false);
+ const recordingRef = useRef(isRecording);
+ recordingRef.current = isRecording;
+ const tapLockedRef = useRef(false);
+ const tapLockObservedRecordingRef = useRef(false);
+ const longPressTimerRef = useRef | null>(null);
+ const [isTapLocked, setTapLocked] = useState(false);
const [alertState, setAlertState] = useState(initialAlertState);
const rippleScale = useSharedValue(1);
@@ -179,14 +163,22 @@ export const VoiceRecordButton: React.FC = ({
if (isRecording) {
rippleScale.value = 1;
rippleOpacity.value = 0.4;
- rippleScale.value = withRepeat(withTiming(2.2, { duration: 1200, easing: Easing.out(Easing.ease) }), -1, false);
- rippleOpacity.value = withRepeat(withTiming(0, { duration: 1200, easing: Easing.out(Easing.ease) }), -1, false);
+ rippleScale.value = withRepeat(
+ withTiming(2.2, { duration: 1200, easing: Easing.out(Easing.ease) }),
+ -1,
+ false,
+ );
+ rippleOpacity.value = withRepeat(
+ withTiming(0, { duration: 1200, easing: Easing.out(Easing.ease) }),
+ -1,
+ false,
+ );
} else {
rippleScale.value = 1;
rippleOpacity.value = 0;
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isRecording]);
const rippleStyle = useAnimatedStyle(() => ({
@@ -194,17 +186,41 @@ export const VoiceRecordButton: React.FC = ({
opacity: rippleOpacity.value,
}));
+ const callbacksRef = useRef({
+ onStartRecording,
+ onStopRecording,
+ onCancelRecording,
+ onInteractionModeChange,
+ });
+ callbacksRef.current = {
+ onStartRecording,
+ onStopRecording,
+ onCancelRecording,
+ onInteractionModeChange,
+ };
+
+ useEffect(
+ () => () => {
+ if (longPressTimerRef.current) clearTimeout(longPressTimerRef.current);
+ longPressTimerRef.current = null;
+ callbacksRef.current.onInteractionModeChange?.('idle');
+ },
+ [],
+ );
+
useEffect(() => {
- if (buttonState.kind === 'loading' || buttonState.kind === 'transcribing') {
- const spin = Animated.loop(Animated.timing(loadingAnim, { toValue: 1, duration: 1000, useNativeDriver: true }));
- spin.start();
- return () => spin.stop();
+ if (!isTapLocked) return;
+ if (isRecording) {
+ tapLockObservedRecordingRef.current = true;
+ return;
}
- loadingAnim.setValue(0);
- }, [buttonState.kind, loadingAnim]);
-
- const callbacksRef = useRef({ onStartRecording, onStopRecording, onCancelRecording });
- callbacksRef.current = { onStartRecording, onStopRecording, onCancelRecording };
+ if (tapLockObservedRecordingRef.current || _error) {
+ tapLockedRef.current = false;
+ tapLockObservedRecordingRef.current = false;
+ setTapLocked(false);
+ onInteractionModeChange?.('idle');
+ }
+ }, [isRecording, isTapLocked, _error, onInteractionModeChange]);
useEffect(() => {
if (isRecording) {
@@ -213,8 +229,16 @@ export const VoiceRecordButton: React.FC = ({
pulseAnim.setValue(1.4);
const pulse = Animated.loop(
Animated.sequence([
- Animated.timing(pulseAnim, { toValue: 1.5, duration: 600, useNativeDriver: true }),
- Animated.timing(pulseAnim, { toValue: 1.4, duration: 600, useNativeDriver: true }),
+ Animated.timing(pulseAnim, {
+ toValue: 1.5,
+ duration: 600,
+ useNativeDriver: true,
+ }),
+ Animated.timing(pulseAnim, {
+ toValue: 1.4,
+ duration: 600,
+ useNativeDriver: true,
+ }),
]),
);
pulse.start();
@@ -223,25 +247,38 @@ export const VoiceRecordButton: React.FC = ({
pulseAnim.setValue(1);
}, [isRecording, pulseAnim]);
- const panResponder = useRef(buildPanResponder({ isDraggingToCancel, cancelOffsetX, callbacksRef })).current;
+ const panResponder = useRef(
+ buildVoiceRecordGesture({
+ isDraggingToCancel,
+ cancelOffsetX,
+ callbacksRef,
+ recordingRef,
+ tapLockedRef,
+ tapLockObservedRecordingRef,
+ longPressTimerRef,
+ setTapLocked,
+ }),
+ ).current;
const handleUnavailableTap = () => {
- setAlertState(showAlert(
- 'Download Voice Model',
- `Download Whisper Base to enable voice input? (${DOWNLOAD_MODEL_SIZE_MB} MB)`,
- [
- { text: 'Cancel', style: 'cancel' },
- {
- text: 'Download',
- onPress: () => {
- setAlertState(hideAlert());
- downloadModel(DOWNLOAD_MODEL_ID).catch((err) => {
- logger.error('[VoiceRecordButton] Download failed:', err);
- });
+ setAlertState(
+ showAlert(
+ 'Download Voice Model',
+ `Download Whisper Base to enable voice input? (${DOWNLOAD_MODEL_SIZE_MB} MB)`,
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Download',
+ onPress: () => {
+ setAlertState(hideAlert());
+ downloadModel(DOWNLOAD_MODEL_ID).catch(err => {
+ logger.error('[VoiceRecordButton] Download failed:', err);
+ });
+ },
},
- },
- ],
- ));
+ ],
+ ),
+ );
};
const alert = (
@@ -255,22 +292,28 @@ export const VoiceRecordButton: React.FC = ({
);
// Audio mode (tap-to-toggle) has no hold gesture, so a load/transcribe spinner can
- // safely REPLACE the button. Chat mode (asSendButton, hold-to-record + slide-to-cancel)
+ // safely REPLACE the button. Chat mode (asSendButton, tap-to-lock or hold + slide-to-cancel)
// must NOT early-return here: replacing the button with a bare spinner unmounts the
// PanResponder mid-hold, severing the finger's gesture the instant a cold model load
// begins — that is what broke slide-to-cancel and left a ghost recording on release
// (no responderRelease reached a handler). Chat mode keeps ONE gesturable wrapper
// mounted across ready/loading/transcribing and swaps only the inner face (below).
- if (!asSendButton && (buttonState.kind === 'loading' || buttonState.kind === 'transcribing')) {
+ if (
+ !asSendButton &&
+ (buttonState.kind === 'loading' || buttonState.kind === 'transcribing')
+ ) {
return (
-
+
{alert}
);
}
- if (buttonState.kind === 'downloading' || buttonState.kind === 'unavailable') {
+ if (
+ buttonState.kind === 'downloading' ||
+ buttonState.kind === 'unavailable'
+ ) {
return (
= ({
onPress={handleUnavailableTap}
disabled={buttonState.kind === 'downloading'}
>
- {buttonState.kind === 'downloading'
- ?
- : }
+ {buttonState.kind === 'downloading' ? (
+
+ ) : (
+
+ )}
{alert}
);
}
- const buttonStyle = buildChatButtonStyle(styles, { asSendButton, isRecording, disabled });
+ const buttonStyle = buildChatButtonStyle(styles, {
+ asSendButton,
+ isRecording,
+ disabled,
+ });
// ── Audio mode: tap-to-toggle (tap to start, tap to stop & send) ───────────
if (!asSendButton) {
@@ -304,9 +356,14 @@ export const VoiceRecordButton: React.FC = ({
return (
- {isRecording && }
+ {isRecording && (
+
+ )}
= ({
disabled={disabled}
activeOpacity={0.7}
>
-
- {isRecording
- ?
- : }
+
+ {isRecording ? (
+
+ ) : (
+
+ )}
@@ -335,16 +405,39 @@ export const VoiceRecordButton: React.FC = ({
{isRecording && partialResult && (
- {partialResult}
+
+ {partialResult}
+
)}
- {isRecording && }
+ {isRecording && (
+
+ )}
-
+
{alert}
diff --git a/src/components/VoiceRecordButton/states.tsx b/src/components/VoiceRecordButton/states.tsx
index 6ae6cb677..4a28e9a10 100644
--- a/src/components/VoiceRecordButton/states.tsx
+++ b/src/components/VoiceRecordButton/states.tsx
@@ -1,7 +1,8 @@
import React from 'react';
-import { View, Animated } from 'react-native';
+import { View } from 'react-native';
import Icon from 'react-native-vector-icons/Feather';
import { useTheme, useThemedStyles } from '../../theme';
+import { LoadingDots } from '../LoadingDots';
import { createStyles } from './styles';
import { ringQuadrants } from './derive';
@@ -9,23 +10,15 @@ import { ringQuadrants } from './derive';
interface LoadingStateProps {
asSendButton: boolean;
- loadingAnim: Animated.Value;
}
-export const LoadingState: React.FC = ({ asSendButton, loadingAnim }) => {
+export const LoadingState: React.FC = ({ asSendButton }) => {
const { colors } = useTheme();
const styles = useThemedStyles(createStyles);
- const spin = loadingAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] });
-
- // Audio mode: a 56px spinner ring sized exactly like the mic (no smaller button +
- // "Loading…" text), so the bottom bar height doesn't shift while the model loads.
- if (!asSendButton) {
- return ;
- }
return (
-
-
-
+
+
+
);
};
@@ -33,22 +26,15 @@ export const LoadingState: React.FC = ({ asSendButton, loadin
interface TranscribingStateProps {
asSendButton: boolean;
- loadingAnim: Animated.Value;
}
-export const TranscribingState: React.FC = ({ asSendButton, loadingAnim }) => {
+export const TranscribingState: React.FC = ({ asSendButton }) => {
const { colors } = useTheme();
const styles = useThemedStyles(createStyles);
- const spin = loadingAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] });
-
- // Audio mode: 56px ring matching the mic footprint (see LoadingState).
- if (!asSendButton) {
- return ;
- }
return (
-
-
-
+
+
+
);
};
diff --git a/src/components/VoiceRecordButton/styles.ts b/src/components/VoiceRecordButton/styles.ts
index 77693b87a..be3c9a5d8 100644
--- a/src/components/VoiceRecordButton/styles.ts
+++ b/src/components/VoiceRecordButton/styles.ts
@@ -57,9 +57,8 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
height: 44,
borderRadius: 22,
backgroundColor: colors.surface,
- borderWidth: 2,
+ borderWidth: 1,
borderColor: colors.primary,
- borderTopColor: 'transparent',
},
// Background STT download (send-slot footprint): a STATIC determinate ring —
// per-quadrant border colors are set from ringQuadrants at render. Matches the
@@ -76,26 +75,24 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
backgroundColor: colors.surface,
borderWidth: 2,
},
- // Audio (voice) mode loading/transcribing: a 56px spinner ring that matches the
- // buttonAudio mic footprint EXACTLY, so the center slot keeps one size across
+ // Audio (voice) mode loading/transcribing: a 56px dot-loader surface that matches the
+ // buttonAudio mic footprint exactly, so the center slot keeps one size across
// mic / loading / transcribing / stop — the bottom bar never grows or shrinks.
buttonAudioLoading: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.surface,
- borderWidth: 2,
+ borderWidth: 1,
borderColor: colors.primary,
- borderTopColor: 'transparent',
},
buttonAudioTranscribing: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.surface,
- borderWidth: 2,
+ borderWidth: 1,
borderColor: colors.info,
- borderTopColor: 'transparent',
},
buttonRecording: {
backgroundColor: colors.primary,
diff --git a/src/components/VoiceRecordButton/voiceRecordGesture.ts b/src/components/VoiceRecordButton/voiceRecordGesture.ts
new file mode 100644
index 000000000..39fd08746
--- /dev/null
+++ b/src/components/VoiceRecordButton/voiceRecordGesture.ts
@@ -0,0 +1,148 @@
+import type React from 'react';
+import {
+ Animated,
+ PanResponder,
+ type GestureResponderEvent,
+ type PanResponderGestureState,
+ Vibration,
+} from 'react-native';
+import logger from '../../utils/logger';
+
+const CANCEL_DISTANCE = 80;
+const LONG_PRESS_DURATION_MS = 350;
+const HOLD_GESTURE_DISTANCE = 12;
+
+export type VoiceRecordInteractionMode = 'idle' | 'holding' | 'locked';
+
+export interface VoiceRecordCallbacks {
+ onStartRecording: () => void;
+ onStopRecording: () => void;
+ onCancelRecording: () => void;
+ onInteractionModeChange?: (mode: VoiceRecordInteractionMode) => void;
+}
+
+interface BuildVoiceRecordGestureInput {
+ isDraggingToCancel: React.MutableRefObject;
+ cancelOffsetX: Animated.Value;
+ callbacksRef: React.MutableRefObject;
+ recordingRef: React.MutableRefObject;
+ tapLockedRef: React.MutableRefObject;
+ tapLockObservedRecordingRef: React.MutableRefObject;
+ longPressTimerRef: React.MutableRefObject | null>;
+ setTapLocked: (locked: boolean) => void;
+}
+
+/**
+ * One gesture owner for tap-to-lock, hold-to-record, and slide-to-cancel.
+ * The returned responder stays mounted while model loading changes the button face.
+ */
+export function buildVoiceRecordGesture({
+ isDraggingToCancel,
+ cancelOffsetX,
+ callbacksRef,
+ recordingRef,
+ tapLockedRef,
+ tapLockObservedRecordingRef,
+ longPressTimerRef,
+ setTapLocked,
+}: BuildVoiceRecordGestureInput) {
+ let pressStartedAt = 0;
+ let pressStartedWhileActive = false;
+
+ const clearLongPressTimer = () => {
+ if (longPressTimerRef.current) clearTimeout(longPressTimerRef.current);
+ longPressTimerRef.current = null;
+ };
+
+ const resetPosition = () => {
+ Animated.spring(cancelOffsetX, {
+ toValue: 0,
+ useNativeDriver: true,
+ }).start();
+ isDraggingToCancel.current = false;
+ };
+
+ const unlock = () => {
+ tapLockedRef.current = false;
+ tapLockObservedRecordingRef.current = false;
+ setTapLocked(false);
+ callbacksRef.current.onInteractionModeChange?.('idle');
+ };
+
+ return PanResponder.create({
+ onStartShouldSetPanResponder: () => true,
+ onMoveShouldSetPanResponder: () => true,
+ onPanResponderGrant: (event: GestureResponderEvent) => {
+ logger.log('[VoiceButton] Press started');
+ Vibration.vibrate(50);
+ clearLongPressTimer();
+ // Use the native touch clock. Model or microphone startup can block the JS
+ // thread, so Date.now() at handler execution can turn a short physical tap
+ // into an apparent long press when the queued release arrives late.
+ pressStartedAt = event.nativeEvent.timestamp;
+ isDraggingToCancel.current = false;
+ pressStartedWhileActive = recordingRef.current || tapLockedRef.current;
+ if (!pressStartedWhileActive) {
+ callbacksRef.current.onInteractionModeChange?.('idle');
+ callbacksRef.current.onStartRecording();
+ longPressTimerRef.current = setTimeout(() => {
+ callbacksRef.current.onInteractionModeChange?.('holding');
+ longPressTimerRef.current = null;
+ }, LONG_PRESS_DURATION_MS);
+ }
+ },
+ onPanResponderMove: (
+ _: GestureResponderEvent,
+ gestureState: PanResponderGestureState,
+ ) => {
+ if (
+ !pressStartedWhileActive &&
+ Math.hypot(gestureState.dx, gestureState.dy) >= HOLD_GESTURE_DISTANCE
+ ) {
+ clearLongPressTimer();
+ callbacksRef.current.onInteractionModeChange?.('holding');
+ }
+ const offsetX = Math.min(0, gestureState.dx);
+ cancelOffsetX.setValue(offsetX);
+ const wasInCancelZone = isDraggingToCancel.current;
+ const isInCancelZone = Math.abs(offsetX) > CANCEL_DISTANCE;
+ if (isInCancelZone && !wasInCancelZone) Vibration.vibrate(30);
+ isDraggingToCancel.current = isInCancelZone;
+ },
+ onPanResponderRelease: (event: GestureResponderEvent) => {
+ logger.log(
+ '[VoiceButton] Press released, cancel:',
+ isDraggingToCancel.current,
+ );
+ Vibration.vibrate(30);
+ clearLongPressTimer();
+ const pressDuration = event.nativeEvent.timestamp - pressStartedAt;
+ if (isDraggingToCancel.current) {
+ callbacksRef.current.onCancelRecording();
+ unlock();
+ } else if (
+ pressStartedWhileActive ||
+ pressDuration >= LONG_PRESS_DURATION_MS
+ ) {
+ callbacksRef.current.onStopRecording();
+ unlock();
+ } else {
+ tapLockedRef.current = true;
+ tapLockObservedRecordingRef.current = recordingRef.current;
+ setTapLocked(true);
+ // The tap intent stays locked across a cold model load. The composer shows
+ // its loading status first, then reveals "Tap mic to stop" as soon as
+ // capture becomes active.
+ callbacksRef.current.onInteractionModeChange?.('locked');
+ }
+ resetPosition();
+ },
+ onPanResponderTerminate: () => {
+ logger.log('[VoiceButton] Press terminated');
+ clearLongPressTimer();
+ callbacksRef.current.onCancelRecording();
+ unlock();
+ resetPosition();
+ },
+ });
+}
diff --git a/src/components/models/WhisperPickerSheet.tsx b/src/components/models/WhisperPickerSheet.tsx
index c4047c8a2..32e608899 100644
--- a/src/components/models/WhisperPickerSheet.tsx
+++ b/src/components/models/WhisperPickerSheet.tsx
@@ -10,6 +10,7 @@ import { TYPOGRAPHY, SPACING } from '../../constants';
import { WHISPER_MODELS } from '../../services/whisperService';
import { useWhisperStore } from '../../stores/whisperStore';
import { useSttDownloadState } from '../../hooks/useSttDownloadState';
+import { presentProgress } from '../../utils/progressPresentation';
type Props = {
visible: boolean;
@@ -51,6 +52,13 @@ export const WhisperPickerSheet: React.FC = ({ visible, onClose }) => {
// while it is busy — several models can download at once, each with its own percentage.
const dl = stateFor(m.id);
const busy = dl?.active ?? false;
+ const progress = dl ? presentProgress({
+ progress: dl.progress,
+ bytesDownloaded: dl.currentBytes,
+ totalBytes: dl.totalBytes,
+ bytesPerSecond: dl.bytesPerSecond,
+ status: dl.queued ? 'pending' : 'running',
+ }) : undefined;
return (
= ({ visible, onClose }) => {
{m.name}{m.lang === 'multi' ? ' · 99 langs' : ' · EN'}
{m.description}
- {m.size} MB
+
+ {dl?.downloading
+ ? progress?.detailText
+ : `${m.size} MB`}
+
{(() => {
if (dl?.queued) return ;
- if (dl?.downloading) return {Math.round(dl.progress * 100)}%;
+ if (dl?.downloading) return {progress?.percentageText ?? 'In progress'};
// selectModel sets downloadedModelId optimistically, so the active row IS the one loading —
// show a spinner on it while it loads (not a premature checkmark), matching text/image.
if (active && isModelLoading) return ;
diff --git a/src/components/settings/sectionRegistry.ts b/src/components/settings/sectionRegistry.ts
index 89fce9d92..da9356ee3 100644
--- a/src/components/settings/sectionRegistry.ts
+++ b/src/components/settings/sectionRegistry.ts
@@ -20,10 +20,16 @@ function emitChange(): void {
for (const l of listeners) l();
}
-export function registerSettingsSection(component: ComponentType): void {
- if (sections.includes(component)) return; // no-op re-register (dev Fast Refresh)
+export function registerSettingsSection(component: ComponentType): () => void {
+ if (sections.includes(component)) return () => undefined; // no-op re-register (dev Fast Refresh)
sections.push(component);
emitChange();
+ return () => {
+ const index = sections.indexOf(component);
+ if (index < 0) return;
+ sections.splice(index, 1);
+ emitChange();
+ };
}
export function getSettingsSections(): ComponentType[] {
diff --git a/src/hooks/useActiveTextModel.ts b/src/hooks/useActiveTextModel.ts
index 0213cddd7..ae8e9782b 100644
--- a/src/hooks/useActiveTextModel.ts
+++ b/src/hooks/useActiveTextModel.ts
@@ -35,14 +35,16 @@ export function useActiveTextModel(): ActiveTextModelResult {
const remoteModel = (discoveredModels[activeServerId] || []).find(
(m) => m.id === activeRemoteTextModelId,
);
- if (remoteModel) {
- return {
- model: remoteModel,
- modelId: remoteModel.id,
- modelName: remoteModel.name,
- isRemote: true,
- };
- }
+ // The persisted server + model IDs are the selection. Discovery metadata is
+ // refreshed independently and can be empty for one render while a provider
+ // is already ready. Keep that remote choice authoritative during the gap;
+ // falling through here loads the last local model into a new chat.
+ return {
+ model: remoteModel ?? null,
+ modelId: activeRemoteTextModelId,
+ modelName: remoteModel?.name ?? activeRemoteTextModelId,
+ isRemote: true,
+ };
}
// Fall back to local. Resolved by the owning service, not by an id comparison here.
const localModel = activeModelService.resolveSelectedTextModel();
diff --git a/src/hooks/useIsProActive.ts b/src/hooks/useIsProActive.ts
index a9cb5d5eb..9069efcca 100644
--- a/src/hooks/useIsProActive.ts
+++ b/src/hooks/useIsProActive.ts
@@ -19,9 +19,9 @@ export const PRO_TOOLS_SCREEN = 'McpServers';
export function useIsProActive(): boolean {
const registered = useHasRegisteredScreen(PRO_TOOLS_SCREEN);
- // Registration says the bundle LOADED; access says this device is still entitled to it. Both, because
- // registries cannot be unregistered: a device the roster deactivated while the app was running has the
- // Pro screens registered for the rest of the session, and would keep every Pro entry point without this.
+ // Registration says the bundle loaded; access says this device is still
+ // entitled. The access check closes the short interval while async runtime
+ // cleanup removes every registered Pro surface.
const hasAccess = useAppStore(selectHasProAccess);
return registered && hasAccess;
}
diff --git a/src/hooks/useOpenProTools.ts b/src/hooks/useOpenProTools.ts
index ffe1ac8a5..2f9819f49 100644
--- a/src/hooks/useOpenProTools.ts
+++ b/src/hooks/useOpenProTools.ts
@@ -2,6 +2,8 @@ import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { getRegisteredScreens } from '../navigation/screenRegistry';
import { RootStackParamList } from '../navigation/types';
+import { useAppStore } from '../stores/appStore';
+import { selectHasProAccess } from '../stores/proAccessSlice';
import { PRO_TOOLS_SCREEN } from './useIsProActive';
/**
@@ -18,8 +20,9 @@ import { PRO_TOOLS_SCREEN } from './useIsProActive';
export function useOpenProTools(): () => void {
const navigation = useNavigation>();
return () => {
+ const hasAccess = selectHasProAccess(useAppStore.getState());
const hasProScreen = getRegisteredScreens().some(s => s.name === PRO_TOOLS_SCREEN);
- if (hasProScreen) {
+ if (hasAccess && hasProScreen) {
navigation.navigate(PRO_TOOLS_SCREEN as any);
} else {
navigation.navigate('ProDetail');
diff --git a/src/hooks/useSttDownloadState.ts b/src/hooks/useSttDownloadState.ts
index f390f2448..9b9301a0b 100644
--- a/src/hooks/useSttDownloadState.ts
+++ b/src/hooks/useSttDownloadState.ts
@@ -25,6 +25,9 @@ interface SttDownloadEntry {
downloading: boolean;
/** Waiting for a concurrency slot (show a clock, not 0%). */
queued: boolean;
+ currentBytes?: number;
+ totalBytes?: number;
+ bytesPerSecond?: number;
}
/** Whisper download-store ids are prefixed `whisper-`; the model ids the UI uses are bare. */
@@ -50,11 +53,19 @@ function deriveSttDownloadState(
active: isActiveStatus(e.status),
downloading: isDownloadingStatus(e.status),
queued: isQueuedStatus(e.status),
+ currentBytes: e.bytesDownloaded + (e.mmProjBytesDownloaded ?? 0),
+ totalBytes: e.combinedTotalBytes || e.totalBytes || undefined,
+ bytesPerSecond: e.bytesPerSecond,
};
}
for (const [id, p] of Object.entries(downloadProgressById)) {
if (id in byId) continue; // canonical entry wins
- byId[id] = { progress: p, active: true, downloading: p > 0, queued: p === 0 };
+ byId[id] = {
+ progress: p,
+ active: true,
+ downloading: p > 0,
+ queued: p === 0,
+ };
}
const anyDownloading = Object.values(byId).some((s) => s.active);
return { byId, anyDownloading };
diff --git a/src/hooks/useWhisperTranscription.ts b/src/hooks/useWhisperTranscription.ts
index 13deb73bb..03085018f 100644
--- a/src/hooks/useWhisperTranscription.ts
+++ b/src/hooks/useWhisperTranscription.ts
@@ -27,6 +27,7 @@ export interface UseWhisperTranscriptionResult {
isRecording: boolean;
isModelLoaded: boolean;
isModelLoading: boolean;
+ isStartingRecording: boolean;
isTranscribing: boolean;
partialResult: string;
finalResult: string;
@@ -39,6 +40,7 @@ export interface UseWhisperTranscriptionResult {
export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscriptionParams): UseWhisperTranscriptionResult => {
const [isRecording, setIsRecording] = useState(false);
+ const [isStartingRecording, setIsStartingRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);
const [partialResult, setPartialResult] = useState('');
const [finalResult, setFinalResult] = useState('');
@@ -54,7 +56,7 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri
const transcribingStartTime = useRef(null);
const pendingResult = useRef(null);
- const { isModelLoaded, isModelLoading } = useWhisperStore();
+ const { isModelLoaded, isModelLoading, transcriptionLanguage } = useWhisperStore();
// On unmount, stop any in-flight realtime session. Without this the mic kept
// capturing after the user navigated away without releasing the button — the
@@ -62,7 +64,7 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri
// mountedRef only flips a flag; it never told the native session to stop.
useEffect(() => () => {
if (whisperService.isCurrentlyTranscribing()) {
- whisperService.forceReset();
+ void whisperService.forceReset();
}
}, []);
@@ -118,9 +120,9 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri
}
}, []);
- // Extra recording time after user releases button (ms)
- // Whisper needs trailing audio/silence to properly process speech
- const TRAILING_RECORD_TIME = 2500;
+ // One short tail gives Whisper enough silence to close the phrase without
+ // making every manual stop feel blocked.
+ const TRAILING_RECORD_TIME_MS = 300;
// Define stopRecording first since startRecording depends on it
const stopRecording = useCallback(async () => {
@@ -131,19 +133,21 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri
// Immediately update UI to show "Transcribing..." state
// But keep recording in background for better accuracy
if (mountedRef.current) setIsRecording(false);
+ if (mountedRef.current) setIsStartingRecording(false);
+ if (mountedRef.current) setIsTranscribing(true);
transcribingStartTime.current = Date.now();
try {
// Continue recording for a bit longer to capture trailing audio
// This helps Whisper process the speech more accurately
// User sees "Transcribing..." during this time
- logger.log('[Whisper] Capturing trailing audio for', TRAILING_RECORD_TIME, 'ms...');
- await new Promise(resolve => setTimeout(() => resolve(), TRAILING_RECORD_TIME));
+ logger.log('[Whisper] Capturing trailing audio for', TRAILING_RECORD_TIME_MS, 'ms...');
+ await new Promise(resolve => setTimeout(() => resolve(), TRAILING_RECORD_TIME_MS));
// Check if cancelled or unmounted during the wait
if (isCancelled.current || !mountedRef.current) {
logger.log('[Whisper] Cancelled/unmounted during trailing capture');
- whisperService.forceReset();
+ await whisperService.forceReset();
return;
}
@@ -154,7 +158,7 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri
} catch (err) {
logger.error('[Whisper] Stop error:', err);
// Force reset on error
- whisperService.forceReset();
+ await whisperService.forceReset();
// On error, also clear transcribing state (only if still mounted)
if (mountedRef.current) {
setIsTranscribing(false);
@@ -166,6 +170,7 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri
const clearResult = useCallback(() => {
setFinalResult('');
setPartialResult('');
+ setIsStartingRecording(false);
setIsTranscribing(false);
isCancelled.current = true;
startNonce.current++; // supersede an in-flight start awaiting model load (no ghost recording)
@@ -196,37 +201,31 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri
// we await, this start has been superseded → abort, or we'd activate a ghost recording no stop reaches.
const currentNonce = ++startNonce.current;
- if (!whisperService.isModelLoaded()) {
- logger.log('[Whisper] Model not loaded, ensuring readiness (blocked → free generation model → retry)...');
- // Route through the SAME recovery the file path uses: a 'blocked' single-model refusal frees the
- // resident generation model and retries. Never call loadModel() directly here — a 'blocked' return
- // is not a throw, so it would dead-end into startRealtimeTranscription → 'No Whisper model loaded'.
- let ready = false;
- try {
- ready = await ensureModelReady();
- } catch {
- ready = false;
- }
- if (startNonce.current !== currentNonce || !mountedRef.current) {
- logger.log('[Whisper] Start superseded during model load (stopped/cancelled) — aborting, no ghost recording');
- return;
- }
- if (!ready) {
- setError("Couldn't load the voice model — free some memory and try again");
- return;
- }
+ logger.log('[Whisper] Ensuring the selected model is resident (blocked → free generation model → retry)...');
+ // Always ask the identity-aware readiness owner. `isModelLoaded()` only says that
+ // some Whisper context exists; after a download or model switch it may be the
+ // context from the previous model.
+ let ready = false;
+ try {
+ ready = await ensureModelReady();
+ } catch {
+ ready = false;
+ }
+ if (startNonce.current !== currentNonce || !mountedRef.current) {
+ logger.log('[Whisper] Start superseded during model load (stopped/cancelled) — aborting, no ghost recording');
+ return;
+ }
+ if (!ready) {
+ setError("Couldn't load the voice model — free some memory and try again");
+ return;
}
-
- // Haptic feedback to indicate recording started
- Vibration.vibrate(50);
try {
isCancelled.current = false;
setError(null);
setPartialResult('');
setFinalResult('');
- setIsRecording(true);
- setIsTranscribing(true);
+ setIsStartingRecording(true);
logger.log('[Whisper] Starting realtime transcription...');
@@ -261,21 +260,29 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri
transcribingStartTime.current = null;
}
}
- });
+ }, { language: transcriptionLanguage });
+ if (startNonce.current !== currentNonce || !mountedRef.current) return;
+ // Do not tell the person to speak before both the fallback recorder and
+ // whisper.rn have installed their native capture handles.
+ setIsStartingRecording(false);
+ setIsRecording(true);
+ setIsTranscribing(true);
+ Vibration.vibrate(50);
} catch (err) {
logger.error('[Whisper] Recording error:', err);
// Force reset whisper service state
- whisperService.forceReset();
+ await whisperService.forceReset();
if (mountedRef.current) {
const errorMsg = err instanceof Error ? err.message : 'Failed to start recording';
setError(errorMsg);
+ setIsStartingRecording(false);
setIsRecording(false);
setIsTranscribing(false);
// Error haptic
Vibration.vibrate([0, 50, 50, 50]);
}
}
- }, [ensureModelReady, stopRecording, finalizeTranscription]);
+ }, [ensureModelReady, stopRecording, finalizeTranscription, transcriptionLanguage]);
const startRecording = useCallback(async () => {
logger.log('[Whisper] startRecording called');
@@ -310,6 +317,7 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri
isRecording,
isModelLoaded: isModelLoaded || whisperService.isModelLoaded(),
isModelLoading,
+ isStartingRecording,
isTranscribing,
partialResult,
finalResult,
diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
index 17eefc540..ae56a2fcf 100644
--- a/src/navigation/AppNavigator.tsx
+++ b/src/navigation/AppNavigator.tsx
@@ -204,6 +204,9 @@ export const AppNavigator: React.FC = () => {
headerShown: false,
contentStyle: { backgroundColor: colors.background },
animation: 'slide_from_right',
+ gestureEnabled: true,
+ fullScreenGestureEnabled: true,
+ gestureDirection: 'horizontal',
}}
>
@@ -215,7 +218,7 @@ export const AppNavigator: React.FC = () => {
@@ -238,12 +241,12 @@ export const AppNavigator: React.FC = () => {
{registeredScreens.map(s => (
diff --git a/src/navigation/screenRegistry.ts b/src/navigation/screenRegistry.ts
index 0b5827cf9..70c7d9c3a 100644
--- a/src/navigation/screenRegistry.ts
+++ b/src/navigation/screenRegistry.ts
@@ -20,14 +20,19 @@ function subscribe(onStoreChange: () => void): () => void {
return () => listeners.delete(onStoreChange);
}
-export function registerScreen(screen: RegisteredScreen): void {
+export function registerScreen(screen: RegisteredScreen): () => void {
// Dedupe by name. loadProFeatures can run more than once (dev Fast Refresh, or
// a future re-activate-on-purchase without restart); duplicate route names
// crash the navigator (the duplicate-screen render bug). First wins. Mirrors
// the guard in sectionRegistry.
- if (screens.some(s => s.name === screen.name)) return;
+ if (screens.some(s => s.name === screen.name)) return () => undefined;
screens = [...screens, screen];
emitChange();
+ return () => {
+ if (!screens.includes(screen)) return;
+ screens = screens.filter(item => item !== screen);
+ emitChange();
+ };
}
export function getRegisteredScreens(): RegisteredScreen[] {
diff --git a/src/navigation/useProExpiryRedirect.ts b/src/navigation/useProExpiryRedirect.ts
new file mode 100644
index 000000000..b4a1e9304
--- /dev/null
+++ b/src/navigation/useProExpiryRedirect.ts
@@ -0,0 +1,60 @@
+import { useCallback, useEffect, useRef } from 'react';
+import {
+ createNavigationContainerRef,
+ type NavigationContainerRef,
+} from '@react-navigation/native';
+import { useAppStore } from '../stores';
+import { selectHasProAccess } from '../stores/proAccessSlice';
+import type { RootStackParamList } from './types';
+
+export const appNavigationRef =
+ createNavigationContainerRef();
+
+/**
+ * Keep an expired installation on the purchase route.
+ *
+ * The returned callback is also the NavigationContainer onReady handler. This handles a credential
+ * that was already expired during cold-start hydration, before the navigator existed.
+ */
+export function useProExpiryRedirect(
+ navigation: ProExpiryNavigation = appNavigationRef,
+): () => void {
+ const expired = useAppStore(state => state.hasExpiredProCredential);
+ const hasProAccess = useAppStore(selectHasProAccess);
+ const hadProAccess = useRef(hasProAccess);
+ const redirectPending = useRef(expired);
+
+ const redirectIfExpired = useCallback(() => {
+ if (!redirectPending.current || !navigation.isReady()) return;
+ if (navigation.getCurrentRoute?.()?.name === 'ProDetail') {
+ redirectPending.current = false;
+ return;
+ }
+ redirectPending.current = false;
+ navigation.resetRoot({
+ index: 0,
+ routes: [{ name: 'ProDetail' }],
+ });
+ }, [navigation]);
+
+ useEffect(() => {
+ if (hasProAccess) {
+ hadProAccess.current = true;
+ redirectPending.current = false;
+ return;
+ }
+ if (expired || hadProAccess.current) {
+ hadProAccess.current = false;
+ redirectPending.current = true;
+ }
+ redirectIfExpired();
+ }, [expired, hasProAccess, redirectIfExpired]);
+
+ return redirectIfExpired;
+}
+type ProExpiryNavigation = Pick<
+ NavigationContainerRef,
+ 'isReady' | 'resetRoot'
+> & {
+ getCurrentRoute?: NavigationContainerRef['getCurrentRoute'];
+};
diff --git a/src/screens/ChatScreen/ChatMessageArea.tsx b/src/screens/ChatScreen/ChatMessageArea.tsx
index 67ec946db..f21b1f68a 100644
--- a/src/screens/ChatScreen/ChatMessageArea.tsx
+++ b/src/screens/ChatScreen/ChatMessageArea.tsx
@@ -5,7 +5,6 @@ import {
Text,
Keyboard,
Platform,
- StyleSheet,
} from 'react-native';
import { useUiModeStore } from '../../stores/uiModeStore';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -156,8 +155,6 @@ export const ChatMessageArea: React.FC = ({
handleScroll,
renderItem,
}) => {
- // Hide FlatList until initial layout + scroll is complete to prevent visible scroll jump
- const [isListReady, setIsListReady] = useState(false);
const hasScrolledRef = React.useRef(false);
const interfaceMode = useUiModeStore(s => s.interfaceMode);
const tabNav = useNavigation>();
@@ -245,8 +242,8 @@ export const ChatMessageArea: React.FC = ({
})()
) : (
item.id}
@@ -258,10 +255,6 @@ export const ChatMessageArea: React.FC = ({
// Initial layout: force scroll to bottom regardless of isNearBottom
flatListRef.current?.scrollToEnd({ animated: false });
hasScrolledRef.current = true;
- // Reveal after a frame so the scroll position settles
- requestAnimationFrame(() => {
- requestAnimationFrame(() => setIsListReady(true));
- });
} else if (isNearBottomRef.current) {
flatListRef.current?.scrollToEnd({ animated: false });
}
@@ -411,7 +404,3 @@ export const ChatMessageArea: React.FC = ({
>
);
};
-
-const hiddenStyle = StyleSheet.create({
- hidden: { opacity: 0 },
-});
diff --git a/src/screens/ChatScreen/ChatScreenComponents.tsx b/src/screens/ChatScreen/ChatScreenComponents.tsx
index 2fc8a1791..533cc3718 100644
--- a/src/screens/ChatScreen/ChatScreenComponents.tsx
+++ b/src/screens/ChatScreen/ChatScreenComponents.tsx
@@ -129,15 +129,14 @@ export const ChatHeader: React.FC<{
{activeProject ? activeProject.name : 'Default'}
- {/* Pro-only: Chat/Voice mode dropdown, on the same line as Models ·
- project, pushed to the right. Empty slot in free builds. */}
- {(() => { const ModeToggle = getSlot(SLOTS.chatInputModeToggle); return ModeToggle ? : null; })()}
- setShowSettingsPanel(true)} testID="chat-settings-icon">
-
-
+ {/* Pro-only Voice mode sits beside the conversation filters. Empty in free builds. */}
+ {(() => { const ModeToggle = getSlot(SLOTS.chatInputModeToggle); return ModeToggle ? : null; })()}
+ setShowSettingsPanel(true)} testID="chat-settings-icon">
+
+
diff --git a/src/screens/ChatScreen/index.tsx b/src/screens/ChatScreen/index.tsx
index 6cebc0eac..e51e9ba6f 100644
--- a/src/screens/ChatScreen/index.tsx
+++ b/src/screens/ChatScreen/index.tsx
@@ -27,6 +27,7 @@ import { WhisperPickerSheet } from '../../components/models/WhisperPickerSheet';
import { VoiceModelsSheet } from '../../components/models/VoiceModelsSheet';
import { useWhisperStore } from '../../stores/whisperStore';
import { WHISPER_MODELS } from '../../services';
+import { getSlot, SLOTS } from '../../bootstrap/slotRegistry';
function countConversationImages(conv: Conversation | undefined): number {
return (conv?.messages || []).reduce((n: number, m: Message) =>
@@ -215,6 +216,12 @@ export const ChatScreen: React.FC = () => {
setShowProjectSelector={chat.setShowProjectSelector}
isRemote={chat.activeModelInfo?.isRemote}
/>
+ {/* Pro-registered overlay pinned above the messages: a paired desktop's pending
+ computer-use approval, answered here. Self-hides when nothing is pending. */}
+ {(() => {
+ const ChatOverlay = getSlot(SLOTS.chatOverlay);
+ return ChatOverlay ? : null;
+ })()}
setModelsManagerOpen(false)}
diff --git a/src/screens/ChatScreen/styles.ts b/src/screens/ChatScreen/styles.ts
index 1bbddecb4..600fb448b 100644
--- a/src/screens/ChatScreen/styles.ts
+++ b/src/screens/ChatScreen/styles.ts
@@ -27,7 +27,15 @@ const createHeaderStyles = (colors: ThemeColors) => ({
backButton: { padding: SPACING.xs },
headerLeft: { flex: 1, marginRight: 12 },
headerTitle: { ...TYPOGRAPHY.h2, color: colors.text, marginBottom: 2 },
- headerSubtitleRow: { flexDirection: 'row' as const, alignItems: 'center' as const, gap: 6, overflow: 'hidden' as const },
+ headerSubtitleRow: {
+ flexDirection: 'row' as const,
+ alignItems: 'center' as const,
+ gap: 6,
+ overflow: 'hidden' as const,
+ // Keep the header's measured height unchanged while giving the title and
+ // controls one full spacing step of visual separation.
+ transform: [{ translateY: SPACING.xs }],
+ },
headerSubtitleDivider: { ...TYPOGRAPHY.meta, color: colors.textMuted, flexShrink: 0 },
// The project name yields, the model control does not. These two shared one row with the shrink
// rule the wrong way round - the project refused to give ground while the model was allowed to -
@@ -40,7 +48,6 @@ const createHeaderStyles = (colors: ThemeColors) => ({
modelSelector: { flexDirection: 'row' as const, alignItems: 'center' as const, flexShrink: 0, overflow: 'hidden' as const },
remoteIcon: { marginRight: 4 },
modelSelectorArrow: { ...TYPOGRAPHY.meta, color: colors.textMuted, marginLeft: SPACING.xs },
- modeToggleWrap: { marginLeft: 'auto' as const },
headerImageBadge: {
width: 18,
height: 18,
@@ -54,7 +61,7 @@ const createHeaderStyles = (colors: ThemeColors) => ({
flexDirection: 'row' as const,
alignItems: 'center' as const,
justifyContent: 'center' as const,
- gap: 4,
+ gap: SPACING.xs,
},
iconButton: {
width: 30,
diff --git a/src/screens/ChatScreen/useChatModelActions.ts b/src/screens/ChatScreen/useChatModelActions.ts
index 4abd54d2f..3b0562685 100644
--- a/src/screens/ChatScreen/useChatModelActions.ts
+++ b/src/screens/ChatScreen/useChatModelActions.ts
@@ -388,14 +388,30 @@ type ModelStateSyncDeps = {
setSupportsVision: (v: boolean) => void;
setSupportsToolCalling: (v: boolean) => void;
setSupportsThinking: (v: boolean) => void;
+ prepareSelectedModel?: boolean;
};
export function useChatModelStateSync(deps: ModelStateSyncDeps): void {
- const { activeModelInfo, activeModelId, activeModel, activeRemoteModel, activeRemoteTextModelId, isModelLoading, setSupportsVision, setSupportsToolCalling, setSupportsThinking } = deps;
+ const { activeModelInfo, activeModelId, activeModel, activeRemoteModel, activeRemoteTextModelId, isModelLoading, setSupportsVision, setSupportsToolCalling, setSupportsThinking, prepareSelectedModel } = deps;
const activeModelMmProjPath = activeModel?.engine === 'llama' ? activeModel.mmProjPath : undefined;
- // The active text model is NOT loaded here (on chat mount / model select). It loads
- // lazily on send, when the generation path recognizes a local text model is needed
- // (ensureModelReady → ensureModelLoaded). Loading eagerly here is what made opening a
- // chat — and switching models — spin up the model before the user sent anything.
+ // A brand-new chat is an explicit request to get the selected model ready. Start the
+ // real load here so the chat renders its authoritative loading state before Send.
+ // Existing conversations still load on demand, and remote models have no local load.
+ useEffect(() => {
+ if (
+ !prepareSelectedModel ||
+ activeModelInfo.isRemote ||
+ !!activeRemoteTextModelId ||
+ !activeModel ||
+ !activeModelId ||
+ isModelReady(activeModel)
+ ) return;
+ initiateModelLoad(deps.modelDeps, false).catch(error => {
+ logger.error('[ChatScreen] New-chat model preparation failed:', error);
+ });
+ // modelDeps is a render snapshot; the identity inputs below own when a new load starts.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [prepareSelectedModel, activeModelInfo.isRemote, activeRemoteTextModelId, activeModelId, activeModel?.filePath]);
+
useEffect(() => {
// Single capability rule (engines.activeTextCapabilities); vision keys on activeModelInfo.isRemote.
setSupportsVision(activeTextCapabilities({
diff --git a/src/screens/ChatScreen/useChatScreen.ts b/src/screens/ChatScreen/useChatScreen.ts
index 4c94187a5..43e72b649 100644
--- a/src/screens/ChatScreen/useChatScreen.ts
+++ b/src/screens/ChatScreen/useChatScreen.ts
@@ -286,6 +286,7 @@ export const useChatScreen = () => {
setSupportsVision,
setSupportsToolCalling,
setSupportsThinking,
+ prepareSelectedModel: route.params?.conversationId == null,
});
const isGeneratingForThisConversation =
diff --git a/src/screens/ChatScreen/useChatScreenLifecycle.ts b/src/screens/ChatScreen/useChatScreenLifecycle.ts
index 55b272492..ae3ff9823 100644
--- a/src/screens/ChatScreen/useChatScreenLifecycle.ts
+++ b/src/screens/ChatScreen/useChatScreenLifecycle.ts
@@ -13,7 +13,6 @@ import {
generationService,
imageGenerationService,
ImageGenerationState,
- llmService,
QueuedMessage,
} from '../../services';
import { generationSession } from '../../services/generationSession';
@@ -154,16 +153,9 @@ export function useChatConversationLifecycle({
) {
generationSession.end('conversation-switch');
}
- let cancelled = false;
- const timer = setTimeout(() => {
- if (!cancelled && llmService.isModelLoaded()) {
- llmService.clearKVCache(false).catch(() => {});
- }
- }, 0);
- return () => {
- cancelled = true;
- clearTimeout(timer);
- };
+ // Native conversation isolation is awaited by generationService immediately
+ // before a local turn starts. A navigation timer here raced the first Send and
+ // could clear too late (context leak) or during prefill (no-op).
}, [activeConversationId]);
}
diff --git a/src/screens/DownloadManagerScreen/downloadItemMapping.ts b/src/screens/DownloadManagerScreen/downloadItemMapping.ts
index 8f4394925..36511ed8d 100644
--- a/src/screens/DownloadManagerScreen/downloadItemMapping.ts
+++ b/src/screens/DownloadManagerScreen/downloadItemMapping.ts
@@ -100,6 +100,7 @@ export function entryToActiveItem(entry: DownloadEntry): DownloadItem {
fileSize: entry.combinedTotalBytes || entry.totalBytes,
bytesDownloaded: entry.bytesDownloaded + (entry.mmProjBytesDownloaded ?? 0),
progress: entry.progress,
+ bytesPerSecond: entry.bytesPerSecond,
status: entry.status,
reason: entry.errorMessage,
reasonCode: entry.errorCode as
diff --git a/src/screens/DownloadManagerScreen/items.tsx b/src/screens/DownloadManagerScreen/items.tsx
index 21db284cd..94b8d1dbc 100644
--- a/src/screens/DownloadManagerScreen/items.tsx
+++ b/src/screens/DownloadManagerScreen/items.tsx
@@ -11,6 +11,7 @@ import { getDownloadStatusLabel, isRetryable } from '../../utils/downloadErrors'
import { downloadStatusIcon } from '../../utils/downloadStatusIcon';
import { formatBytes } from '../../utils/formatBytes';
import { createStyles } from './styles';
+import { presentProgress } from '../../utils/progressPresentation';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -26,6 +27,7 @@ export type DownloadItem = {
fileSize: number;
bytesDownloaded: number;
progress: number;
+ bytesPerSecond?: number;
status: string;
downloadedAt?: string;
filePath?: string;
@@ -80,6 +82,14 @@ export const ActiveDownloadCard: React.FC = ({ item, on
: item.status === 'retrying' || item.status === 'waiting_for_network'
? colors.warning
: colors.primary;
+ const presented = presentProgress({
+ progress: item.progress,
+ bytesDownloaded: item.bytesDownloaded,
+ totalBytes: item.fileSize,
+ bytesPerSecond: item.bytesPerSecond,
+ status: item.status,
+ });
+ const percentage = presented.progress.percentage ?? 0;
// Icon per status is owned by downloadStatusIcon() so this row and ModelCard match
// (queued -> clock, previously text-only here).
@@ -111,10 +121,10 @@ export const ActiveDownloadCard: React.FC = ({ item, on
-
+
-
- {formatBytes(item.bytesDownloaded)} / {formatBytes(item.fileSize)}
+
+ {[presented.percentageText, presented.detailText].filter(Boolean).join(' · ')}
@@ -199,6 +209,13 @@ export const CompletedDownloadCard: React.FC = ({ it
// ~900MB mmproj re-download, instead of a bare indeterminate spinner (OD2).
const repairEntry = useDownloadStore(s => s.downloads[item.modelId]);
const showRepairProgress = isRepairingVision && !!repairEntry;
+ const repairProgress = repairEntry ? presentProgress({
+ progress: repairEntry.progress,
+ bytesDownloaded: repairEntry.bytesDownloaded,
+ totalBytes: repairEntry.totalBytes,
+ bytesPerSecond: repairEntry.bytesPerSecond,
+ status: repairEntry.status,
+ }) : undefined;
return (
@@ -237,7 +254,7 @@ export const CompletedDownloadCard: React.FC = ({ it
- {formatBytes(repairEntry.bytesDownloaded)} / {formatBytes(repairEntry.totalBytes)}
+ {[repairProgress?.percentageText, repairProgress?.detailText].filter(Boolean).join(' · ')}
)}
diff --git a/src/screens/DownloadManagerScreen/useVoiceDownloadItems.ts b/src/screens/DownloadManagerScreen/useVoiceDownloadItems.ts
index d157055dc..0cb306b14 100644
--- a/src/screens/DownloadManagerScreen/useVoiceDownloadItems.ts
+++ b/src/screens/DownloadManagerScreen/useVoiceDownloadItems.ts
@@ -50,7 +50,8 @@ async function loadItems(): Promise {
items.push({
type: 'active', modelType: 'tts', modelId: engineId, fileName: d.name,
author: 'Voice', quantization: '', fileSize: d.sizeBytes,
- bytesDownloaded: d.bytesDownloaded, progress: d.progress, status: 'downloading', name: d.name,
+ bytesDownloaded: d.bytesDownloaded, bytesPerSecond: d.bytesPerSecond,
+ progress: d.progress, status: 'downloading', name: d.name,
});
} else if (d.status === 'error') {
// A failed Kokoro fetch. Surface it as a failed active item so the
diff --git a/src/screens/HomeScreen/hooks/useHomeScreen.ts b/src/screens/HomeScreen/hooks/useHomeScreen.ts
index 97057e2c6..27ffb54b6 100644
--- a/src/screens/HomeScreen/hooks/useHomeScreen.ts
+++ b/src/screens/HomeScreen/hooks/useHomeScreen.ts
@@ -20,7 +20,7 @@ export type { HomeScreenNavigationProp, ModelPickerType, LoadingState };
// Track if we've synced native state to avoid repeated calls
let hasInitializedNativeSync = false;
-let hasRunLANDiscovery = false;
+let lanDiscoveryState: 'idle' | 'scheduled' | 'complete' = 'idle';
function deleteConversationWithAlert(
conversation: Conversation,
@@ -121,36 +121,66 @@ export const useHomeScreen = (navigation: HomeScreenNavigationProp) => {
} = useRemoteModelHandlers({ activeModelId, setPickerType, setLoadingState, setAlertState });
useEffect(() => {
+ let lanDiscoveryTimer: ReturnType | null = null;
+ let cancelHydrationListener: (() => void) | null = null;
+ let cancelled = false;
const task = InteractionManager.runAfterInteractions(() => {
loadData();
if (!hasInitializedNativeSync) {
hasInitializedNativeSync = true;
activeModelService.syncWithNativeState();
}
- if (!hasRunLANDiscovery) {
- hasRunLANDiscovery = true;
+ if (lanDiscoveryState === 'idle') {
+ lanDiscoveryState = 'scheduled';
// One-time default for the auto-discover toggle: fresh installs → OFF; grandfather users who
// already had a gateway → ON. Guard on the remote-server store being hydrated so we read the
// real (persisted) server list, not the empty initial state. runLANDiscovery self-gates on
// the resulting setting, so a slow hydration simply skips this launch (correct next launch).
- const migrateAutoDiscover = (): void => {
+ const migrateAndScheduleDiscovery = (): void => {
+ if (cancelled) return;
const next = resolveAutoDiscoverMigration(
useAppStore.getState().settings.autoDiscoverRemoteModels,
useRemoteServerStore.getState().servers.length > 0,
);
if (next !== undefined) useAppStore.getState().updateSettings({ autoDiscoverRemoteModels: next });
+ // Delay LAN scan so the home screen is fully rendered and interactive first.
+ // Start this delay only after persisted remote settings are available, or the
+ // scan can read the empty initial store and skip a valid saved gateway.
+ lanDiscoveryTimer = setTimeout(() => {
+ lanDiscoveryTimer = null;
+ if (cancelled) {
+ lanDiscoveryState = 'idle';
+ return;
+ }
+ runLANDiscovery();
+ lanDiscoveryState = 'complete';
+ }, 3000);
};
// `.persist` is a zustand-middleware addition; guard it so this is safe under test mocks
// that don't include it (treat "no persist API" as already-hydrated).
- const persistApi = (useRemoteServerStore as { persist?: { hasHydrated?: () => boolean; onFinishHydration?: (cb: () => void) => void } }).persist;
- if (!persistApi?.hasHydrated || persistApi.hasHydrated()) migrateAutoDiscover();
- else persistApi.onFinishHydration?.(migrateAutoDiscover);
- // Delay LAN scan so the home screen is fully rendered and interactive first
- setTimeout(runLANDiscovery, 3000);
+ const persistApi = (useRemoteServerStore as {
+ persist?: {
+ hasHydrated?: () => boolean;
+ onFinishHydration?: (cb: () => void) => (() => void) | void;
+ };
+ }).persist;
+ if (!persistApi?.hasHydrated || persistApi.hasHydrated()) {
+ migrateAndScheduleDiscovery();
+ } else {
+ cancelHydrationListener = persistApi.onFinishHydration?.(migrateAndScheduleDiscovery) ?? null;
+ }
}
});
isFirstMount.current = false;
- return () => task.cancel();
+ return () => {
+ cancelled = true;
+ task.cancel();
+ cancelHydrationListener?.();
+ if (lanDiscoveryTimer !== null) {
+ clearTimeout(lanDiscoveryTimer);
+ }
+ if (lanDiscoveryState === 'scheduled') lanDiscoveryState = 'idle';
+ };
}, []);
diff --git a/src/screens/HomeScreen/index.tsx b/src/screens/HomeScreen/index.tsx
index 35325e852..46fe6baf1 100644
--- a/src/screens/HomeScreen/index.tsx
+++ b/src/screens/HomeScreen/index.tsx
@@ -159,6 +159,8 @@ export const HomeScreen: React.FC = ({ navigation }) => {
onPress={() => navigation.navigate('ProDetail')}
hitSlop={8}
style={styles.crownButton}
+ accessibilityRole="button"
+ accessibilityLabel="Open Off Grid AI Pro"
>
diff --git a/src/screens/ModelDownloadScreen.tsx b/src/screens/ModelDownloadScreen.tsx
index 1b2620325..eddafaf9c 100644
--- a/src/screens/ModelDownloadScreen.tsx
+++ b/src/screens/ModelDownloadScreen.tsx
@@ -45,7 +45,7 @@ interface RecommendedCardProps {
model: typeof RECOMMENDED_MODELS[number];
recFile: ModelFile;
index: number;
- progress: { progress: number; queued?: boolean; bytes?: { downloaded: number; total: number } } | null | undefined;
+ progress: { progress: number; queued?: boolean; bytes?: { downloaded: number; total: number; bytesPerSecond?: number } } | null | undefined;
downloaded: DownloadedModel | undefined;
totalRamGB: number;
isTrending: boolean;
@@ -79,7 +79,7 @@ interface LiteRTCardProps {
file: ModelFile;
index: number;
curatedEntry: CuratedLiteRTEntry | undefined;
- progress: { progress: number; queued?: boolean; bytes?: { downloaded: number; total: number } } | null | undefined;
+ progress: { progress: number; queued?: boolean; bytes?: { downloaded: number; total: number; bytesPerSecond?: number } } | null | undefined;
downloaded: DownloadedModel | undefined;
totalRamGB: number;
onDownload: () => void;
@@ -121,15 +121,15 @@ const LiteRTModelCard: React.FC = ({ file, index, curatedEntry,
* `bytes` feeds the shared card's "X MB / Y MB" line so onboarding matches the
* Text/Image/STT tabs (same ModelCard, same props) instead of showing % only. */
export function downloadProgressFor(
- entry: { status: string; progress: number; bytesDownloaded?: number; totalBytes?: number; combinedTotalBytes?: number; mmProjBytesDownloaded?: number } | undefined,
-): { progress: number; queued: boolean; bytes?: { downloaded: number; total: number } } | null {
+ entry: { status: string; progress: number; bytesDownloaded?: number; totalBytes?: number; combinedTotalBytes?: number; mmProjBytesDownloaded?: number; bytesPerSecond?: number } | undefined,
+): { progress: number; queued: boolean; bytes?: { downloaded: number; total: number; bytesPerSecond?: number } } | null {
if (!entry || !isActiveStatus(entry.status as any)) return null;
const total = entry.combinedTotalBytes ?? entry.totalBytes ?? 0;
const downloaded = (entry.bytesDownloaded ?? 0) + (entry.mmProjBytesDownloaded ?? 0);
return {
progress: entry.progress,
queued: entry.status === 'pending',
- bytes: total > 0 ? { downloaded, total } : undefined,
+ bytes: total > 0 ? { downloaded, total, bytesPerSecond: entry.bytesPerSecond } : undefined,
};
}
diff --git a/src/screens/ModelsScreen/ImageModelsTab.tsx b/src/screens/ModelsScreen/ImageModelsTab.tsx
index c5c3223d7..4cd9075bc 100644
--- a/src/screens/ModelsScreen/ImageModelsTab.tsx
+++ b/src/screens/ModelsScreen/ImageModelsTab.tsx
@@ -87,7 +87,11 @@ export const ImageModelCardItem: React.FC = ({
isDownloading={isDownloading}
isQueued={isQueued}
downloadProgress={progressValue}
- downloadBytes={entry ? { downloaded: Math.round(progressValue * model.size), total: model.size } : undefined}
+ downloadBytes={entry ? {
+ downloaded: entry.bytesDownloaded + (entry.mmProjBytesDownloaded ?? 0),
+ total: entry.combinedTotalBytes || entry.totalBytes || model.size,
+ bytesPerSecond: entry.bytesPerSecond,
+ } : undefined}
isCompatible={isCompatible}
incompatibleReason={incompatibleReason}
testID={`image-model-card-${index}`}
diff --git a/src/screens/ModelsScreen/TextModelsTab.tsx b/src/screens/ModelsScreen/TextModelsTab.tsx
index 702aaff64..0ea0bc772 100644
--- a/src/screens/ModelsScreen/TextModelsTab.tsx
+++ b/src/screens/ModelsScreen/TextModelsTab.tsx
@@ -130,6 +130,7 @@ const ModelDetailView: React.FC = ({
progress: entry.progress,
bytesDownloaded: entry.bytesDownloaded + (entry.mmProjBytesDownloaded ?? 0),
totalBytes: entry.combinedTotalBytes,
+ bytesPerSecond: entry.bytesPerSecond,
status: entry.status,
}
: undefined;
@@ -173,7 +174,11 @@ const ModelDetailView: React.FC = ({
isDownloading={!!s.progress && !s.hasFailed && !isQueuedStatus(s.progress.status)}
isQueued={isQueuedStatus(s.progress?.status ?? 'completed')}
downloadProgress={s.progress?.progress}
- downloadBytes={s.progress && !s.hasFailed ? { downloaded: s.progress.bytesDownloaded, total: s.progress.totalBytes } : undefined}
+ downloadBytes={s.progress && !s.hasFailed ? {
+ downloaded: s.progress.bytesDownloaded,
+ total: s.progress.totalBytes,
+ bytesPerSecond: s.progress.bytesPerSecond,
+ } : undefined}
isRepairingVision={s.repairingVision}
isCompatible={!fileExceedsBudget(item.size, ramGB)} testID={`file-card-${index}`}
onDownload={onDownload}
diff --git a/src/screens/ModelsScreen/TranscriptionModelsTab.tsx b/src/screens/ModelsScreen/TranscriptionModelsTab.tsx
index 6b775e819..538c4deb4 100644
--- a/src/screens/ModelsScreen/TranscriptionModelsTab.tsx
+++ b/src/screens/ModelsScreen/TranscriptionModelsTab.tsx
@@ -15,6 +15,7 @@ import { View, Text, ScrollView, TouchableOpacity } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import Icon from 'react-native-vector-icons/Feather';
import { ModelCard } from '../../components';
+import { TranscriptionLanguageSelect } from '../../components/TranscriptionLanguageSelect';
import { CustomAlert, showAlert, hideAlert, AlertState, initialAlertState } from '../../components/CustomAlert';
import { useTheme, useThemedStyles } from '../../theme';
import type { ThemeColors, ThemeShadows } from '../../theme';
@@ -38,21 +39,22 @@ interface WhisperCardProps {
downloading: boolean;
queued: boolean;
downloadProgress: number;
+ downloadBytes?: { downloaded: number; total: number; bytesPerSecond?: number };
onDownload: (id: string) => void;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
}
const WhisperCard: React.FC = ({
- model, index, downloadedModelId, presentModelIds, downloading, queued, downloadProgress, onDownload, onSelect, onDelete,
+ model, index, downloadedModelId, presentModelIds, downloading, queued, downloadProgress, downloadBytes, onDownload, onSelect, onDelete,
}) => {
const present = presentModelIds.includes(model.id);
const active = downloadedModelId === model.id;
// WHISPER_MODELS sizes are in MB. Surface bytes so the STT card matches the
// Text/Image cards ("X MB / Y MB"); for a queued model this reads "0 B / 142 MB".
const totalBytes = model.size * 1024 * 1024;
- const downloadBytes = (downloading || queued)
- ? { downloaded: Math.round(downloadProgress * totalBytes), total: totalBytes }
+ const visibleDownloadBytes = (downloading || queued)
+ ? (downloadBytes ?? { downloaded: Math.round(downloadProgress * totalBytes), total: totalBytes })
: undefined;
return (
= ({
isDownloading={downloading}
isQueued={queued}
downloadProgress={downloadProgress}
- downloadBytes={downloadBytes}
+ downloadBytes={visibleDownloadBytes}
testID={`transcription-model-card-${index}`}
// Present but not active → tap to use; not present → tap to download.
onPress={downloading ? undefined : (present ? (active ? undefined : () => onSelect(model.id)) : () => onDownload(model.id))}
@@ -138,6 +140,11 @@ export const TranscriptionModelsTab: React.FC = () => {
downloading={state?.downloading ?? false}
queued={state?.queued ?? false}
downloadProgress={state?.progress ?? 0}
+ downloadBytes={state?.totalBytes ? {
+ downloaded: state.currentBytes ?? 0,
+ total: state.totalBytes,
+ bytesPerSecond: state.bytesPerSecond,
+ } : undefined}
onDownload={handleDownload}
onSelect={handleSelect}
onDelete={handleDelete}
@@ -158,6 +165,8 @@ export const TranscriptionModelsTab: React.FC = () => {
)}
+
+
English only
{ENGLISH_MODELS.map((m, i) => renderWhisperCard(m, i))}
diff --git a/src/screens/ProDetailScreen/ProManageSection.tsx b/src/screens/ProDetailScreen/ProManageSection.tsx
index 8ab4526bf..57b8a6dae 100644
--- a/src/screens/ProDetailScreen/ProManageSection.tsx
+++ b/src/screens/ProDetailScreen/ProManageSection.tsx
@@ -2,11 +2,11 @@
* ProManageSection
*
* Shown on the Pro screen when a protected credential is saved. Surfaces
- * subscription status from the cached license (lifetime vs yearly + expiry).
+ * subscription status from the cached license tier and expiry.
* Active licensed
* devices are managed from the Pro-owned Sync screen, so there is one list and
* one action owner rather than a second read-only copy here.
- * For a recurring (yearly) license it explains how to cancel or update payment:
+ * For a recurring license it explains how to cancel or update payment:
* via the link RevenueCat emails with every purchase and renewal. There is no
* in-app portal because RevenueCat authenticates Web Billing customers by email.
*/
@@ -110,7 +110,7 @@ export const ProManageSection: React.FC = () => {
if (!repainted) {
Alert.alert(
'Pro was reset',
- 'This phone no longer holds the licence. Restart the app to finish unloading Pro features.',
+ 'This phone no longer holds the licence. Pro features are now locked.',
);
}
})
diff --git a/src/screens/ProDetailScreen/ProUnlockModal.tsx b/src/screens/ProDetailScreen/ProUnlockModal.tsx
index e6308200b..529851097 100644
--- a/src/screens/ProDetailScreen/ProUnlockModal.tsx
+++ b/src/screens/ProDetailScreen/ProUnlockModal.tsx
@@ -1,16 +1,5 @@
import React, { useState, useEffect } from 'react';
-import {
- View,
- Text,
- TouchableOpacity,
- Modal,
- TextInput,
- Linking,
- KeyboardAvoidingView,
- Platform,
- Keyboard,
- TouchableWithoutFeedback,
-} from 'react-native';
+import { View, Text, TouchableOpacity, TextInput, Linking } from 'react-native';
import Icon from 'react-native-vector-icons/Feather';
import {
projectPersonalMeshActivationFailure,
@@ -24,6 +13,7 @@ import {
PRO_PAY_PAGE_URL,
} from '../../services/proLicenseService';
import { withUtm } from '../../utils/utm';
+import { AppSheet } from '../../components/AppSheet';
type ErrorMsg = Pick<
PersonalMeshActivationFailureProjection,
@@ -36,7 +26,7 @@ type Props = {
onUnlocked: () => void;
};
-// Activation modal: the user pastes the license key from their email and we
+// Activation sheet: the user pastes the license key from their email and we
// activate it on this device. Paying is a separate path — "Get Pro" opens the
// web pay page; the buyer is then emailed a key to paste here.
export const ProUnlockModal: React.FC = ({
@@ -115,23 +105,24 @@ export const ProUnlockModal: React.FC = ({
});
};
- if (success) {
- return (
-
-
-
+ const hasInput = licenseKey.trim().length > 0;
+
+ return (
+
+
+ {success ? (
+ <>
- Pro activated
-
- You're all set. Pro is active on this device.
-
+ Pro is active on this device.
= ({
>
Got it
-
-
-
- );
- }
-
- const hasInput = licenseKey.trim().length > 0;
-
- return (
-
-
- {/* Tap the dimmed area to dismiss the keyboard */}
-
-
-
-
- {/* Close X */}
-
-
-
+ >
+ ) : (
+ <>
+
+ Paste the license key from your email. It works on up to 5
+ devices.
+
- {/* Header */}
- Enter your license key
-
- Paste the license key from your email. It works on up to 5 devices.
-
+ {/* License key input */}
+ {
+ setLicenseKey(t);
+ clearError();
+ }}
+ editable={!loading}
+ testID="license-key-input"
+ />
- {/* License key input */}
- {
- setLicenseKey(t);
- clearError();
- }}
- editable={!loading}
- testID="license-key-input"
- />
+ {/* Inline error */}
+ {error ? (
+
+ {error.title}
+ {error.description}
+
+ ) : null}
- {/* Inline error */}
- {error ? (
-
- {error.title}
- {error.description}
-
- ) : null}
-
- {/* Primary CTA */}
-
-
- {loading ? 'Activating...' : 'Activate'}
-
-
+ {/* Primary CTA */}
+
+
+ {loading ? 'Activating...' : 'Activate'}
+
+
- {/* Footer — not a member yet, go to the pay page */}
-
- Not a member yet? Get Pro
-
-
-
-
-
+ {/* Footer — not a member yet, go to the pay page */}
+
+ Not a member yet? Get Pro
+
+
+ >
+ )}
+
+
);
};
-const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
- overlay: {
- flex: 1,
- backgroundColor: 'rgba(0,0,0,0.6)',
- justifyContent: 'center' as const,
+const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
+ content: {
paddingHorizontal: SPACING.xl,
- },
- dismissArea: {
- position: 'absolute' as const,
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- },
- card: {
- backgroundColor: colors.surface,
- borderRadius: 20,
- borderWidth: 1,
- borderColor: colors.border,
- paddingHorizontal: SPACING.xl,
- paddingTop: SPACING.md,
+ paddingTop: SPACING.lg,
paddingBottom: SPACING.xl,
- ...shadows.small,
- },
-
- closeBtn: {
- alignSelf: 'flex-end' as const,
- padding: SPACING.sm,
- marginBottom: SPACING.xs,
- },
-
- title: {
- ...TYPOGRAPHY.h2,
- color: colors.text,
- marginBottom: SPACING.xs,
},
subtitle: {
...TYPOGRAPHY.bodySmall,
@@ -353,12 +287,6 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
alignSelf: 'center' as const,
marginBottom: SPACING.lg,
},
- successTitle: {
- ...TYPOGRAPHY.h2,
- color: colors.text,
- textAlign: 'center' as const,
- marginBottom: SPACING.sm,
- },
successSub: {
...TYPOGRAPHY.body,
color: colors.textSecondary,
diff --git a/src/screens/ProDetailScreen/index.tsx b/src/screens/ProDetailScreen/index.tsx
index acf2d6b9e..833f17db1 100644
--- a/src/screens/ProDetailScreen/index.tsx
+++ b/src/screens/ProDetailScreen/index.tsx
@@ -29,7 +29,6 @@ import { ScreenHeader } from '../../components/ScreenHeader';
import { ProManageSection } from './ProManageSection';
import { ProIncludedSection } from './ProIncludedSection';
import { ProUnlockModal } from './ProUnlockModal';
-import { useHasRegisteredScreen } from '../../navigation/screenRegistry';
import type { RootStackParamList } from '../../navigation/types';
// Off Grid AI Pro is the ambient intelligence layer across desktop + phone, not a
@@ -70,7 +69,6 @@ export const ProDetailScreen: React.FC = () => {
const hasSavedProCredential = useAppStore(s => s.hasSavedProCredential);
const isProActive = useAppStore(s => s.isProActive);
const hasProAccess = useAppStore(selectHasProAccess);
- const hasSyncBootstrap = useHasRegisteredScreen('Sync');
const [verifyModalVisible, setVerifyModalVisible] = useState(false);
const pricing = getPricingCopy();
const isDevelopmentAccess = __DEV__ && isProActive && !hasSavedProCredential;
@@ -217,14 +215,6 @@ export const ProDetailScreen: React.FC = () => {
onPress={openVerifyModal}
style={styles.verifyButton}
/>
- {hasSyncBootstrap ? (
-