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.