diff --git a/.changeset/29735-mobile-shell-files.md b/.changeset/29735-mobile-shell-files.md
new file mode 100644
index 00000000000..7ea7a4ca14f
--- /dev/null
+++ b/.changeset/29735-mobile-shell-files.md
@@ -0,0 +1,18 @@
+---
+'@object-ui/mobile': patch
+'@object-ui/fields': patch
+'@object-ui/app-shell': patch
+'@object-ui/i18n': patch
+---
+
+feat(console): photos, attachments and record sharing go through the mobile shell's `pickImage()`, `openFile()` and `share()`
+
+Clause-②: yes
+Metadata-protocol: none
+
+The ObjectStack mobile shell offers three optional bridge methods for files, each feature-detected with `has()`. The Console now calls them, and a plain browser or an older shell behaves exactly as before.
+
+- `@object-ui/mobile`: `ObjectStackNative` gains the optional `pickImage?`, `openFile?` and `share?` members, with the typed wrappers `nativePickImage`, `nativeOpenFile` and `nativeShare` (they resolve `unavailable | ok | failed` and never throw), the types `NativePickImageOptions`, `NativeFile` and `NativeShareContent`, the bridge limit `NATIVE_FILE_MAX_BYTES` (8 MiB), and two helpers: `fileFromNativeFile` (a bridge file to an uploadable `File`) and `nativeFileFromBlob` (bytes to a bridge file).
+- File and image fields: in a shell with `pickImage`, "Take photo" and "Choose photo" use the shell's camera and photo library. The picked image goes through the field's existing upload pipeline, so the size check and the stored `sys_file` reference are the same as for an `` pick. A cancel uploads nothing; a shell failure shows an error toast. New strings: `fields.file.choosePhoto` and `fields.file.photoFailed`.
+- Record attachments: in a shell with `openFile`, tapping an attachment fetches its bytes with the Console's own session and hands the bytes to the shell. A file over 8 MiB, a failed fetch, or a refusal from the shell keeps the browser behaviour. The signed download URL never crosses the bridge.
+- Record page Share: in a shell with `share`, it opens the OS share sheet with the record's page link and title. A refusal from the shell keeps today's `navigator.share` or clipboard copy.
diff --git a/ui/packages/app-shell/src/views/RecordAttachmentsPanel.tsx b/ui/packages/app-shell/src/views/RecordAttachmentsPanel.tsx
index b33baf8c8f3..477f942ab53 100644
--- a/ui/packages/app-shell/src/views/RecordAttachmentsPanel.tsx
+++ b/ui/packages/app-shell/src/views/RecordAttachmentsPanel.tsx
@@ -24,6 +24,14 @@ import { createAuthenticatedFetch } from '@object-ui/auth';
import { useObjectTranslation, isPermissionError, classifyLoadError } from '@object-ui/react';
import { usePermissions } from '@object-ui/permissions';
import { resolveAffordance, type ConfirmationHandler } from '@object-ui/core';
+import {
+ NATIVE_FILE_MAX_BYTES,
+ getNativeHost,
+ nativeFileFromBlob,
+ nativeHostCan,
+ nativeOpenFile,
+ type NativeFile,
+} from '@object-ui/mobile';
import { ActionConfirmDialog, type ConfirmDialogState } from './ActionConfirmDialog.js';
import { RECORD_PAGE_ATTACHMENT_ROWS, recordAttachmentsCountField } from './recordPagePayload.js';
@@ -127,6 +135,42 @@ function formatSize(bytes?: number | null): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
+
+/**
+ * Inside the ObjectStack mobile shell, when it announces `openFile`: fetch the
+ * attachment's bytes from the signed URL this panel already fetched with the
+ * Console's own session, and hand the BYTES to the shell, which previews or
+ * opens them with the OS (objectstack-ai/mobile `docs/HYBRID-SHELL.md`,
+ * "Files"; the shell never downloads a URL or reads the session). Answers
+ * whether the shell took the file.
+ *
+ * `false` keeps the browser path (`window.open` on the signed URL), exactly as
+ * outside the shell: no shell or an older one, a file over the bridge's
+ * `NATIVE_FILE_MAX_BYTES` (judged on the row's size before any byte is
+ * fetched, and on the fetched size), a fetch that fails (a cross-origin
+ * storage URL without CORS), and the shell refusing (`bad_args`) or failing
+ * the call. ⛔ The signed URL is a credential: only bytes cross the
+ * bridge, never the URL.
+ */
+async function openInNativeShell(target: string, row: AttachmentRow): Promise {
+ if (!nativeHostCan(getNativeHost(), 'openFile')) return false;
+ if (typeof row.size === 'number' && row.size > NATIVE_FILE_MAX_BYTES) return false;
+ let file: NativeFile;
+ try {
+ const res = await fetch(target);
+ if (!res.ok) return false;
+ let blob = await res.blob();
+ if (blob.size > NATIVE_FILE_MAX_BYTES) return false;
+ if (!blob.type && row.mime_type) blob = new Blob([blob], { type: row.mime_type });
+ file = await nativeFileFromBlob(blob, row.file_name || row.file_id);
+ } catch {
+ return false;
+ }
+ // Anything but `ok` (the shell refusing the file with `bad_args`, or failing
+ // it) takes the browser path, so a tap is never answered worse than before.
+ return (await nativeOpenFile(file)).status === 'ok';
+}
+
export const RecordAttachmentsPanel: React.FC = ({
objectName,
recordId,
@@ -465,6 +509,7 @@ export const RecordAttachmentsPanel: React.FC = ({
const url: string | undefined = body?.url ?? body?.data?.url;
if (!url) throw new Error('Download URL missing from response');
const target = /^https?:/i.test(url) ? url : `${baseUrl}${url}`;
+ if (await openInNativeShell(target, row)) return;
window.open(target, '_blank', 'noopener,noreferrer');
} catch (err: any) {
setError(friendlyError(err));
diff --git a/ui/packages/app-shell/src/views/RecordDetailView.favoritesEntry-12427.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.favoritesEntry-12427.test.tsx
index 2af9e55502c..8f34de4e2ef 100644
--- a/ui/packages/app-shell/src/views/RecordDetailView.favoritesEntry-12427.test.tsx
+++ b/ui/packages/app-shell/src/views/RecordDetailView.favoritesEntry-12427.test.tsx
@@ -210,3 +210,84 @@ describe('record page: the favorites entry is in the overflow, ahead of Share (o
);
});
});
+
+/**
+ * The overflow's Share inside the ObjectStack mobile shell, when it announces
+ * `share` (objectstack-ai/mobile `docs/HYBRID-SHELL.md`, "Files"): the OS share
+ * sheet through the shell, with the record's page link and its title. Only
+ * that record URL crosses the bridge. The control cases (a plain browser, an
+ * older shell without `share`, a shell refusing the call) keep today's
+ * path: `navigator.share` when present, else the clipboard copy.
+ */
+describe('record page: Share through the mobile shell', () => {
+ const GLOBAL = 'ObjectStackNative';
+
+ function installShell(opts: { capabilities?: string[]; refuse?: boolean } = {}) {
+ const capabilities = opts.capabilities ?? ['share', 'openFile'];
+ const share = vi.fn(async (_content: { url?: string; text?: string }) => {
+ if (opts.refuse) throw Object.assign(new Error('url must be http(s)'), { code: 'bad_args' });
+ return null;
+ });
+ const host = {
+ version: 1,
+ platform: 'ios',
+ appVersion: '2.2.0',
+ capabilities,
+ safeArea: { top: 0, bottom: 0, left: 0, right: 0 },
+ has: (c: string) => capabilities.includes(c),
+ share,
+ openFile: vi.fn(async () => null),
+ openExternal: vi.fn(async () => null),
+ ready: vi.fn(),
+ routeChanged: vi.fn(),
+ };
+ (window as unknown as Record)[GLOBAL] = host;
+ return host;
+ }
+
+ let writeText: ReturnType;
+ beforeEach(() => {
+ document.title = 'Intro call · CRM';
+ writeText = vi.fn(async () => {});
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
+ });
+ afterEach(() => {
+ delete (window as unknown as Record)[GLOBAL];
+ });
+
+ async function chooseShare() {
+ await mountRecordPage();
+ await openOverflow();
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Share' }));
+ }
+
+ it('opens the shell share sheet with the record URL and title, and copies nothing', async () => {
+ const host = installShell();
+ await chooseShare();
+ await waitFor(() => expect(host.share).toHaveBeenCalledTimes(1));
+ // Exactly the record's page link and its title: nothing else crosses the bridge.
+ expect(host.share).toHaveBeenCalledWith({ url: window.location.href, text: 'Intro call · CRM' });
+ expect(host.openFile).not.toHaveBeenCalled();
+ expect(host.openExternal).not.toHaveBeenCalled();
+ expect(writeText).not.toHaveBeenCalled();
+ });
+
+ it('a shell refusing the call keeps the browser path', async () => {
+ const host = installShell({ refuse: true });
+ await chooseShare();
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith(window.location.href));
+ expect(host.share).toHaveBeenCalledTimes(1);
+ });
+
+ it('CONTROL: an older shell without share copies the link as today', async () => {
+ const host = installShell({ capabilities: ['openFile'] });
+ await chooseShare();
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith(window.location.href));
+ expect(host.share).not.toHaveBeenCalled();
+ });
+
+ it('CONTROL: a plain browser copies the link as today', async () => {
+ await chooseShare();
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith(window.location.href));
+ });
+});
diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx
index a1d5aa9a326..db5d4e9edcd 100644
--- a/ui/packages/app-shell/src/views/RecordDetailView.tsx
+++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx
@@ -92,6 +92,7 @@ import {
useWorkspacePreference,
} from '../utils/workspacePreference.js';
import { shortcutKeycaps, useAdvertiseShortcut, type AdvertisedShortcut, type ShortcutChord, type ShortcutLabelTranslate } from '../chrome/advertisedShortcuts.js';
+import { nativeShare } from '@object-ui/mobile';
import { useActionModal } from '../hooks/useActionModal.js';
import { resolveCascadeDeleteLabels } from '../hooks/useObjectActions.js';
import { useRecentItems } from '../hooks/useRecentItems.js';
@@ -4482,6 +4483,17 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
order: 110,
component: 'action:menu',
onClick: async () => {
+ // Inside the ObjectStack mobile shell, when it announces `share`: the
+ // OS share sheet through the shell, with this record's page link and
+ // its title (objectstack-ai/mobile `docs/HYBRID-SHELL.md`, "Files").
+ // Only the record URL crosses the bridge. A shell that refuses or
+ // fails the call, an older shell and a plain browser take the path
+ // below, unchanged.
+ const shared = await nativeShare({
+ url: window.location.href,
+ ...(document.title ? { text: document.title } : {}),
+ });
+ if (shared.status === 'ok') return;
try {
if ((navigator as any).share) {
await (navigator as any).share({
diff --git a/ui/packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx b/ui/packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx
index 4a76e4ddfa7..b57e023de4c 100644
--- a/ui/packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx
+++ b/ui/packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx
@@ -1,6 +1,6 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RecordAttachmentsPanel } from '../RecordAttachmentsPanel';
@@ -712,3 +712,137 @@ describe('RecordAttachmentsPanel — authenticated signed-URL download (#2970)',
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
+
+/**
+ * Inside the ObjectStack mobile shell, when it announces `openFile`
+ * (objectstack-ai/mobile `docs/HYBRID-SHELL.md`, "Files"): the row tap
+ * fetches the signed URL with the Console's session as before, then fetches
+ * the BYTES and hands them to the shell's `openFile`. Over the bridge's
+ * 8 MiB, a failed byte fetch or a `bad_args` refusal keeps the browser path.
+ * ⛔ The signed URL is a credential, so it never crosses the bridge.
+ */
+describe('RecordAttachmentsPanel — mobile shell openFile', () => {
+ const GLOBAL = 'ObjectStackNative';
+ const SIGNED = '/api/v1/storage/_local/raw/tok123';
+
+ function installShell(opts: { capabilities?: string[]; refuse?: boolean } = {}) {
+ const capabilities = opts.capabilities ?? ['openFile', 'share'];
+ const openFile = vi.fn(async (_file: { base64: string; name: string; mime: string }) => {
+ if (opts.refuse) throw Object.assign(new Error('Files cross the bridge up to 8 MB'), { code: 'bad_args' });
+ return null;
+ });
+ const host = {
+ version: 1,
+ platform: 'ios',
+ appVersion: '2.2.0',
+ capabilities,
+ safeArea: { top: 0, bottom: 0, left: 0, right: 0 },
+ has: (c: string) => capabilities.includes(c),
+ openFile,
+ openExternal: vi.fn(async () => null),
+ share: vi.fn(async () => null),
+ ready: vi.fn(),
+ routeChanged: vi.fn(),
+ };
+ (window as unknown as Record)[GLOBAL] = host;
+ return host;
+ }
+
+ /** `/files/:id/url` answers the signed URL; the signed URL answers `bytes`. */
+ function mockStorage(bytes: BlobPart = 'hello', type = 'application/pdf', bytesStatus = 200) {
+ return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
+ const url = String(input instanceof Request ? input.url : input);
+ if (url.includes('/api/v1/storage/files/f1/url')) {
+ return new Response(JSON.stringify({ success: true, data: { url: SIGNED } }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (url.endsWith(SIGNED)) return new Response(new Blob([bytes], { type }), { status: bytesStatus });
+ throw new Error(`unexpected fetch ${url}`);
+ });
+ }
+
+ async function tapDownload(rows: unknown[] = [ROW]) {
+ setup(makeDataSource({ find: vi.fn(async () => rows) }));
+ await waitFor(() => expect(screen.getByText('report.pdf')).toBeInTheDocument());
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Download' }));
+ }
+
+ afterEach(() => {
+ delete (window as unknown as Record)[GLOBAL];
+ });
+
+ it('hands the fetched bytes to openFile, never the URL, and opens no tab', async () => {
+ const host = installShell();
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
+ const fetchSpy = mockStorage();
+ await tapDownload();
+
+ await waitFor(() => expect(host.openFile).toHaveBeenCalledTimes(1));
+ // "hello" in standard padded base64; the bridge file has exactly these keys.
+ expect(host.openFile.mock.calls[0][0]).toEqual({ base64: 'aGVsbG8=', name: 'report.pdf', mime: 'application/pdf' });
+ expect(fetchSpy.mock.calls.map((c) => String(c[0]))).toEqual([
+ expect.stringContaining('/api/v1/storage/files/f1/url'),
+ expect.stringContaining(SIGNED),
+ ]);
+ expect(JSON.stringify(host.openFile.mock.calls)).not.toContain('tok123');
+ expect(host.openExternal).not.toHaveBeenCalled();
+ expect(host.share).not.toHaveBeenCalled();
+ expect(openSpy).not.toHaveBeenCalled();
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('a row over 8 MiB keeps the browser path without fetching the bytes', async () => {
+ const host = installShell();
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
+ const fetchSpy = mockStorage();
+ await tapDownload([{ ...ROW, size: 8 * 1024 * 1024 + 1 }]);
+
+ await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer'));
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(host.openFile).not.toHaveBeenCalled();
+ });
+
+ it('fetched bytes over 8 MiB keep the browser path', async () => {
+ const host = installShell();
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
+ mockStorage(new Uint8Array(8 * 1024 * 1024 + 1));
+ await tapDownload([{ ...ROW, size: null }]);
+
+ await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer'));
+ expect(host.openFile).not.toHaveBeenCalled();
+ });
+
+ it('a bad_args refusal keeps the browser path', async () => {
+ const host = installShell({ refuse: true });
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
+ mockStorage();
+ await tapDownload();
+
+ await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer'));
+ expect(host.openFile).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('a failed byte fetch keeps the browser path', async () => {
+ const host = installShell();
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
+ mockStorage('nope', 'text/plain', 403);
+ await tapDownload();
+
+ await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer'));
+ expect(host.openFile).not.toHaveBeenCalled();
+ });
+
+ it('CONTROL: an older shell without openFile fetches no bytes and opens the tab as today', async () => {
+ const host = installShell({ capabilities: ['share'] });
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
+ const fetchSpy = mockStorage();
+ await tapDownload();
+
+ await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer'));
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(host.openFile).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/packages/fields/src/widgets/FileField.tsx b/ui/packages/fields/src/widgets/FileField.tsx
index fad2ef87c2c..b3db284bbea 100644
--- a/ui/packages/fields/src/widgets/FileField.tsx
+++ b/ui/packages/fields/src/widgets/FileField.tsx
@@ -2,7 +2,7 @@ import React, { useRef, useState, useCallback } from 'react';
import { Button, EmptyValue, cn } from '@object-ui/components';
import { useUpload } from '@object-ui/providers';
import { useObjectTranslation } from '@object-ui/i18n';
-import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2, Lock } from 'lucide-react';
+import { Upload, X, File as FileIcon, ImageIcon, Images, Camera, Loader2, Lock } from 'lucide-react';
import { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import { toHostGroupProps } from './toHostGroupProps.js';
@@ -20,6 +20,7 @@ import {
type FileValueView,
} from './file-value.js';
import { fileValueForResubmit } from './file-refused.js';
+import { pickNativeImage, useNativeImagePickOffered } from './nativeImagePick.js';
// The one view/download affordance every `file` surface renders
// (objectui#9161) — shared with `FileCellRenderer` rather than copied.
import { FileValueAffordance, useRestrictedFileText } from './file-affordance.js';
@@ -164,7 +165,14 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
);
// Auto-enable camera button on touch devices when image upload is permitted, unless explicitly disabled.
const isTouchDevice = typeof navigator !== 'undefined' && (navigator.maxTouchPoints > 0 || /Mobi|Android/i.test(navigator.userAgent));
- const cameraEnabled = captureMode === false ? false : (captureMode ?? (acceptsImages && isTouchDevice ? 'environment' : null));
+ // Inside the mobile shell, when it announces `pickImage`, the photo buttons
+ // go through the shell's own camera and library (`nativeImagePick`) and are
+ // offered whenever the field accepts images, touch probe or not. The camera
+ // button still honours `capture: false`.
+ const nativePickOffered = useNativeImagePickOffered() && acceptsImages;
+ const cameraEnabled = captureMode === false
+ ? false
+ : (captureMode ?? (acceptsImages && (isTouchDevice || nativePickOffered) ? 'environment' : null));
const [isDragOver, setIsDragOver] = useState(false);
const files = value ? (Array.isArray(value) ? value : [value]) : [];
@@ -172,6 +180,8 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
const { processFiles, errors, uploading, uploadProgress, recent } = useFileUploads({
files, multiple, maxSize, onChange, holdScope,
});
+ // The picked photo enters the same `processFiles` an input pick does.
+ const pickNative = (camera: boolean) => pickNativeImage(camera, t as TranslateFn).then(processFiles);
const fallbackName = t('fields.file.fileFallback', { defaultValue: 'File' });
// Normalised for display: accepts a bare reference id, the expanded
// `{ id, name, size, mimeType, url }`, or a legacy inline blob.
@@ -271,7 +281,7 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
onChange={handleFileChange}
className="hidden"
/>
- {cameraEnabled && (
+ {cameraEnabled && !nativePickOffered && (
{
e.stopPropagation();
- cameraRef.current?.click();
+ if (nativePickOffered) void pickNative(true);
+ else cameraRef.current?.click();
}}
data-testid="file-field-camera-button"
>
@@ -359,6 +370,23 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
)}
+ {nativePickOffered && (
+
+ )}
+
{/* Upload progress indicator */}
{uploading && (
diff --git a/ui/packages/fields/src/widgets/ImageField.tsx b/ui/packages/fields/src/widgets/ImageField.tsx
index 95d85241e2f..7b0b1123ecc 100644
--- a/ui/packages/fields/src/widgets/ImageField.tsx
+++ b/ui/packages/fields/src/widgets/ImageField.tsx
@@ -2,7 +2,7 @@ import React, { useRef, useState, useCallback, lazy, Suspense } from 'react';
import { Button, EmptyValue } from '@object-ui/components';
import { useUpload } from '@object-ui/providers';
import { useObjectTranslation } from '@object-ui/i18n';
-import { X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
+import { X, Image as ImageIcon, Images, Camera, Crop as CropIcon, Loader2 } from 'lucide-react';
import { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import { ImageLightbox } from './ImageLightbox.js';
@@ -27,6 +27,8 @@ import {
import { fileValueForResubmit } from './file-refused.js';
// The restricted state a file the reader may not open draws (objectui#12339).
import { RestrictedFileAffordance } from './file-affordance.js';
+import { PHONE_TOUCH_HEIGHT } from './touchTarget.js';
+import { pickNativeImage, useNativeImagePickOffered } from './nativeImagePick.js';
// Lazy-load the cropper so the dialog (canvas + crop logic) is not in the initial
// ImageField bundle. Consumers that never crop pay zero cost.
@@ -72,6 +74,9 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
// The upload's own lifetime, which can outlast this widget — both upload
// paths below take it (objectui#10180). See `useUploadingScopeHold`.
const holdScope = useUploadingScopeHold();
+ // Inside the mobile shell, when it announces `pickImage`: "Take photo" and
+ // "Choose photo" through the shell's camera and library (`pickNative` below).
+ const nativePickOffered = useNativeImagePickOffered();
// Derived value + memoized handlers must run before the readonly early return
// so hook order stays stable across renders.
@@ -205,13 +210,13 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
);
}
- const handleFileChange = async (e: React.ChangeEvent) => {
- const selectedFiles = Array.from(e.target.files || []);
- // Reset the input up front rather than in `finally`: a pick rejected below
- // returns before the upload runs, and leaving the old value in place would
- // stop the user re-picking the same name after shrinking the image.
- // `selectedFiles` already holds the File objects, so clearing is safe.
- if (inputRef.current) inputRef.current.value = '';
+ /**
+ * The one upload pipeline for picked images: the `` change
+ * below and the mobile shell's picker (`nativeImagePick`) both hand their
+ * `File`s here, so a shell photo is size-checked, uploaded and stored
+ * exactly as an input pick is.
+ */
+ const uploadPicked = async (selectedFiles: File[]) => {
if (selectedFiles.length === 0) return;
// Enforce the declared limit before anything touches the network. Partial
@@ -269,6 +274,20 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
}
};
+ // The picked photo enters the same `uploadPicked` an input pick does.
+ const pickNative = (camera: boolean) => pickNativeImage(camera, t as TranslateFn).then(uploadPicked);
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const selectedFiles = Array.from(e.target.files || []);
+ // Reset the input up front rather than after the upload: a pick rejected
+ // in `uploadPicked` returns before the upload runs, and leaving the old
+ // value in place would stop the user re-picking the same name after
+ // shrinking the image. `selectedFiles` already holds the File objects, so
+ // clearing is safe.
+ if (inputRef.current) inputRef.current.value = '';
+ return uploadPicked(selectedFiles);
+ };
+
const handleRemove = (index: number) => {
if (multiple) {
const newImages = images
@@ -365,6 +384,35 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
: t('fields.image.upload')}
+ {nativePickOffered && (
+
+
+
+
+ )}
+
{/* Oversize picks (objectui#4141) and failed uploads (objectui#10226).
Same presentation as FileField's error row. */}
{errors.length > 0 && (
diff --git a/ui/packages/fields/src/widgets/nativeImagePick.shellPhoto.test.tsx b/ui/packages/fields/src/widgets/nativeImagePick.shellPhoto.test.tsx
new file mode 100644
index 00000000000..a354f02be95
--- /dev/null
+++ b/ui/packages/fields/src/widgets/nativeImagePick.shellPhoto.test.tsx
@@ -0,0 +1,215 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * "Take photo" / "Choose photo" on the file and image fields, through the
+ * ObjectStack mobile shell's `pickImage()` bridge method (objectstack-ai/mobile
+ * `docs/HYBRID-SHELL.md`, "Files").
+ *
+ * The stub below is `window.ObjectStackNative` with the members the fields
+ * read. The picked image must reach the field's EXISTING upload pipeline: the
+ * spy adapter behind `UploadProvider` is the one an `` pick
+ * reaches, and the `maxSize` case proves the picked file is size-checked by
+ * that same pipeline. The control cases are a desktop browser (no global) and
+ * an older shell (`has('pickImage')` says no): no shell button, and the
+ * `` path as before.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup, act, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { toast } from '@object-ui/components';
+import { UploadProvider, type UploadAdapter } from '@object-ui/providers';
+import { FileField } from './FileField';
+import { ImageField } from './ImageField';
+import './ImageCropperDialog';
+
+const GLOBAL = 'ObjectStackNative';
+/** "hello", five bytes. */
+const PICKED = { base64: 'aGVsbG8=', name: 'IMG_0001.jpg', mime: 'image/jpeg' };
+
+type PickAnswer = typeof PICKED | null | Error;
+
+function installShell(opts: { capabilities?: string[]; answer?: PickAnswer } = {}) {
+ const capabilities = opts.capabilities ?? ['haptic', 'pickImage'];
+ const pickImage = vi.fn(async (_opts?: { camera?: boolean }) => {
+ const answer = opts.answer === undefined ? PICKED : opts.answer;
+ if (answer instanceof Error) throw answer;
+ return answer;
+ });
+ (window as unknown as Record)[GLOBAL] = {
+ version: 1,
+ platform: 'ios',
+ appVersion: '2.2.0',
+ capabilities,
+ safeArea: { top: 0, bottom: 0, left: 0, right: 0 },
+ has: (c: string) => capabilities.includes(c),
+ haptic: vi.fn(async () => null),
+ pickImage,
+ ready: vi.fn(),
+ routeChanged: vi.fn(),
+ };
+ return pickImage;
+}
+
+afterEach(() => {
+ cleanup();
+ delete (window as unknown as Record)[GLOBAL];
+ vi.restoreAllMocks();
+});
+
+async function settle(): Promise {
+ for (let i = 0; i < 6; i++) {
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ });
+ }
+}
+
+async function tap(testId: string): Promise {
+ await act(async () => {
+ fireEvent.click(screen.getByTestId(testId));
+ });
+ await settle();
+}
+
+function renderWidget(Widget: typeof FileField, field: Record = {}) {
+ const upload = vi.fn(async (f: File | Blob) => {
+ const name = (f as File).name;
+ return { url: `https://cdn.example/${name}`, name, size: f.size, mimeType: f.type, meta: { fileId: 'file_picked' } };
+ });
+ const adapter: UploadAdapter = { name: 'spy', upload };
+ const onChange = vi.fn();
+ render(
+
+
+ ,
+ );
+ return { upload, onChange };
+}
+
+function expectUploadedPick(upload: ReturnType) {
+ expect(upload).toHaveBeenCalledTimes(1);
+ const file = upload.mock.calls[0][0] as File;
+ expect(file).toBeInstanceOf(File);
+ expect(file.name).toBe('IMG_0001.jpg');
+ expect(file.type).toBe('image/jpeg');
+ expect(file.size).toBe(5);
+}
+
+describe('FileField: the shell photo buttons', () => {
+ it('CONTROL: a plain browser draws no shell button and keeps the input', () => {
+ renderWidget(FileField);
+ expect(screen.queryByTestId('file-field-library-button')).toBeNull();
+ // jsdom is no touch device, so the web camera button stays off, as before.
+ expect(screen.queryByTestId('file-field-camera-button')).toBeNull();
+ expect(document.querySelector('input[type="file"]')).not.toBeNull();
+ });
+
+ it('CONTROL: an older shell without pickImage draws no shell button', () => {
+ const pickImage = installShell({ capabilities: ['haptic', 'scan'] });
+ renderWidget(FileField);
+ expect(screen.queryByTestId('file-field-library-button')).toBeNull();
+ expect(screen.queryByTestId('file-field-camera-button')).toBeNull();
+ expect(pickImage).not.toHaveBeenCalled();
+ });
+
+ it('"Take photo" opens the shell camera and uploads the picked image through the field pipeline', async () => {
+ const pickImage = installShell();
+ const { upload, onChange } = renderWidget(FileField);
+ // The shell takes over the camera button: no `capture` input is drawn.
+ expect(screen.queryByTestId('file-field-camera-input')).toBeNull();
+ await tap('file-field-camera-button');
+ expect(pickImage).toHaveBeenCalledWith({ camera: true });
+ expectUploadedPick(upload);
+ expect(onChange).toHaveBeenCalledWith('file_picked');
+ });
+
+ it('"Choose photo" opens the shell library', async () => {
+ const pickImage = installShell();
+ const { upload, onChange } = renderWidget(FileField);
+ await tap('file-field-library-button');
+ expect(pickImage).toHaveBeenCalledWith({ camera: false });
+ expectUploadedPick(upload);
+ expect(onChange).toHaveBeenCalledWith('file_picked');
+ });
+
+ it('a cancel (null) uploads nothing', async () => {
+ installShell({ answer: null });
+ const { upload, onChange } = renderWidget(FileField);
+ await tap('file-field-library-button');
+ expect(upload).not.toHaveBeenCalled();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('a shell failure shows the error toast with the shell message and uploads nothing', async () => {
+ installShell({ answer: Object.assign(new Error('Camera permission denied'), { code: 'failed' }) });
+ const toastError = vi.spyOn(toast, 'error');
+ const { upload } = renderWidget(FileField);
+ await tap('file-field-camera-button');
+ expect(toastError).toHaveBeenCalledWith('Could not get the photo', { description: 'Camera permission denied' });
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it('`capture: false` keeps the camera off; the library stays offered', () => {
+ installShell();
+ renderWidget(FileField, { capture: false });
+ expect(screen.queryByTestId('file-field-camera-button')).toBeNull();
+ expect(screen.getByTestId('file-field-library-button')).toBeInTheDocument();
+ });
+
+ it('a field that accepts no images offers no photo button', () => {
+ installShell();
+ renderWidget(FileField, { accept: ['.pdf'] });
+ expect(screen.queryByTestId('file-field-library-button')).toBeNull();
+ expect(screen.queryByTestId('file-field-camera-button')).toBeNull();
+ });
+});
+
+describe('ImageField: the shell photo buttons', () => {
+ it('CONTROL: a plain browser and an older shell draw no shell button', () => {
+ renderWidget(ImageField as unknown as typeof FileField);
+ expect(screen.queryByTestId('image-field-camera-button')).toBeNull();
+ expect(screen.queryByTestId('image-field-library-button')).toBeNull();
+ cleanup();
+ installShell({ capabilities: ['haptic'] });
+ renderWidget(ImageField as unknown as typeof FileField);
+ expect(screen.queryByTestId('image-field-camera-button')).toBeNull();
+ expect(screen.getByTestId('image-field-upload-button')).toBeInTheDocument();
+ });
+
+ it('"Take photo" and "Choose photo" upload the picked image through the field pipeline', async () => {
+ const pickImage = installShell();
+ const { upload, onChange } = renderWidget(ImageField as unknown as typeof FileField);
+ await tap('image-field-camera-button');
+ expect(pickImage).toHaveBeenLastCalledWith({ camera: true });
+ expectUploadedPick(upload);
+ expect(onChange).toHaveBeenLastCalledWith('file_picked');
+ await tap('image-field-library-button');
+ expect(pickImage).toHaveBeenLastCalledWith({ camera: false });
+ expect(upload).toHaveBeenCalledTimes(2);
+ });
+
+ it('a cancel (null) uploads nothing', async () => {
+ installShell({ answer: null });
+ const { upload, onChange } = renderWidget(ImageField as unknown as typeof FileField);
+ await tap('image-field-library-button');
+ expect(upload).not.toHaveBeenCalled();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('the picked image meets the same maxSize check an input pick does', async () => {
+ installShell();
+ const { upload, onChange } = renderWidget(ImageField as unknown as typeof FileField, { maxSize: 4 });
+ await tap('image-field-camera-button');
+ expect(upload).not.toHaveBeenCalled();
+ expect(onChange).not.toHaveBeenCalled();
+ expect(screen.getByText(/IMG_0001\.jpg/)).toBeInTheDocument();
+ });
+});
diff --git a/ui/packages/fields/src/widgets/nativeImagePick.ts b/ui/packages/fields/src/widgets/nativeImagePick.ts
new file mode 100644
index 00000000000..d2484d9b3a9
--- /dev/null
+++ b/ui/packages/fields/src/widgets/nativeImagePick.ts
@@ -0,0 +1,55 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * The mobile shell's image picker for the file and image fields.
+ *
+ * Inside the ObjectStack mobile shell, when it announces `pickImage`, the
+ * fields offer "Take photo" and "Choose photo" through the shell's own camera
+ * and photo library. What the shell resolves becomes a `File` and goes through
+ * the field's existing upload pipeline, the same one an ``
+ * pick takes, so there is one upload route. A plain browser and an older shell
+ * keep the `` path, unchanged.
+ *
+ * Internal to `@object-ui/fields`; not exported from the package entry.
+ */
+import { toast } from '@object-ui/components';
+import {
+ fileFromNativeFile,
+ nativeHostCan,
+ nativePickImage,
+ useNativeHost,
+} from '@object-ui/mobile';
+import type { TranslateFn } from './file-size-guard.js';
+
+/**
+ * Whether a field may offer the shell's picker: only inside the mobile shell,
+ * and only when that shell announces `pickImage`. Re-renders when a late
+ * bridge is injected.
+ */
+export function useNativeImagePickOffered(): boolean {
+ return nativeHostCan(useNativeHost(), 'pickImage');
+}
+
+/**
+ * Open the shell's picker (`camera: true` for the camera, `false` for the
+ * library) and resolve the picked image as one `File`, for the caller to hand
+ * to its upload pipeline. A cancel resolves no file. A failure (camera
+ * permission denied, a picker already open) resolves no file and is shown with
+ * the error toast, carrying the shell's message.
+ */
+export async function pickNativeImage(camera: boolean, t: TranslateFn): Promise {
+ const result = await nativePickImage({ camera });
+ if (result.status === 'ok') return result.value ? [fileFromNativeFile(result.value)] : [];
+ if (result.status === 'failed') {
+ toast.error(t('fields.file.photoFailed', { defaultValue: 'Could not get the photo' }), {
+ description: result.message,
+ });
+ }
+ return [];
+}
diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts
index 1d926f82800..d34fada458c 100644
--- a/ui/packages/i18n/src/locales/ar.ts
+++ b/ui/packages/i18n/src/locales/ar.ts
@@ -314,6 +314,8 @@ const ar = {
takePhoto: 'التقاط صورة',
takeSelfie: 'التقاط صورة ذاتية',
cameraCapture: 'التقاط بالكاميرا',
+ choosePhoto: 'اختيار صورة',
+ photoFailed: 'تعذّر الحصول على الصورة',
uploading: 'جارٍ الرفع…',
uploadingPct: 'جارٍ الرفع… ({{pct}}%)',
fileFallback: 'ملف',
diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts
index 6b63fa7ab0d..1019af01af7 100644
--- a/ui/packages/i18n/src/locales/de.ts
+++ b/ui/packages/i18n/src/locales/de.ts
@@ -296,6 +296,8 @@ const de = {
takePhoto: 'Foto aufnehmen',
takeSelfie: 'Selfie aufnehmen',
cameraCapture: 'Kameraaufnahme',
+ choosePhoto: 'Foto auswählen',
+ photoFailed: 'Foto konnte nicht abgerufen werden',
uploading: 'Wird hochgeladen…',
uploadingPct: 'Wird hochgeladen… ({{pct}} %)',
fileFallback: 'Datei',
diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts
index a12caaf8ed9..b3ffd5152c9 100644
--- a/ui/packages/i18n/src/locales/en.ts
+++ b/ui/packages/i18n/src/locales/en.ts
@@ -412,6 +412,8 @@ const en = {
takePhoto: 'Take photo',
takeSelfie: 'Take selfie',
cameraCapture: 'Camera capture',
+ choosePhoto: 'Choose photo',
+ photoFailed: 'Could not get the photo',
uploading: 'Uploading…',
uploadingPct: 'Uploading… ({{pct}}%)',
fileFallback: 'File',
diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts
index 6b510adc24a..617d23ba3dd 100644
--- a/ui/packages/i18n/src/locales/es.ts
+++ b/ui/packages/i18n/src/locales/es.ts
@@ -304,6 +304,8 @@ const es = {
takePhoto: 'Tomar foto',
takeSelfie: 'Tomar selfie',
cameraCapture: 'Captura de cámara',
+ choosePhoto: 'Elegir foto',
+ photoFailed: 'No se pudo obtener la foto',
uploading: 'Subiendo…',
uploadingPct: 'Subiendo… ({{pct}} %)',
fileFallback: 'Archivo',
diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts
index 8615213b4c4..b17d78b7940 100644
--- a/ui/packages/i18n/src/locales/fr.ts
+++ b/ui/packages/i18n/src/locales/fr.ts
@@ -299,6 +299,8 @@ const fr = {
takePhoto: 'Prendre une photo',
takeSelfie: 'Prendre un selfie',
cameraCapture: 'Capture photo',
+ choosePhoto: 'Choisir une photo',
+ photoFailed: "Impossible d'obtenir la photo",
uploading: 'Téléversement…',
uploadingPct: 'Téléversement… ({{pct}} %)',
fileFallback: 'Fichier',
diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts
index 9137eb3bdaa..2157eef2be0 100644
--- a/ui/packages/i18n/src/locales/ja.ts
+++ b/ui/packages/i18n/src/locales/ja.ts
@@ -296,6 +296,8 @@ const ja = {
takePhoto: '写真を撮る',
takeSelfie: '自撮り',
cameraCapture: 'カメラ撮影',
+ choosePhoto: '写真を選択',
+ photoFailed: '写真を取得できませんでした',
uploading: 'アップロード中…',
uploadingPct: 'アップロード中…({{pct}}%)',
fileFallback: 'ファイル',
diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts
index a31503a9014..89ad22befb7 100644
--- a/ui/packages/i18n/src/locales/ko.ts
+++ b/ui/packages/i18n/src/locales/ko.ts
@@ -296,6 +296,8 @@ const ko = {
takePhoto: '사진 촬영',
takeSelfie: '셀피 촬영',
cameraCapture: '카메라 촬영',
+ choosePhoto: '사진 선택',
+ photoFailed: '사진을 가져오지 못했습니다',
uploading: '업로드 중…',
uploadingPct: '업로드 중…({{pct}}%)',
fileFallback: '파일',
diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts
index 023c30e6e46..29c713a8cf8 100644
--- a/ui/packages/i18n/src/locales/pt.ts
+++ b/ui/packages/i18n/src/locales/pt.ts
@@ -299,6 +299,8 @@ const pt = {
takePhoto: 'Tirar foto',
takeSelfie: 'Tirar selfie',
cameraCapture: 'Captura da câmera',
+ choosePhoto: 'Escolher foto',
+ photoFailed: 'Não foi possível obter a foto',
uploading: 'Enviando…',
uploadingPct: 'Enviando… ({{pct}} %)',
fileFallback: 'Arquivo',
diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts
index ea68016db06..62a30eebb60 100644
--- a/ui/packages/i18n/src/locales/ru.ts
+++ b/ui/packages/i18n/src/locales/ru.ts
@@ -305,6 +305,8 @@ const ru = {
takePhoto: 'Сделать фото',
takeSelfie: 'Сделать селфи',
cameraCapture: 'Съёмка камерой',
+ choosePhoto: 'Выбрать фото',
+ photoFailed: 'Не удалось получить фото',
uploading: 'Загрузка…',
uploadingPct: 'Загрузка… ({{pct}} %)',
fileFallback: 'Файл',
diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts
index 0bfbf9fe45f..1378c34ede1 100644
--- a/ui/packages/i18n/src/locales/zh.ts
+++ b/ui/packages/i18n/src/locales/zh.ts
@@ -280,6 +280,8 @@ const zh = {
takePhoto: '拍照',
takeSelfie: '自拍',
cameraCapture: '拍摄',
+ choosePhoto: '从相册选择',
+ photoFailed: '无法获取照片',
uploading: '上传中…',
uploadingPct: '上传中…({{pct}}%)',
fileFallback: '文件',
diff --git a/ui/packages/mobile/README.md b/ui/packages/mobile/README.md
index a8015404158..a1a57bb091f 100644
--- a/ui/packages/mobile/README.md
+++ b/ui/packages/mobile/README.md
@@ -340,11 +340,15 @@ shell; the bridge contract is owned by the mobile app (`docs/HYBRID-SHELL.md`,
loading cover on it, so the cover hands over to that spinner instead of staying up
through the session round-trip.
- `nativeGetPushToken`, `nativeHaptic`, `nativeOpenExternal`, `nativeNavigate`,
- `nativeResetServer`, `nativeShowConnectionSwitcher`, `nativeGetAppLock` and
- `nativeOpenAppLockSettings` check `has(capability)` and
- that the method exists before calling. They never throw: each resolves
- `{ status: 'unavailable' }` without the capability, `{ status: 'ok', value }` on
- success, or `{ status: 'failed', code, message }` when the shell refuses the call.
+ `nativeResetServer`, `nativeShowConnectionSwitcher`, `nativeGetAppLock`,
+ `nativeOpenAppLockSettings`, `nativePickImage`, `nativeOpenFile` and `nativeShare`
+ check `has(capability)` and that the method exists before calling. They never
+ throw: each resolves `{ status: 'unavailable' }` without the capability,
+ `{ status: 'ok', value }` on success, or `{ status: 'failed', code, message }` when the shell refuses the call.
+- Files cross the bridge as base64 bytes, never as a URL, up to
+ `NATIVE_FILE_MAX_BYTES` (8 MiB): `fileFromNativeFile` turns what `nativePickImage`
+ resolves into an uploadable `File`, and `nativeFileFromBlob` turns bytes the page
+ fetched with its own session into what `nativeOpenFile` takes.
- `nativeHostCan(host, method)` is that same check, for a control that picks its
native or web path before it renders (the header's organization button opens the
shell's switcher this way).
diff --git a/ui/packages/mobile/src/__tests__/nativeHost.test.tsx b/ui/packages/mobile/src/__tests__/nativeHost.test.tsx
index 843be025950..b25dd5a1465 100644
--- a/ui/packages/mobile/src/__tests__/nativeHost.test.tsx
+++ b/ui/packages/mobile/src/__tests__/nativeHost.test.tsx
@@ -34,6 +34,12 @@ import {
nativeResetServer,
nativeScan,
nativeShowConnectionSwitcher,
+ nativePickImage,
+ nativeOpenFile,
+ nativeShare,
+ fileFromNativeFile,
+ nativeFileFromBlob,
+ NATIVE_FILE_MAX_BYTES,
nativeGetAppLock,
nativeOpenAppLockSettings,
postNativeReady,
@@ -57,6 +63,9 @@ const ALL_METHODS: NativeHostMethod[] = [
'resetServer',
'scan',
'showConnectionSwitcher',
+ 'pickImage',
+ 'openFile',
+ 'share',
'getAppLock',
'openAppLockSettings',
];
@@ -79,6 +88,9 @@ function fakeBridge(capabilities: readonly string[] = ALL_METHODS): ObjectStackN
resetServer: vi.fn(async () => null),
scan: vi.fn(async () => 'ASSET-0042'),
showConnectionSwitcher: vi.fn(async () => null),
+ pickImage: vi.fn(async () => ({ base64: 'aGVsbG8=', name: 'IMG_0001.jpg', mime: 'image/jpeg' })),
+ openFile: vi.fn(async () => null),
+ share: vi.fn(async () => null),
getAppLock: vi.fn(async () => ({ enabled: true, timeoutSeconds: 300 })),
openAppLockSettings: vi.fn(async () => null),
ready: vi.fn<() => void>(),
@@ -246,6 +258,9 @@ describe('typed call wrappers feature-detect and never throw', () => {
['resetServer', () => nativeResetServer()],
['scan', () => nativeScan()],
['showConnectionSwitcher', () => nativeShowConnectionSwitcher()],
+ ['pickImage', () => nativePickImage({ camera: true })],
+ ['openFile', () => nativeOpenFile({ base64: 'aGVsbG8=', name: 'a.txt', mime: 'text/plain' })],
+ ['share', () => nativeShare({ url: 'https://example.com/r/1' })],
['getAppLock', () => nativeGetAppLock()],
['openAppLockSettings', () => nativeOpenAppLockSettings()],
];
@@ -344,6 +359,66 @@ describe('typed call wrappers feature-detect and never throw', () => {
});
});
+ it('pickImage forwards its options and resolves the picked file, or null on cancel', async () => {
+ const bridge = fakeBridge();
+ install(bridge);
+ await expect(nativePickImage({ camera: true, maxWidth: 1600 })).resolves.toEqual({
+ status: 'ok',
+ value: { base64: 'aGVsbG8=', name: 'IMG_0001.jpg', mime: 'image/jpeg' },
+ });
+ expect(bridge.pickImage).toHaveBeenCalledWith({ camera: true, maxWidth: 1600 });
+ expect((bridge.pickImage as Mock).mock.contexts[0]).toBe(bridge);
+ bridge.pickImage = vi.fn(async () => null);
+ await expect(nativePickImage()).resolves.toEqual({ status: 'ok', value: null });
+ });
+
+ it('openFile forwards the file and resolves "failed" with bad_args when the shell refuses it', async () => {
+ const bridge = fakeBridge();
+ install(bridge);
+ const file = { base64: 'aGVsbG8=', name: 'a.pdf', mime: 'application/pdf' };
+ await expect(nativeOpenFile(file)).resolves.toEqual({ status: 'ok', value: null });
+ expect(bridge.openFile).toHaveBeenCalledWith(file);
+ bridge.openFile = vi.fn(async () => {
+ throw Object.assign(new Error('Files cross the bridge up to 8 MB'), { code: 'bad_args' });
+ });
+ await expect(nativeOpenFile(file)).resolves.toEqual({
+ status: 'failed',
+ code: 'bad_args',
+ message: 'Files cross the bridge up to 8 MB',
+ });
+ });
+
+ it('share forwards the url and text', async () => {
+ const bridge = fakeBridge();
+ install(bridge);
+ await expect(nativeShare({ url: 'https://example.com/r/1', text: 'Acme' })).resolves.toEqual({ status: 'ok', value: null });
+ expect(bridge.share).toHaveBeenCalledWith({ url: 'https://example.com/r/1', text: 'Acme' });
+ });
+
+ it('the bridge file limit is the shell 8 MiB', () => {
+ expect(NATIVE_FILE_MAX_BYTES).toBe(8 * 1024 * 1024);
+ });
+
+ it('fileFromNativeFile decodes the base64 bytes into a typed, named File', async () => {
+ const file = fileFromNativeFile({ base64: 'aGVsbG8=', name: 'IMG_0001.jpg', mime: 'image/jpeg' });
+ expect(file).toBeInstanceOf(File);
+ expect(file.name).toBe('IMG_0001.jpg');
+ expect(file.type).toBe('image/jpeg');
+ expect(file.size).toBe(5);
+ expect(await file.text()).toBe('hello');
+ });
+
+ it('nativeFileFromBlob encodes the bytes with no data: prefix and a parameter-free type', async () => {
+ await expect(nativeFileFromBlob(new Blob(['hello'], { type: 'text/plain;charset=utf-8' }), 'a.txt')).resolves.toEqual({
+ base64: 'aGVsbG8=',
+ name: 'a.txt',
+ mime: 'text/plain',
+ });
+ await expect(nativeFileFromBlob(new Blob(['hello']), 'a.bin')).resolves.toMatchObject({
+ mime: 'application/octet-stream',
+ });
+ });
+
it('a call the shell rejects resolves "failed" with the shell error code', async () => {
const bridge = fakeBridge();
bridge.openExternal = vi.fn(async () => {
diff --git a/ui/packages/mobile/src/index.ts b/ui/packages/mobile/src/index.ts
index 772220974af..a1305b6d6ff 100644
--- a/ui/packages/mobile/src/index.ts
+++ b/ui/packages/mobile/src/index.ts
@@ -76,6 +76,12 @@ export {
nativeResetServer,
nativeScan,
nativeShowConnectionSwitcher,
+ nativePickImage,
+ nativeOpenFile,
+ nativeShare,
+ fileFromNativeFile,
+ nativeFileFromBlob,
+ NATIVE_FILE_MAX_BYTES,
nativeGetAppLock,
nativeOpenAppLockSettings,
nativeHostCan,
@@ -90,6 +96,9 @@ export {
type NativePushToken,
type NativeSafeArea,
type NativeScanOptions,
+ type NativePickImageOptions,
+ type NativeFile,
+ type NativeShareContent,
} from './nativeHost.js';
// Re-export types for convenience
diff --git a/ui/packages/mobile/src/nativeHost.ts b/ui/packages/mobile/src/nativeHost.ts
index 28bcd1af6ce..6bdce8b2111 100644
--- a/ui/packages/mobile/src/nativeHost.ts
+++ b/ui/packages/mobile/src/nativeHost.ts
@@ -99,6 +99,43 @@ export interface NativeScanOptions {
formats?: string[];
}
+/** Options of `pickImage()`. */
+export interface NativePickImageOptions {
+ /** `true` takes a photo with the camera; omitted or `false` opens the photo library. */
+ camera?: boolean;
+ /** Downscale to this many pixels wide (an integer from 1 to 8192; default 2048, never upscaled). */
+ maxWidth?: number;
+ /** JPEG quality, 0 to 1 (default 0.8). */
+ quality?: number;
+}
+
+/**
+ * A file as it crosses the bridge: the bytes as standard, padded base64 with
+ * no `data:` prefix, a file name with no path, and a `type/subtype` MIME type.
+ * `pickImage()` resolves one; `openFile()` takes one.
+ */
+export interface NativeFile {
+ base64: string;
+ name: string;
+ mime: string;
+}
+
+/** What `share()` hands the OS share sheet: at least one of the two. */
+export interface NativeShareContent {
+ /** An http(s) URL. */
+ url?: string;
+ /** At most 10,000 characters. */
+ text?: string;
+}
+
+/**
+ * The most file bytes the bridge carries: an image `pickImage()` resolves and
+ * a file `openFile()` takes (the shell's `MAX_FILE_BYTES`, 8 MiB). The shell
+ * refuses a larger `openFile()` with `bad_args`; a Console holding a larger
+ * file keeps its browser handling of it and need not encode it at all.
+ */
+export const NATIVE_FILE_MAX_BYTES = 8 * 1024 * 1024;
+
/** The callable bridge methods. Each is announced as a capability of the same name. */
export type NativeHostMethod =
| 'getPushToken'
@@ -108,6 +145,9 @@ export type NativeHostMethod =
| 'resetServer'
| 'scan'
| 'showConnectionSwitcher'
+ | 'pickImage'
+ | 'openFile'
+ | 'share'
| 'getAppLock'
| 'openAppLockSettings';
@@ -148,6 +188,21 @@ export interface ObjectStackNative {
* `has('showConnectionSwitcher')`.
*/
showConnectionSwitcher?(): Promise;
+ /**
+ * Take a photo (`camera: true`) or choose one from the library. Resolves to
+ * the downscaled JPEG, or `null` when the user cancelled. Optional: a shell
+ * older than the file handoff does not carry it, so feature-detect with
+ * `has('pickImage')`.
+ */
+ pickImage?(opts?: NativePickImageOptions): Promise;
+ /**
+ * Hand a file's bytes to the OS to preview or open. The page fetches the
+ * file with its own session; the shell never downloads a URL. At most
+ * `NATIVE_FILE_MAX_BYTES`. Optional: feature-detect with `has('openFile')`.
+ */
+ openFile?(file: NativeFile): Promise;
+ /** Open the OS share sheet. Optional: feature-detect with `has('share')`. */
+ share?(content: NativeShareContent): Promise;
/**
* Read the app-lock setting. Read-only: no bridge method changes the lock.
* Optional: a shell older than the app lock does not carry it, so
@@ -280,6 +335,62 @@ export function nativeShowConnectionSwitcher(): Promise>
return callNative('showConnectionSwitcher', (host) => host.showConnectionSwitcher!());
}
+/**
+ * Take or choose a photo with the shell's picker. `ok` carries the picked
+ * image, or `null` when the user cancelled; `failed` carries the shell's
+ * message (camera permission denied, a picker already open, the image over
+ * the bridge's size limit).
+ */
+export function nativePickImage(opts?: NativePickImageOptions): Promise> {
+ // `callNative` calls this only once it has seen the method is a function.
+ return callNative('pickImage', (host) => host.pickImage!(opts));
+}
+
+/**
+ * Hand a file's bytes to the OS. `failed` with `bad_args` means the shell
+ * refused the file itself (over `NATIVE_FILE_MAX_BYTES`, a bad name or type):
+ * the caller takes its browser path for it.
+ */
+export function nativeOpenFile(file: NativeFile): Promise> {
+ // `callNative` calls this only once it has seen the method is a function.
+ return callNative('openFile', (host) => host.openFile!(file));
+}
+
+/** Open the OS share sheet with a link and/or a text. */
+export function nativeShare(content: NativeShareContent): Promise> {
+ // `callNative` calls this only once it has seen the method is a function.
+ return callNative('share', (host) => host.share!(content));
+}
+
+/** A `File` holding the bytes of a bridge file (what `pickImage()` resolves), ready to upload. */
+export function fileFromNativeFile(file: NativeFile): File {
+ const binary = atob(file.base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
+ return new File([bytes], file.name, { type: file.mime });
+}
+
+/**
+ * A bridge file holding `blob`'s bytes under `name`, for `openFile()`. The
+ * MIME type is the blob's own, without parameters, or
+ * `application/octet-stream` when it has none.
+ */
+export async function nativeFileFromBlob(blob: Blob, name: string): Promise {
+ const dataUrl = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result ?? ''));
+ reader.onerror = () => reject(reader.error ?? new Error('The file could not be read'));
+ reader.readAsDataURL(blob);
+ });
+ const comma = dataUrl.indexOf(',');
+ const mime = (blob.type.split(';')[0] ?? '').trim().toLowerCase();
+ return {
+ base64: comma >= 0 ? dataUrl.slice(comma + 1) : '',
+ name,
+ mime: mime || 'application/octet-stream',
+ };
+}
+
/** The shell's app-lock setting. No event announces a change, so read it again when the page is shown. */
export function nativeGetAppLock(): Promise> {
// `callNative` calls this only once it has seen the method is a function.