From d7491e8fa1c1b9c60a2bb7b809579accaba27472 Mon Sep 17 00:00:00 2001 From: Ayush Yadav Date: Wed, 28 Oct 2026 21:16:34 +0530 Subject: [PATCH 1/3] fix(mobile): show the real error when a native PDF import fails On iOS and Android the upload response was parsed as JSON before its status was checked, so a proxy error page or an empty body showed up as a JSON parse error. Read it through the same helper the web path uses. --- apps/mobile/src/api-response.ts | 11 +++++++++++ apps/mobile/src/screens.tsx | 6 ++---- apps/mobile/test/api-response.test.ts | 25 ++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/api-response.ts b/apps/mobile/src/api-response.ts index d6e8b017d..d9a9a3e3b 100644 --- a/apps/mobile/src/api-response.ts +++ b/apps/mobile/src/api-response.ts @@ -22,3 +22,14 @@ export async function readApiPayload(response: Response, fallback?: string): throw new Error(`The server returned an unreadable response (${response.status}).`); return payload as T; } + +/** + * Same as readApiPayload, for the native upload and download helpers, which + * return the status and body text instead of a Response. + */ +export function readNativePayload( + result: { status: number; body: string }, + fallback?: string, +): Promise { + return readApiPayload(new Response(result.body, { status: result.status }), fallback); +} diff --git a/apps/mobile/src/screens.tsx b/apps/mobile/src/screens.tsx index 5f2345a84..5d56f3921 100644 --- a/apps/mobile/src/screens.tsx +++ b/apps/mobile/src/screens.tsx @@ -39,6 +39,7 @@ import type { EmailDraft, } from "../../../packages/domain/src"; import { API_URL } from "./api"; +import { readNativePayload } from "./api-response"; import { browserAddress } from "./browser-address"; import { localDateTime, startOfZonedDay, zonedInstant } from "./date-time"; import { @@ -964,10 +965,7 @@ export function FilesScreen() { mimeType: "application/pdf", headers: { Authorization: `Bearer ${api.token}` }, }); - const payload = JSON.parse(result.body); - if (result.status < 200 || result.status >= 300) - throw new Error(payload.error || "Could not import this PDF."); - artifact = payload; + artifact = await readNativePayload(result, "Could not import this PDF."); } await refresh(); open({ type: "file", file: artifact }); diff --git a/apps/mobile/test/api-response.test.ts b/apps/mobile/test/api-response.test.ts index cd5ebb24f..63e311dcd 100644 --- a/apps/mobile/test/api-response.test.ts +++ b/apps/mobile/test/api-response.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { readApiPayload } from "../src/api-response.ts"; +import { readApiPayload, readNativePayload } from "../src/api-response.ts"; test("JSON responses pass through and server errors keep their message", async () => { assert.deepEqual( @@ -52,3 +52,26 @@ test("successful responses without a readable body fail clearly", async () => { ); await assert.rejects(readApiPayload(new Response("OK", { status: 200 })), /unreadable response/); }); + +test("native upload and download results report the real failure", async () => { + assert.deepEqual( + await readNativePayload({ status: 201, body: JSON.stringify({ id: "file-1" }) }), + { id: "file-1" }, + ); + await assert.rejects( + readNativePayload( + { status: 413, body: JSON.stringify({ error: "PDF is too large" }) }, + "Could not import this PDF.", + ), + /PDF is too large/, + ); + for (const body of ["Bad Gateway", ""]) { + const error = await readNativePayload( + { status: 502, body }, + "Could not import this PDF.", + ).catch((e: unknown) => e); + assert.ok(error instanceof Error); + assert.equal(error.message, "Could not import this PDF."); + assert.equal(error instanceof SyntaxError, false); + } +}); From 9f8146daf146c2b7a50b111e358ac3cf34a92834 Mon Sep 17 00:00:00 2001 From: Ayush Yadav Date: Wed, 28 Oct 2026 21:16:34 +0530 Subject: [PATCH 2/3] fix(mobile): don't share an error response as a PDF On iOS and Android, sharing a PDF downloaded it without checking the status, so an expired session or a deleted file saved the server's error as the PDF and offered it in the share sheet. Show the server's error instead. --- apps/mobile/src/details.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/details.tsx b/apps/mobile/src/details.tsx index 8751ff921..e7c9907a7 100644 --- a/apps/mobile/src/details.tsx +++ b/apps/mobile/src/details.tsx @@ -33,6 +33,7 @@ import { type ProposalInput, } from "../../../packages/domain/src"; import { DelegateSheet, NotificationsSheet, TaskDetail } from "./agent-ui"; +import { readNativePayload } from "./api-response"; import BrowserConsole from "./BrowserConsole"; import { browserAddress, browserSite } from "./browser-address"; import { ComputerSheet } from "./computer"; @@ -777,9 +778,17 @@ function FileDetail({ file: f }: { file: Artifact }) { return; } const target = `${FileSystem.cacheDirectory}${f.id}.pdf`; - await FileSystem.downloadAsync(url, target, { + const download = await FileSystem.downloadAsync(url, target, { headers: { Authorization: `Bearer ${api.token}` }, }); + if (download.status < 200 || download.status >= 300) { + // downloadAsync saves an error response to the file instead of failing, so read + // the server's message from it and never offer that file as a PDF. + const body = await FileSystem.readAsStringAsync(target).catch(() => ""); + await FileSystem.deleteAsync(target, { idempotent: true }); + // Always throws here, with the server's message when it sent one. + await readNativePayload({ status: download.status, body }, "Could not download this PDF."); + } if (await Sharing.isAvailableAsync()) await Sharing.shareAsync(target, { mimeType: "application/pdf", UTI: "com.adobe.pdf" }); else throw new Error("Sharing is not available on this device."); From df975661f8ece185fd2aa90ee782b146ff7dd28a Mon Sep 17 00:00:00 2001 From: Ayush Yadav Date: Wed, 28 Oct 2026 21:16:34 +0530 Subject: [PATCH 3/3] fix(mobile): clear the time zone error once the zone is valid The event editor showed 'Choose a valid time zone.' while the zone was being typed and kept showing it after the zone was corrected. A date or time changed while the zone was invalid also stayed unconverted, with the raw Intl message. Once the zone is valid, convert that date and time and clear the message, and show 'Choose a valid time zone.' for an unknown zone in both places. --- apps/mobile/src/DateTimeEditor.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/DateTimeEditor.tsx b/apps/mobile/src/DateTimeEditor.tsx index 92e78ee7a..898389aab 100644 --- a/apps/mobile/src/DateTimeEditor.tsx +++ b/apps/mobile/src/DateTimeEditor.tsx @@ -3,6 +3,13 @@ import { Text, View } from "react-native"; import DateFields from "./DateFields"; import { isCompleteInstant, localDateTime, zonedInstant } from "./date-time"; import { colors, s } from "./ui"; + +// An unknown time zone surfaces as Intl's RangeError; the other errors are already readable. +function editorError(e: unknown) { + if (e instanceof RangeError) return "Choose a valid time zone."; + return e instanceof Error ? e.message : String(e); +} + export default function DateTimeEditor({ label, value, @@ -23,13 +30,19 @@ export default function DateTimeEditor({ try { if (allDay) { setDate(value.slice(0, 10)); + setError(""); } else if (isCompleteInstant(value)) { const local = localDateTime(value, timeZone); setDate(local.date); setTime(local.time); + setError(""); + } else if (value === `${date} ${time}`) { + // A date or time entered while the zone was invalid; convert it now the zone changed. + onChange(zonedInstant(date, time, timeZone)); + setError(""); } - } catch { - setError("Choose a valid time zone."); + } catch (e) { + setError(editorError(e)); } }, [value, timeZone, allDay]); function change(nextDate: string, nextTime: string) { @@ -44,7 +57,7 @@ export default function DateTimeEditor({ onChange(zonedInstant(nextDate, nextTime, timeZone)); } catch (e) { onChange(`${nextDate} ${nextTime}`); - setError(e instanceof Error ? e.message : String(e)); + setError(editorError(e)); } } return (