import type { ChatFileAttachment } from "@t3tools/contracts";import type { Directory } from "expo-file-system";import type { SharingOptions } from "expo-sharing"; import { beginForegroundHandoff } from "./foreground-handoff";import { uuidv4 } from "./uuid"; const ATTACHMENT_DOWNLOAD_DIRECTORY = "t3-attachment-downloads";const DOWNLOAD_RETENTION_MS = 24 * 60 * 60_000;const DOWNLOAD_DIRECTORY_NAME = /^(\d+)-[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;const activeDirectories = new Set<string>(); function downloadFileName(name: string): string { const basename = name.split(/[\\/]/).at(-1) ?? ""; const sanitized = Array.from(basename, (character) => { const codePoint = character.codePointAt(0)!; return codePoint < 32 || (codePoint >= 127 && codePoint <= 159) || (codePoint >= 0xd800 && codePoint <= 0xdfff) ? "_" : character; }) .join("") .trim(); if (!sanitized || /^\.+$/.test(sanitized)) { return "attachment"; } const encoder = new TextEncoder(); if (encoder.encode(sanitized).byteLength <= 255) { return sanitized; } const extensionMatch = /\.[a-z0-9]{1,16}$/i.exec(sanitized); const extension = extensionMatch && extensionMatch.index > 0 ? extensionMatch[0] : ""; const stem = extension ? sanitized.slice(0, -extension.length) : sanitized; let remainingBytes = 255 - encoder.encode(extension).byteLength; let shortStem = ""; for (const character of stem) { const bytes = encoder.encode(character).byteLength; if (bytes > remainingBytes) break; shortStem += character; remainingBytes -= bytes; } return `${shortStem || "attachment"}${extension}`;} function removeDownloadDirectory(directory: Directory): void { try { if (directory.exists) { directory.delete(); } } catch (error) { console.warn("[attachment-downloads] could not remove a cached file", error); }} type AttachmentFileMetadata = Pick<ChatFileAttachment, "name" | "mimeType">; export interface AttachmentPreviewFile { readonly uri: string; readonly share: (signal: AbortSignal, sourceIdentifier?: string) => Promise<void>; readonly dispose: () => void;} async function availableSharing(signal: AbortSignal) { if (signal.aborted) return null; const Sharing = await import("expo-sharing"); const canShare = await Sharing.isAvailableAsync(); if (signal.aborted) return null; if (!canShare) { throw new Error("Saving and sharing files is unavailable on this device."); } return Sharing;} async function createCachedAttachmentFile(attachment: AttachmentFileMetadata) { const { Directory, File, Paths } = await import("expo-file-system"); const cache = new Directory(Paths.cache, ATTACHMENT_DOWNLOAD_DIRECTORY); cache.create({ idempotent: true, intermediates: true }); const now = Date.now(); try { for (const entry of cache.list()) { const match = DOWNLOAD_DIRECTORY_NAME.exec(entry.name); if ( entry instanceof Directory && match && Number(match[1]) < now - DOWNLOAD_RETENTION_MS && !activeDirectories.has(entry.uri) ) { removeDownloadDirectory(entry); } } } catch (error) { console.warn("[attachment-downloads] could not inspect cached files", error); } const directory = new Directory(cache, `${now}-${uuidv4()}`); directory.create(); let file: InstanceType<typeof File>; try { file = new File(directory, downloadFileName(attachment.name)); } catch (error) { removeDownloadDirectory(directory); throw error; } activeDirectories.add(directory.uri); let disposed = false; let shared = false; let sharing = false; const release = () => { if (!disposed || sharing) return; activeDirectories.delete(directory.uri); // A receiver can still be reading after Android's chooser returns. if (!shared) removeDownloadDirectory(directory); }; const preview: AttachmentPreviewFile = { uri: file.uri, dispose: () => { disposed = true; release(); }, share: async (signal, sourceIdentifier) => { if (disposed || sharing || signal.aborted) return; sharing = true; try { const Sharing = await availableSharing(signal); if (Sharing === null || disposed) return; const endHandoff = beginForegroundHandoff(); try { const options: SharingOptions = { mimeType: attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream", dialogTitle: attachment.name, }; if (sourceIdentifier) { const { shareFileFromSource } = await import("./shareFileFromSource"); if (signal.aborted || disposed) return; await shareFileFromSource(file.uri, options, sourceIdentifier); } else { await Sharing.shareAsync(file.uri, options); } shared = true; } catch (cause) { if (!signal.aborted) { throw new Error("Could not open the share sheet. Try again.", { cause }); } } finally { endHandoff(); } } finally { sharing = false; release(); } }, }; return { file, preview };} /** The caller owns this cached file until disposal, unless it has been shared with another app. */export async function downloadAttachmentForPreview(input: { readonly url: string; readonly attachment: AttachmentFileMetadata; readonly signal: AbortSignal;}): Promise<AttachmentPreviewFile | null> { if (input.signal.aborted) return null; const { File } = await import("expo-file-system"); const cached = await createCachedAttachmentFile(input.attachment); try { if (input.signal.aborted) { cached.preview.dispose(); return null; } await File.downloadFileAsync(input.url, cached.file, { signal: input.signal }); if (input.signal.aborted) { cached.preview.dispose(); return null; } return cached.preview; } catch (cause) { // Android may leave a partial file after a failed or interrupted request. cached.preview.dispose(); if (input.signal.aborted) return null; throw new Error("Could not download the attachment. Check the connection and try again.", { cause, }); }} /** Downloads original bytes for the native save/share sheet, including inline video responses. */export async function downloadAndShareAttachment(input: { readonly url: string; readonly attachment: AttachmentFileMetadata; readonly signal: AbortSignal; readonly sourceIdentifier?: string;}): Promise<void> { if ((await availableSharing(input.signal)) === null) return; const file = await downloadAttachmentForPreview(input); if (file === null) return; try { await file.share(input.signal, input.sourceIdentifier); } finally { file.dispose(); }} /** Shares a cache copy so another app never relies on the lifetime of a composer draft. */export async function shareLocalAttachment(input: { readonly uri: string; readonly attachment: AttachmentFileMetadata; readonly signal: AbortSignal; readonly sourceIdentifier?: string;}): Promise<void> { if ((await availableSharing(input.signal)) === null) return; const { File } = await import("expo-file-system"); const cached = await createCachedAttachmentFile(input.attachment); try { if (input.signal.aborted) return; try { await new File(input.uri).copy(cached.file); } catch (cause) { if (input.signal.aborted) return; throw new Error("Could not prepare the attachment for sharing.", { cause }); } if (!input.signal.aborted) { await cached.preview.share(input.signal, input.sourceIdentifier); } } finally { cached.preview.dispose(); }}
import type { ChatFileAttachment } from "@t3tools/contracts";
import type { Directory } from "expo-file-system";
import type { SharingOptions } from "expo-sharing";
import { beginForegroundHandoff } from "./foreground-handoff";
import { uuidv4 } from "./uuid";
const ATTACHMENT_DOWNLOAD_DIRECTORY = "t3-attachment-downloads";
const DOWNLOAD_RETENTION_MS = 24 * 60 * 60_000;
const DOWNLOAD_DIRECTORY_NAME = /^(\d+)-[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
const activeDirectories = new Set<string>();
function downloadFileName(name: string): string {
const basename = name.split(/[\\/]/).at(-1) ?? "";
const sanitized = Array.from(basename, (character) => {
const codePoint = character.codePointAt(0)!;
return codePoint < 32 ||
(codePoint >= 127 && codePoint <= 159) ||
(codePoint >= 0xd800 && codePoint <= 0xdfff)
? "_"
: character;
})
.join("")
.trim();
if (!sanitized || /^\.+$/.test(sanitized)) {
return "attachment";
}
const encoder = new TextEncoder();
if (encoder.encode(sanitized).byteLength <= 255) {
return sanitized;
}
const extensionMatch = /\.[a-z0-9]{1,16}$/i.exec(sanitized);
const extension = extensionMatch && extensionMatch.index > 0 ? extensionMatch[0] : "";
const stem = extension ? sanitized.slice(0, -extension.length) : sanitized;
let remainingBytes = 255 - encoder.encode(extension).byteLength;
let shortStem = "";
for (const character of stem) {
const bytes = encoder.encode(character).byteLength;
if (bytes > remainingBytes) break;
shortStem += character;
remainingBytes -= bytes;
}
return `${shortStem || "attachment"}${extension}`;
}
function removeDownloadDirectory(directory: Directory): void {
try {
if (directory.exists) {
directory.delete();
}
} catch (error) {
console.warn("[attachment-downloads] could not remove a cached file", error);
}
}
type AttachmentFileMetadata = Pick<ChatFileAttachment, "name" | "mimeType">;
export interface AttachmentPreviewFile {
readonly uri: string;
readonly share: (signal: AbortSignal, sourceIdentifier?: string) => Promise<void>;
readonly dispose: () => void;
}
async function availableSharing(signal: AbortSignal) {
if (signal.aborted) return null;
const Sharing = await import("expo-sharing");
const canShare = await Sharing.isAvailableAsync();
if (signal.aborted) return null;
if (!canShare) {
throw new Error("Saving and sharing files is unavailable on this device.");
}
return Sharing;
}
async function createCachedAttachmentFile(attachment: AttachmentFileMetadata) {
const { Directory, File, Paths } = await import("expo-file-system");
const cache = new Directory(Paths.cache, ATTACHMENT_DOWNLOAD_DIRECTORY);
cache.create({ idempotent: true, intermediates: true });
const now = Date.now();
try {
for (const entry of cache.list()) {
const match = DOWNLOAD_DIRECTORY_NAME.exec(entry.name);
if (
entry instanceof Directory &&
match &&
Number(match[1]) < now - DOWNLOAD_RETENTION_MS &&
!activeDirectories.has(entry.uri)
) {
removeDownloadDirectory(entry);
}
}
} catch (error) {
console.warn("[attachment-downloads] could not inspect cached files", error);
}
const directory = new Directory(cache, `${now}-${uuidv4()}`);
directory.create();
let file: InstanceType<typeof File>;
try {
file = new File(directory, downloadFileName(attachment.name));
} catch (error) {
removeDownloadDirectory(directory);
throw error;
}
activeDirectories.add(directory.uri);
let disposed = false;
let shared = false;
let sharing = false;
const release = () => {
if (!disposed || sharing) return;
activeDirectories.delete(directory.uri);
// A receiver can still be reading after Android's chooser returns.
if (!shared) removeDownloadDirectory(directory);
};
const preview: AttachmentPreviewFile = {
uri: file.uri,
dispose: () => {
disposed = true;
release();
},
share: async (signal, sourceIdentifier) => {
if (disposed || sharing || signal.aborted) return;
sharing = true;
try {
const Sharing = await availableSharing(signal);
if (Sharing === null || disposed) return;
const endHandoff = beginForegroundHandoff();
try {
const options: SharingOptions = {
mimeType: attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream",
dialogTitle: attachment.name,
};
if (sourceIdentifier) {
const { shareFileFromSource } = await import("./shareFileFromSource");
if (signal.aborted || disposed) return;
await shareFileFromSource(file.uri, options, sourceIdentifier);
} else {
await Sharing.shareAsync(file.uri, options);
}
shared = true;
} catch (cause) {
if (!signal.aborted) {
throw new Error("Could not open the share sheet. Try again.", { cause });
}
} finally {
endHandoff();
}
} finally {
sharing = false;
release();
}
},
};
return { file, preview };
}
/** The caller owns this cached file until disposal, unless it has been shared with another app. */
export async function downloadAttachmentForPreview(input: {
readonly url: string;
readonly attachment: AttachmentFileMetadata;
readonly signal: AbortSignal;
}): Promise<AttachmentPreviewFile | null> {
if (input.signal.aborted) return null;
const { File } = await import("expo-file-system");
const cached = await createCachedAttachmentFile(input.attachment);
try {
if (input.signal.aborted) {
cached.preview.dispose();
return null;
}
await File.downloadFileAsync(input.url, cached.file, { signal: input.signal });
if (input.signal.aborted) {
cached.preview.dispose();
return null;
}
return cached.preview;
} catch (cause) {
// Android may leave a partial file after a failed or interrupted request.
cached.preview.dispose();
if (input.signal.aborted) return null;
throw new Error("Could not download the attachment. Check the connection and try again.", {
cause,
});
}
}
/** Downloads original bytes for the native save/share sheet, including inline video responses. */
export async function downloadAndShareAttachment(input: {
readonly url: string;
readonly attachment: AttachmentFileMetadata;
readonly signal: AbortSignal;
readonly sourceIdentifier?: string;
}): Promise<void> {
if ((await availableSharing(input.signal)) === null) return;
const file = await downloadAttachmentForPreview(input);
if (file === null) return;
try {
await file.share(input.signal, input.sourceIdentifier);
} finally {
file.dispose();
}
}
/** Shares a cache copy so another app never relies on the lifetime of a composer draft. */
export async function shareLocalAttachment(input: {
readonly uri: string;
readonly attachment: AttachmentFileMetadata;
readonly signal: AbortSignal;
readonly sourceIdentifier?: string;
}): Promise<void> {
if ((await availableSharing(input.signal)) === null) return;
const { File } = await import("expo-file-system");
const cached = await createCachedAttachmentFile(input.attachment);
try {
if (input.signal.aborted) return;
try {
await new File(input.uri).copy(cached.file);
} catch (cause) {
if (input.signal.aborted) return;
throw new Error("Could not prepare the attachment for sharing.", { cause });
}
if (!input.signal.aborted) {
await cached.preview.share(input.signal, input.sourceIdentifier);
}
} finally {
cached.preview.dispose();
}
}