1import type { ChatFileAttachment } from "@t3tools/contracts";
2import type { Directory } from "expo-file-system";
3import type { SharingOptions } from "expo-sharing";
4
5import { beginForegroundHandoff } from "./foreground-handoff";
6import { uuidv4 } from "./uuid";
7
8const ATTACHMENT_DOWNLOAD_DIRECTORY = "t3-attachment-downloads";
9const DOWNLOAD_RETENTION_MS = 24 * 60 * 60_000;
10const DOWNLOAD_DIRECTORY_NAME = /^(\d+)-[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
11const activeDirectories = new Set<string>();
12
13function downloadFileName(name: string): string {
14 const basename = name.split(/[\\/]/).at(-1) ?? "";
15 const sanitized = Array.from(basename, (character) => {
16 const codePoint = character.codePointAt(0)!;
17 return codePoint < 32 ||
18 (codePoint >= 127 && codePoint <= 159) ||
19 (codePoint >= 0xd800 && codePoint <= 0xdfff)
20 ? "_"
21 : character;
22 })
23 .join("")
24 .trim();
25 if (!sanitized || /^\.+$/.test(sanitized)) {
26 return "attachment";
27 }
28 const encoder = new TextEncoder();
29 if (encoder.encode(sanitized).byteLength <= 255) {
30 return sanitized;
31 }
32 const extensionMatch = /\.[a-z0-9]{1,16}$/i.exec(sanitized);
33 const extension = extensionMatch && extensionMatch.index > 0 ? extensionMatch[0] : "";
34 const stem = extension ? sanitized.slice(0, -extension.length) : sanitized;
35 let remainingBytes = 255 - encoder.encode(extension).byteLength;
36 let shortStem = "";
37 for (const character of stem) {
38 const bytes = encoder.encode(character).byteLength;
39 if (bytes > remainingBytes) break;
40 shortStem += character;
41 remainingBytes -= bytes;
42 }
43 return `${shortStem || "attachment"}${extension}`;
44}
45
46function removeDownloadDirectory(directory: Directory): void {
47 try {
48 if (directory.exists) {
49 directory.delete();
50 }
51 } catch (error) {
52 console.warn("[attachment-downloads] could not remove a cached file", error);
53 }
54}
55
56type AttachmentFileMetadata = Pick<ChatFileAttachment, "name" | "mimeType">;
57
58export interface AttachmentPreviewFile {
59 readonly uri: string;
60 readonly share: (signal: AbortSignal, sourceIdentifier?: string) => Promise<void>;
61 readonly dispose: () => void;
62}
63
64async function availableSharing(signal: AbortSignal) {
65 if (signal.aborted) return null;
66 const Sharing = await import("expo-sharing");
67 const canShare = await Sharing.isAvailableAsync();
68 if (signal.aborted) return null;
69 if (!canShare) {
70 throw new Error("Saving and sharing files is unavailable on this device.");
71 }
72 return Sharing;
73}
74
75async function createCachedAttachmentFile(attachment: AttachmentFileMetadata) {
76 const { Directory, File, Paths } = await import("expo-file-system");
77 const cache = new Directory(Paths.cache, ATTACHMENT_DOWNLOAD_DIRECTORY);
78 cache.create({ idempotent: true, intermediates: true });
79 const now = Date.now();
80 try {
81 for (const entry of cache.list()) {
82 const match = DOWNLOAD_DIRECTORY_NAME.exec(entry.name);
83 if (
84 entry instanceof Directory &&
85 match &&
86 Number(match[1]) < now - DOWNLOAD_RETENTION_MS &&
87 !activeDirectories.has(entry.uri)
88 ) {
89 removeDownloadDirectory(entry);
90 }
91 }
92 } catch (error) {
93 console.warn("[attachment-downloads] could not inspect cached files", error);
94 }
95
96 const directory = new Directory(cache, `${now}-${uuidv4()}`);
97 directory.create();
98 let file: InstanceType<typeof File>;
99 try {
100 file = new File(directory, downloadFileName(attachment.name));
101 } catch (error) {
102 removeDownloadDirectory(directory);
103 throw error;
104 }
105 activeDirectories.add(directory.uri);
106 let disposed = false;
107 let shared = false;
108 let sharing = false;
109 const release = () => {
110 if (!disposed || sharing) return;
111 activeDirectories.delete(directory.uri);
112 // A receiver can still be reading after Android's chooser returns.
113 if (!shared) removeDownloadDirectory(directory);
114 };
115 const preview: AttachmentPreviewFile = {
116 uri: file.uri,
117 dispose: () => {
118 disposed = true;
119 release();
120 },
121 share: async (signal, sourceIdentifier) => {
122 if (disposed || sharing || signal.aborted) return;
123 sharing = true;
124 try {
125 const Sharing = await availableSharing(signal);
126 if (Sharing === null || disposed) return;
127 const endHandoff = beginForegroundHandoff();
128 try {
129 const options: SharingOptions = {
130 mimeType: attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream",
131 dialogTitle: attachment.name,
132 };
133 if (sourceIdentifier) {
134 const { shareFileFromSource } = await import("./shareFileFromSource");
135 if (signal.aborted || disposed) return;
136 await shareFileFromSource(file.uri, options, sourceIdentifier);
137 } else {
138 await Sharing.shareAsync(file.uri, options);
139 }
140 shared = true;
141 } catch (cause) {
142 if (!signal.aborted) {
143 throw new Error("Could not open the share sheet. Try again.", { cause });
144 }
145 } finally {
146 endHandoff();
147 }
148 } finally {
149 sharing = false;
150 release();
151 }
152 },
153 };
154 return {
155 file,
156 preview,
157 retainAfterHandoff: () => {
158 shared = true;
159 },
160 };
161}
162
163/** A readable reason for a viewer refusing a file; native rejections carry stack text. */
164export function nativeViewerErrorMessage(cause: unknown): string {
165 const text = cause instanceof Error ? cause.message : String(cause);
166 return /ActivityNotFound|cannot be previewed/i.test(text)
167 ? "No app on this device can show this format. Save or share it to open it elsewhere."
168 : "The file could not be opened. Check the connection and try again.";
169}
170
171/** Open an Android document in a viewer, retaining the cache while another app reads it. */
172export async function openAttachmentInViewer(input: {
173 readonly uri: string;
174 readonly attachment: AttachmentFileMetadata;
175 readonly signal: AbortSignal;
176}): Promise<void> {
177 const { File } = await import("expo-file-system");
178 const { requireNativeModule } = await import("expo");
179 if (input.signal.aborted) return;
180 const cached = await createCachedAttachmentFile(input.attachment);
181 try {
182 if (/^(file|content):/.test(input.uri)) {
183 await new File(input.uri).copy(cached.file);
184 } else {
185 await File.downloadFileAsync(input.uri, cached.file, { signal: input.signal });
186 }
187 if (input.signal.aborted) return;
188 const endHandoff = beginForegroundHandoff();
189 try {
190 await requireNativeModule<{ openFile(uri: string, mimeType: string): Promise<void> }>(
191 "T3NativeControls",
192 ).openFile(
193 cached.file.uri,
194 input.attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream",
195 );
196 cached.retainAfterHandoff();
197 } finally {
198 endHandoff();
199 }
200 } finally {
201 cached.preview.dispose();
202 }
203}
204
205/** The caller owns this cached file until disposal, unless it has been shared with another app. */
206export async function downloadAttachmentForPreview(input: {
207 readonly url: string;
208 readonly attachment: AttachmentFileMetadata;
209 readonly signal: AbortSignal;
210}): Promise<AttachmentPreviewFile | null> {
211 if (input.signal.aborted) return null;
212 const { File } = await import("expo-file-system");
213 const cached = await createCachedAttachmentFile(input.attachment);
214 try {
215 if (input.signal.aborted) {
216 cached.preview.dispose();
217 return null;
218 }
219 await File.downloadFileAsync(input.url, cached.file, { signal: input.signal });
220 if (input.signal.aborted) {
221 cached.preview.dispose();
222 return null;
223 }
224 return cached.preview;
225 } catch (cause) {
226 // Android may leave a partial file after a failed or interrupted request.
227 cached.preview.dispose();
228 if (input.signal.aborted) return null;
229 throw new Error("Could not download the attachment. Check the connection and try again.", {
230 cause,
231 });
232 }
233}
234
235/** Downloads original bytes for the native save/share sheet, including inline video responses. */
236export async function downloadAndShareAttachment(input: {
237 readonly url: string;
238 readonly attachment: AttachmentFileMetadata;
239 readonly signal: AbortSignal;
240 readonly sourceIdentifier?: string;
241}): Promise<void> {
242 if ((await availableSharing(input.signal)) === null) return;
243 const file = await downloadAttachmentForPreview(input);
244 if (file === null) return;
245 try {
246 await file.share(input.signal, input.sourceIdentifier);
247 } finally {
248 file.dispose();
249 }
250}
251
252/** Shares a cache copy so another app never relies on the lifetime of a composer draft. */
253export async function shareLocalAttachment(input: {
254 readonly uri: string;
255 readonly attachment: AttachmentFileMetadata;
256 readonly signal: AbortSignal;
257 readonly sourceIdentifier?: string;
258}): Promise<void> {
259 if ((await availableSharing(input.signal)) === null) return;
260 const { File } = await import("expo-file-system");
261 const cached = await createCachedAttachmentFile(input.attachment);
262 try {
263 if (input.signal.aborted) return;
264 try {
265 await new File(input.uri).copy(cached.file);
266 } catch (cause) {
267 if (input.signal.aborted) return;
268 throw new Error("Could not prepare the attachment for sharing.", { cause });
269 }
270 if (!input.signal.aborted) {
271 await cached.preview.share(input.signal, input.sourceIdentifier);
272 }
273 } finally {
274 cached.preview.dispose();
275 }
276}
277
278/**
279 * Shares bytes made on this device (an MCP App's download) through the same
280 * cache and share sheet. False when the share sheet could not be shown.
281 */
282export async function shareGeneratedAttachment(input: {
283 readonly bytes: Uint8Array;
284 readonly attachment: AttachmentFileMetadata;
285 readonly signal: AbortSignal;
286}): Promise<boolean> {
287 // An unavailable share sheet is a refusal for the caller, not a failure.
288 const sharing = await availableSharing(input.signal).catch(() => null);
289 if (sharing === null) return false;
290 const cached = await createCachedAttachmentFile(input.attachment);
291 try {
292 if (input.signal.aborted) return false;
293 cached.file.write(input.bytes);
294 await cached.preview.share(input.signal);
295 return true;
296 } finally {
297 cached.preview.dispose();
298 }
299}