アンドロイド - SAF(Storage Access Framework)にファイル読み書きする方法

SAF(Storage Access Framework)は、Android 4.4に公開された。 文書や画像などの各種ファイルを移動して保存する作業を簡単にしようと導入しました。

フレームワークという言葉で感じることができるようSAFは、ファイルを提供するProviderとファイルを使用しているClientに分けられます。 サードパーティーアプリはプロバイダがされて、ファイルを提供することができます。また、他のプロバイダが提供するファイルを使用しているクライアントになることがあります。

SAFコンポーネントと特徴

Android developerには、SAF、次のトピックが含まれていると紹介しています。

  • Document Provider: 文書(ファイル)を提供する主体
  • Client app: プロバイダが提供するドキュメントを使用するアプリ
  • Selector: 一種のシステムUIで、クライアントアプリから必要なファイルをユーザーが選択したときに使用されます

SAFの特徴は次のとおりです。

  • デバイスは、複数のプロバイダが存在することができます。
  • ユーザーは、プロバイダが提供するすべてのファイルを閲覧することができます。
  • アプリはプロバイダが提供するドキュメントへのアクセス権を持つことができます。
  • このアクセス権にアプリは、ファイルを追加、編集、保存、および削除することができます。
  • USB接続時にUSBのデータを提供するプロバイダもあります。

認識については?

この記事では、次の項目についてどのように実装するかの例と一緒に認識される。

  • ファイルの読み取り
  • ファイルの作成
  • ファイルの変更
  • ファイルの削除
  • 継続的なアクセス権
  • フォルダのアクセス権

サンプルコードでは、画像ファイルを説明したが、PDFやTXTなどのファイルも同じように読み書きすることができます。

サンプルアプリケーションは、鼻間違って作成されておりSDK API 29でテストされた。サンプルは、GitHubで確認することができます。

今までのアプリでは、SAFをよく使用していないようです。しかし、Android 10で必ず使用する機能になりました。

Android 10のScoped Storageでアプリは外部ストレージのファイルに直接アクセスすることができなくなりました。 MediaStoreを利用したり、SAFを使用して、ファイルに間接的にアクセスする必要があります。

必要な権限

ファイルにアクセスする際SAFは、権限を必要としません。代わりにSelector UIを浮かせ、ユーザーがアプリのファイルにアクセスすることを許可する必要があります。

Selector UIは下の図のように見えました。ファイルエクスプローラのようにディレクトリを移動することができ、ファイルを選択すると、アプリがアクセスすることができます。 saf selector

ファイルの読み取り

まずSelector画面を盛り上げなければならいます。

以下は、Selector Activityを実行するコードです。

val READ_REQUEST_CODE: Int = 42

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {    // 1
    addCategory(Intent.CATEGORY_OPENABLE)   // 2
    type = "image/*"    // 3
}

startActivityForResult(intent, READ_REQUEST_CODE)   // 4

上記の "// 1"のようにコメントとして表示したコードは、以下に詳細説明しました。

  1. ファイルを開くときにIntent.ACTION_OPEN_DOCUMENTアクションを使用します。
  2. 開くことができるファイルだけを見たいときにIntent.CATEGORY_OPENABLEをカテゴリに入れます。
  3. タイプと一致するファイルだけフィルタリングして表示します。 "image/*"タイプに設定すると、画像ファイルのみ表示します。
  4. インテントで実行された画面で、ファイルを選択すると、そのファイルのUriが私のアプリのアクティビティに渡されます。

上記のコードを実行すると、次のような画面が出ます。 openable images

選択したファイルのUriリターン受ける

画像を一つ選択すると、そのファイルのUriがアプリに渡されます。アプリは、次のようなコードでUriを受けることができます。

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    super.onActivityResult(requestCode, resultCode, resultData)

    if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
        resultData?.data?.also { uri ->
            Log.i(TAG, "Uri: $uri")   // 1
            dumpImageMetaData(uri)    // 2
            showImage(uri)    // 3
        }
    }
}
  1. Uri情報を文字列に出力します。
  2. ImageのMetaDataを出力します。

3.画像ファイルを読み込み、アクティビティに出力します。

メタデータ

UriにMetaDataは、次のようにインポートすることができます。

fun dumpImageMetaData(uri: Uri) {
    val cursor: Cursor? = contentResolver.query( uri, null, null, null, null, null)
    cursor?.use {
        if (it.moveToFirst()) {
            val displayName: String =
                it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))  // 1
            Log.i(TAG, "Display Name: $displayName")

            val sizeIndex: Int = it.getColumnIndex(OpenableColumns.SIZE)  // 2
            val size: String = if (!it.isNull(sizeIndex)) {
                it.getString(sizeIndex)
            } else {
                "Unknown"
            }
            Log.i(TAG, "Size: $size")
        }
    }
}
  1. ContentResolverに照会して、取得したデータからOpenableColumns.DISPLAY_NAMEカラムにDisplay Nameを取得することができます。 Display Nameはファイル名です。
  2. OpenableColumns.SIZEはファイルのサイズ(Byte)のインポートに使用するコラムです。

UriとMetaDataをログに出力すると、次のように見えます。

OpenExampleActivity: Uri: content://com.android.providers.media.documents/document/image%3A45
OpenExampleActivity: Display Name: matterhorn-4535693_1920.jpg
OpenExampleActivity: Size: 862778

画像を画面に出力

Uriから画像ファイルを読み込み、アクティビティのImageViewに出力するコードは次のとおりです。

private fun showImage(uri: Uri) {
    GlobalScope.launch {    // 1
        val bitmap = getBitmapFromUri(uri)    // 2
        withContext(Dispatchers.Main) {
            mainImageView.setImageBitmap(bitmap)    // 3
        }
    }
}

@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
}
  1. 画像を読み取る作業は時間がかかるため、coroutineを使用して、他のスレッドで動作するようにしました。
  2. ContentResolverを通じてUriのFile descriptiorを取得して画像ファイルを読みました。画像はBitmapに変換しました。
  3. bitmapをImageViewにsetしました。 UIの操作は、main threadでなければならないので、Dispatchers.Mainで作業するようにしました。

結果を見ると、次のように画像が画面に出力されます。 image open result

このようにSAFファイルを開いて、ファイルのデータを読み取ることができます。

SAF UI実行時、特定のパスに移動し

ACTION_OPEN_DOCUMENTインテントを実行して、ファイルまたはフォルダのアクセス権を取得することができます。 実行されているUIはDocumentsUIというアプリのUIです。問題は、一番最後に実行されたフォルダのパスが実行されるということです もし任意のフォルダのUriだけ知っている場合は、そのパスの位置からUIが起動するようにすることができます。

必要なのはDocumentFileのUriなのに、うわにはUriを取得する方法は、私もわかりません。 私使用した方法は、onActivityResult()で返さ受けUriを通しDocumentFileをもたらし、これのUriを取得しました。

Log.d(TAG, "Uri from onActivityResult: $uri")
val docFile = DocumentFile.fromTreeUri(this, uri)!!
Log.d(TAG, "Uri from DocumentFile: ${docFile.uri}")

上記二つのUriを出力してみると、結果が似ていますが、違います。

Uri from onActivityResult: content://com.android.externalstorage.documents/tree/14E7-1B13%3AMovies
Uri from DocumentFile: content://com.android.externalstorage.documents/tree/14E7-1B13%3AMovies/document/14E7-1B13%3AMovies

DocumentFileのUriを別に保存しておき、次にアクセス許可要求が必要なときには、次のようにインテントの EXTRA_INITIAL_URIにuriを入れています。

val docUri = Uri.parse("content://com.android.externalstorage.documents/tree/14E7-1B13%3AMovies/document/14E7-1B13%3AMovies")

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, docUri);
startActivityForResult(intent, REQUEST_OPEN_TREE)

このインテントにDocumentsUIアプリのactivityを実行すると、Uriが指すフォルダの場所から実行されます。

以下は、Developerで取得した EXTRA_INITIAL_URIの説明です。

Sets the desired initial location visible to user when file chooser is shown.

Applicable to Intent with actions:
  Intent#ACTION_OPEN_DOCUMENT
  Intent#ACTION_CREATE_DOCUMENT
  Intent#ACTION_OPEN_DOCUMENT_TREE

Location should specify a document URI or a tree URI with document ID. If this URI identifies a non-directory, document navigator will attempt to use the parent of the document as the initial location.

The initial location is system specific if this extra is missing or document navigator failed to locate the desired initial location.

ファイルの作成

ファイルを作成することは、Openと少し異なります。存在しないファイルを作成し、そのファイルにデータをwriteする必要があります。

  1. 空のファイルを作成します。
  2. 生成されたファイルにデータをwriteする。

アプリは、ファイルを作成する権限がないため、Selector UIを浮かせ、ユーザーがファイルを作る必要があります。

次はSAFファイルを生成するコードです。

val WRITE_REQUEST_CODE: Int = 43

val fileName = "NewImage.jpg"   // 1
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {  // 2
    addCategory(Intent.CATEGORY_OPENABLE)   // 3
    type = "image/jpg"    // 4
    putExtra(Intent.EXTRA_TITLE, fileName)   // 5
}

startActivityForResult(intent, WRITE_REQUEST_CODE)    // 6
  1. ファイル名を決めてくれればSelect UIに自動的に生成することが、ファイル名が入力されます。
  2. ファイルを生成する際にIntent.ACTION_CREATE_DOCUMENTインテントを使用します。
  3. UIに開くことができるファイルのみ見たいIntent.CATEGORY_OPENABLEをカテゴリに入力します。
  4. ファイルのタイプを設定します。
  5. ファイル名をIntent.EXTRA_TITLEという名前のExtraにインテントに設定します。
  6. Selector UIでユーザーがファイルを作れば、生成されたファイルのUriが私のアプリのアクティビティに渡されます。

生成されたファイルのUriリターン受ける

ユーザーがSelector UIでファイルを作成すると、作成されたファイルのUriが私のアプリのアクティビティに渡されます。

Uri情報を受信するコードは次のとおりです。

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    super.onActivityResult(requestCode, resultCode, resultData)

    if (requestCode == WRITE_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
        resultData?.data?.also { uri ->
            Log.i(TAG, "Uri: $uri")   // 1
            writeImage(uri)   // 2
        }
    }
}
  1. Uri情報出力
  2. Uriに画像ファイルをwrite

Uriにファイルwrite

上記で作成したファイルは、0byteサイズの空のファイルです。このファイルに私はしたいデータをwriteしてくれるとします。

private fun writeImage(uri: Uri) {
    GlobalScope.launch {
        contentResolver.openFileDescriptor(uri, "w").use {    // 1
            FileOutputStream(it!!.fileDescriptor).use { it ->   // 2
                writeFromRawDataToFile(it)    // 3
                it.close()
            }
        }
        withContext(Dispatchers.Main) {   //  4
            Toast.makeText(applicationContext, "Done writing an image", Toast.LENGTH_SHORT).show()
        }
    }
}

private fun writeFromRawDataToFile(outStream: FileOutputStream) {   
    val imageInputStream = resources.openRawResource(R.raw.my_image)  // 5
    while (true) {
        val data = imageInputStream.read()
        if (data == -1) {
            break
        }
        outStream.write(data)   // 6
    }
    imageInputStream.close()
}
  1. UriでParcelFileDescriptorを書き込み専用に持って来るのコードです。
  2. ParcelFileDescriptorをFileOutputStreamに変更します。
  3. File descriptorにデータをwriteします。
  4. Toastでwriteを終えたと知らせるコードです。 UI操作であるためDispatchers.Mainで実行します。
  5. Webで画像を受けて書いてはシナリオ面よりいいのですが、私は簡便アプリの "/res/raw/"フォルダに画像を保存し、それを読んでwriteするために実装しました。
  6. ByteをFileOutputStreamにwriteするコードです。

完了したトーストが表示されたら、作業がすべて終了したものです。上記のOpen例として、ファイルが保存され確認することができます。

saf write result

ファイルを削除する

ファイルを削除することはOpenと似ています。 Openを通じてUriを受けてデータを読み取る代わりに削除をします。

上記のOpen例と同様にSelector UIを表示させ。

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
    addCategory(Intent.CATEGORY_OPENABLE)
    type = "image/*"
}

startActivityForResult(intent, READ_REQUEST_CODE)

ファイルを選択すると、私のアプリのアクティビティにUriが渡されます。

次のコードでUriを受けて、ファイルを削除することができます。

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    super.onActivityResult(requestCode, resultCode, resultData)

    if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
        resultData?.data?.also { uri ->
            Log.i(TAG, "Uri: $uri")
            deleteFile(uri)   // 1
        }
    }
}

private fun deleteFile(uri: Uri) {
    DocumentsContract.deleteDocument(contentResolver, uri)    // 2
    Toast.makeText(applicationContext, "Done deleting an image", Toast.LENGTH_SHORT).show()
}
  1. Uriでファイル削除します。
  2. DocumentsContract.deleteDocument()にContentResolverとUriを引数として渡すと、そのUriを削除します。

削除が完了すると、実際に削除されたことを確認してください。

権限を維持

アプリは、ユーザーが選択したファイルをデバイスが再実行されるまで読ん/書き込むことができます。しかし、デバイスが再実行されると、アクセス権を失った。その後、再びユーザーレポートファイルを選択して権限が必要です。

このようなUXは非常に良くないので、デバイスが再実行されても権限を維持する方法があります。

SelectorからUriを受けた時、次のコードを実行すると、ファイルのアクセス権を維持し続けることができます。アプリのローカルDBにUriの情報のみを記憶していれば、デバイスが再実行されても、ファイルにアクセスすることができます。

val takeFlags: Int = intent.flags and
        (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
// Check for the freshest data.
contentResolver.takePersistableUriPermission(uri, takeFlags)

フォルダのアクセス権

今までのファイルの1つに対してアクセスする例を説明しました。フォルダ全体のアクセス権を取得することができます。

以下は、フォルダへのアクセス権を要求しているコードです。

private val REQUEST_OPEN_TREE = 44

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)   // 1
startActivityForResult(intent, REQUEST_OPEN_TREE)
  1. Dirに対してアクセス権を得るためにSAFを浮かべ。

その後、次のようにSelector UIが表示されます。以前との違いは、ファイルを選択することができず、下部に「ALLOW ACCESS TO〜」ボタンを押して、Dir全体に対して権限を与えることができます。 下部のボタンを押すと、ディレクトリのすべてのファイルに対するアクセスを許可するのかは、ポップアップを表示します。

saf open tree result

権限を受け取ったら、DirのUriがアプリに渡されます。

以下は、Uriを受けて処理するコードです。

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    super.onActivityResult(requestCode, resultCode, resultData)

    if (requestCode == REQUEST_OPEN_TREE && resultCode == Activity.RESULT_OK) {
        resultData?.data?.also { uri ->
            val takeFlags: Int = intent.flags and
                    (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
            contentResolver.takePersistableUriPermission(uri, takeFlags)  // 1
            printDirFiles(uri) // 2
        }
    }
}

private fun printDirFiles(uri: Uri) {
    val docFile = DocumentFile.fromTreeUri(this, uri)!!   // 3
    if (docFile.canRead()) {
        val files = docFile.listFiles()   // 4
        for (file : DocumentFile in files) {
            Log.d(TAG, "List files: ${file.name}")  // 5
        }
    }
}
  1. 受信したUriに対して継続的な権限を得るように要求します。要求すると、デバイスがRebootもアプリは権限を維持することができます。
  2. UriのDirに存在するすべてのファイルを出力します。
  3. DocumentFile文書を簡単に扱うために抽象化されたクラスです。引数としてContextとUriを渡します。
  4. listFiles()は、UriのサブファイルをDocumentFile配列で返されています。
  5. For文ですべてのDocumentFileを出力します。

DocumentFileはGradleで[implementation 'androidx.documentfile:documentfile:1.0.1']を追加すると、使用することができます。

実行結果を見ると、このフォルダに存在する2つのファイルがすべて出力されます。

TreeExampleActivity: List files: NewImage.jpg (1)
TreeExampleActivity: List files: NewImage.jpg

このフォルダの継続的な(Persistable)権限を得たので、デバイスをRebootも、このフォルダにアクセスすることができます。 UriをAppのPreferenceに保存して、reboot後、保存したUriに対してアクセスしましょう。権限が維持されたことを確認することができます。

Uriを保存してReboot後に再アクセスする例の完全なコードを見るには、GitHub - TreeExampleActivity.ktを確認してください

参考

codechachaCopyright ©2019 codechacha