From 2aaab5fa87325d2cc8390909f33d04149a18f18c Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sun, 18 Oct 2026 12:31:42 +0800 Subject: [PATCH] fix(android): prevent consumed shares from restarting clipping --- .../src/lib/incoming-share-hook.test.ts | 141 ++++++++++++++++++ apps/mobile/src/screens/WorkspaceScreen.tsx | 25 ++-- patches/expo-sharing@57.0.22.patch | 84 ++++++++++- 3 files changed, 235 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/lib/incoming-share-hook.test.ts diff --git a/apps/mobile/src/lib/incoming-share-hook.test.ts b/apps/mobile/src/lib/incoming-share-hook.test.ts new file mode 100644 index 00000000..7e4d7618 --- /dev/null +++ b/apps/mobile/src/lib/incoming-share-hook.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +// Exercise the patched dependency's actual hook without booting React Native. +const hookSource = readFileSync( + new URL("../../node_modules/expo-sharing/src/useIncomingShare.ts", import.meta.url), "utf8", +); +const hookCode = new Bun.Transpiler({ loader: "ts" }).transformSync(hookSource) + .replace(/^import .* from [^;]+;\n/gm, "") + .replace("export function useIncomingShare", "function useIncomingShare"); + +const article = [{ value: "https://www.zhihu.com/pin/2094371908041052336", mimeType: "text/plain", shareType: "url" }]; +const deferred = () => { + let resolve!: (value: unknown[]) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; + +function mountHook() { + const slots: unknown[] = []; + let cursor = 0; + let nativePayloads = article; + const requests: ReturnType[] = []; + const useState = (initial: unknown) => { + const index = cursor++; + if (!(index in slots)) slots[index] = initial; + return [slots[index], (next: unknown) => { slots[index] = next; }]; + }; + const useRef = (initial: unknown) => { + const index = cursor++; + if (!(index in slots)) slots[index] = { current: initial }; + return slots[index]; + }; + const hook = new Function( + "useState", "useRef", "useCallback", "useEffect", "AppState", + "clearNativeSharedPayloads", "getSharedPayloads", "getResolvedSharedPayloadsAsync", + `${hookCode}\nreturn useIncomingShare;`, + )( + useState, useRef, (callback: unknown) => callback, () => {}, {}, + () => { nativePayloads = []; }, () => nativePayloads, + () => { const request = deferred(); requests.push(request); return request.promise; }, + ); + return { + render: () => { cursor = 0; return hook(); }, + shareAgain: () => { nativePayloads = article; }, + requests, + }; +} + +describe("incoming share hook", () => { + test("clearing during resolution prevents a late result replaying a consumed Zhihu share", async () => { + const app = mountHook(); + const pending = app.render().refreshSharePayloads(); + app.render().clearSharedPayloads(); + app.requests[0]!.resolve(article); + await pending; + expect(app.render().sharedPayloads).toEqual([]); + expect(app.render().resolvedSharedPayloads).toEqual([]); + expect(app.render().isResolving).toBe(false); + await app.render().refreshSharePayloads(); // returning to the foreground + expect(app.requests).toHaveLength(1); + }); + + test("a stale failure cannot overwrite a new share or end its loading state", async () => { + const app = mountHook(); + const first = app.render().refreshSharePayloads(); + app.render().clearSharedPayloads(); + app.shareAgain(); + const second = app.render().refreshSharePayloads(); + app.requests[0]!.reject(new Error("old request failed")); + await first; + expect(app.render().error).toBeNull(); + expect(app.render().isResolving).toBe(true); + app.requests[1]!.resolve(article); + await second; + expect(app.render().resolvedSharedPayloads).toEqual(article); + expect(app.render().isResolving).toBe(false); + }); + + test("the user can deliberately share the same link after completing a clip", async () => { + const app = mountHook(); + const first = app.render().refreshSharePayloads(); + app.requests[0]!.resolve(article); + await first; + app.render().clearSharedPayloads(); + expect(app.render().resolvedSharedPayloads).toEqual([]); + await app.render().refreshSharePayloads(); + app.shareAgain(); + const second = app.render().refreshSharePayloads(); + expect(app.requests).toHaveLength(2); + app.requests[1]!.resolve(article); + await second; + expect(app.render().resolvedSharedPayloads).toEqual(article); + }); +}); + +test("workspace finishes the accepted clip when resolved payloads rerun its effect", async () => { + const source = readFileSync(new URL("../screens/WorkspaceScreen.tsx", import.meta.url), "utf8"); + const start = source.indexOf(" useEffect(() => {\n if (incomingShareIsResolving)"); + const end = source.indexOf(" }, [\n incomingShareError,", start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + const effectBody = source.slice(start + " useEffect(() => {".length, end); + const request = deferred(); + const opened: unknown[] = []; + let importing = false; + let handled = 0; + const context = { + incomingShareIsResolving: false, + incomingShareError: null, + incomingSharePayloads: article, + incomingShareUrl: article[0]!.value, + sharedImages: [], + notebooks: [{}], + notebooksQuery: { isSuccess: true }, + processedShareUrlRef: { current: null }, + incomingShareTaskRef: { current: null }, + resolvedLocale: "zh-CN", + isWeChatArticleUrl: () => false, + setIsImportingShare: (value: boolean) => { importing = value; }, + buildMobileWebClipDraft: () => request.promise, + openIncomingClipDraft: (draft: unknown) => { opened.push(draft); }, + onIncomingShareHandledRef: { current: () => { handled++; } }, + Alert: { alert: () => { throw new Error("unexpected alert"); } }, + }; + const runEffect = () => new Function(...Object.keys(context), effectBody)(...Object.values(context)); + const cleanup = runEffect(); + expect(importing).toBe(true); + // React cleans up the previous effect when the hook replaces raw payloads + // with resolved payloads, even though the article URL has not changed. + if (typeof cleanup === "function") cleanup(); + runEffect(); + const draft = { title: "知乎", contentMarkdown: "来源链接" }; + request.resolve([draft]); + await request.promise; + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(opened).toEqual([[draft]]); + expect(importing).toBe(false); + expect(handled).toBe(1); +}); diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index 917f8045..2a13dfe0 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -189,6 +189,10 @@ export const WorkspaceScreen = ({ const memoDraftPrefetchRef = useRef(new Map>()); const memoDraftValueRef = useRef(new Map()); const processedShareUrlRef = useRef(null); + const incomingShareTaskRef = useRef(null); + useEffect(() => () => { + incomingShareTaskRef.current = null; + }, []); const onIncomingShareHandledRef = useRef(onIncomingShareHandled); onIncomingShareHandledRef.current = onIncomingShareHandled; const debouncedSearchText = useDebouncedValue(searchText.trim(), 250); @@ -655,37 +659,36 @@ export const WorkspaceScreen = ({ return; } - let active = true; + // Payload resolution and notebook refreshes rerun this effect. They must not + // cancel an accepted clip or skip its cleanup just because its URL is unchanged. + const task = {}; + incomingShareTaskRef.current = task; + const isActive = () => incomingShareTaskRef.current === task; processedShareUrlRef.current = sourceUrl; setIsImportingShare(true); if (isWeChatArticleUrl(sourceUrl)) { setIncomingClipCaptureUrl(sourceUrl); - return () => { - active = false; - }; + return; } void buildMobileWebClipDraft(sourceUrl, { locale: resolvedLocale }) .then((draft) => { - if (!active) { + if (!isActive()) { return; } openIncomingClipDraft(draft); }) .catch(() => { - if (active) { + if (isActive()) { Alert.alert("剪藏失败", "无法读取分享的网页,请稍后重试。"); } }) .finally(() => { - if (active) { + if (isActive()) { + incomingShareTaskRef.current = null; setIsImportingShare(false); onIncomingShareHandledRef.current?.(); } }); - - return () => { - active = false; - }; }, [ incomingShareError, incomingShareIsResolving, diff --git a/patches/expo-sharing@57.0.22.patch b/patches/expo-sharing@57.0.22.patch index c7c691ff..1041545d 100644 --- a/patches/expo-sharing@57.0.22.patch +++ b/patches/expo-sharing@57.0.22.patch @@ -1,8 +1,15 @@ diff --git a/android/src/main/java/expo/modules/sharing/ShareIntoUtils.kt b/android/src/main/java/expo/modules/sharing/ShareIntoUtils.kt --- a/android/src/main/java/expo/modules/sharing/ShareIntoUtils.kt +++ b/android/src/main/java/expo/modules/sharing/ShareIntoUtils.kt -@@ -50,6 +50,20 @@ - return intent.type != null && allowedShareActions.contains(intent.action) +@@ -47,9 +47,26 @@ + internal fun isShareIntent(intent: Intent): Boolean { + val allowedShareActions = + listOf("android.intent.action.SEND", "android.intent.action.SEND_MULTIPLE") +- return intent.type != null && allowedShareActions.contains(intent.action) ++ // Android can recreate a task with its original SEND intent. That is a ++ // history launch, not a new share, even after the JS process has been killed. ++ return intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY == 0 && ++ intent.type != null && allowedShareActions.contains(intent.action) } +internal fun getShareIntentUris(intent: Intent): List { @@ -147,13 +154,82 @@ diff --git a/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareI diff --git a/src/useIncomingShare.ts b/src/useIncomingShare.ts --- a/src/useIncomingShare.ts +++ b/src/useIncomingShare.ts -@@ -60,6 +60,9 @@ - setIsResolving(true); +@@ -2,7 +2,7 @@ + import type { AppStateStatus } from 'react-native'; + import { AppState } from 'react-native'; + +-import { clearSharedPayloads, getResolvedSharedPayloadsAsync, getSharedPayloads } from './Sharing'; ++import { clearSharedPayloads as clearNativeSharedPayloads, getResolvedSharedPayloadsAsync, getSharedPayloads } from './Sharing'; + import type { ResolvedSharePayload, SharePayload, UseIncomingShareResult } from './Sharing.types'; + + function sharePayloadsAreEqual(a: SharePayload[], b: SharePayload[]): boolean { +@@ -42,6 +42,17 @@ + const [error, setError] = useState(null); + const currentSharedDataRef = useRef([]); + ++ const resolutionVersionRef = useRef(0); ++ const clearSharedPayloads = useCallback(() => { ++ clearNativeSharedPayloads(); ++ resolutionVersionRef.current += 1; ++ currentSharedDataRef.current = []; ++ setSharedPayloads([]); ++ setResolvedSharedPayloads([]); ++ setError(null); ++ setIsResolving(false); ++ }, []); ++ + const refreshSharePayloads = useCallback(async () => { + try { + const newSharedData: SharePayload[] = getSharedPayloads(); +@@ -51,22 +62,28 @@ + return; + } + ++ const version = ++resolutionVersionRef.current; + currentSharedDataRef.current = newSharedData; + setSharedPayloads(newSharedData); + setResolvedSharedPayloads([]); + setError(null); + ++ setIsResolving(newSharedData.length > 0); + if (newSharedData.length > 0) { +- setIsResolving(true); try { const resolved = await getResolvedSharedPayloadsAsync(); ++ if (version !== resolutionVersionRef.current) return; + if (resolved.length === 0) { + throw new Error('Shared content could not be resolved'); + } setResolvedSharedPayloads(resolved); } catch (e) { ++ if (version !== resolutionVersionRef.current) return; setError( + e instanceof Error ? e : new Error('Unknown error during shared payload resolution') + ); + } finally { +- setIsResolving(false); ++ if (version === resolutionVersionRef.current) setIsResolving(false); + } + } + } catch (e) { +diff --git a/android/src/main/java/expo/modules/sharing/SharingModule.kt b/android/src/main/java/expo/modules/sharing/SharingModule.kt +--- a/android/src/main/java/expo/modules/sharing/SharingModule.kt ++++ b/android/src/main/java/expo/modules/sharing/SharingModule.kt +@@ -75,6 +75,17 @@ + + Function("clearSharedPayloads") { + SharingSingleton.intent = null ++ // ReactActivity retains the last SEND intent across task/activity recreation. ++ // Clearing only the singleton lets a consumed share be imported again. ++ appContext.currentActivity?.let { activity -> ++ val intent = activity.intent ++ if (intent != null && (isShareIntent(intent) || intent.data?.host == "expo-sharing")) { ++ activity.intent = Intent(Intent.ACTION_MAIN).apply { ++ component = intent.component ++ addCategory(Intent.CATEGORY_LAUNCHER) ++ } ++ } ++ } + } + + OnActivityResult { _, (requestCode) ->