DEV Community

José Núñez Riveros
José Núñez Riveros

Posted on

React Native - Make your app appear in your device's file picker [Expo Modules]

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?

Image description

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.

Image description

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()
  } 
}
Enter fullscreen mode Exit fullscreen mode

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 };

Enter fullscreen mode Exit fullscreen mode

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"],
      },
    ],
  },
Enter fullscreen mode Exit fullscreen mode

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 };
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

So now, we can pick a file from our app:

That's it! 🔥

See my other posts:

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

Top comments (0)

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay