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 ? ( -