Androidアプリでファイルを選択!Storage Access Framework (`ACTION_OPEN_DOCUMENT`) の使い方

Android開発

はじめに

Androidアプリ開発において、ユーザーがデバイス内のファイル(写真、ドキュメント、音声など)を選択する必要がある場面は多々あります。
しかし、Androidのストレージアクセスはバージョンアップごとにセキュリティとプライバシーが強化され、その方法は複雑化してきました。
この記事では、Android 4.4 (API Level 19) 以降で推奨されている「Storage Access Framework (SAF)」を利用し、特にACTION_OPEN_DOCUMENTインテントを使ってユーザーに安全かつ簡単にファイルを選択させる方法を、具体的なコード例と共に解説します。
初級から中級のAndroid開発者の方々が、現場で今すぐ使える知識とスキルを身につけられるよう、丁寧に解説していきます。

Storage Access Framework (SAF) とは?

Storage Access Framework (SAF) は、ユーザーがGoogle Drive、Dropbox、ローカルストレージなどのプロバイダを横断してドキュメント、画像、その他のファイルを参照し、開くことを可能にするフレームワークです。
アプリはSAFを通じてファイルにアクセスする際、直接ストレージのパスを扱うのではなく、抽象化されたURIを受け取ります。
これにより、アプリは特定のストレージ権限(例: READ_EXTERNAL_STORAGE)を要求することなく、ユーザーが明示的に選択したファイルへの一時的または永続的な読み取り(および必要に応じて書き込み)アクセス権を得られます。

  • セキュリティとプライバシーの向上: アプリがデバイス全体のストレージにアクセスするのを防ぎ、ユーザーが許可したファイルのみにアクセスできます。
  • ユーザー体験の向上: 一貫したファイルピッカーUIを提供し、異なるプロバイダ間でのファイル選択を容易にします。
  • ストレージプロバイダの抽象化: アプリはファイルがどこに保存されているかを意識する必要がなく、URIを通じて操作できます。

ACTION_OPEN_DOCUMENTでファイルを選択する基本的な流れ

ACTION_OPEN_DOCUMENTは、ユーザーが単一のドキュメントを選択できるようにするインテントアクションです。
基本的な流れは以下のようになります。

  1. Intentを作成し、アクションをACTION_OPEN_DOCUMENTに設定します。
  2. 選択させたいファイルのMIMEタイプ(例: "image/*", "application/pdf")を指定します。
  3. インテントを起動し、結果を待ちます。
  4. ユーザーがファイルを選択すると、そのファイルのURIが結果として返されます。
  5. 返されたURIを使ってファイルの内容を読み込みます。

具体的な実装例

ここでは、ユーザーがボタンをタップするとファイルピッカーが表示され、選択されたファイルのURIとファイル名を表示するシンプルなAndroidアプリをKotlinで実装します。
最新のAndroid開発では、startActivityForResult()の代わりにregisterForActivityResult()を使用することが推奨されています。

1. レイアウトXMLの準備

activity_main.xmlにボタンと、選択されたファイル情報を表示するTextViewを追加します。


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/selectFileButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="ファイルを選択"
        app:layout_constraintBottom_toTopOf="@+id/selectedFileTextView"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed"/>

    <TextView
        android:id="@+id/selectedFileTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:layout_marginStart="16dp"
        android:layout_marginEnd="16dp"
        android:text="選択されたファイル: なし"
        android:textSize="16sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/selectFileButton"/>

</androidx.constraintlayout.widget.ConstraintLayout>

2. Activityでの実装(Kotlin)

MainActivity.ktで、ボタンクリックイベントを処理し、ACTION_OPEN_DOCUMENTインテントを起動します。
選択結果はregisterForActivityResultで受け取ります。


package com.example.safexample

import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.widget.Button
import android.widget.TextView
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {

    private lateinit var selectedFileTextView: TextView
    private lateinit var selectFileButton: Button

    // ActivityResultLauncherを宣言
    private val openDocumentLauncher: ActivityResultLauncher<Array<String>> =
        registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
            // ファイル選択結果を処理
            uri?.let {
                // 選択されたファイルのURIを表示
                selectedFileTextView.text = "選択されたファイルURI: $it"
                // ファイル名を取得して表示
                val fileName = getFileName(it)
                if (fileName != null) {
                    selectedFileTextView.append("\nファイル名: $fileName")
                }

                // 永続的なアクセス権限をリクエストする場合 (オプション)
                // val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
                // contentResolver.takePersistableUriPermission(it, takeFlags)

                // ここでURIを使ってファイルの内容を読み込むなどの処理を行う
                // 例: readTextFromUri(it)
            } ?: run {
                selectedFileTextView.text = "選択されたファイル: なし"
            }
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        selectedFileTextView = findViewById(R.id.selectedFileTextView)
        selectFileButton = findViewById(R.id.selectFileButton)

        selectFileButton.setOnClickListener {
            // ファイル選択インテントを起動
            openDocumentLauncher.launch(arrayOf("*/*")) // すべてのMIMEタイプを許可
            // 特定のMIMEタイプのみを許可する場合の例:
            // openDocumentLauncher.launch(arrayOf("image/*")) // 画像ファイルのみ
            // openDocumentLauncher.launch(arrayOf("image/png", "image/jpeg")) // PNGとJPEGのみ
            // openDocumentLauncher.launch(arrayOf("application/pdf")) // PDFファイルのみ
        }
    }

    /**
     * URIからファイル名を取得するヘルパー関数
     */
    private fun getFileName(uri: Uri): String? {
        var result: String? = null
        if (uri.scheme == "content") {
            contentResolver.query(uri, null, null, null, null)?.use { cursor ->
                if (cursor.moveToFirst()) {
                    val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
                    if (nameIndex != -1) {
                        result = cursor.getString(nameIndex)
                    }
                }
            }
        }
        if (result == null) {
            result = uri.path
            val cut = result?.lastIndexOf('/')
            if (cut != -1) {
                result = result?.substring(cut!! + 1)
            }
        }
        return result
    }

    // URIからテキストを読み込む例
    /*
    private fun readTextFromUri(uri: Uri): String {
        val stringBuilder = StringBuilder()
        contentResolver.openInputStream(uri)?.use { inputStream ->
            BufferedReader(InputStreamReader(inputStream)).use { reader ->
                var line: String? = reader.readLine()
                while (line != null) {
                    stringBuilder.append(line)
                    line = reader.readLine()
                }
            }
        }
        return stringBuilder.toString()
    }
    */
}

コードの解説

  • registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> ... }

    これは、ファイル選択ピッカーを起動し、その結果を受け取るための現代的な方法です。
    ActivityResultContracts.OpenDocument()は、ACTION_OPEN_DOCUMENTインテントを生成し、単一のURIを結果として返すためのContractです。
    ユーザーがファイルを選択すると、ラムダ式内のuri変数に選択されたファイルのUriが渡されます。
    ユーザーが選択をキャンセルした場合はuriはnullになります。

  • openDocumentLauncher.launch(arrayOf("*/*"))

    ファイル選択ピッカーを起動します。引数には、選択可能なファイルのMIMEタイプの配列を渡します。
    "image/*"であればすべての画像ファイル、"application/pdf"であればPDFファイルのみが選択肢として表示されます。
    "*/*"はすべてのファイルタイプを許可します。

  • getFileName(uri: Uri): String?

    SAFから返されるURIは、ファイルシステムのパスとは異なる「コンテンツURI」です。
    この関数は、ContentResolverとOpenableColumns.DISPLAY_NAMEを使って、選択されたファイルのユーザーが認識しやすいファイル名を取得します。

  • contentResolver.takePersistableUriPermission(...) (コメントアウト部分)

    SAFから取得したURIへのアクセス権は、デフォルトではアプリのプロセスが終了すると失われます。
    もしアプリを再起動しても同じURIにアクセスしたい場合は、takePersistableUriPermission()を呼び出すことで、永続的なアクセス権を要求できます。
    これにより、アプリはデバイスの再起動後もそのURIにアクセスできるようになります。
    ただし、ユーザーがアプリの設定から権限を取り消すことも可能です。

パーミッションについて

Storage Access Framework (SAF) のACTION_OPEN_DOCUMENTを使用する場合、通常はREAD_EXTERNAL_STORAGEやWRITE_EXTERNAL_STORAGEといったストレージ関連の実行時パーミッションをアプリのマニフェストに宣言したり、実行時にリクエストしたりする必要はありません。
これは、ユーザーがファイルピッカーを通じて明示的にファイルを選択することで、そのファイルへの一時的なアクセス権をアプリに付与しているためです。
SAFが提供するセキュリティモデルの大きなメリットの一つです。

注意点とベストプラクティス

  • MIMEタイプを適切に指定する: ユーザーに選択させたいファイルの種類に合わせて、MIMEタイプを正確に指定しましょう。これにより、ファイルピッカーに表示されるファイルのフィルタリングが行われ、ユーザー体験が向上します。
  • エラーハンドリング: ユーザーがファイル選択をキャンセルした場合(uriがnullの場合)や、選択されたファイルへのアクセスに失敗した場合に備えて、適切なエラーハンドリングを行いましょう。
  • 永続的なアクセス権限の扱い: アプリの再起動後もファイルへのアクセスが必要な場合は、takePersistableUriPermission()を使用しますが、不要な場合は呼び出さないでください。また、不要になったアクセス権限はreleasePersistableUriPermission()で解放することが推奨されます。
  • 複数のファイル選択: 複数のファイルをユーザーに選択させたい場合は、ActivityResultContracts.OpenMultipleDocuments()を使用します。これはACTION_OPEN_DOCUMENT_MULTIPLEインテントに対応しています。

まとめ

この記事では、Androidアプリでユーザーにファイルを選択させるためのモダンで安全な方法として、Storage Access Framework (SAF) のACTION_OPEN_DOCUMENTインテントの使い方を詳しく解説しました。
registerForActivityResultを使った実装により、ファイル選択ピッカーの起動から結果の処理、そして選択されたファイル情報の取得までを一貫して学ぶことができたかと思います。
SAFを適切に利用することで、アプリのセキュリティとユーザー体験を向上させることができます。
ぜひ、あなたのAndroidアプリ開発にSAFを取り入れてみてください。

コメント

タイトルとURLをコピーしました