fix(mobile): accept the first Android share after the process dies
Android restores the singleTask host with its launcher intent, so the share never reaches JS. A standard receiver copies the payload into the app cache and then opens the host.
This commit is contained in:
1 parent
91498c475f
commit
9756979c24
3 files changed
+459
-111
No files matched your search
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { setAndroidIntentFilters, SHARE_RECEIVER_ACTIVITY } from "../../node_modules/expo-sharing/plugin/build/android/withAndroidIntentFilters.js";
|
||||
|
||||
const sendFilters = [
|
||||
{
|
||||
action: "android.intent.action.SEND",
|
||||
category: "android.intent.category.DEFAULT",
|
||||
data: [{ mimeType: "text/plain" }, { mimeType: "image/*" }],
|
||||
},
|
||||
{
|
||||
action: "android.intent.action.SEND_MULTIPLE",
|
||||
category: "android.intent.category.DEFAULT",
|
||||
data: [{ mimeType: "text/plain" }, { mimeType: "image/*" }],
|
||||
},
|
||||
];
|
||||
|
||||
function manifestConfig() {
|
||||
return {
|
||||
modResults: {
|
||||
manifest: {
|
||||
application: [
|
||||
{
|
||||
$: { "android:name": ".MainApplication" },
|
||||
activity: [
|
||||
{
|
||||
$: { "android:name": ".MainActivity", "android:launchMode": "singleTask" },
|
||||
"intent-filter": [
|
||||
{
|
||||
action: [{ $: { "android:name": "android.intent.action.MAIN" } }],
|
||||
category: [{ $: { "android:name": "android.intent.category.LAUNCHER" } }],
|
||||
},
|
||||
{
|
||||
$: { "expo-sharing-intent-filters": "true" },
|
||||
action: [{ $: { "android:name": "android.intent.action.SEND" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Android share receiver manifest", () => {
|
||||
test("moves SEND filters off the singleTask host", () => {
|
||||
const config = manifestConfig();
|
||||
setAndroidIntentFilters(config, sendFilters);
|
||||
const activities = config.modResults.manifest.application[0].activity;
|
||||
const main = activities.find((activity) => activity.$["android:name"] === ".MainActivity");
|
||||
const receiver = activities.find((activity) => activity.$["android:name"] === SHARE_RECEIVER_ACTIVITY);
|
||||
expect(main["intent-filter"]).toHaveLength(1);
|
||||
expect(main["intent-filter"][0].action[0].$["android:name"]).toBe("android.intent.action.MAIN");
|
||||
expect(receiver.$["android:launchMode"]).toBe("standard");
|
||||
expect(receiver.$["android:taskAffinity"]).toBe("");
|
||||
expect(receiver.$["android:noHistory"]).toBe("true");
|
||||
expect(receiver["intent-filter"].map((filter) => filter.action[0].$["android:name"])).toEqual([
|
||||
"android.intent.action.SEND",
|
||||
"android.intent.action.SEND_MULTIPLE",
|
||||
]);
|
||||
});
|
||||
|
||||
test("removes the receiver when sharing is disabled", () => {
|
||||
const config = manifestConfig();
|
||||
setAndroidIntentFilters(config, sendFilters);
|
||||
setAndroidIntentFilters(config, []);
|
||||
const names = config.modResults.manifest.application[0].activity.map((activity) => activity.$["android:name"]);
|
||||
expect(names).toEqual([".MainActivity"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("share receiver copies a cold-start payload before opening the host", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../../node_modules/expo-sharing/android/src/main/java/expo/modules/sharing/ShareReceiverActivity.kt", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const copy = source.indexOf("materializeShareIntent");
|
||||
const publish = source.indexOf("publishSharedIntent");
|
||||
const open = source.indexOf("openShareHost");
|
||||
expect(copy).toBeGreaterThan(-1);
|
||||
expect(publish).toBeGreaterThan(copy);
|
||||
expect(open).toBeGreaterThan(publish);
|
||||
});
|
||||
+373
-111
@@ -1,17 +1,114 @@
|
||||
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
|
||||
@@ -47,9 +47,26 @@
|
||||
@@ -5,10 +5,15 @@
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
+import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import com.facebook.react.ReactActivity
|
||||
+import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
+import java.io.File
|
||||
+import java.io.FileNotFoundException
|
||||
import java.net.URL
|
||||
+import java.util.UUID
|
||||
|
||||
// Emits intent received event to JS. This is because the `SEND` and `SEND_MULTIPLE` intents
|
||||
// have no uri and do not inform the navigation libraries of a native intent.
|
||||
@@ -32,22 +37,100 @@
|
||||
// Emits intent received event to JS. This is because the `SEND` and `SEND_MULTIPLE` intents
|
||||
// do not navigation libraries of a native intent. This aligns the behaviour with ios.
|
||||
internal fun emitShareIntentReceived(reactActivity: ReactActivity, originalIntent: Intent) {
|
||||
- SharingSingleton.intent = originalIntent
|
||||
+ publishSharedIntent(reactActivity, originalIntent)
|
||||
+}
|
||||
|
||||
- val currentContext = reactActivity.reactDelegate?.reactHost?.currentReactContext
|
||||
+internal fun publishSharedIntent(context: Context, intent: Intent) {
|
||||
+ SharingSingleton.intent = intent
|
||||
+ val reactContext = (context.applicationContext as? ReactApplication)
|
||||
+ ?.reactHost
|
||||
+ ?.currentReactContext
|
||||
+ ?: return
|
||||
val deviceEventManagerModule =
|
||||
- currentContext?.getNativeModule(DeviceEventManagerModule::class.java)
|
||||
+ reactContext.getNativeModule(DeviceEventManagerModule::class.java) ?: return
|
||||
+ getShareIntentUri(context)?.let { uri ->
|
||||
+ deviceEventManagerModule.emitNewIntentReceived(uri)
|
||||
+ }
|
||||
+}
|
||||
|
||||
- getShareIntentUri(reactActivity)?.let { uri ->
|
||||
- deviceEventManagerModule?.emitNewIntentReceived(uri)
|
||||
+internal fun materializeShareIntent(context: Context, source: Intent): Intent {
|
||||
+ val uris = getShareIntentUris(source)
|
||||
+ if (uris.isEmpty()) {
|
||||
+ return baseShareIntent(source).apply {
|
||||
+ if (!hasExtra(Intent.EXTRA_TEXT)) {
|
||||
+ source.clipData?.let { clipData = it }
|
||||
+ }
|
||||
+ }
|
||||
}
|
||||
+
|
||||
+ val copied = mutableListOf<File>()
|
||||
+ try {
|
||||
+ val localUris = uris.map { uri ->
|
||||
+ val file = copySharedUri(context, uri)
|
||||
+ copied.add(file)
|
||||
+ FileProvider.getUriForFile(context, "${context.packageName}.SharingFileProvider", file)
|
||||
+ }
|
||||
+ return baseShareIntent(source).apply {
|
||||
+ if (action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
+ putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(localUris))
|
||||
+ } else {
|
||||
+ putExtra(Intent.EXTRA_STREAM, localUris.first())
|
||||
+ }
|
||||
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
+ }
|
||||
+ } catch (error: Exception) {
|
||||
+ copied.forEach { it.delete() }
|
||||
+ throw error
|
||||
+ }
|
||||
}
|
||||
|
||||
+private fun baseShareIntent(source: Intent): Intent {
|
||||
+ return Intent(source.action).apply {
|
||||
+ type = source.type
|
||||
+ source.getCharSequenceExtra(Intent.EXTRA_TEXT)?.let { putExtra(Intent.EXTRA_TEXT, it) }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+private fun copySharedUri(context: Context, uri: Uri): File {
|
||||
+ val scheme = uri.scheme
|
||||
+ if (scheme != "content" && scheme != "file") {
|
||||
+ throw FileNotFoundException("Unsupported shared URI")
|
||||
+ }
|
||||
+ val rawName = uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() } ?: "shared"
|
||||
+ val safeName = rawName.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80)
|
||||
+ val file = File(context.cacheDir, "incoming-share-${UUID.randomUUID()}-$safeName")
|
||||
+ context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
+ file.outputStream().use { output -> input.copyTo(output) }
|
||||
+ } ?: throw FileNotFoundException(uri.toString())
|
||||
+ if (file.length() == 0L) {
|
||||
+ file.delete()
|
||||
+ throw FileNotFoundException(uri.toString())
|
||||
+ }
|
||||
+ return file
|
||||
+}
|
||||
+
|
||||
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> {
|
||||
+ val streams = when (intent.action) {
|
||||
+ Intent.ACTION_SEND -> listOfNotNull(intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))
|
||||
@@ -24,11 +121,87 @@ diff --git a/android/src/main/java/expo/modules/sharing/ShareIntoUtils.kt b/andr
|
||||
+
|
||||
+ val clipData = intent.clipData ?: return emptyList()
|
||||
+ return (0 until clipData.itemCount).mapNotNull { index -> clipData.getItemAt(index).uri }
|
||||
}
|
||||
|
||||
internal inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(name: String): T? {
|
||||
diff --git a/android/src/main/java/expo/modules/sharing/ShareReceiverActivity.kt b/android/src/main/java/expo/modules/sharing/ShareReceiverActivity.kt
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/android/src/main/java/expo/modules/sharing/ShareReceiverActivity.kt
|
||||
@@ -0,0 +1,52 @@
|
||||
+package expo.modules.sharing
|
||||
+
|
||||
+import android.app.Activity
|
||||
+import android.content.Context
|
||||
+import android.content.Intent
|
||||
+import android.net.Uri
|
||||
+import android.os.Bundle
|
||||
+
|
||||
+/**
|
||||
+ * Receives ACTION_SEND while the singleTask host is dead.
|
||||
+ *
|
||||
+ * Android then brings the existing task forward with its original launcher
|
||||
+ * intent and does not deliver the share to that activity. This standard-mode
|
||||
+ * activity is created with the share intent, copies the payload into app
|
||||
+ * cache, and only then opens the host.
|
||||
+ */
|
||||
+class ShareReceiverActivity : Activity() {
|
||||
+ override fun onCreate(savedInstanceState: Bundle?) {
|
||||
+ super.onCreate(savedInstanceState)
|
||||
+ try {
|
||||
+ val incoming = intent
|
||||
+ if (incoming != null && isShareIntent(incoming)) {
|
||||
+ val prepared = try {
|
||||
+ materializeShareIntent(this, incoming)
|
||||
+ } catch (_: Exception) {
|
||||
+ unreadableShareIntent(incoming)
|
||||
+ }
|
||||
+ publishSharedIntent(this, prepared)
|
||||
+ openShareHost(this)
|
||||
+ }
|
||||
+ } finally {
|
||||
+ finish()
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
internal inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(name: String): T? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getParcelableExtra(name, T::class.java)
|
||||
+internal fun unreadableShareIntent(source: Intent): Intent {
|
||||
+ return Intent(source.action ?: Intent.ACTION_SEND).apply {
|
||||
+ type = source.type ?: "image/*"
|
||||
+ putExtra(Intent.EXTRA_STREAM, Uri.parse("content://expo.modules.sharing/unreadable-share"))
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+internal fun openShareHost(context: Context) {
|
||||
+ val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return
|
||||
+ getShareIntentUri(context)?.let { uri ->
|
||||
+ launch.action = Intent.ACTION_VIEW
|
||||
+ launch.data = uri
|
||||
+ launch.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
+ }
|
||||
+ context.startActivity(launch)
|
||||
+}
|
||||
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) ->
|
||||
diff --git a/android/src/main/java/expo/modules/sharing/dataParsers/ResolvingShareIntentDataParser.kt b/android/src/main/java/expo/modules/sharing/dataParsers/ResolvingShareIntentDataParser.kt
|
||||
--- a/android/src/main/java/expo/modules/sharing/dataParsers/ResolvingShareIntentDataParser.kt
|
||||
+++ b/android/src/main/java/expo/modules/sharing/dataParsers/ResolvingShareIntentDataParser.kt
|
||||
@@ -151,111 +324,6 @@ diff --git a/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareI
|
||||
return SharePayload().apply {
|
||||
value = uri.toString()
|
||||
shareType = ShareType.fromMimeType(specificType)
|
||||
diff --git a/src/useIncomingShare.ts b/src/useIncomingShare.ts
|
||||
--- a/src/useIncomingShare.ts
|
||||
+++ b/src/useIncomingShare.ts
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import type { AppStateStatus } from 'react-native';
|
||||
-import { AppState } from 'react-native';
|
||||
+import { AppState, Linking } 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) {
|
||||
@@ -83,7 +100,20 @@
|
||||
}
|
||||
});
|
||||
|
||||
+ // A warm share may arrive after the activity is already active. Android
|
||||
+ // emits this URL after storing the intent, so AppState alone is insufficient.
|
||||
+ const shareSubscription = Linking.addEventListener('url', ({ url }) => {
|
||||
+ try {
|
||||
+ if (new URL(url).hostname === 'expo-sharing') {
|
||||
+ void refreshSharePayloads();
|
||||
+ }
|
||||
+ } catch {
|
||||
+ // Ignore unrelated malformed deep links.
|
||||
+ }
|
||||
+ });
|
||||
+
|
||||
return () => {
|
||||
+ shareSubscription.remove();
|
||||
subscription.remove();
|
||||
};
|
||||
}, [refreshSharePayloads]);
|
||||
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) ->
|
||||
diff --git a/build/useIncomingShare.js b/build/useIncomingShare.js
|
||||
--- a/build/useIncomingShare.js
|
||||
+++ b/build/useIncomingShare.js
|
||||
@@ -339,3 +407,197 @@ diff --git a/build/useIncomingShare.js b/build/useIncomingShare.js
|
||||
subscription.remove();
|
||||
};
|
||||
}, [refreshSharePayloads]);
|
||||
diff --git a/plugin/build/android/withAndroidIntentFilters.js b/plugin/build/android/withAndroidIntentFilters.js
|
||||
--- a/plugin/build/android/withAndroidIntentFilters.js
|
||||
+++ b/plugin/build/android/withAndroidIntentFilters.js
|
||||
@@ -5,6 +5,8 @@
|
||||
exports.default = renderIntentFilters;
|
||||
const config_plugins_1 = require("@expo/config-plugins");
|
||||
const SHARING_GENERATED_TAG = 'expo-sharing-intent-filters';
|
||||
+const SHARE_RECEIVER_ACTIVITY = 'expo.modules.sharing.ShareReceiverActivity';
|
||||
+exports.SHARE_RECEIVER_ACTIVITY = SHARE_RECEIVER_ACTIVITY;
|
||||
const withAndroidIntentFilters = (config, { intentFilters }) => {
|
||||
return (0, config_plugins_1.withAndroidManifest)(config, (config) => {
|
||||
setAndroidIntentFilters(config, intentFilters);
|
||||
@@ -13,17 +15,31 @@
|
||||
};
|
||||
exports.withAndroidIntentFilters = withAndroidIntentFilters;
|
||||
function setAndroidIntentFilters(config, intentFilters) {
|
||||
- const mainActivity = config_plugins_1.AndroidConfig.Manifest.getMainActivityOrThrow(config.modResults);
|
||||
- // Remove all generated tags from previous runs...
|
||||
- if (mainActivity['intent-filter']?.length) {
|
||||
- mainActivity['intent-filter'] = mainActivity['intent-filter'].filter(
|
||||
- // @ts-ignore
|
||||
- (value) => value.$?.[SHARING_GENERATED_TAG] !== 'true');
|
||||
+ const application = config_plugins_1.AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
|
||||
+ const activities = application.activity ?? [];
|
||||
+ for (const activity of activities) {
|
||||
+ if (activity['intent-filter']?.length) {
|
||||
+ activity['intent-filter'] = activity['intent-filter'].filter((value) => value.$?.[SHARING_GENERATED_TAG] !== 'true');
|
||||
+ }
|
||||
}
|
||||
+ // A singleTask host loses a share when Android restores its launcher intent
|
||||
+ // after the process has been killed. Keep SEND on a standard activity.
|
||||
+ application.activity = activities.filter((activity) => activity.$?.['android:name'] !== SHARE_RECEIVER_ACTIVITY);
|
||||
if (!intentFilters.length) {
|
||||
return config;
|
||||
}
|
||||
- mainActivity['intent-filter'] = mainActivity['intent-filter']?.concat(renderIntentFilters(intentFilters));
|
||||
+ application.activity.push({
|
||||
+ $: {
|
||||
+ 'android:name': SHARE_RECEIVER_ACTIVITY,
|
||||
+ 'android:exported': 'true',
|
||||
+ 'android:excludeFromRecents': 'true',
|
||||
+ 'android:launchMode': 'standard',
|
||||
+ 'android:noHistory': 'true',
|
||||
+ 'android:taskAffinity': '',
|
||||
+ 'android:theme': '@android:style/Theme.NoDisplay',
|
||||
+ },
|
||||
+ 'intent-filter': renderIntentFilters(intentFilters),
|
||||
+ });
|
||||
return config;
|
||||
}
|
||||
function renderIntentFilters(intentFilters) {
|
||||
diff --git a/plugin/src/android/withAndroidIntentFilters.ts b/plugin/src/android/withAndroidIntentFilters.ts
|
||||
--- a/plugin/src/android/withAndroidIntentFilters.ts
|
||||
+++ b/plugin/src/android/withAndroidIntentFilters.ts
|
||||
@@ -10,6 +10,7 @@
|
||||
import { SingleIntentFilter, MultiIntentFilter, IntentFilter } from '../sharingPlugin.types';
|
||||
|
||||
const SHARING_GENERATED_TAG = 'expo-sharing-intent-filters';
|
||||
+export const SHARE_RECEIVER_ACTIVITY = 'expo.modules.sharing.ShareReceiverActivity';
|
||||
type WithAndroidIntentFiltersOptions = {
|
||||
intentFilters: IntentFilter[];
|
||||
};
|
||||
@@ -28,22 +29,37 @@
|
||||
config: ExportedConfigWithProps<AndroidManifest>,
|
||||
intentFilters: IntentFilter[]
|
||||
): ExportedConfigWithProps<AndroidManifest> {
|
||||
- const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(config.modResults);
|
||||
- // Remove all generated tags from previous runs...
|
||||
- if (mainActivity['intent-filter']?.length) {
|
||||
- mainActivity['intent-filter'] = mainActivity['intent-filter'].filter(
|
||||
- // @ts-ignore
|
||||
- (value) => value.$?.[SHARING_GENERATED_TAG] !== 'true'
|
||||
- );
|
||||
+ const application = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
|
||||
+ const activities = application.activity ?? [];
|
||||
+ for (const activity of activities) {
|
||||
+ if (activity['intent-filter']?.length) {
|
||||
+ activity['intent-filter'] = activity['intent-filter'].filter(
|
||||
+ (value) => value.$?.[SHARING_GENERATED_TAG] !== 'true'
|
||||
+ );
|
||||
+ }
|
||||
}
|
||||
+ // A singleTask host loses a share when Android restores its launcher intent
|
||||
+ // after the process has been killed. Keep SEND on a standard activity.
|
||||
+ application.activity = activities.filter(
|
||||
+ (activity) => activity.$?.['android:name'] !== SHARE_RECEIVER_ACTIVITY
|
||||
+ );
|
||||
|
||||
if (!intentFilters.length) {
|
||||
return config;
|
||||
}
|
||||
|
||||
- mainActivity['intent-filter'] = mainActivity['intent-filter']?.concat(
|
||||
- renderIntentFilters(intentFilters)
|
||||
- );
|
||||
+ application.activity.push({
|
||||
+ $: {
|
||||
+ 'android:name': SHARE_RECEIVER_ACTIVITY,
|
||||
+ 'android:exported': 'true',
|
||||
+ 'android:excludeFromRecents': 'true',
|
||||
+ 'android:launchMode': 'standard',
|
||||
+ 'android:noHistory': 'true',
|
||||
+ 'android:taskAffinity': '',
|
||||
+ 'android:theme': '@android:style/Theme.NoDisplay',
|
||||
+ },
|
||||
+ 'intent-filter': renderIntentFilters(intentFilters),
|
||||
+ });
|
||||
|
||||
return config;
|
||||
}
|
||||
diff --git a/src/useIncomingShare.ts b/src/useIncomingShare.ts
|
||||
--- a/src/useIncomingShare.ts
|
||||
+++ b/src/useIncomingShare.ts
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import type { AppStateStatus } from 'react-native';
|
||||
-import { AppState } from 'react-native';
|
||||
+import { AppState, Linking } 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) {
|
||||
@@ -83,7 +100,20 @@
|
||||
}
|
||||
});
|
||||
|
||||
+ // A warm share may arrive after the activity is already active. Android
|
||||
+ // emits this URL after storing the intent, so AppState alone is insufficient.
|
||||
+ const shareSubscription = Linking.addEventListener('url', ({ url }) => {
|
||||
+ try {
|
||||
+ if (new URL(url).hostname === 'expo-sharing') {
|
||||
+ void refreshSharePayloads();
|
||||
+ }
|
||||
+ } catch {
|
||||
+ // Ignore unrelated malformed deep links.
|
||||
+ }
|
||||
+ });
|
||||
+
|
||||
return () => {
|
||||
+ shareSubscription.remove();
|
||||
subscription.remove();
|
||||
};
|
||||
}, [refreshSharePayloads]);
|
||||
@@ -55,6 +55,7 @@ PREBUILD_FINGERPRINT="$({
|
||||
apps/mobile/app.json \
|
||||
apps/mobile/assets/adaptive-icon-transparent.svg \
|
||||
apps/web/public/pwa-512x512.png \
|
||||
patches/expo-sharing@57.0.22.patch \
|
||||
bun.lock
|
||||
} | shasum | awk '{print $1}')"
|
||||
PREBUILD_STAMP="$ANDROID_DIR/.edgeever-prebuild-fingerprint"
|
||||
|
||||
Reference in new issue
Block a user