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 @@ 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 { + val streams = when (intent.action) { + Intent.ACTION_SEND -> listOfNotNull(intent.getParcelableExtraCompat(Intent.EXTRA_STREAM)) + Intent.ACTION_SEND_MULTIPLE -> intent.getParcelableArrayListExtraCompat(Intent.EXTRA_STREAM).orEmpty() + else -> emptyList() + } + if (streams.isNotEmpty()) { + return streams + } + + val clipData = intent.clipData ?: return emptyList() + return (0 until clipData.itemCount).mapNotNull { index -> clipData.getItemAt(index).uri } +} + internal inline fun Intent.getParcelableExtraCompat(name: String): T? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { getParcelableExtra(name, T::class.java) 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 @@ -6,9 +6,11 @@ import android.net.Uri import android.provider.OpenableColumns import java.io.File +import java.io.FileNotFoundException import java.io.FileOutputStream import java.net.HttpURLConnection import java.net.URL +import java.util.UUID import kotlin.text.indexOf import kotlin.text.substring @@ -25,21 +27,22 @@ } private fun handleSendAction(context: Context, intent: Intent, type: String): List { - return if (type == "text/plain") { + val uri = getShareIntentUris(intent).firstOrNull() + return if (uri != null) { + listOf(resolveUri(context, uri, type)) + } else if (type == "text/plain") { extractTextPayload(intent) - } else { - val uri = intent.getParcelableExtraCompat(Intent.EXTRA_STREAM) - listOfNotNull(uri?.let { resolveUri(context, it, type) }) - } + } else emptyList() } private fun handleSendMultipleAction(context: Context, intent: Intent, type: String): List { - val uris = intent.getParcelableArrayListExtraCompat(Intent.EXTRA_STREAM) - return uris?.map { resolveUri(context, it, type) } ?: emptyList() + return getShareIntentUris(intent).map { resolveUri(context, it, type) } } private fun extractTextPayload(intent: Intent): List { - val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return emptyList() + val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() + ?: intent.clipData?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text?.toString() + ?: return emptyList() if (android.util.Patterns.WEB_URL.matcher(text).matches()) { return listOf(resolveUrlContext(text)) @@ -58,19 +61,27 @@ val contentResolver = context.contentResolver val fileName = getFileName(contentResolver, uri) ?: "unknown" val fileSize = getFileSize(contentResolver, uri) - val specificMimeType = contentResolver.getType(uri) ?: mimeType + val specificMimeType = contentResolver.getType(uri) + ?: mimeType.takeUnless { it == "text/plain" } + ?: "image/*" val type = ShareType.fromMimeType(specificMimeType) // Copy to cache dir to ensure access - val file = File(context.cacheDir, fileName) + val file = File(context.cacheDir, "shared-${UUID.randomUUID()}-${File(fileName).name}") try { - contentResolver.openInputStream(uri)?.use { input -> + val inputStream = contentResolver.openInputStream(uri) + ?: throw FileNotFoundException("No readable stream for shared URI") + inputStream.use { input -> FileOutputStream(file).use { output -> input.copyTo(output) } } + if (file.length() == 0L) { + throw FileNotFoundException("The shared file is empty") + } } catch (e: Exception) { - e.printStackTrace() + file.delete() + throw FailedToResolveSharedDataException("Could not read the shared file", e) } return ResolvedSharePayload().apply { diff --git a/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt b/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt --- a/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt +++ b/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt @@ -17,8 +17,13 @@ } private fun handleSendAction(context: Context, intent: Intent, type: String): List { - return if (type == "text/plain") { - val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return emptyList() + val uri = getShareIntentUris(intent).firstOrNull() + return if (uri != null) { + listOf(createUriPayload(context, uri, type)) + } else if (type == "text/plain") { + val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() + ?: intent.clipData?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text?.toString() + ?: return emptyList() val isUrl = android.util.Patterns.WEB_URL.matcher(text).matches() listOf( @@ -28,19 +33,17 @@ mimeType = "text/plain" } ) - } else { - val uri = intent.getParcelableExtraCompat(Intent.EXTRA_STREAM) - listOfNotNull(uri?.let { createUriPayload(context, it, type) }) - } + } else emptyList() } private fun handleSendMultipleAction(context: Context, intent: Intent, type: String): List { - val uris = intent.getParcelableArrayListExtraCompat(Intent.EXTRA_STREAM) - return uris?.map { createUriPayload(context, it, type) } ?: emptyList() + return getShareIntentUris(intent).map { createUriPayload(context, it, type) } } private fun createUriPayload(context: Context, uri: Uri, defaultType: String): SharePayload { - val specificType = context.contentResolver.getType(uri) ?: defaultType + val specificType = context.contentResolver.getType(uri) + ?: defaultType.takeUnless { it == "text/plain" } + ?: "image/*" 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(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) { @@ -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 @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback, useRef } from 'react'; -import { AppState } from 'react-native'; -import { clearSharedPayloads, getResolvedSharedPayloadsAsync, getSharedPayloads } from './Sharing'; +import { AppState, Linking } from 'react-native'; +import { clearSharedPayloads as clearNativeSharedPayloads, getResolvedSharedPayloadsAsync, getSharedPayloads } from './Sharing'; function sharePayloadsAreEqual(a, b) { if (a.length !== b.length) { return false; @@ -30,6 +30,16 @@ const [isResolving, setIsResolving] = useState(false); 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 = getSharedPayloads(); @@ -37,21 +47,30 @@ if (sharePayloadsAreEqual(newSharedData, currentSharedDataRef.current)) { 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); } } } @@ -66,7 +85,20 @@ refreshSharePayloads(); } }); + // 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]);