fix(android): prevent consumed shares from restarting clipping
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ->
|
||||
Reference in new issue
Block a user