ダウンロードフォルダへのファイル保存を実現するExpo Moduleを作成しました

こんにちは!エンジニアの倉本です。今回は、Android/iOSにおいてグローバルのダウンロードフォルダにファイルを保存できるExpo Moduleを自作しました。この記事ではその背景や実装の詳細について紹介します。

背景と課題

私は「ポケットサインアプリ」の開発を担当しています。

弊社では、ポケットサインアプリ内で動作するポケットサインミニアプリを開発しており、その中で「画像やテキストファイルを端末にダウンロードしたい」という要望がアプリ開発チームに上がってきました。

これに対応するため、ダウンロード機能の実装が必要となりました。

ミニアプリについて

ミニアプリ

ミニアプリはポケットサインアプリ内のWebView上で動作するWebアプリケーションです。 In-App SDKを用いてポケットサインアプリの機能を呼び出すことができます。

docs.p8n.app

In-App SDK

ミニアプリ(Web)とアプリ(ネイティブ)の橋渡しを行うため、In-App SDKを開発しています。 これにより、ミニアプリからアプリの情報やネイティブの機能を呼び出すことができます。

docs.p8n.app

アプリについて

ポケットサインアプリは、Expoを活用して開発からストアへの提出までを行っています。 Expoは、React Nativeをベースにしたオープンソースのプラットフォームであり、モバイルアプリの開発をより簡単かつ効率的に行うためのツールやサービスを提供しています。

当アプリでは、Expo Managed Workflowを採用しています。これにより、普段の開発ではTypeScriptやReactのコードに集中でき、ビルドやデプロイなどの複雑なプロセスをExpoに任せることができます。

また、OTA(Over The Air)アップデート という機能を利用できる点も大きなメリットです。これは、アプリストアの審査プロセスを経ずに、JavaScriptコードやアセット(画像など)をユーザーの端末に直接配信・更新できる仕組みで、迅速なバグ修正や機能改善に役立ちます。

一方で、ネイティブコードの直接記述が難しいため、ネイティブ機能を利用する場合は、Expo Moduleを利用する必要があります。

ダウンロードについて

WebView自体にはファイルのダウンロード機能が備わっていません。

そのため、ミニアプリ(Web)から「画像やテキストファイルをダウンロード」する処理は、アプリ(ネイティブ)側で実装する必要があります。

さらに、ミニアプリから利用する場合は、In-App SDKを通じて機能を提供する必要があります。

既存ライブラリの制約

今回実現したいダウンロード機能の主な要件は以下の通りでした。

  1. ユーザーがアクセスしやすい共有フォルダ(例: Androidの「ダウンロード」フォルダやiOSの「ファイル」アプリで選択できる場所)にファイルを保存できる
  2. 画像だけでなく、テキストファイルなど任意の形式のファイルを扱える
  3. ミニアプリ(WebView)からネイティブ機能を呼び出し、ファイル保存を実行できる

これらの要件を満たすライブラリを探しましたが、既存のものでは以下のような制約があり、今回の要件を満たすことが困難でした。

react-native-share

Web Share APIのような共有UIを利用することでダウンロードが実現できると考えました。 しかし、Androidにおいて共有UIにダウンロードの選択肢が表示されず、期待通りのダウンロード動作が得られないケースがありました。

expo-file-system

Expoが標準で提供するファイルシステムAPIですが、アクセスできるのはアプリ固有のサンドボックス領域内に限定されています。そのため、ユーザーがアクセスしやすい共有のダウンロードフォルダに保存することができません。

expo-media-library

主に画像や動画などのメディアファイルをデバイスのギャラリー(写真アプリなど)に保存することに特化したライブラリです。テキストファイルなど、メディア以外の一般的なファイルをダウンロードフォルダに保存する用途には適していませんでした。

これらの制約を解決するため、Expo Modules APIを利用した自作モジュールの開発に踏み切ることになりました。

本記事では、この開発プロセスと実装の詳細を中心に解説します。開発したモジュールは expo-downloads として公開しています。

実装の詳細

Expo Modules API

Expo Modules APIは、Expo環境下でネイティブ機能を追加するための仕組みです。

ポケットサインアプリではExpo Managed Workflowを採用しているため、直接ネイティブコードを記述することはなく、必要な場合はExpo Moduleを用いてネイティブ機能を利用しています。

今回のモジュールもその一例で、AndroidやiOSそれぞれのネイティブ機能を利用しながら、JavaScript側からは共通したインターフェースで操作できるよう設計されています。

docs.expo.dev

実装の概要

Expo Modules APIでは、以下のように各プラットフォーム側(Kotlin/Java for Android、Swift/Objective-C for iOS)でモジュールを定義します。

import ExpoModulesCore

struct SaveFileOptions: Record {
    @Field var name: String
    @Field var type: String
    @Field var data: String
    @Field var encoding: Encoding?
}

enum Encoding: String, Enumerable {
    case base64
    case utf8
}

public final class DownloadsModule: Module {

    public func definition() -> ModuleDefinition {
        Name("Downloads")

        AsyncFunction("saveFile") {
            (options: SaveFileOptions, promise: Promise) in
            //
        }
    }
}
package jp.co.pocketsign.expo.downloads

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import expo.modules.kotlin.types.Enumerable

data class SaveFileOptions(
    @Field val name: String,
    @Field val type: String,
    @Field val data: String,
    @Field val encoding: Encoding?
) : Record

enum class Encoding(val value: String) : Enumerable {
    utf8("utf8"),
    base64("base64")
}

class DownloadsModule : Module() {

    override fun definition() = ModuleDefinition {
        Name("Downloads")

        AsyncFunction("saveFile") { options: SaveFileOptions ->
                // 
        }
    }
}

このように定義することで、JavaScript側からは以下のように呼び出せるようになります。

import { NativeModule, requireNativeModule } from "expo";

type SaveFileOptions = {
  name: string;
  type: string;
  data: string;
  encoding?: "base64" | "utf8";
};

declare class DownloadsModule extends NativeModule {
  saveFile(options: SaveFileOptions): Promise<SaveFileResponse>;
}

export default requireNativeModule<DownloadsModule>("Downloads");

src/Downloads.ts

import Downloads from "./Downloads";

export const saveFile = (options: SaveFileOptions) => {
  return Downloads.saveFile(options);
};

src/index.ts

このようにネイティブ側の機能をJavaScript側から利用できるようになります。

ダウンロードフォルダへの保存

Androidの場合

Android 10以上

MediaStore APIを利用することで、システムが管理するグローバルな「ダウンロード」フォルダにファイルを保存できます。

OS側でファイル名の衝突を自動で解決する仕組みもあり、ユーザーにとっても分かりやすい動作となっています。

developer.android.com

Android 9以下

MediaStore APIが利用できないため、WRITE_EXTERNAL_STORAGE権限を活用して、Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) のフォルダに直接ファイルを保存します。

developer.android.com

iOSの場合

グローバルなダウンロードフォルダへの直接保存はできなかったため、UIDocumentPickerViewControllerを利用して、ユーザーが任意の場所を選択して保存できるようにしています。

具体的には、一時ファイルとして作成されたデータをユーザーが指定した場所にコピーする処理を行っています。

developer.apple.com

ハマりやすいポイント

Expo Moduleプロジェクトの開き方

Expo Moduleを初めて開発した際、IntelliJ IDEAやXcodeでandroidiosのフォルダを直接開いてしまい、コード補完が有効にならない問題に遭遇したことがあります。

実際には、example/androidexample/ios を開くのが正しいです。

Expo Moduleのディレクトリ構成は以下のようになっており、example/androidexample/iosを開くことで、IDEでの補完が適切に動作します。

expo-downloads
├── android
├── ios
├── src
└── example
    ├── android ← IDEで開くのはこれ
    └── ios     ← IDEで開くのはこれ

androidiosを直接開いてしまうと、必要な依存関係が解決できず、補完やビルド設定が正常に動作しません。

独自の例外(CodedException)の使い方

Expo公式モジュールでは、KotlinやSwift側からエラーをJavaScriptに渡す場合、CodedExceptionを継承した独自の例外をスローする方法が使われています。

例えば、以下のように独自例外を定義します。

import expo.modules.kotlin.exception.CodedException

class FileSaveFailedException : CodedException("Failed to save file")

Kotlin (Android)

import ExpoModulesCore

class FileSaveFailedException: CodedException {
    override var reason: String {
        "Failed to save file"
    }
}

Swift (iOS)

このネイティブ側で定義した例外 (FileSaveFailedException) を throw すると、JavaScript側では CodedError として捕捉されます。

JavaScript(TypeScript)

import { CodedError } from 'expo-modules-core';

try {
    await saveFile({ fileName, mimeType, base64Data });
} catch (e) {
    // JavaScript側では CodedError として受け取る
    if (e instanceof CodedError) {
        // エラーコードで処理を分岐できる
        if (e.code === 'ERR_FILE_SAVE_FAILED') { // ← 自動生成されたエラーコード
            console.error('ファイルの保存に失敗しました:', e.message); // e.message には "Failed to save file" が入る
        }
        // 他のエラーコードの処理...
    }
}

Expo Module APIは、ネイティブ側で定義されたCodedExceptionのクラス名(FileSaveFailedException)から、JavaScript側で使うエラーコード(ERR_FILE_SAVE_FAILED)を自動的に生成します。

変換ルールは以下のようになっています(Expoの内部実装によるものです)

github.com

  1. Exception を削除(例: FileSaveFailedExceptionFileSaveFailed
  2. 大文字のスネークケース(SCREAMING_SNAKE_CASE)に変換(例: FileSaveFailedFILE_SAVE_FAILED
  3. 先頭に ERR_ を付与(例: FILE_SAVE_FAILEDERR_FILE_SAVE_FAILED

このように、ネイティブのクラス名に基づいてエラーコードが生成されるため、コード上での対応関係が分かりやすくなっています。

独自の例外(CodedException)の注意点

KotlinでProguard/R8(Androidのコード圧縮・難読化ツール)を使用する場合、注意が必要です。コードの圧縮・最適化プロセスによって、定義した CodedException の例外クラス名が変更されてしまうことがあります。

前述の通り、Expoはクラス名からエラーコードを自動生成するため、クラス名が変わってしまうと、JavaScript側で期待しているエラーコード(例: ERR_FILE_SAVE_FAILED)とは異なるコードが生成され、エラーを正しく判別できなくなってしまいます。(実際に、私もリリースビルド時にこの問題に直面し、エラーハンドリングが機能しない原因を特定するのに少し時間がかかりました)

そのため、Proguard/R8を使用する場合は、以下のように proguard-rules.pro にルールを追加し、CodedException を継承したクラスの名前が変更されないように保護する必要があります。

-keepnames class * extends expo.modules.kotlin.exception.CodedException

Expoの場合は、app.jsonapp.config.jsで以下の記述をする必要があります。

  plugins: [
    [
      'expo-build-properties',
      {
        android: {
          enableProguardInReleaseBuilds: true,
          extraProguardRules: '-keepnames class * extends expo.modules.kotlin.exception.CodedException',
        },
      }
    ]
  ]

おわりに

この記事では、ExpoアプリでAndroid/iOSのダウンロードフォルダへのファイル保存を実現するために自作したExpo Moduleの開発背景と実装の詳細について紹介しました。Expo Modules APIを活用することで、Managed Workflowの利便性を維持しつつ、ネイティブ機能の拡張が可能になります。

付録:expo-downloadsについて

今回開発したモジュール expo-downloads の使い方を紹介します。

www.npmjs.com

インストール方法

Expoを利用しているプロジェクトにおいて、以下のコマンドでインストールできます。

npx expo install expo-downloads

使用方法

次に示すサンプルコードは、example.txtという名前のテキストファイルをダウンロードフォルダに保存し、その保存先のファイルを開く一連の流れを実現した例です。

import * as Downloads from "expo-downloads";

const options = {
  name: "example.txt",
  type: "text/plain",
  data: "Hello, World!",
};

// Android 9 以下の場合、WRITE_EXTERNAL_STORAGE の権限を取得します。
const permissions = await Downloads.getPermissionsAsync();
if (!permissions.granted) {
  const newPermissions = await Downloads.requestPermissionsAsync();
  if (!newPermissions.granted) {
    console.error("権限が取得できませんでした");
    return;
  }
}

const result = await Downloads.saveFile(options);
if (result.cancelled) {
  console.log("ダウンロードがキャンセルされました");
} else {
  console.log(`ファイルが保存されました: ${result.uri}`);
  // 保存直後にファイルを開く例
  await Downloads.openFile({ uri: result.uri, type: options.type });
}

動作例

実際の動作としては、以下のようになります。(動画では画像をダウンロードしています。)

Android

iOS