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
154 changes: 153 additions & 1 deletion apps/mobile/src/components/agents/attachment-picker.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/* eslint-disable max-lines -- the source-sheet suite and the picture-entry suite share one module mock harness. */
import { type ActionSheetProps } from '@expo/react-native-action-sheet';
import * as DocumentPicker from 'expo-document-picker';
import * as ImagePicker from 'expo-image-picker';
import * as SecureStore from 'expo-secure-store';
import * as Sentry from '@sentry/react-native';
import { describe, expect, it, vi } from 'vitest';

import { normalizeImageAsset, pickAgentAttachments } from './attachment-picker';
import { normalizeImageAsset, pickAgentAttachments, pickAgentPicture } from './attachment-picker';

const reactNativeMock = vi.hoisted(() => ({
alert: vi.fn(),
Expand Down Expand Up @@ -62,6 +63,7 @@ vi.mock('expo-document-picker', () => ({
vi.mock('expo-image-picker', () => ({
launchCameraAsync: vi.fn(),
launchImageLibraryAsync: vi.fn(),
PermissionStatus: { GRANTED: 'granted' },
requestCameraPermissionsAsync: vi.fn(),
}));

Expand Down Expand Up @@ -102,6 +104,33 @@ async function pickWithSheetSelection(
return resultPromise;
}

/** The same driver for the picture entry point (its own surface/session). */
async function pickPictureWithSheetSelection(
buttonIndex: number
): Promise<Awaited<ReturnType<typeof pickAgentPicture>>> {
const showActionSheet = vi.fn() as unknown as ShowActionSheet & {
mock: { calls: [unknown, SheetButtonHandler][] };
};
const resultPromise = pickAgentPicture(showActionSheet, {
userId: 'user-1',
surface: 'agent-picture',
sessionId: null,
});
const registered = showActionSheet.mock.calls[0]?.[1];
expect(registered).toEqual(expect.any(Function));
await Promise.resolve(registered?.(buttonIndex));
return resultPromise;
}

function grantedCameraPermission(): ImagePicker.PermissionResponse {
return {
canAskAgain: true,
expires: 'never',
granted: true,
status: ImagePicker.PermissionStatus.GRANTED,
};
}

describe('normalizeImageAsset', () => {
it('keeps the picker fileName when present', () => {
expect(
Expand Down Expand Up @@ -187,6 +216,14 @@ describe('agent attachment picker', () => {
);
});

it('resolves empty when the library picker invocation throws', async () => {
vi.mocked(ImagePicker.launchImageLibraryAsync).mockImplementationOnce(() => {
throw new Error('native launch threw');
});

expect(await pickWithSheetSelection(1)).toEqual([]);
});

it('launches the picker when the launch context write fails', async () => {
vi.mocked(SecureStore.setItemAsync).mockRejectedValueOnce(new Error('store write failed'));
const result: Awaited<ReturnType<typeof ImagePicker.launchImageLibraryAsync>> = {
Expand Down Expand Up @@ -275,3 +312,118 @@ describe('agent attachment picker (document MIME derivation)', () => {
expect(await pickWithSheetSelection(2)).toEqual([]);
});
});

describe('agent picture picker', () => {
it('offers only the camera and a single-select photo library, with cancel last', () => {
const showActionSheet = vi.fn() as unknown as ShowActionSheet & {
mock: { calls: unknown[][] };
};

void pickAgentPicture(showActionSheet, {
userId: 'user-1',
surface: 'agent-picture',
sessionId: null,
});

expect(showActionSheet).toHaveBeenCalledWith(
{
options: ['Camera', 'Photo Library', 'Cancel'],
cancelButtonIndex: 2,
},
expect.any(Function)
);
});

it('launches the camera through the permission gate on the first option', async () => {
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValueOnce(
grantedCameraPermission()
);
const result: Awaited<ReturnType<typeof ImagePicker.launchCameraAsync>> = {
canceled: false,
assets: [{ uri: 'file:///cache/camera.jpg', width: 100, height: 100 }],
};
vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValueOnce(result);

const candidates = await pickPictureWithSheetSelection(0);

expect(ImagePicker.requestCameraPermissionsAsync).toHaveBeenCalledTimes(1);
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledTimes(1);
expect(candidates.map(candidate => candidate.uri)).toEqual(['file:///cache/camera.jpg']);
});

it('launches the photo library single-select on the second option', async () => {
const result: Awaited<ReturnType<typeof ImagePicker.launchImageLibraryAsync>> = {
canceled: false,
assets: [{ uri: 'file:///cache/screenshot.png', width: 100, height: 100 }],
};
vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValueOnce(result);

const candidates = await pickPictureWithSheetSelection(1);

expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith(
expect.objectContaining({ allowsMultipleSelection: false })
);
expect(candidates.map(candidate => candidate.uri)).toEqual(['file:///cache/screenshot.png']);
});

it('records the picture surface so Android recovery can match the result', async () => {
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValueOnce(
grantedCameraPermission()
);
const result: Awaited<ReturnType<typeof ImagePicker.launchCameraAsync>> = {
canceled: true,
assets: null,
};
vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValueOnce(result);

expect(await pickPictureWithSheetSelection(0)).toEqual([]);

const stored = vi.mocked(SecureStore.setItemAsync).mock.calls.at(-1)?.[1] ?? '';
expect(JSON.parse(stored)).toMatchObject({
userId: 'user-1',
surface: 'agent-picture',
sessionId: null,
});
});

// A rejected permission request, or a synchronous throw while building the
// native launch promise, must settle the public helper instead of leaving it
// pending forever with the person stuck on Home.
it('resolves empty when the camera permission request rejects', async () => {
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockRejectedValueOnce(
new Error('permission module unavailable')
);

expect(await pickPictureWithSheetSelection(0)).toEqual([]);
});

it('resolves empty and reports when the camera launch throws synchronously', async () => {
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValueOnce(
grantedCameraPermission()
);
vi.mocked(ImagePicker.launchCameraAsync).mockImplementationOnce(() => {
throw new Error('native launch threw');
});
vi.mocked(Sentry.captureException).mockClear();

expect(await pickPictureWithSheetSelection(0)).toEqual([]);

expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error), {
tags: {
'error.subsystem': 'agent-attachments',
'error.operation': 'pick-attachment-source',
},
extra: { source: 'camera', surface: 'agent-picture', hasSession: false },
});
});

it('resolves empty on cancel without launching a picker', async () => {
const cameraCalls = vi.mocked(ImagePicker.launchCameraAsync).mock.calls.length;
const libraryCalls = vi.mocked(ImagePicker.launchImageLibraryAsync).mock.calls.length;

expect(await pickPictureWithSheetSelection(2)).toEqual([]);

expect(vi.mocked(ImagePicker.launchCameraAsync).mock.calls.length).toBe(cameraCalls);
expect(vi.mocked(ImagePicker.launchImageLibraryAsync).mock.calls.length).toBe(libraryCalls);
});
});
135 changes: 103 additions & 32 deletions apps/mobile/src/components/agents/attachment-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { i18n } from '@/i18n';
import { AGENT_ATTACHMENT_EXTENSION_REGEX } from '@/lib/agent-attachments/constants';
import { mimeForExtension, normalizeAttachmentExtension } from '@/lib/agent-attachments/validate';
import { IMAGE_PICKER_OPTIONS, launchImagePicker } from '@/lib/agent-attachments/image-picker';
import { writePickerLaunchContext } from '@/lib/agent-attachments/picker-launch-context';
import {
type AttachmentSurface,
writePickerLaunchContext,
} from '@/lib/agent-attachments/picker-launch-context';
import { type AgentAttachmentCandidate } from '@/lib/agent-attachments/use-agent-attachment-upload';
import { registerTempFile } from '@/lib/temp-file-registry';

Expand Down Expand Up @@ -98,11 +101,13 @@ async function pickAgentCameraImage(): Promise<AgentAttachmentCandidate[]> {
return candidates;
}

async function pickAgentLibraryImages(): Promise<AgentAttachmentCandidate[]> {
async function pickAgentLibraryImages(
allowsMultipleSelection: boolean
): Promise<AgentAttachmentCandidate[]> {
const assets = await launchImagePicker(
ImagePicker.launchImageLibraryAsync({
...IMAGE_PICKER_OPTIONS,
allowsMultipleSelection: true,
allowsMultipleSelection,
})
);
const candidates = assets.map(asset => normalizeImageAsset(asset));
Expand Down Expand Up @@ -130,33 +135,58 @@ async function pickAgentDocuments(): Promise<AgentAttachmentCandidate[]> {

type AttachmentSource = 'camera' | 'library' | 'files';

function buildAttachmentSourceOptions(): string[] {
return [
i18n.t('agentChat.attachmentPicker.camera'),
i18n.t('agentChat.attachmentPicker.photoLibrary'),
i18n.t('agentChat.attachmentPicker.files'),
i18n.t('common.cancel'),
];
function attachmentSourceLabel(source: AttachmentSource): string {
if (source === 'camera') {
return i18n.t('agentChat.attachmentPicker.camera');
}
if (source === 'library') {
return i18n.t('agentChat.attachmentPicker.photoLibrary');
}
return i18n.t('agentChat.attachmentPicker.files');
}

/** Sheet option labels for `sources`, with Cancel always appended last. */
function buildAttachmentSourceOptions(sources: readonly AttachmentSource[]): string[] {
return [...sources.map(source => attachmentSourceLabel(source)), i18n.t('common.cancel')];
}

async function pickFromSource(source: AttachmentSource): Promise<AgentAttachmentCandidate[]> {
async function pickFromSource(
source: AttachmentSource,
libraryMultipleSelection: boolean
): Promise<AgentAttachmentCandidate[]> {
if (source === 'camera') {
return pickAgentCameraImage();
}
if (source === 'library') {
return pickAgentLibraryImages();
return pickAgentLibraryImages(libraryMultipleSelection);
}
return pickAgentDocuments();
}

export function pickAgentAttachments(
type AttachmentPickerContext = {
userId: string | undefined;
surface: AttachmentSurface;
sessionId: string | null;
};

type AttachmentSourceSheet = {
sources: readonly AttachmentSource[];
libraryMultipleSelection: boolean;
};

/**
* Show the source sheet and resolve the picked candidates. `sources` drives
* both the option list (Cancel is appended last) and the button-index mapping:
* the cancel button, or any index with no source, resolves with no candidates.
* Identical for every entry point apart from its sources and the library's
* multi-select flag.
*/
function showAttachmentSourceSheet(
showActionSheetWithOptions: ActionSheetProps['showActionSheetWithOptions'],
context: {
userId: string | undefined;
surface: 'agent-new' | 'agent-chat';
sessionId: string | null;
}
context: AttachmentPickerContext,
sheet: AttachmentSourceSheet
): Promise<AgentAttachmentCandidate[]> {
const { sources, libraryMultipleSelection } = sheet;
return new Promise(resolve => {
let settled = false;
const settle = (value: AgentAttachmentCandidate[]) => {
Expand All @@ -166,10 +196,6 @@ export function pickAgentAttachments(
}
};
const handle = async (source: AttachmentSource) => {
// Record the launching composer + account before the camera/library
// launch so the recovery hook can match a pending result after an
// Activity recreation. NOT before the Files branch, which uses
// `startActivityForResult` and is unaffected by that bug.
// Record the launching composer + account before the camera/library
// launch so the recovery hook can match a pending result after an
// Activity recreation. NOT before the Files branch, which uses
Expand All @@ -196,26 +222,71 @@ export function pickAgentAttachments(
});
}
}
const result = await pickFromSource(source);
settle(result);
try {
settle(await pickFromSource(source, libraryMultipleSelection));
} catch (error) {
// A rejected `requestCameraPermissionsAsync` (or any other failure
// inside the source helper, e.g. a synchronous throw while building
// the native launch promise) must still settle the public helper.
// `handle` runs detached, so an uncaught rejection would hang the
// caller's `await` forever; no candidates means "nothing picked", so
// the caller stays where it is and can tap again.
Sentry.captureException(error, {
tags: {
'error.subsystem': 'agent-attachments',
'error.operation': 'pick-attachment-source',
},
extra: { source, surface: context.surface, hasSession: context.sessionId !== null },
});
settle([]);
}
};
const options = buildAttachmentSourceOptions();
const options = buildAttachmentSourceOptions(sources);
showActionSheetWithOptions(
{
options,
cancelButtonIndex: options.length - 1,
},
index => {
if (index === 0) {
void handle('camera');
} else if (index === 1) {
void handle('library');
} else if (index === 2) {
void handle('files');
} else {
// The sheet's button index is optional by its own types; the cancel
// button is the label appended after the last source.
const source = index === undefined ? undefined : sources[index];
if (source === undefined) {
settle([]);
return;
}
void handle(source);
}
);
});
}

/**
* Composer entry point: Camera, Photo Library, and Files, with a multi-select
* library. Resolves with no candidates on cancel, denied permission, or a
* failed launch.
*/
export function pickAgentAttachments(
showActionSheetWithOptions: ActionSheetProps['showActionSheetWithOptions'],
context: AttachmentPickerContext
): Promise<AgentAttachmentCandidate[]> {
return showAttachmentSourceSheet(showActionSheetWithOptions, context, {
sources: ['camera', 'library', 'files'],
libraryMultipleSelection: true,
});
}

/**
* Picture entry point: Camera and the single-select Photo Library only, so one
* tap on a screenshot returns it. Resolves with no candidates on cancel,
* denied permission, or a failed launch — the caller stays where it is.
*/
export function pickAgentPicture(
showActionSheetWithOptions: ActionSheetProps['showActionSheetWithOptions'],
context: AttachmentPickerContext
): Promise<AgentAttachmentCandidate[]> {
return showAttachmentSourceSheet(showActionSheetWithOptions, context, {
sources: ['camera', 'library'],
libraryMultipleSelection: false,
});
}
Loading