Skip to content
18 changes: 18 additions & 0 deletions .changeset/29735-mobile-shell-files.md
Original file line number Diff line number Diff line change
@@ -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 `<input type=file>` 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.
45 changes: 45 additions & 0 deletions ui/packages/app-shell/src/views/RecordAttachmentsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<boolean> {
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<RecordAttachmentsPanelProps> = ({
objectName,
recordId,
Expand Down Expand Up @@ -465,6 +509,7 @@ export const RecordAttachmentsPanel: React.FC<RecordAttachmentsPanelProps> = ({
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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)[GLOBAL] = host;
return host;
}

let writeText: ReturnType<typeof vi.fn>;
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<string, unknown>)[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));
});
});
12 changes: 12 additions & 0 deletions ui/packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, unknown>)[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<string, unknown>)[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();
});
});
Loading
Loading