Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions apps/mobile/src/components/consent/consent-details.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const useCurrentUserIdMock = vi.hoisted(() => ({
email: 'a@b.c',
isLoading: false,
isError: false,
isFetchError: false,
refetch: vi.fn(),
})),
}));
Expand Down Expand Up @@ -83,10 +84,29 @@ const voiceNetworkConsentMock = vi.hoisted(() => ({

vi.mock('@/lib/voice-input/voice-network-consent', () => voiceNetworkConsentMock);

// The real preference module reads expo-secure-store at import time; the row
// only needs the hook's three values.
const gatewayTranscriptionPreferenceMock = vi.hoisted(() => ({
gatewayTranscriptionEnabled: false,
hasLoaded: true,
setGatewayTranscriptionEnabled: vi.fn(),
}));

vi.mock('@/lib/voice-input/gateway/gateway-transcription-preference', () => ({
useGatewayTranscriptionPreference: () => ({
gatewayTranscriptionEnabled: gatewayTranscriptionPreferenceMock.gatewayTranscriptionEnabled,
hasLoaded: gatewayTranscriptionPreferenceMock.hasLoaded,
setGatewayTranscriptionEnabled:
gatewayTranscriptionPreferenceMock.setGatewayTranscriptionEnabled,
}),
}));

// ---- helpers ----

type SectionProps = {
title?: string;
what?: string;
who?: string;
footer?: React.ReactElement;
};

Expand Down Expand Up @@ -255,18 +275,37 @@ describe('ConsentDetails copy', () => {
)
).toBe(true);
});

it('names the gateway voice-transcription destination and what the gateway carries', () => {
const renderer = mount();
const sections = findAllSectionProps(renderer.root);
const voiceTranscription = sections.find(s => s.title === 'Voice transcription');
const gateway = sections.find(s => s.title === 'Kilo Gateway (our backend)');
expect(voiceTranscription).toBeDefined();
expect(gateway).toBeDefined();

expect(voiceTranscription?.who).toBe(
'Your device, the Kilo gateway with your chosen transcription model, or Apple or Google for online recognition.'
);
expect(gateway?.what).toBe(
'Account ID, request metadata, token usage, and the prompts and voice recordings the app sends through it.'
);
});
});

describe('Voice transcription section', () => {
beforeEach(() => {
vi.clearAllMocks();
voiceInputControllerMock.supportsOnDevice.mockReturnValue(true);
voiceNetworkConsentMock.readVoiceNetworkConsent.mockResolvedValue('unset');
gatewayTranscriptionPreferenceMock.gatewayTranscriptionEnabled = false;
gatewayTranscriptionPreferenceMock.hasLoaded = true;
useCurrentUserIdMock.useCurrentUserId.mockReturnValue({
userId: 'u1',
email: 'a@b.c',
isLoading: false,
isError: false,
isFetchError: false,
refetch: vi.fn(),
});
});
Expand Down Expand Up @@ -294,6 +333,31 @@ describe('Voice transcription section', () => {
expect(findSwitches(renderer.root).length).toBe(0);
});

it('names the Kilo gateway and drops the On device claim when gateway transcription is on', () => {
voiceInputControllerMock.supportsOnDevice.mockReturnValue(true);
gatewayTranscriptionPreferenceMock.gatewayTranscriptionEnabled = true;
const renderer = mountVoiceControl();

const strings = findTextStrings(renderer.root);
// Regression: the row printed "On device" from supportsOnDevice() while
// the gateway engine was the one that actually ran.
expect(strings).toContain('Via the Kilo gateway');
expect(strings).not.toContain('On device');
expect(findSwitches(renderer.root).length).toBe(0);
});

it('shows the loading placeholder while the gateway preference is read', () => {
voiceInputControllerMock.supportsOnDevice.mockReturnValue(true);
gatewayTranscriptionPreferenceMock.hasLoaded = false;
const renderer = mountVoiceControl();

const texts = findTextStrings(renderer.root);
expect(texts).toContain('Loading…');
expect(texts).not.toContain('On device');
expect(texts).not.toContain('Via the Kilo gateway');
expect(findSwitches(renderer.root).length).toBe(0);
});

it('shows Online, allowed with the switch on when consent is granted', async () => {
voiceInputControllerMock.supportsOnDevice.mockReturnValue(false);
voiceNetworkConsentMock.readVoiceNetworkConsent.mockResolvedValue('granted');
Expand Down Expand Up @@ -366,6 +430,7 @@ describe('Voice transcription section', () => {
email: 'a@b.c',
isLoading: false,
isError: false,
isFetchError: false,
refetch: vi.fn(),
});
const renderer = mountVoiceControl();
Expand All @@ -382,6 +447,7 @@ describe('Voice transcription section', () => {
email: 'a@b.c',
isLoading: true,
isError: false,
isFetchError: false,
refetch: vi.fn(),
});
const renderer = mountVoiceControl();
Expand All @@ -400,6 +466,7 @@ describe('Voice transcription section', () => {
email: 'a@b.c',
isLoading: false,
isError: true,
isFetchError: true,
refetch,
});
const renderer = mountVoiceControl();
Expand All @@ -422,4 +489,97 @@ describe('Voice transcription section', () => {
});
expect(refetch).toHaveBeenCalled();
});

it('shows the retry CTA instead of the on-device claim when loading the user fails', () => {
// The device's on-device support is not a substitute for the account read:
// with the account unreachable the row must not claim "On device", it must
// surface the retryable error and its Retry (vr1 e1 device repro: the
// on-device branch shadowed this one).
voiceInputControllerMock.supportsOnDevice.mockReturnValue(true);
const refetch = vi.fn();
useCurrentUserIdMock.useCurrentUserId.mockReturnValue({
userId: undefined,
email: 'a@b.c',
isLoading: false,
isError: true,
isFetchError: true,
refetch,
});
const renderer = mountVoiceControl();

const texts = findTextStrings(renderer.root);
expect(texts).toContain('Could not load your transcription setting.');
expect(texts).not.toContain('On device');
expect(findSwitches(renderer.root).length).toBe(0);

const buttons = renderer.root.findAll(
n => typeof n.type === 'string' && (n.type as string) === 'Button'
);
expect(buttons.length).toBe(1);
const button = buttons[0];
if (!button) {
throw new Error('expected a Retry Button');
}
TestRenderer.act(() => {
(button.props.onPress as () => void)();
});
expect(refetch).toHaveBeenCalled();
});

it('shows the loading placeholder instead of the on-device claim while the user loads', () => {
// Same precedence while the account read is still in flight: the row waits
// for it before claiming an on-device destination.
voiceInputControllerMock.supportsOnDevice.mockReturnValue(true);
useCurrentUserIdMock.useCurrentUserId.mockReturnValue({
userId: undefined,
email: 'a@b.c',
isLoading: true,
isError: false,
isFetchError: false,
refetch: vi.fn(),
});
const renderer = mountVoiceControl();

const texts = findTextStrings(renderer.root);
expect(texts).toContain('Loading…');
expect(texts).not.toContain('On device');
expect(findSwitches(renderer.root).length).toBe(0);
});

it('shows the retry CTA instead of a cached on-device claim when the account refetch fails', () => {
// Offline with a warm read cache: the identity is still cached, so the
// hook's `isError` stays false, but the latest account fetch failed. The
// row must not present the cached destination as current — it shows the
// retryable error and its Retry (vr1 e1 device repro: the API killed after
// the state was restored left the row on "On device").
voiceInputControllerMock.supportsOnDevice.mockReturnValue(true);
const refetch = vi.fn();
useCurrentUserIdMock.useCurrentUserId.mockReturnValue({
userId: 'u1',
email: 'a@b.c',
isLoading: false,
isError: false,
isFetchError: true,
refetch,
});
const renderer = mountVoiceControl();

const texts = findTextStrings(renderer.root);
expect(texts).toContain('Could not load your transcription setting.');
expect(texts).not.toContain('On device');
expect(findSwitches(renderer.root).length).toBe(0);

const buttons = renderer.root.findAll(
n => typeof n.type === 'string' && (n.type as string) === 'Button'
);
expect(buttons.length).toBe(1);
const button = buttons[0];
if (!button) {
throw new Error('expected a Retry Button');
}
TestRenderer.act(() => {
(button.props.onPress as () => void)();
});
expect(refetch).toHaveBeenCalled();
});
});
67 changes: 49 additions & 18 deletions apps/mobile/src/components/consent/consent-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { PRIVACY_URL } from '@/lib/config';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useGatewayTranscriptionPreference } from '@/lib/voice-input/gateway/gateway-transcription-preference';
import { voiceInputController } from '@/lib/voice-input/native-voice-input';
import {
readVoiceNetworkConsent,
Expand All @@ -26,7 +27,8 @@ type ConsentDetailsProps = {
};

export function VoiceTranscriptionControl() {
const { userId, isLoading, isError, refetch } = useCurrentUserId();
const { userId, isLoading, isFetchError, refetch } = useCurrentUserId();
const { gatewayTranscriptionEnabled, hasLoaded } = useGatewayTranscriptionPreference();
const supportsOnDevice = voiceInputController.supportsOnDevice();
const [consent, setConsent] = useState<VoiceNetworkConsent>('unset');
const { t } = useTranslation();
Expand Down Expand Up @@ -55,6 +57,52 @@ export function VoiceTranscriptionControl() {
};
}, [userId]);

// The gateway preference owns the whole session while it is on, so it is
// named before any on-device claim: a device that supports on-device
// recognition still sends every dictation to the gateway when the switch is
// on (voice-input-engine-mode.ts), and printing "On device" there was wrong.
//
// The account read gates every claim the row makes, so its loading and error
// states come before the gateway and on-device branches: a device that
// supports on-device recognition used to print "On device" while the account
// read had failed, so the retryable error branch never surfaced on it (vr1
// e1 device repro, 2026-09-15). `isFetchError` (not `isError`) is the gate:
// a failed refetch keeps the cached identity, and presenting that cached
// destination as the current one is exactly the state this branch exists to
// avoid. One loading branch covers both reads so the row never stacks two
// indicators.
if (!hasLoaded || isLoading) {
// A SecureStore or account read is still pending; do not claim a state
// that has not been read yet.
return <Text className="mt-3 text-sm text-muted-foreground">{t('common.loading')}</Text>;
}

if (isFetchError) {
return (
<View className="mt-3 gap-2">
<Text className="text-sm text-muted-foreground">
{t('consent.couldNotLoadTranscription')}
</Text>
<Button variant="outline" onPress={refetch} accessibilityLabel={t('common.retry')}>
<Text>{t('common.retry')}</Text>
</Button>
</View>
);
}

if (gatewayTranscriptionEnabled) {
// Status row only: the engine is chosen in Preferences, so this row carries
// no switch — matching the on-device row below.
return (
<View className="mt-3 flex-row items-center justify-between gap-3">
<Text className="flex-1 text-sm text-muted-foreground">
{t('consent.voiceTranscriptionTitle')}
</Text>
<Text className="text-sm font-medium text-foreground">{t('consent.viaGateway')}</Text>
</View>
);
}

if (supportsOnDevice) {
// Status row in the toggle row's geometry (label left, value right) so it
// reads as the transcription state, not a stray fragment under the Who
Expand All @@ -72,23 +120,6 @@ export function VoiceTranscriptionControl() {
);
}

if (isLoading) {
return <Text className="mt-3 text-sm text-muted-foreground">{t('common.loading')}</Text>;
}

if (isError) {
return (
<View className="mt-3 gap-2">
<Text className="text-sm text-muted-foreground">
{t('consent.couldNotLoadTranscription')}
</Text>
<Button variant="outline" onPress={refetch} accessibilityLabel={t('common.retry')}>
<Text>{t('common.retry')}</Text>
</Button>
</View>
);
}

// No non-retryable unhappy state exists: `readVoiceNetworkConsent` never
// fails (an absent or corrupt value reads as 'unset'), and the control has no
// terminal failure mode — a write failure is always retryable.
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/i18n/locales/af.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions apps/mobile/src/i18n/locales/am.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions apps/mobile/src/i18n/locales/ar.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading