1import Constants from "expo-constants";
2import * as Crypto from "expo-crypto";
3import { PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contracts";
4import {
5 clearSharedPayloads,
6 getResolvedSharedPayloadsAsync,
7 getSharedPayloads,
8 type ResolvedSharePayload,
9 type SharePayload,
10} from "expo-sharing";
11import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
12import { Alert, AppState, Platform } from "react-native";
13
14import {
15 buildIncomingShareDraft,
16 isShareFileUriUnderOwnedRoots,
17 type IncomingShareDestination,
18 type IncomingShareDraft,
19} from "./incoming-share-model";
20import { createIncomingSharePayloadReader } from "./incoming-share-native";
21import { IncomingShareInbox } from "./incoming-share-inbox";
22import { persistComposerAttachmentFile } from "../../lib/composerImages";
23import {
24 loadIncomingShareDrafts,
25 removeIncomingShareDraft,
26 writeIncomingShareDraft,
27} from "./incoming-share-storage";
28
29type IncomingShareContextValue = {
30 readonly pendingShare: IncomingShareDraft | null;
31 readonly isLoading: boolean;
32 readonly error: Error | null;
33 readonly getShare: (shareId: string) => IncomingShareDraft | null;
34 readonly reserveShare: (shareId: string, destination: IncomingShareDestination) => Promise<void>;
35 readonly releaseShareReservation: (
36 shareId: string,
37 expectedDestination: IncomingShareDestination,
38 ) => Promise<void>;
39 readonly consumeShare: (shareId: string) => Promise<void>;
40 readonly refresh: () => Promise<void>;
41};
42
43const IncomingShareContext = React.createContext<IncomingShareContextValue | null>(null);
44
45function receiveSharingEnabled(): boolean {
46 if (Platform.OS === "android") {
47 return true;
48 }
49 if (Platform.OS !== "ios") {
50 return false;
51 }
52 return Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true;
53}
54
55const getIncomingSharePayloads = createIncomingSharePayloadReader({
56 platform: Platform.OS,
57 readPayloads: getSharedPayloads,
58});
59
60async function resolvedPayloadsForFiles(): Promise<ReadonlyArray<ResolvedSharePayload>> {
61 try {
62 return await getResolvedSharedPayloadsAsync();
63 } catch (error) {
64 // iOS already gives the containing app a copied file:// URL, so raw
65 // payloads remain usable. Android normally resolves content:// into a
66 // private cache file; its modern File API can still read the raw URI when
67 // resolution fails.
68 console.warn("[incoming-share] could not resolve shared file metadata", error);
69 return [];
70 }
71}
72
73async function incomingShareIdForPayloads(payloads: ReadonlyArray<SharePayload>): Promise<string> {
74 const fingerprint = JSON.stringify(
75 payloads.map((payload) => ({
76 shareType: payload.shareType,
77 mimeType: payload.mimeType ?? null,
78 value: payload.value,
79 })),
80 );
81 const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, fingerprint);
82 return `share-${digest}`;
83}
84
85async function readBase64(uri: string): Promise<string> {
86 const { File } = await import("expo-file-system");
87 return new File(uri).base64();
88}
89
90async function readFileSize(uri: string): Promise<number | null> {
91 const { File } = await import("expo-file-system");
92 return new File(uri).size ?? null;
93}
94
95async function removeOwnedFile(uri: string): Promise<void> {
96 if (!uri.startsWith("file:")) {
97 return;
98 }
99 try {
100 const { File, Paths } = await import("expo-file-system");
101 // Only delete files in directories this app owns: its documents and cache
102 // sandbox and its share-extension App Group container. An iOS
103 // open-in-place share points at the sender's own storage; deleting that
104 // URI would destroy the user's document.
105 const ownedRootUris = [
106 Paths.document.uri,
107 Paths.cache.uri,
108 ...Object.values(Paths.appleSharedContainers ?? {}).map((directory) => directory.uri),
109 ];
110 if (!isShareFileUriUnderOwnedRoots(uri, ownedRootUris)) {
111 return;
112 }
113 const file = new File(uri);
114 if (file.exists) {
115 file.delete();
116 }
117 } catch (error) {
118 console.warn("[incoming-share] could not remove temporary shared file", error);
119 }
120}
121
122async function removeReplayedPayloadFiles(payloads: ReadonlyArray<SharePayload>): Promise<void> {
123 const uris = new Set<string>();
124 for (const payload of payloads) {
125 if (["image", "file", "audio", "video"].includes(payload.shareType)) {
126 uris.add(payload.value);
127 }
128 }
129 if (uris.size === 0) {
130 return;
131 }
132 const resolvedPayloads = payloads.some((payload) =>
133 ["file", "audio", "video"].includes(payload.shareType),
134 )
135 ? []
136 : await resolvedPayloadsForFiles();
137 for (const payload of resolvedPayloads) {
138 if (["image", "file", "audio", "video"].includes(payload.shareType) && payload.contentUri) {
139 uris.add(payload.contentUri);
140 }
141 }
142 await Promise.all([...uris].map(removeOwnedFile));
143}
144
145// Keep one operation queue across provider remounts (including development
146// Strict Mode remounts) so two app lifecycle notifications cannot ingest the
147// same native handoff independently.
148const incomingShareInbox = new IncomingShareInbox({
149 loadDrafts: loadIncomingShareDrafts,
150 writeDraft: writeIncomingShareDraft,
151 removeDraft: removeIncomingShareDraft,
152 getPayloads: getIncomingSharePayloads,
153 clearPayloads: clearSharedPayloads,
154 buildDraft: async ({ payloads, id, createdAt }) => {
155 const cleanupUris = new Set<string>();
156 const persistedUris = new Set<string>();
157 const hasGenericFilePayload = payloads.some((payload) =>
158 ["file", "audio", "video"].includes(payload.shareType),
159 );
160 const resolvedPayloads =
161 !hasGenericFilePayload && payloads.some((payload) => payload.shareType === "image")
162 ? await resolvedPayloadsForFiles()
163 : [];
164 const draft = await buildIncomingShareDraft({
165 payloads,
166 resolvedPayloads,
167 fileReader: {
168 readBase64,
169 persistFile: async (uri, name) => {
170 const persistedUri = await persistComposerAttachmentFile(
171 uri,
172 name,
173 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
174 );
175 persistedUris.add(persistedUri);
176 return persistedUri;
177 },
178 readSize: readFileSize,
179 removeOwnedFile: (uri) => {
180 cleanupUris.add(uri);
181 },
182 },
183 id,
184 createdAt,
185 });
186 return {
187 draft,
188 cleanup: async () => {
189 await Promise.all([...cleanupUris].map(removeOwnedFile));
190 },
191 rollback: async () => {
192 await Promise.all([...persistedUris].map(removeOwnedFile));
193 },
194 };
195 },
196 cleanupReplayedPayloads: removeReplayedPayloadFiles,
197 idForPayloads: incomingShareIdForPayloads,
198 now: () => new Date().toISOString(),
199 onClearError: (error) => {
200 console.warn("[incoming-share] could not acknowledge native payload", error);
201 },
202 onCleanupError: (error) => {
203 console.warn("[incoming-share] could not remove temporary shared file", error);
204 },
205});
206
207export function IncomingShareProvider(props: React.PropsWithChildren) {
208 const enabled = receiveSharingEnabled();
209 const [drafts, setDrafts] = useState<ReadonlyArray<IncomingShareDraft>>([]);
210 const [isLoading, setIsLoading] = useState(true);
211 const [error, setError] = useState<Error | null>(null);
212 const refreshPromiseRef = useRef<Promise<void> | null>(null);
213 const mountedRef = useRef(false);
214
215 useEffect(() => {
216 mountedRef.current = true;
217 return () => {
218 mountedRef.current = false;
219 };
220 }, []);
221
222 const refresh = useCallback(async () => {
223 if (refreshPromiseRef.current) {
224 return refreshPromiseRef.current;
225 }
226
227 const operation = (async () => {
228 try {
229 const snapshot = await incomingShareInbox.refresh({ ingestNative: enabled });
230 if (mountedRef.current) {
231 setDrafts(snapshot);
232 setError(null);
233 }
234 } catch (cause) {
235 const persisted = await incomingShareInbox
236 .refresh({ ingestNative: false })
237 .catch(() => null);
238 if (mountedRef.current) {
239 if (persisted) {
240 setDrafts(persisted);
241 }
242 setError(cause instanceof Error ? cause : new Error("Could not import shared content."));
243 }
244 }
245 })().finally(() => {
246 refreshPromiseRef.current = null;
247 });
248
249 refreshPromiseRef.current = operation;
250 return operation;
251 }, [enabled]);
252
253 useEffect(() => {
254 void refresh().finally(() => {
255 if (mountedRef.current) {
256 setIsLoading(false);
257 }
258 });
259 }, [refresh]);
260
261 const refreshOnAppActive = useEffectEvent(() => {
262 void refresh();
263 });
264
265 useEffect(() => {
266 if (!enabled) {
267 return;
268 }
269 const subscription = AppState.addEventListener("change", (state) => {
270 if (state === "active") {
271 refreshOnAppActive();
272 }
273 });
274 return () => subscription.remove();
275 }, [enabled]);
276
277 useEffect(() => {
278 if (!error) {
279 return;
280 }
281 Alert.alert("Could not import shared content", error.message, [
282 { text: "Dismiss", style: "cancel", onPress: () => setError(null) },
283 {
284 text: "Retry",
285 onPress: () => {
286 setError(null);
287 void refresh();
288 },
289 },
290 ]);
291 }, [error, refresh]);
292
293 const consumeShare = useCallback(async (shareId: string) => {
294 const snapshot = await incomingShareInbox.consume(shareId);
295 if (mountedRef.current) {
296 setDrafts(snapshot);
297 }
298 }, []);
299 const reserveShare = useCallback(
300 async (shareId: string, destination: IncomingShareDestination) => {
301 const snapshot = await incomingShareInbox.reserve(shareId, destination);
302 if (mountedRef.current) {
303 setDrafts(snapshot);
304 }
305 },
306 [],
307 );
308 const releaseShareReservation = useCallback(
309 async (shareId: string, expectedDestination: IncomingShareDestination) => {
310 const snapshot = await incomingShareInbox.releaseReservation(shareId, expectedDestination);
311 if (mountedRef.current) {
312 setDrafts(snapshot);
313 }
314 },
315 [],
316 );
317 const getShare = useCallback(
318 (shareId: string) => drafts.find((draft) => draft.id === shareId) ?? null,
319 [drafts],
320 );
321
322 const value = useMemo<IncomingShareContextValue>(
323 () => ({
324 pendingShare: drafts[0] ?? null,
325 isLoading,
326 error,
327 getShare,
328 releaseShareReservation,
329 reserveShare,
330 consumeShare,
331 refresh,
332 }),
333 [
334 consumeShare,
335 drafts,
336 error,
337 getShare,
338 isLoading,
339 refresh,
340 releaseShareReservation,
341 reserveShare,
342 ],
343 );
344
345 return (
346 <IncomingShareContext.Provider value={value}>{props.children}</IncomingShareContext.Provider>
347 );
348}
349
350export function useIncomingShare(): IncomingShareContextValue {
351 const value = React.use(IncomingShareContext);
352 if (value === null) {
353 throw new Error("useIncomingShare must be used within IncomingShareProvider.");
354 }
355 return value;
356}