fix(android): prevent consumed shares from restarting clipping

This commit is contained in:
tianma-if committed 2026-10-18 12:31:55 +08:00
1 parent 6c4e70262e
commit 2aaab5fa87
3 files changed
+235 -15

No files matched your search

@@ -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<unknown[]>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
};
function mountHook() {
const slots: unknown[] = [];
let cursor = 0;
let nativePayloads = article;
const requests: ReturnType<typeof deferred>[] = [];
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);
});
+14 -11
View File
@@ -189,6 +189,10 @@ export const WorkspaceScreen = ({
const memoDraftPrefetchRef = useRef(new Map<string, Promise<MobileMemoDraft | null>>());
const memoDraftValueRef = useRef(new Map<string, MobileMemoDraft | null>());
const processedShareUrlRef = useRef<string | null>(null);
const incomingShareTaskRef = useRef<object | null>(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,
+80 -4
View File
@@ -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<Uri> {
@@ -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<Error | null>(null);
const currentSharedDataRef = useRef<SharePayload[]>([]);
+ 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) ->