共有ストレージのドキュメントやファイルにアクセスする

Android 4.4(API レベル 19)以降を搭載するデバイスでは、ストレージ アクセス フレームワークを使用して、外部ストレージ ボリュームやクラウドベースのストレージを含めた、ドキュメント プロバイダを使用できます。このフレームワークでは、システムの選択ツールを使用してドキュメント プロバイダを選択し、アプリで作成したり、開いたり、変更したりするドキュメントやファイルを選択できます。

アプリがアクセスできるファイルやディレクトリの選択をユーザーが行うため、このメカニズムにシステム権限は必要なく、ユーザー制御とプライバシーが強化されます。また、このファイルはアプリ固有のディレクトリとメディアストアの外に保存され、アプリがアンインストールされた後もデバイスに残ります。

このフレームワークを使用する手順は次のとおりです。

  1. アプリがストレージ関連のアクションを含むインテントを呼び出します。このアクションは、フレームワークで利用できる特定のユースケースに対応します。
  2. システムの選択ツールが表示され、ドキュメント プロバイダを確認して、ストレージ関連の操作を行う場所やドキュメントを選択できるようになります。
  3. アプリは、ユーザーが選択した場所やドキュメントを表す URI に対する読み書きのアクセス権を取得します。この URI を使用して、アプリは選択した場所に関する操作を行うことができます。

Android 9(API レベル 28)以前を搭載しているデバイスでメディア ファイルへのアクセスをサポートするには、READ_EXTERNAL_STORAGE 権限を宣言し、maxSdkVersion28 に設定します。

このガイドでは、フレームワークがサポートしているファイルやドキュメントを操作するユースケースについて説明します。また、ユーザーが選択した場所に関する操作を行う方法についても説明します。

ドキュメントやファイルにアクセスするユースケース

ストレージ アクセス フレームワークは、次のようなファイルやドキュメントにアクセスするユースケースをサポートしています。

新しいファイルを作成する
ACTION_CREATE_DOCUMENT インテントのアクションを使用すると、ユーザーは特定の場所にファイルを保存できます。
ドキュメントやファイルを開く
ACTION_OPEN_DOCUMENT インテントのアクションを使用すると、ユーザーは特定のドキュメントやファイルを選択して開くことができます。
ディレクトリの内容へのアクセス権を付与する
ACTION_OPEN_DOCUMENT_TREE インテントのアクションは、Android 5.0(API レベル 21)以降で使用でき、ユーザーは特定のディレクトリを選択して、そのディレクトリ内のすべてのファイルとサブディレクトリへのアクセス権を付与できます。

以降のセクションでは、各ユースケースの設定方法について説明します。

新しいファイルを作成する

ACTION_CREATE_DOCUMENT インテントのアクションを使用してシステムのファイル選択ツールを読み込んで、ファイルの内容を書き込む場所をユーザーが選択できるようにします。この手順は、他のオペレーティング システムで使用されている「名前を付けて保存」ダイアログで使用されている手順と似ています。

注: ACTION_CREATE_DOCUMENT では既存のファイルを上書きできません。アプリが同じ名前のファイルを保存しようとすると、ファイル名の末尾にかっこで囲まれた数字が追加されます。

たとえば、confirmation.pdf という名前のファイルを同じ名前のファイルがすでに存在しているディレクトリに保存しようとすると、confirmation(1).pdf という名前の新しいファイルが保存されます。

インテントを設定するときは、ファイルの名前と MIME タイプを指定します。必要に応じ、EXTRA_INITIAL_URI インテント エクストラを使用して、ファイル選択ツールが最初に読み込まれたときに表示するファイルまたはディレクトリの URI も指定します。

次のコード スニペットは、ファイルを作成するインテントを作成して呼び出す方法を示しています。

// Request code for creating a PDF document.
const
val CREATE_FILE = 1

private fun createFile(pickerInitialUri: Uri) {
   
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
        addCategory
(Intent.CATEGORY_OPENABLE)
        type
= "application/pdf"
        putExtra
(Intent.EXTRA_TITLE, "invoice.pdf")

       
// Optionally, specify a URI for the directory that should be opened in
       
// the system file picker before your app creates the document.
        putExtra
(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
   
}
    startActivityForResult
(intent, CREATE_FILE)
}
// Request code for creating a PDF document.
private static final int CREATE_FILE = 1;

private void createFile(Uri pickerInitialUri) {
   
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
    intent
.addCategory(Intent.CATEGORY_OPENABLE);
    intent
.setType("application/pdf");
    intent
.putExtra(Intent.EXTRA_TITLE, "invoice.pdf");

   
// Optionally, specify a URI for the directory that should be opened in
   
// the system file picker when your app creates the document.
    intent
.putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri);

    startActivityForResult
(intent, CREATE_FILE);
}

ファイルを開く

アプリは、ユーザーが同僚と共有したり他のドキュメントに読み込んだりするデータを入力するストレージ ユニットとしてドキュメントを使用します。たとえば、ユーザーが生産性向上マニュアルを開く場合や、EPUB ファイルとして保存された書籍を開く場合などが該当します。

このような場合、システムのファイル選択アプリを開く ACTION_OPEN_DOCUMENT インテントを呼び出して、ユーザーが対象ファイルを選択できるようにします。アプリがサポートするファイルタイプだけを表示させるには、MIME タイプを指定します。また、必要に応じ、EXTRA_INITIAL_URI インテント エクストラを使用して、ファイル選択ツールが最初に読み込まれたときに表示するファイルの URI も指定します。

次のコード スニペットは、PDF ドキュメントを開くインテントを作成して呼び出す方法を示しています。

// Request code for selecting a PDF document.
const
val PICK_PDF_FILE = 2

fun openFile(pickerInitialUri: Uri) {
   
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
        addCategory
(Intent.CATEGORY_OPENABLE)
        type
= "application/pdf"

       
// Optionally, specify a URI for the file that should appear in the
       
// system file picker when it loads.
        putExtra
(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
   
}

    startActivityForResult
(intent, PICK_PDF_FILE)
}
// Request code for selecting a PDF document.
private static final int PICK_PDF_FILE = 2;

private void openFile(Uri pickerInitialUri) {
   
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
    intent
.addCategory(Intent.CATEGORY_OPENABLE);
    intent
.setType("application/pdf");

   
// Optionally, specify a URI for the file that should appear in the
   
// system file picker when it loads.
    intent
.putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri);

    startActivityForResult
(intent, PICK_PDF_FILE);
}

アクセス制限

Android 11(API レベル 30)以降では、ACTION_OPEN_DOCUMENT インテントのアクションを使用して、次のディレクトリから個々のファイルを選択するようユーザーにリクエストすることはできません。

  • Android/data/ ディレクトリとすべてのサブディレクトリ。
  • Android/obb/ ディレクトリとすべてのサブディレクトリ。

ディレクトリの内容へのアクセス権を付与する

ファイル管理アプリやメディア作成アプリは通常、ディレクトリ階層内のファイルのグループを管理します。アプリでこの機能を使用するには、ACTION_OPEN_DOCUMENT_TREE インテントのアクションを使用します。これにより、Android 11(API レベル 30)以降の一部の例外を除き、ユーザーはディレクトリ ツリー全体へのアクセス権を付与できます。こうして、アプリは選択されたディレクトリとそのサブディレクトリ内のすべてのファイルにアクセスできるようになります。

ACTION_OPEN_DOCUMENT_TREE を使用している場合、アプリがアクセスできるのは、ユーザーが選択したディレクトリ内のファイルに限られます。ユーザーが選択したディレクトリの外部にある別アプリのファイルにはアクセスできません。このようなユーザー制御に基づくアクセス権限により、ユーザーは、アプリで共有するコンテンツを、自分の想定どおりに選択できます。

必要に応じ、EXTRA_INITIAL_URI インテント エクストラを使用して、ファイル選択ツールが最初に読み込まれたときに表示するディレクトリの URI を指定します。

次のコード スニペットは、ディレクトリを開くインテントを作成して呼び出す方法を示しています。

fun openDirectory(pickerInitialUri: Uri) {
   
// Choose a directory using the system's file picker.
   
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
       
// Optionally, specify a URI for the directory that should be opened in
       
// the system file picker when it loads.
        putExtra
(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
   
}

    startActivityForResult
(intent, your-request-code)
}
public void openDirectory(Uri uriToLoad) {
   
// Choose a directory using the system's file picker.
   
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);

   
// Optionally, specify a URI for the directory that should be opened in
   
// the system file picker when it loads.
    intent
.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uriToLoad);

    startActivityForResult
(intent, your-request-code);
}

アクセス制限

Android 11(API レベル 30)以降では、ACTION_OPEN_DOCUMENT_TREE インテントのアクションを使用して次のディレクトリへのアクセスをリクエストすることはできません。

  • 内部ストレージ ボリュームのルート ディレクトリ。
  • エミューレートされているカードかリムーバブル カードかによらず、デバイス メーカーが信頼できるとみなす各 SD カード ボリュームのルート ディレクトリ。信頼できるボリュームとは、ほとんどの場合にアプリが正常にアクセスできるボリュームです。
  • Download ディレクトリ。

さらに Android 11(API レベル 30)以降では、ACTION_OPEN_DOCUMENT_TREE インテントのアクションを使用して、次のディレクトリから個々のファイルを選択するようユーザーにリクエストすることはできません。

  • Android/data/ ディレクトリとすべてのサブディレクトリ。
  • Android/obb/ ディレクトリとすべてのサブディレクトリ。

選択した場所に関する操作を行う

ユーザーがシステムのファイル選択ツールでファイルまたはディレクトリを選択した後、onActivityResult() の次のコードを使用して、選択したアイテムの URI を取得できます。

override fun onActivityResult(
        requestCode
: Int, resultCode: Int, resultData: Intent?) {
   
if (requestCode == your-request-code
           
&& resultCode == Activity.RESULT_OK) {
       
// The result data contains a URI for the document or directory that
       
// the user selected.
        resultData
?.data?.also { uri ->
           
// Perform operations on the document using its URI.
       
}
   
}
}
@Override
public void onActivityResult(int requestCode, int resultCode,
       
Intent resultData) {
   
if (requestCode == your-request-code
           
&& resultCode == Activity.RESULT_OK) {
       
// The result data contains a URI for the document or directory that
       
// the user selected.
       
Uri uri = null;
       
if (resultData != null) {
            uri
= resultData.getData();
           
// Perform operations on the document using its URI.
       
}
   
}
}

選択したアイテムの URI への参照を取得することで、アイテムに対する操作を行えます。たとえば、アイテムのメタデータにアクセスして、その場でアイテムを編集し、アイテムを削除できます。

次のセクションでは、ユーザーが選択したファイルに対する操作を完了する方法を説明します。

プロバイダがサポートする操作を確認する

行える操作はコンテンツ プロバイダによって異なります(ドキュメントのコピーやサムネイルの表示など)。プロバイダがサポートする操作を確認するには、Document.COLUMN_FLAGS の値を確認します。これによって、プロバイダがサポートする選択肢のみを UI に表示できます。

権限を保持する

アプリが読み取りまたは書き込みのためにファイルを開くとき、システムからそのファイルに対する URI 権限を付与されます。この権限はデバイスが再起動するまで保持されます。しかし、たとえば、画像編集アプリで、ユーザーが最後に編集した 5 つの画像にアプリから直接アクセスできるようにしたいとします。デバイスが再起動した場合は、そのファイルを見つけるために、ユーザーをシステムの選択ツールに戻す必要があります。

デバイスの再起動後もファイルへのアクセス権を維持して、ユーザー エクスペリエンスを向上させるために、システムで提供される永続的 URI 権限付与を取得できます。次のコード スニペットをご覧ください。

val contentResolver = applicationContext.contentResolver

val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
       
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
// Check for the freshest data.
contentResolver
.takePersistableUriPermission(uri, takeFlags)
final int takeFlags = intent.getFlags()
           
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
           
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
// Check for the freshest data.
getContentResolver
().takePersistableUriPermission(uri, takeFlags);

ドキュメントのメタデータを調べる

ドキュメントの URI がわかっていれば、そのメタデータへのアクセス権を取得できます。次のスニペットは、URI で指定されたドキュメントのメタデータを取得し、ログに記録します。

val contentResolver = applicationContext.contentResolver

fun dumpImageMetaData(uri: Uri) {

   
// The query, because it only applies to a single document, returns only
   
// one row. There's no need to filter, sort, or select fields,
   
// because we want all fields for one document.
   
val cursor: Cursor? = contentResolver.query(
            uri
, null, null, null, null, null)

    cursor
?.use {
       
// moveToFirst() returns false if the cursor has 0 rows. Very handy for
       
// "if there's anything to look at, look at it" conditionals.
       
if (it.moveToFirst()) {

           
// Note it's called "Display Name". This is
           
// provider-specific, and might not necessarily be the file name.
           
val displayName: String =
                    it
.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
           
Log.i(TAG, "Display Name: $displayName")

           
val sizeIndex: Int = it.getColumnIndex(OpenableColumns.SIZE)
           
// If the size is unknown, the value stored is null. But because an
           
// int can't be null, the behavior is implementation-specific,
           
// and unpredictable. So as
           
// a rule, check if it's null before assigning to an int. This will
           
// happen often: The storage API allows for remote files, whose
           
// size might not be locally known.
           
val size: String = if (!it.isNull(sizeIndex)) {
               
// Technically the column stores an int, but cursor.getString()
               
// will do the conversion automatically.
                it
.getString(sizeIndex)
           
} else {
               
"Unknown"
           
}
           
Log.i(TAG, "Size: $size")
       
}
   
}
}
public void dumpImageMetaData(Uri uri) {

   
// The query, because it only applies to a single document, returns only
   
// one row. There's no need to filter, sort, or select fields,
   
// because we want all fields for one document.
   
Cursor cursor = getActivity().getContentResolver()
           
.query(uri, null, null, null, null, null);

   
try {
       
// moveToFirst() returns false if the cursor has 0 rows. Very handy for
       
// "if there's anything to look at, look at it" conditionals.
       
if (cursor != null && cursor.moveToFirst()) {

           
// Note it's called "Display Name". This is
           
// provider-specific, and might not necessarily be the file name.
           
String displayName = cursor.getString(
                    cursor
.getColumnIndex(OpenableColumns.DISPLAY_NAME));
           
Log.i(TAG, "Display Name: " + displayName);

           
int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE);
           
// If the size is unknown, the value stored is null. But because an
           
// int can't be null, the behavior is implementation-specific,
           
// and unpredictable. So as
           
// a rule, check if it's null before assigning to an int. This will
           
// happen often: The storage API allows for remote files, whose
           
// size might not be locally known.
           
String size = null;
           
if (!cursor.isNull(sizeIndex)) {
               
// Technically the column stores an int, but cursor.getString()
               
// will do the conversion automatically.
                size
= cursor.getString(sizeIndex);
           
} else {
                size
= "Unknown";
           
}
           
Log.i(TAG, "Size: " + size);
       
}
   
} finally {
        cursor
.close();
   
}
}

ドキュメントを開く

ドキュメントの URI への参照があれば、あとで処理するためにドキュメントを開くことができます。次のセクションでは、ビットマップと入力ストリームを開く例を示します。

ビットマップ

次のコード スニペットは、指定された URI の Bitmap ファイルを開く方法を示しています。

val contentResolver = applicationContext.contentResolver

@Throws(IOException::class)
private fun getBitmapFromUri(uri: Uri): Bitmap {
   
val parcelFileDescriptor: ParcelFileDescriptor =
            contentResolver
.openFileDescriptor(uri, "r")
   
val fileDescriptor: FileDescriptor = parcelFileDescriptor.fileDescriptor
   
val image: Bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor)
    parcelFileDescriptor
.close()
   
return image
}
private Bitmap getBitmapFromUri(Uri uri) throws IOException {
   
ParcelFileDescriptor parcelFileDescriptor =
            getContentResolver
().openFileDescriptor(uri, "r");
   
FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
   
Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
    parcelFileDescriptor
.close();
   
return image;
}

開いたビットマップは ImageView に表示できます。

入力ストリーム

次のコード スニペットは、指定された URI の InputStream オブジェクトを開く方法を示しています。このスニペットでは、ファイルが 1 行ずつ文字列に読み込まれます。

val contentResolver = applicationContext.contentResolver

@Throws(IOException::class)
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()
}
private String readTextFromUri(Uri uri) throws IOException {
   
StringBuilder stringBuilder = new StringBuilder();
   
try (InputStream inputStream =
            getContentResolver
().openInputStream(uri);
           
BufferedReader reader = new BufferedReader(
           
new InputStreamReader(Objects.requireNonNull(inputStream)))) {
       
String line;
       
while ((line = reader.readLine()) != null) {
            stringBuilder
.append(line);
       
}
   
}
   
return stringBuilder.toString();
}

ドキュメントを編集する

ストレージ アクセス フレームワークを使用してテキスト ドキュメントをその場で編集できます。

次のコード スニペットは、指定された URI で表されるドキュメントの内容を上書きします。

val contentResolver = applicationContext.contentResolver

private fun alterDocument(uri: Uri) {
   
try {
        contentResolver
.openFileDescriptor(uri, "w")?.use {
           
FileOutputStream(it.fileDescriptor).use {
                it
.write(
                   
("Overwritten at ${System.currentTimeMillis()}\n")
                       
.toByteArray()
               
)
           
}
       
}
   
} catch (e: FileNotFoundException) {
        e
.printStackTrace()
   
} catch (e: IOException) {
        e
.printStackTrace()
   
}
}
private void alterDocument(Uri uri) {
   
try {
       
ParcelFileDescriptor pfd = getActivity().getContentResolver().
                openFileDescriptor
(uri, "w");
       
FileOutputStream fileOutputStream =
               
new FileOutputStream(pfd.getFileDescriptor());
        fileOutputStream
.write(("Overwritten at " + System.currentTimeMillis() +
               
"\n").getBytes());
       
// Let the document provider know you're done by closing the stream.
        fileOutputStream
.close();
        pfd
.close();
   
} catch (FileNotFoundException e) {
        e
.printStackTrace();
   
} catch (IOException e) {
        e
.printStackTrace();
   
}
}

ドキュメントを削除する

ドキュメントの URI がわかっていて、その Document.COLUMN_FLAGSSUPPORTS_DELETE が含まれていれば、そのドキュメントを削除できます。次に例を示します。

DocumentsContract.deleteDocument(applicationContext.contentResolver, uri)
DocumentsContract.deleteDocument(applicationContext.contentResolver, uri);

同等のメディア URI を取得する

getMediaUri() メソッドは、特定のドキュメント プロバイダ URI と同等のメディアストア URI を提供します。この 2 つの URI は、同じ基本アイテムを参照しています。メディアストア URI を使用すると、共有ストレージからメディア ファイルにアクセスしやすくなります。

getMediaUri() メソッドは ExternalStorageProvider URI をサポートしています。Android 12(API レベル 31)以降では、MediaDocumentsProvider URI もサポートしています。

仮想ファイルを開く

Android 7.0(API レベル 25)以降では、ストレージ アクセス フレームワークで提供される仮想ファイルを使用できます。仮想ファイルにはバイナリ表現がありませんが、別のファイル形式に自動変換したり、ACTION_VIEW インテントのアクションを使用して表示したりして、コンテンツを開くことができます。

仮想ファイルを開くには、クライアント アプリに特別なロジックを入れる必要があります。プレビューするためなど、ファイルのバイト表現を取得する場合は、ドキュメント プロバイダに別の MIME タイプをリクエストする必要があります。

次のコード スニペットに示すように、ユーザーが選択した後で、結果データの URI を使用してファイルが仮想かどうかを確認してください。

private fun isVirtualFile(uri: Uri): Boolean {
   
if (!DocumentsContract.isDocumentUri(this, uri)) {
       
return false
   
}

   
val cursor: Cursor? = contentResolver.query(
            uri
,
            arrayOf
(DocumentsContract.Document.COLUMN_FLAGS),
           
null,
           
null,
           
null
   
)

   
val flags: Int = cursor?.use {
       
if (cursor.moveToFirst()) {
            cursor
.getInt(0)
       
} else {
           
0
       
}
   
} ?: 0

   
return flags and DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT != 0
}
private boolean isVirtualFile(Uri uri) {
   
if (!DocumentsContract.isDocumentUri(this, uri)) {
       
return false;
   
}

   
Cursor cursor = getContentResolver().query(
        uri
,
       
new String[] { DocumentsContract.Document.COLUMN_FLAGS },
       
null, null, null);

   
int flags = 0;
   
if (cursor.moveToFirst()) {
        flags
= cursor.getInt(0);
   
}
    cursor
.close();

   
return (flags & DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT) != 0;
}

ドキュメントが仮想ファイルであることを確認したら、ファイルを "image/png" のような MIME タイプに自動変換できます。次のコード スニペットは、仮想ファイルが画像として表現できるかどうかを確認し、可能な場合は仮想ファイルから入力ストリームを取得する方法を示しています。

@Throws(IOException::class)
private fun getInputStreamForVirtualFile(
        uri
: Uri, mimeTypeFilter: String): InputStream {

   
val openableMimeTypes: Array<String>? =
            contentResolver
.getStreamTypes(uri, mimeTypeFilter)

   
return if (openableMimeTypes?.isNotEmpty() == true) {
        contentResolver
               
.openTypedAssetFileDescriptor(uri, openableMimeTypes[0], null)
               
.createInputStream()
   
} else {
       
throw FileNotFoundException()
   
}
}
private InputStream getInputStreamForVirtualFile(Uri uri, String mimeTypeFilter)
   
throws IOException {

   
ContentResolver resolver = getContentResolver();

   
String[] openableMimeTypes = resolver.getStreamTypes(uri, mimeTypeFilter);

   
if (openableMimeTypes == null ||
        openableMimeTypes
.length < 1) {
       
throw new FileNotFoundException();
   
}

   
return resolver
       
.openTypedAssetFileDescriptor(uri, openableMimeTypes[0], null)
       
.createInputStream();
}

参考情報

ドキュメントやファイルを保存、アクセスする方法については、次のリソースをご覧ください。

サンプル

動画