Storage Access Framework(SAF)は、ファイルピッカーでアクセス対処のファイルを指定する仕組みです。
SAFはアンドロイドシステムに実装されています。SAFを用いれば、ピッカーを自作する必要はありません。
アプリ開発が楽になります。
しかも、指定された外部ストレージ上のファイルは、アプリに対するアクセス許可を付与したものになります。
改めて、許可の取得は不要です。そのまま、ファイルを読み書き出来ます。
今回は「外部ストレージへStorage Access Frameworkでアクセス」を、まとめます。
※環境:Android Studio Narwhal Feature Drop | 2025.1.2 Patch 1
目次
Storage Access Framework(SAF)
Storage Access Frameworkは、ファイルピッカー(GUI)でアクセス対処のファイル(フォルダ)を指定する仕組みです。
API≧19で導入されました。
外部ストレージに保存される全てのデータがアクセス対象です。
主に、「ドキュメントと他のファイル」のアクセスに適しています。メディアデータのアクセスも出来ますが、メディア情報が記録されたデータベースを扱えません。
外部ストレージ_プライマリ外部ストレージ プライマリ /storage/emulated/ユーザーID | Java / Android API | Media Store API | SAF | |
---|---|---|---|---|
メディアデータ | Pictures | 他:△wrp | 他:◎wrp | 他:△wr |
DCIM | ||||
Movies | ||||
Music | ||||
Alarms | ||||
Notifications | ||||
Podcasts | ||||
Ringtones | ||||
Audiobooks(API≧30) | ||||
Recordings(API≧31) | ||||
ドキュメント 他のファイル | Downloads | 他:○wrp | 他:◎wrp | 他:◎wr |
Documants | ||||
Android (Android/dataを除く) | ||||
ユーザーフォルダ | ||||
ユーザーファイル | ||||
アプリ別 Android/data/パッケージ名/files | 他:△wrp | 他:○wr |
||
※自:自アプリのファイル 他:他アプリのファイル ◎:アクセスできる(最適) ○:アクセスできる △:アクセスできる(不適または制限有) ×:アクセスできない 空:アクセスできない(サポート外) w:書き込みできる r:読み出しできる p:パーミッションが必要 |
外部ストレージ プライマリ /storage/emulated/ユーザーID | Java / Android API | Media Store API | SAF | |
---|---|---|---|---|
メディアデータ | Pictures | 他:× | 他:◎ rp | 他:△wr |
DCIM | ||||
Movies | ||||
Music | ||||
Alarms | ||||
Notifications | ||||
Podcasts | ||||
Ringtones | ||||
Audiobooks(API≧30) | ||||
Recordings(API≧31) | ||||
ドキュメント 他のファイル | Downloads | 他:× | 他:× | 他:◎wr |
Documants | ||||
Android (Android/dataを除く) | ||||
ユーザーフォルダ | ||||
ユーザーファイル | ||||
アプリ別 Android/data/パッケージ名/files | 他:× | 他:○wr |
||
※自:自アプリのファイル 他:他アプリのファイル ◎:アクセスできる(最適) ○:アクセスできる △:アクセスできる(不適または制限有) ×:アクセスできない 空:アクセスできない(サポート外) w:書き込みできる r:読み出しできる p:パーミッションが必要 |
外部ストレージ プライマリ /storage/emulated/ユーザーID | Java / Android API | Media Store API | SAF | |
---|---|---|---|---|
メディアデータ | Pictures | 他:× | 他:◎ rp ※1 | 他:△wr |
DCIM | ||||
Movies | ||||
Music | ||||
Alarms | ||||
Notifications | ||||
Podcasts | ||||
Ringtones | ||||
Audiobooks(API≧30) | ||||
Recordings(API≧31) | ||||
ドキュメント 他のファイル | Downloads | 他:× | 他:× | 他:◎wr |
Documants | ||||
Android (Android/dataを除く) | 他:× | |||
ユーザーフォルダ | ||||
ユーザーファイル | ||||
アプリ別 Android/data/パッケージ名/files | 他:× | 他:× |
||
※自:自アプリのファイル 他:他アプリのファイル ◎:アクセスできる(最適) ○:アクセスできる △:アクセスできる(不適または制限有) ×:アクセスできない 空:アクセスできない(サポート外) w:書き込みできる r:読み出しできる p:パーミッションが必要 ※1:書き込みはURI(ファイル)毎にアクセス許可が必要、通常のパーミッションとは別 |
外部ストレージ プライマリ /storage/emulated/ユーザーID | Java / Android API | Media Store API | SAF | |
---|---|---|---|---|
メディアデータ | Pictures | 他:× | 他:◎ rp ※1 | 他:△wr |
DCIM | ||||
Movies | ||||
Music | ||||
Alarms | ||||
Notifications | ||||
Podcasts | ||||
Ringtones | ||||
Audiobooks(API≧30) | ||||
Recordings(API≧31) | ||||
ドキュメント 他のファイル | Downloads | 他:× | 他:× | 他:◎wr |
Documants | ||||
Android (Android/dataを除く) | 他:× | |||
ユーザーフォルダ | ||||
ユーザーファイル | ||||
アプリ別 Android/data/パッケージ名/files | 他:× | 他:× |
||
※自:自アプリのファイル 他:他アプリのファイル ◎:アクセスできる(最適) ○:アクセスできる △:アクセスできる(不適または制限有) ×:アクセスできない 空:アクセスできない(サポート外) w:書き込みできる r:読み出しできる p:パーミッションが必要(images/video/audioデータ毎) ※1:書き込みはURI(ファイル)毎にアクセス許可が必要、通常のパーミッションとは別 |
外部ストレージ セカンダリ /storage/NNN-NNN/ | Java / Android API | Media Store API | SAF | |
---|---|---|---|---|
ドキュメント 他のファイル | Downloads | 他:× | 他:◎wr |
|
Documents | ||||
Android (Android/dataを除く) |
||||
ユーザーフォルダ | ||||
ユーザーファイル | ||||
アプリ別 (Android/data/パッケージ名/files) | 他:△ rp | 他:○wr |
||
※自:自アプリのファイル 他:他アプリのファイル ◎:アクセスできる(最適) ○:アクセスできる △:アクセスできる(不適または制限有) ×:アクセスできない 空:アクセスできない(サポート外) w:書き込みできる r:読み出しできる p:パーミッションが必要 |
外部ストレージ セカンダリ /storage/NNN-NNN/ | Java / Android API | Media Store API | SAF | |
---|---|---|---|---|
ドキュメント 他のファイル | Downloads | 他:× | 他:◎wr |
|
Documents | ||||
Android (Android/dataを除く) |
||||
ユーザーフォルダ | ||||
ユーザーファイル | ||||
アプリ別 (Android/data/パッケージ名/files) | 他:× | 他:○wr |
||
※自:自アプリのファイル 他:他アプリのファイル ◎:アクセスできる(最適) ○:アクセスできる △:アクセスできる(不適または制限有) ×:アクセスできない 空:アクセスできない(サポート外) w:書き込みできる r:読み出しできる p:パーミッションが必要 |
外部ストレージ セカンダリ /storage/NNN-NNN/ | Java / Android API | Media Store API | SAF | |
---|---|---|---|---|
ドキュメント 他のファイル | Downloads | 他:× | 他:◎wr |
|
Documents | ||||
Android (Android/dataを除く) | 他:× |
|||
ユーザーフォルダ | ||||
ユーザーファイル | ||||
アプリ別 (Android/data/パッケージ名/files) | 他:× | 他:× |
||
※自:自アプリのファイル 他:他アプリのファイル ◎:アクセスできる(最適) ○:アクセスできる △:アクセスできる(不適または制限有) ×:アクセスできない 空:アクセスできない(サポート外) w:書き込みできる r:読み出しできる p:パーミッションが必要 |
パーミッションの取得不要
ファイルの指定にユーザーの操作が介在するので、ファイルを指定した時点でアクセスする許可を得たものと見なされます。
ですので、外部ストレージに対するアクセスであっても、パーミッションの取得は不要です。
ファイル指定の手順
ファイルの指定は、次のような手順で行います。
Content Resolver/Provider経由でアクセスを行います。
アクセスを行いたいタイミングで、Clientアプリを起動します。
手順2Clinentアプリはピッカーを表示して、ユーザーにアクセス対象のファイル(フォルダ)の指定を求めます。
手順3ピッカーで指定したURI(ファイル)をコールバックで返します。このURIはアクセスする許可を取得済みものです。
手順1~3はアプリケーションコンポーネント(Activity)の連携と同じ動作です。
※詳細は「App component:Activity」を参照
書き込みの例
書き込みの具体的な例です。※Activity Result APIで記述しています。
コールバックの定義と登録
registerForActivityResult(SafPicker)はコールバックの定義と登録を行います。
ラムダ式がコールバックです。コールバックは2段構成にしています。
1段目コールバックでresultからURIを取り出ます。
「ファイル指定の成功・失敗」はresultCodeで判断できます。成功の場合のみ、2段目コールバックの実行を行っています。
fun ComponentActivity.SafPicker(callback: (Uri) -> Unit) = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> if(result.resultCode == RESULT_OK) { // ファイル指定が成功 val _intent = result.data _intent?.let { val _uri = it.data _uri?.let { callback(it) } // 2段目コールバックの実行 } } else { } // ファイル指定が失敗 }
2段目コールバックで書き込み処理を行います。
そして、このコールバックを利用するコンポーネントのランチャー(_safWritePicker : ActivityResultLauncher)を返します。
val _safWritePicker = SafPicker { uri -> writeToUri_Resolver(_data, uri) }
registerForActivityResult(SafPicker)は、LifecycleOwnerのcurrentStateが「STARTED」より前のタイミングで実行して下さい。Activityのライフサイクルと密接に関係しています。
※LifecycleOwnerについては「ライフサイクル対応コンポーネント作成」を参照
ピッカーの起動
Clientアプリを起動(launch)し、ピッカーを表示させます。
launchの引数はアクセスのリクエスト(Intent)です。書き込みを行いたいのでIntent.ACTION_CREATE_DOCUMENTアクションを使って、リクエストを構築しています。
_safWritePicker.launch(FileCreateIntent("sample.txt"))
fun FileCreateIntent(filename: String) = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) putExtra(Intent.EXTRA_TITLE, filename) // デフォルトのファイル名 type = "*/*" // 表示するファイルのMIME }
書き込みの実行
ピッカーが表示されて、ユーザにフォルダの場所とファイル名の指定を求めます。
図(エミュレータ―で実行、API33)は、外部ストレージへ「UserDir(フォルダ)」を作り、その中に「sample.txt」を書き込む例です。
指定されたURI(ファイル)は、このアプリによる書き込みが可能です。
例えば書き込みするのであれば、通常のアクセスと同様に、下記の拡張関数で行えます。
fun Context.writeToUri_Resolver(data: ByteArray, uri: Uri) { try { contentResolver.openOutputStream(uri, "w")?.use { outputStream -> outputStream.write(data) outputStream.flush() } } catch (e: Exception) { Log.i(TAG, "[writeToUri] ${e}") } }
# pwd
/storage/emulated/0
# ls -ld * UserDir/*
drwxrws--- 2 u0_a166 media_rw 4096 2025-08-24 23:56 Alarms
drwxrws--x 5 media_rw media_rw 4096 2025-08-24 23:55 Android
drwxrws--- 2 u0_a166 media_rw 4096 2025-08-24 23:56 Audiobooks
drwxrws--- 2 u0_a166 media_rw 4096 2025-08-24 23:56 DCIM
...
drwxrws--- 2 u0_a166 media_rw 4096 2025-08-24 23:56 Recordings
drwxrws--- 2 u0_a166 media_rw 4096 2025-08-24 23:56 Ringtones
drwxrws--- 2 u0_a166 media_rw 4096 2025-08-25 02:20 UserDir
-rw-rw---- 1 u0_a166 media_rw 30 2025-08-25 02:20 UserDir/sample.txt
※エミュレータ(API 33)で実行
読み出しの例
読み出しの具体的な例です。※Activity Result APIで記述しています。
コールバックの定義と登録
1段目コールバックは書き込み時と同じです。
2段目コールバックで読み出し処理を行います。
そして、このコールバックを利用するコンポーネントのランチャー(_safReadPicker : ActivityResultLauncher)を返します。
val _safReadPicker = SafPicker { uri -> val _data = readFromUri_Resolver(uri) Log.i(TAG, "Data = ${_data.decodeToString()}") }
ピッカーの起動
Clientアプリを起動(launch)し、ピッカーを表示させます。
launchの引数はアクセスのリクエスト(Intent)です。読み出しを行いたいのでIntent.ACTION_OPEN_DOCUMENTアクションを使って、リクエストを構築しています。
_safReadPicker.launch(FileOpenIntent())
fun FileOpenIntent() = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "*/*" // 表示するファイルのMIME }
読み出しの実行
ピッカーが表示されて、ユーザにフォルダの場所とファイル名の指定を求めます。
図(エミュレータ―で実行、API33)は、外部ストレージへ「UserDir(フォルダ)」を開き、その中から「sample.txt」を読み出す例です。
指定されたURI(ファイル)は、このアプリによる読み出しが可能です。
例えば読み出しするのであれば、通常のアクセスと同様に、下記の拡張関数で行えます。
fun Context.readFromUri_Resolver(uri: Uri): ByteArray { val _os = ByteArrayOutputStream() contentResolver.openInputStream(uri).use { stream -> _os.use { stream?.copyTo(_os) } } return _os.toByteArray() }
Data = Hello World !! (1756087962165)
※エミュレータ(API 33)で実行
リクエストで利用するアクション
リクエスト(Intent)で指定するアクションは、表のような意味が有ります。
アクション(Intent.*) | リクエスト | コメント |
---|---|---|
ACTION_CREATE_DOCUMENT | 新しくファイルを作成 | 書き込み可能(新規作成) ファイル名の重複は番号を付けて回避 例:sample(1).txt |
ACTION_OPEN_DOCUMENT | 既存のファイルを開く | 書き込み可能(上書き) 読み出し可能 |
ACTION_OPEN_DOCUMENT_TREE (API≧21) | フォルダを開く | フォルダ全体のアクセス権を持つ |
※ACTION_OPEN_DOCUMENT_TREEはAPI≧21で利用可能です。
関連記事: