When you select a file to attach to an email, have you ever wondered how to make your app appear as shown below and enable file selection?
That's possible thanks to Expo Modules, and I'm going to show you how to do it with Expo. It's kind of simple.
Disclaimer: This only works on Android. iOS does not support this functionality.
The project I'll show you can be found on my Github.
First, in an expo project, we need to start by creating a new expo-module
npx create-expo-module expo-settings
I named the module ContentIntent
, and it will be created in the modules folder at the root of your project.
For your app to appear in the file picker, it is necessary to expose the getIntent
and setIntent
functions from the Android native code. These functions allow us to receive the intent for picking a file from our app and send the file back. This is why we need a native module.
So we modified modules/content-intent/android/src/main/java/../contentintent/ContentIntentModule.kt
to expose these native functions:
class ContentIntentModule : Module() {
override fun definition() = ModuleDefinition {
Name("ContentIntent")
Function("getIntent") { getIntent() }
Function("setResult") { options: ResultOptions -> setResult(options) }
}
private val currentActivity
get() =
appContext.activityProvider?.currentActivity
?: throw CodedException(
"Activity which was provided during module initialization is no longer available."
)
private val context
get() = requireNotNull(appContext.reactContext)
private fun getIntent(): Map<String, Any?> {
val intent = currentActivity.intent
return mapOf(
"action" to intent.action,
"categories" to intent.categories?.toList(),
"data" to intent.dataString,
"type" to intent.type,
"flags" to intent.flags,
"extras" to intent.extras
)
}
private fun setResult(options: ResultOptions) {
val resultCode = Activity.RESULT_OK
val resultIntent =
Intent(
if (options.action != null) options.action else context.packageName + ".RESULT_ACTION"
)
resultIntent.data = Uri.parse(options.uris[0])
if (options.uris.size > 1) {
val clipData = ClipData.newRawUri(null, Uri.parse(options.uris[1]))
for (i in 2 until options.uris.size) {
clipData.addItem(ClipData.Item(Uri.parse(options.uris[i])))
}
resultIntent.clipData = clipData
}
resultIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
currentActivity.setResult(resultCode, resultIntent)
currentActivity.finish()
}
}
In modules/content-intent/index.ts
, we import the exposed APIs and export them for use in the app:
import { Intent, ResultOptions } from "./src/ContentIntent.types";
import ContentIntentModule from "./src/ContentIntentModule";
import { Platform } from "react-native";
export function getIntent(): Intent | null {
if (Platform.OS !== "android") return null;
return ContentIntentModule.getIntent();
}
export function setResult(options: ResultOptions): void {
if (Platform.OS !== "android") return;
ContentIntentModule.setResult(options);
}
export { Intent, ResultOptions };
Then, in app.json
, we must add the GET_CONTENT
intent filters so that our app can be selected in the File Picker.
android: {
...
intentFilters: [
{
action: "GET_CONTENT",
data: {
mimeType: "*/*",
},
category: ["DEFAULT", "OPENABLE"],
},
],
},
We create a custom hook called useCurrentIntent
that allows us to listen for the GET_CONTENT
intent and set a result for it.
import {
ResultOptions,
getIntent,
setResult as setResultBase,
} from "../modules/content-intent";
import {
cacheDirectory,
copyAsync,
getContentUriAsync,
getInfoAsync,
} from "expo-file-system";
import { useCallback, useState } from "react";
export function useCurrentIntent() {
const [intent] = useState(getIntent);
const setResult = useCallback(
async ({ isOK, action, uris }: ResultOptions) => {
if (!isOK || !uris?.length) {
setResultBase({ isOK: false });
return;
}
if (
!intent?.extras?.["android.intent.extra.EXTRA_ALLOW_MULTIPLE"] &&
uris.length > 1
) {
console.error("Multiple files are not allowed", { isOK, action, uris });
setResultBase({ isOK: false });
return;
}
const destFiles = await Promise.all(
uris.map(async (uri: any) => {
const destFile = (cacheDirectory ?? "") + new URL(uri).pathname;
if (!(await getInfoAsync(destFile)).exists) {
await copyAsync({ from: uri, to: destFile });
}
return getContentUriAsync(destFile);
})
);
setResultBase({ isOK, action, uris: destFiles });
},
[intent]
);
return { intent, setResult };
}
Finally, in app.tsx
, we use the custom hook to change the view when the app is opened from the File Picker, and we return the picked file to the intent.
export default function App() {
const { intent, setResult } = useCurrentIntent();
const handleSetFile = async (url: string) => {
const res = await downloadFile(url);
await setResult({ isOK: true, uris: [res.uri] });
};
return (
<View style={styles.container}>
...
{intent?.action === "android.intent.action.GET_CONTENT" && (
<View style={{ height: 120, gap: 4 }}>
<Text style={{ alignSelf: "center", fontWeight: "bold" }}>
Choose a file:
</Text>
<ScrollView
horizontal
style={{ height: 120 }}
contentContainerStyle={{
gap: 8,
height: 120,
}}
>
{EXAMPLE_FILES.map((url) => (
<TouchableOpacity key={url} onPress={() => handleSetFile(url)}>
<Image
source={{ uri: url }}
style={{ width: 120, height: 120 }}
/>
</TouchableOpacity>
))}
</ScrollView>
</View>
)}
...
</View>
);
}
So now, we can pick a file from our app:

That's it! 🔥
See my other posts:
Top comments (0)