1import {
2 clampFileAttachmentUploadBytes,
3 fileAttachmentTooLargeMessage,
4} from "@t3tools/client-runtime/state/attachments";
5import {
6 isProviderSendTurnSupportedImageMimeType,
7 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
8 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
9 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
10} from "@t3tools/contracts";
11import * as Schema from "effect/Schema";
12import type { ResolvedSharePayload, SharePayload } from "expo-sharing";
13
14import { DraftComposerAttachmentSchema } from "../../lib/composer-image-schema";
15import { fitComposerImage } from "../../lib/composerImageFit";
16import type { DraftComposerAttachment } from "../../lib/composerImages";
17import { estimateBase64ByteSize } from "../../lib/base64";
18
19export interface IncomingShareDraft {
20 readonly schemaVersion: 1;
21 readonly id: string;
22 readonly createdAt: string;
23 readonly destination?: IncomingShareDestination;
24 readonly text: string;
25 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
26 readonly warnings: ReadonlyArray<string>;
27}
28
29export interface IncomingShareDestination {
30 readonly environmentId: string;
31 readonly projectId: string;
32}
33
34const IncomingShareDestinationSchema = Schema.Struct({
35 environmentId: Schema.String,
36 projectId: Schema.String,
37});
38
39export const IncomingShareDraftSchema = Schema.Struct({
40 schemaVersion: Schema.Literal(1),
41 id: Schema.String,
42 createdAt: Schema.String,
43 destination: Schema.optional(IncomingShareDestinationSchema),
44 text: Schema.String,
45 attachments: Schema.Array(DraftComposerAttachmentSchema),
46 warnings: Schema.Array(Schema.String),
47});
48
49const decodeIncomingShareDraftSync = Schema.decodeUnknownSync(IncomingShareDraftSchema);
50
51export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft {
52 return decodeIncomingShareDraftSync(value);
53}
54
55/**
56 * `file:` path with the iOS `/private` prefix stripped, so URIs that reach the
57 * same file through the `/var` symlink and through `/private/var` compare
58 * equal. Null for anything that is not a `file:` URI.
59 */
60function normalizedFileUriPath(uri: string): string | null {
61 try {
62 const url = new URL(uri);
63 if (url.protocol !== "file:") {
64 return null;
65 }
66 const path = decodeURIComponent(url.pathname);
67 // URL parsing collapses literal ".." segments, but an encoded separator
68 // survives it: "..%2F.." decodes to "../..", which the filesystem would
69 // resolve outside the root the lexical containment check accepted.
70 if (path.split("/").includes("..")) {
71 return null;
72 }
73 return path.startsWith("/private/var/") ? path.slice("/private".length) : path;
74 } catch {
75 return null;
76 }
77}
78
79/**
80 * Whether a shared `file:` URI points strictly inside one of the directories
81 * this app owns (its sandbox and its share-extension App Group container).
82 * Share cleanup must never delete anything else: an iOS open-in-place share
83 * hands over the sender's own file URL, and deleting it destroys the user's
84 * document.
85 */
86export function isShareFileUriUnderOwnedRoots(
87 uri: string,
88 ownedRootUris: ReadonlyArray<string>,
89): boolean {
90 const path = normalizedFileUriPath(uri);
91 if (path === null) {
92 return false;
93 }
94 return ownedRootUris.some((rootUri) => {
95 const rootPath = normalizedFileUriPath(rootUri);
96 if (rootPath === null) {
97 return false;
98 }
99 const root = rootPath.endsWith("/") ? rootPath : `${rootPath}/`;
100 return path.startsWith(root) && path.length > root.length;
101 });
102}
103
104export interface IncomingShareFileReader {
105 readonly readBase64: (uri: string) => Promise<string>;
106 readonly removeOwnedFile: (uri: string) => Promise<void> | void;
107 readonly persistFile?: (uri: string, name: string) => Promise<string>;
108 readonly readSize?: (uri: string) => Promise<number | null>;
109}
110
111/** Apply the destination server's file support after the user chooses a project. */
112export function selectIncomingShareAttachments(input: {
113 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
114 readonly maxFileAttachmentBytes: number | null;
115}): {
116 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
117 readonly warnings: ReadonlyArray<string>;
118} {
119 const attachments: DraftComposerAttachment[] = [];
120 const warnings: string[] = [];
121
122 for (const attachment of input.attachments) {
123 if (attachment.type === "image") {
124 attachments.push(attachment);
125 continue;
126 }
127 if (input.maxFileAttachmentBytes === null) {
128 warnings.push(`'${attachment.name}' was skipped because this server does not support files.`);
129 continue;
130 }
131 const maxFileAttachmentBytes = clampFileAttachmentUploadBytes(input.maxFileAttachmentBytes);
132 if (attachment.sizeBytes > maxFileAttachmentBytes) {
133 warnings.push(fileAttachmentTooLargeMessage(attachment.name, maxFileAttachmentBytes));
134 continue;
135 }
136 attachments.push(attachment);
137 }
138
139 return { attachments, warnings };
140}
141
142export function selectIncomingShareAttachmentsForServer(input: {
143 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
144 readonly serverConfig: {
145 readonly environment: {
146 readonly capabilities: {
147 readonly attachmentUploads?: boolean;
148 readonly fileAttachments?: { readonly maxUploadBytes: number };
149 };
150 };
151 } | null;
152}):
153 | { readonly status: "pending" }
154 | {
155 readonly status: "ready";
156 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
157 readonly warnings: ReadonlyArray<string>;
158 } {
159 const hasFiles = input.attachments.some((attachment) => attachment.type === "file");
160 if (hasFiles && input.serverConfig === null) {
161 return { status: "pending" };
162 }
163 const capabilities = input.serverConfig?.environment.capabilities;
164 const maxFileAttachmentBytes =
165 capabilities?.attachmentUploads === true
166 ? (capabilities.fileAttachments?.maxUploadBytes ?? null)
167 : null;
168 return {
169 status: "ready",
170 ...selectIncomingShareAttachments({
171 attachments: input.attachments,
172 maxFileAttachmentBytes,
173 }),
174 };
175}
176
177function sharedText(payloads: ReadonlyArray<SharePayload>): string {
178 const seen = new Set<string>();
179 const values: string[] = [];
180 for (const payload of payloads) {
181 if (payload.shareType !== "text" && payload.shareType !== "url") {
182 continue;
183 }
184 const value = payload.value.trim();
185 if (!value || seen.has(value)) {
186 continue;
187 }
188 seen.add(value);
189 values.push(value);
190 }
191 return values.join("\n\n");
192}
193
194function resolvedImageFor(
195 payload: SharePayload,
196 index: number,
197 resolvedPayloads: ReadonlyArray<ResolvedSharePayload>,
198 consumedIndexes: Set<number>,
199): ResolvedSharePayload | undefined {
200 const sameIndex = resolvedPayloads[index];
201 if (
202 !consumedIndexes.has(index) &&
203 sameIndex?.shareType === payload.shareType &&
204 sameIndex.value === payload.value
205 ) {
206 consumedIndexes.add(index);
207 return sameIndex;
208 }
209 const matchingIndex = resolvedPayloads.findIndex(
210 (candidate, candidateIndex) =>
211 !consumedIndexes.has(candidateIndex) &&
212 candidate.shareType === payload.shareType &&
213 candidate.value === payload.value,
214 );
215 if (matchingIndex < 0) {
216 return undefined;
217 }
218 consumedIndexes.add(matchingIndex);
219 return resolvedPayloads[matchingIndex];
220}
221
222async function releaseOwnedFiles(
223 fileReader: IncomingShareFileReader,
224 uris: ReadonlyArray<string | undefined>,
225): Promise<void> {
226 for (const uri of new Set(uris.filter((candidate): candidate is string => Boolean(candidate)))) {
227 try {
228 await fileReader.removeOwnedFile(uri);
229 } catch {
230 // Temporary-file cleanup is best-effort and must never discard content
231 // that was successfully converted into a durable composer attachment.
232 }
233 }
234}
235
236function fallbackName(uri: string, index: number, mimeType: string): string {
237 try {
238 const pathName = new URL(uri).pathname.split("/").findLast((segment) => segment.length > 0);
239 if (pathName) {
240 return decodeURIComponent(pathName);
241 }
242 } catch {
243 // Fall through to a deterministic attachment name.
244 }
245 const family = mimeType.split("/")[0]?.toLowerCase();
246 const kind = family === "image" || family === "audio" || family === "video" ? family : "file";
247 const extension =
248 mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || (kind === "image" ? "png" : "bin");
249 return `shared-${kind}-${index + 1}.${extension}`;
250}
251
252export async function buildIncomingShareDraft(input: {
253 readonly payloads: ReadonlyArray<SharePayload>;
254 readonly resolvedPayloads: ReadonlyArray<ResolvedSharePayload>;
255 readonly fileReader: IncomingShareFileReader;
256 readonly id: string;
257 readonly createdAt: string;
258}): Promise<IncomingShareDraft> {
259 const attachments: DraftComposerAttachment[] = [];
260 const warnings: string[] = [];
261 const consumedResolvedPayloadIndexes = new Set<number>();
262 let warnedAttachmentLimit = false;
263
264 for (const [index, payload] of input.payloads.entries()) {
265 if (
266 payload.shareType !== "image" &&
267 payload.shareType !== "file" &&
268 payload.shareType !== "audio" &&
269 payload.shareType !== "video"
270 ) {
271 continue;
272 }
273 const resolved = resolvedImageFor(
274 payload,
275 index,
276 input.resolvedPayloads,
277 consumedResolvedPayloadIndexes,
278 );
279 const uri = resolved?.contentUri ?? payload.value;
280 if (attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
281 if (!warnedAttachmentLimit) {
282 warnings.push(
283 `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared ${payload.shareType === "image" ? "images" : "files"} were attached.`,
284 );
285 warnedAttachmentLimit = true;
286 }
287 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
288 continue;
289 }
290
291 const mimeType = (
292 resolved?.contentMimeType ??
293 payload.mimeType ??
294 (payload.shareType === "image" ? "image/png" : "application/octet-stream")
295 ).toLowerCase();
296 if (payload.shareType !== "image") {
297 // The patched native module never emits a blank display name, but keep
298 // the guard: an empty name would fail the attachment name contract.
299 const sharedFileName =
300 typeof payload.originalName === "string" && payload.originalName.trim().length > 0
301 ? payload.originalName
302 : undefined;
303 const name = resolved?.originalName ?? sharedFileName ?? fallbackName(uri, index, mimeType);
304 if (!uri) {
305 warnings.push("One shared file could not be read.");
306 continue;
307 }
308 let persistedFileUri: string | undefined;
309 let retainedFileUri: string | undefined;
310 try {
311 let sizeBytes = resolved?.contentSize ?? (await input.fileReader.readSize?.(uri)) ?? null;
312 if (
313 (sizeBytes === null || (sizeBytes === 0 && uri.startsWith("content:"))) &&
314 input.fileReader.persistFile
315 ) {
316 persistedFileUri = await input.fileReader.persistFile(uri, name);
317 sizeBytes = (await input.fileReader.readSize?.(persistedFileUri)) ?? null;
318 }
319 if (sizeBytes === null) {
320 warnings.push(`The size of '${name}' could not be determined.`);
321 if (persistedFileUri) {
322 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
323 }
324 continue;
325 }
326 if (sizeBytes <= 0) {
327 warnings.push(`'${name}' is empty or could not be read.`);
328 if (persistedFileUri) {
329 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
330 }
331 continue;
332 }
333 if (sizeBytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) {
334 warnings.push(fileAttachmentTooLargeMessage(name, PROVIDER_SEND_TURN_MAX_FILE_BYTES));
335 if (persistedFileUri) {
336 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
337 }
338 continue;
339 }
340 if (persistedFileUri === undefined && input.fileReader.persistFile) {
341 persistedFileUri = await input.fileReader.persistFile(uri, name);
342 // An Android content: source can misreport its size while the
343 // stored copy is what uploads, so the copy's measured size is what
344 // the attachment must record. A measured zero means the copy holds
345 // no bytes: reject it, whatever the source claimed.
346 const storedSize = (await input.fileReader.readSize?.(persistedFileUri)) ?? null;
347 if (storedSize !== null) {
348 sizeBytes = storedSize;
349 }
350 if (sizeBytes <= 0) {
351 warnings.push(`'${name}' is empty or could not be read.`);
352 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
353 continue;
354 }
355 if (sizeBytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) {
356 warnings.push(fileAttachmentTooLargeMessage(name, PROVIDER_SEND_TURN_MAX_FILE_BYTES));
357 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
358 continue;
359 }
360 }
361 attachments.push({
362 id: `${input.id}:file:${index}`,
363 type: "file",
364 name,
365 mimeType,
366 sizeBytes,
367 fileUri: persistedFileUri ?? uri,
368 });
369 retainedFileUri = persistedFileUri ?? uri;
370 } catch (error) {
371 warnings.push(error instanceof Error ? error.message : `Could not read '${name}'.`);
372 // A copy persisted before the failure has no attachment referencing
373 // it; release it or it leaks in the app's attachment directory.
374 if (persistedFileUri !== undefined) {
375 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
376 }
377 } finally {
378 await releaseOwnedFiles(
379 input.fileReader,
380 [uri, payload.value].filter((candidate) => candidate !== retainedFileUri),
381 );
382 }
383 continue;
384 }
385 if (!uri || !mimeType.startsWith("image/")) {
386 warnings.push("One shared item was not a supported image.");
387 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
388 continue;
389 }
390 if (!isProviderSendTurnSupportedImageMimeType(mimeType)) {
391 warnings.push(
392 `'${resolved?.originalName ?? fallbackName(uri, index, mimeType)}' is not a supported image type.`,
393 );
394 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
395 continue;
396 }
397 if (
398 resolved?.contentSize !== null &&
399 resolved?.contentSize !== undefined &&
400 resolved.contentSize > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
401 ) {
402 warnings.push(
403 `'${resolved.originalName ?? fallbackName(uri, index, mimeType)}' exceeds the 10 MB attachment limit.`,
404 );
405 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
406 continue;
407 }
408
409 try {
410 const sharedBase64 = await input.fileReader.readBase64(uri);
411 // Rendered from the bytes already read, since the provider's file may not be readable
412 // by the native image loader.
413 const fitted = await fitComposerImage({
414 uri: `data:${mimeType};base64,${sharedBase64}`,
415 mimeType,
416 base64: sharedBase64,
417 });
418 const base64 = fitted?.base64 ?? sharedBase64;
419 const sizeBytes = fitted
420 ? estimateBase64ByteSize(base64)
421 : (resolved?.contentSize ?? estimateBase64ByteSize(base64));
422 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
423 warnings.push(
424 `'${resolved?.originalName ?? fallbackName(uri, index, mimeType)}' exceeds the 10 MB attachment limit.`,
425 );
426 continue;
427 }
428 const dataUrl = `data:${mimeType};base64,${base64}`;
429 attachments.push({
430 id: `${input.id}:image:${index}`,
431 type: "image",
432 name: resolved?.originalName ?? fallbackName(uri, index, mimeType),
433 mimeType,
434 sizeBytes,
435 dataUrl,
436 // The share provider's file is temporary. A data-backed preview keeps
437 // the composer valid after its source file and App Group entry are gone.
438 previewUri: dataUrl,
439 });
440 } catch {
441 warnings.push(`Could not read '${fallbackName(uri, index, mimeType)}'.`);
442 } finally {
443 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
444 }
445 }
446
447 return {
448 schemaVersion: 1,
449 id: input.id,
450 createdAt: input.createdAt,
451 text: sharedText(input.payloads),
452 attachments,
453 warnings,
454 };
455}
456
457export function hasIncomingShareContent(draft: IncomingShareDraft): boolean {
458 return draft.text.trim().length > 0 || draft.attachments.length > 0;
459}