2021/10/19-21に開催されたDroidKaigi 2021のDay3にて発表した「Now and Future of Media Access 〜メディアアクセス古今東西〜」の発表資料です。
Now and Future of Media Access〜メディアアクセス古今東⻄〜DroidKaigi20 212021/10/2 1 Yoshihiro Wada - @e10dokupCyberAgent, Inc.
View Slide
Yoshihiro Wada@ e10dokupCyberAgent, Inc. / AmebaPhotography / Motorsports / Gadget
• 本セッションで扱うトピック‧ゴール• Androidで扱われてきたメディアアクセスの⼿法• Androidにおけるメディアアクセスで抑えておくべき知識• ContentResolverとMediaStore APIによるメディアアクセス• google/modernstorage を覗き⾒るアジェンダ3
• モバイルアプリに於けるメディアを扱うユースケース• SNS等のサービスにアップロードするための画像、動画データの取得• 画像、動画を編集し、その結果を新しいデータとして保存する• 本セッションでは「外部アプリとのメディア共有」について扱う• 外部アプリによって⽣成されたデータの取得• ⽣成したデータの外部アプリへの共有• 取得したデータそのものの扱い(アップロード、加⼯)については扱わない本セッションで扱うトピック4
• ContentResolverを⽤いたメディアアクセスについて理解する• 画像、動画を始めとしたメディアの扱いの勘所を把握する• google/modernstorageによるメディアアクセス⼿法の変化を垣間⾒る本セッションのゴール5Androidの最新状況に即したメディアアクセスの実装⼿法を知り、画像、動画を始めとしたメディアの扱いをよりスマートにする
Androidで扱われてきたメディアアクセスの⼿法
• アプリ内で扱うためのメディアファイルのURIを取得するのが⽬的• 他アプリ、システムを利⽤する• Intent発⾏によるギャラリーアプリ等の呼び出し• アプリ内に実装する• ContentResolverとMediaStore APIを利⽤するAndroidで扱われてきたメディアアクセスの⼿法7
• Intent経由でギャラリーアプリ等を呼び出し、結果をonActivityResultで受け取る• ACTION_GET_CONTENT• データの読み取りとインポートのみを⾏う• ACTION_OPEN_DOCUMENT等(Android4.4〜)• ドキュメントへの永続的なアクセスを可能にする• 編集等が可能になる• Storage Access Framework(SAF)を利⽤Intent発⾏によるギャラリーアプリ等の呼び出し8
• Android4.4以降で採⽤されたフレームワーク• 標準のシステムUIによるドキュメント選択を提供することが可能• 端末内だけではなくGoogle Drive、GooglePhotosのようなサービスからも取得することが可能Storage Access Framework9
• 端末内のメディアはMediaStoreに定義されたコレクションに追加される• システムによる⾃動的なスキャンで追加されるようになっている• MediaStore.Images(画像) / MediaStore.Videos(動画) / etc…• ContentResolverを使⽤することで、これらのコレクションに抽象的にアクセスし、参照や更新を⾏うことができるContentResolverとMediaStore APIを利⽤する10
• ContentResolverはContentProviderへのアクセスを⾏う実装• ContentProviderはアプリ間をまたがったデータ共有を⾏うセントラル‧リポジトリとしてRDBのようにデータ提供を⾏うフレームワーク• MediaStoreではContentProviderに対し、各メディアタイプのテーブルを作成し、そこに共有可能なメディアを格納している• ContentResolverによってContentProvider上のMediaStoreのテーブルをクエリしたりすることで、メディアのCRUD処理を⾏うことが可能になるContentResolverとMediaStore11
ContentResolverとMediaStore12データストレージ(ここではファイル)ContentProviderContentResolverContentResolverを操作する実装
ContentResolverとMediaStore13データストレージ(ここではファイル)ContentProviderContentResolverContentResolverを操作する実装MediaStoreによって画像、動画等に分類されたテーブルが⽣成されている
ContentResolverとMediaStore14データストレージ(ここではファイル)ContentProviderContentResolverContentResolverを操作する実装MediaStoreが⽤意しているカラムに沿ってメディアのクエリ、保存といった操作を⾏う
Androidにおけるメディアアクセスで備えておくべき知識
Androidのストレージの区別について(Scoped Storage)16アプリ固有のファイル メディアドキュメント/ファイル• アプリ内でのみ使⽤するファイル• 権限不要だが、外部/内部ストレージで外部アプリへの共有可否が変わる• 共有可能なメディアファイル• MediaStore API経由でアクセス• 外部アプリのメディアにアクセスするには READ_EXTERNAL_STORAGE かWRITE_EXTERNAL_STORAGE 権限が必要• Android9以前ではすべてのメディアに対して権限が必要• メディア以外のコンテンツ• SAF経由でアクセス• 権限不要
• FileスキーマURI - eg. file://storage/emulated/0/Pictures/hoge.jpg• ファイルの実体パス• ContentスキーマURI - eg. content://media/external/images/media/123• Content Provider内のデータを特定するURI• MediaStoreに格納される際に抽象化されたURI• 端末ストレージにあるファイルだけでなく、Google Drive等のクラウド上の画像もContent URIで扱われるFileスキーマURIとContentスキーマURI17
• Android7.0以降、プライベートディレクトリ周りのアクセス制限が強化• targetSdkVersion24以降、File URIをIntentに⽤いることができなくなった• FileUriExposedExceptionがthrowされる• 回避策• FileProvider#getUriForFile によってContent URIに変換する• 対象のファイルがAndroidManifestによって共有指定をした ディレクトリか外部ストレージにあることが条件File URIとContent URIとアプリ間の共有制限18
• AndroidManifest上でrequestLegacyExternalStorage をtrueにすることでScoped Storageをオプトアウトすることができた• targetSdkVersion29に併せたScoped Storageの対応が間に合わないアプリに対しての救済措置• アプリがアンインストールされるまでオプトアウトが有効になる• targetSdkVersion30+ Android1 1からScoped Storageの対応は必須となったため、このオプトアウトは無効になるので注意が必要requestLegacyExternalStorage19
ContentResolverとMediaStore APIによるメディアアクセスの実装
• 起動後に「Load Images」ボタンをタップすることで、端末内の画像のロードが⾏われ、グリッド表⽰される• ロードされたアイテムをタップすることで、画像をクロップし、新規に画像を保存する• ここでは以下の項⽬について解説する• 端末内の画像のロード• 編集後の画像の新規保存サンプルアプリの概要21
ContentResolverによる端末内の画像ロードの実装
• ContentResolverからContentProviderへのクエリを発⾏する• ContextからContentResolverのインスタンスを取得する• Context#getContentResolverメソッド• Projectionを指定する• Selection + args、SortOrderを指定する• ContentResolver#queryメソッドでクエリを発⾏する• クエリ結果からデータを取り出す• 実際に取得したデータを表⽰するContentResolverによる端末内の画像ロードの実装23
• ContentResolverからContentProviderへのクエリを発⾏する• ContextからContentResolverのインスタンスを取得する• Context#getContentResolverメソッド• Projectionを指定する• Selection + args、SortOrderを指定する• ContentResolver#queryメソッドでクエリを発⾏する• クエリ結果からデータを取り出す• 実際に取得したデータを表⽰するContentResolverによる端末内の画像ロードの実装24
• Projectionを指定することによって、ContentResolverが問い合わせて取得するパラメータを指定することができる• ContentResolverが発⾏するクエリはSQL⽂に相当し、Projectionは カラムの指定に相当するProjectionを指定する25val projection = arrayOf(MediaStore.Images.Media._ID, // ContentProvider上におけるIDMediaStore.Images.Media.DISPLAY_NAME, // ファイル名MediaStore.Images.Media.SIZE // ファイルサイズ)
• MediaStore.(Images|Video|Audio).Media それぞれに以下のインターフェースの実装を⾏うことで定義している•android.provider.BaseColumns•android.provider.MediaStore.MediaColumns• それぞれのMediaStoreが独⾃に持つ値を定義したColumns InterfaceNOTE:Projectionの定義について26
MediaStore.Images.MediaにおけるProjectionの例27カラム名 内容_IDそのメディアが格納されているContentProvider上のID。 ContentスキーマURIで利⽤。DISPLAY_NAME そのメディアファイルの実際のファイル名に相当BUCKET_DISPLAY_NAME そのメディアファイルが属しているバケット(= ディレクトリ)名に相当HEIGHT/WIDTH そのメディアファイルが持っている縦幅/横幅MIME_TYPE そのメディアファイルが持っているMimeTypeDATE_TAKEN そのメディアファイルが撮影された(画像だとEXIF由来)の⽇時
• ContentResolverからContentProviderへのクエリを発⾏する• ContextからContentResolverのインスタンスを取得する• Context#getContentResolverメソッド• Projectionを指定する• Selection + args、SortOrderを指定する• ContentResolver#queryメソッドでクエリを発⾏する• クエリ結果からデータを取り出す• 実際に取得したデータを表⽰するContentResolverによる端末内の画像ロードの実装28
• Selectionとそこに代⼊するArgsを指定することでSQLにおける WHERE句に相当する条件設定が可能• SortOrderを指定することでSQLにおけるORDER BY句に相当する順序指定が可能• どちらも不要な場合はnullを指定すればOKSelection + Args、SortOrderを指定する29val selection = “${MediaStore.Images.Media.WIDTH} > ?”val selectionArg = arrayOf(480)val sortOrder = “${MediaStore.Images.Media.DISPLAY_NAME} ASC”
• Selectionとそこに代⼊するArgsを指定することでSQLにおける WHERE句に相当する条件設定が可能• SortOrderを指定することでSQLにおけるORDER BY句に相当する順序指定が可能• どちらも不要な場合はnullを指定すればOKSelection + Args、SortOrderを指定する30val selection = “${MediaStore.Images.Media.WIDTH} > ?”val selectionArg = arrayOf(480)val sortOrder = “${MediaStore.Images.Media.DISPLAY_NAME} ASC”横幅480px以上の画像、という条件設定になる
• Selectionとそこに代⼊するArgsを指定することでSQLにおける WHERE句に相当する条件設定が可能• SortOrderを指定することでSQLにおけるORDER BY句に相当する順序指定が可能• どちらも不要な場合はnullを指定すればOKSelection + Args、SortOrderを指定する31val selection = “${MediaStore.Images.Media.WIDTH} > ?”val selectionArg = arrayOf(480)val sortOrder = “${MediaStore.Images.Media.DISPLAY_NAME} ASC”
• Selectionとそこに代⼊するArgsを指定することでSQLにおける WHERE句に相当する条件設定が可能• SortOrderを指定することでSQLにおけるORDER BY句に相当する順序指定が可能• どちらも不要な場合はnullを指定すればOKSelection + Args、SortOrderを指定する32val selection = “${MediaStore.Images.Media.WIDTH} > ?”val selectionArg = arrayOf(480)val sortOrder = “${MediaStore.Images.Media.DISPLAY_NAME} ASC”ファイル名の昇順での順序指定をしている
• ContentResolverからContentProviderへのクエリを発⾏する• ContextからContentResolverのインスタンスを取得する• Context#getContentResolverメソッド• Projectionを指定する• Selection + args、SortOrderを指定する• ContentResolver#queryメソッドでクエリを発⾏する• クエリ結果からデータを取り出す• 実際に取得したデータを表⽰するContentResolverによる端末内の画像ロードの実装33
• ⽤意したProjection、Selection、SortOrderを⽤い、ContentResolver#queryメソッドをワーカースレッドで実⾏する• 第⼀引数はSQL⽂のFROM句に相当するので、対象となるURIを選ぶContentResolver#queryメソッドでクエリを発⾏する34val query = contentResolver.query(collection, // クエリ対象となるコレクションのURIprojection,selection,selectionArgs,sortOrder)
• クエリ対象となるコレクションのURIを指定する• Android10以降では MediaStore.VOLUME_EXTERNAL から取得するのが推奨されるクエリ対象となるコレクションのURI⽣成35val collection = if (Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT) {MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)} else {MediaStore.Images.Media.EXTERNAL_CONTENT_URI}
• ContentResolverからContentProviderへのクエリを発⾏する• ContextからContentResolverのインスタンスを取得する• Context#getContentResolverメソッド• Projectionを指定する• Selection + args、SortOrderを指定する• ContentResolver#queryメソッドでクエリを発⾏する• クエリ結果からデータを取り出す• 実際に取得したデータを表⽰するContentResolverによる端末内の画像ロードの実装36
• クエリ結果のCursorインスタンスを操作してデータを抽出していくクエリ結果からデータを取り出す37query?.use { // itはCursorのインスタンス// カラムインデックスのキャッシュ処理val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)val displayNameColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)val sizeColumn =it.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)// cursorを行の数だけループさせて結果のリストを作る(次項目)}
• クエリ結果のCursorインスタンスを操作してデータを抽出していくクエリ結果からデータを取り出す38query?.use { // itはCursorのインスタンス// カラムインデックスのキャッシュ処理val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)val displayNameColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)val sizeColumn =it.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)// cursorを行の数だけループさせて結果のリストを作る(次項目)}⾏ごとにループする際にこのメソッドが毎回呼ばれるのを回避するため、あらかじめキャッシュしておく
クエリ結果からデータを取り出す39query?.use { // itはCursorのインスタンス// cursorを行の数だけループさせて結果のリストを作るwhile (it.moveToNext()) {val displayName = it.getString(displayNameColumn)val size = it.getInt(sizeColumn)val contentUri = ContentUris.withAppendedId([コレクションのURI],it.getLong(idColumn))// あとはデータ構造を作って、そのリストを結果として返す(省略)}}
クエリ結果からデータを取り出す40query?.use { // itはCursorのインスタンス// cursorを行の数だけループさせて結果のリストを作るwhile (it.moveToNext()) {val displayName = it.getString(displayNameColumn)val size = it.getInt(sizeColumn)val contentUri = ContentUris.withAppendedId([コレクションのURI],it.getLong(idColumn))// あとはデータ構造を作って、そのリストを結果として返す(省略)}}キャッシュしておいたカラムインデックスを使って 実際に各⾏が持っているデータを抽出する
クエリ結果からデータを取り出す41query?.use { // itはCursorのインスタンス// cursorを行の数だけループさせて結果のリストを作るwhile (it.moveToNext()) {val displayName = it.getString(displayNameColumn)val size = it.getInt(sizeColumn)val contentUri = ContentUris.withAppendedId([コレクションのURI],it.getLong(idColumn))// あとはデータ構造を作って、そのリストを結果として返す(省略)}}末尾にIDを追加することでそのメディアファイルを ⽰すContentスキーマURIを⽣成する
• ContentResolverからContentProviderへのクエリを発⾏する• ContextからContentResolverのインスタンスを取得する• Context#getContentResolverメソッド• Projectionを指定する• Selection + args、SortOrderを指定する• ContentResolver#queryメソッドでクエリを発⾏する• クエリ結果からデータを取り出す• 実際に取得したデータを表⽰するContentResolverによる端末内の画像ロードの実装42
• Glideのような画像読み込みライブラリを⽤いてURIからロードする• Bitmapとして読み込んで表⽰させる• 取得したURIをつかってParcelFileDescriptorとして開き、 BitmapFactory#DecodeFileDescriptorメソッドでデコードする• 取得したURIをつかってInputStreamとして開き BitmapFactory#decodeStreamメソッドでデコードする実際に取得したデータを表⽰する43
• ギャラリーのサムネイルのような「⼩さいサイズでしか表⽰しないのでオリジナルサイズの画像は必要ない」ケース• オリジナル画像だとサイズが⼤きすぎてメモリを圧迫する• ContentResolver#loadThumbnailメソッドを利⽤することで、サムネイルとして指定したサイズの画像をBitmapで取得することができるNOTE: ギャラリーのサムネイルについて44val thumbnail: Bitmap = context.contentResolver.loadThumbnail([ContentスキーマURI], Size(640, 480), null)
• クエリ結果のCursorインスタンスにある⾏の数だけ回す• 対象のファイルが多いほど結果が表⽰されるのは遅くなる• Orderをページング⽤に追加指定することで⼀回の読み込み量を減らす• eg. [もともとのOrder] limit 100 offset 100• targetSdkVersion30以降 + Android1 1以降ではlimit句が使⽤不可• Selection、Orderと⼀緒にBundleで指定できるオーバーロードがAPI26からあるので、バージョン分岐させてそちらを使うようにするNOTE: クエリのページングについて(1/2)45
NOTE: クエリのページングについて(2/2)46contentResolver.query(uri,projection,bundleOf(ContentResolver.QUERY_ARG_SQL_SORT_ORDER to order,ContentResolver.QUERY_ARG_SQL_SELECTION to selection,ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to selectionArg,ContentResolver.QUERY_ARG_OFFSET to offset,ContentResolver.QUERY_ARG_LIMIT to limit,),null)
編集後の画像の新規保存
• ファイル名等、保存に必要な要素を⽤意する• 画像であればファイル名、MimeTypeなど…• ファイル保存先のURIを取得する• アプリ固有のファイルとして保存するURIを取得する• MediaStoreに登録したURIを取得する• 外部のアプリに対して共有できるメディアになる• 取得したURIにファイルの中⾝を書き込む編集後の画像の新規保存の実装48
• ファイル名等、保存に必要な要素を⽤意する• 画像であればファイル名、MimeTypeなど…• ファイル保存先のURIを取得する• アプリ固有のファイルとして保存するURIを取得する• MediaStoreに登録したURIを取得する• 外部のアプリに対して共有できるメディアになる• 取得したURIにファイルの中⾝を書き込む編集後の画像の新規保存の実装49
• Context#getExternalFilesDir の返り値にファイル名を組み合わせてURIを⽣成する• FileスキーマのURIが⽣成されるので、それに画像の中⾝を書き込むアプリ固有のファイルとして保存する50val externalFilesDir =context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)val imageFile = File(externalFilesDir, fileName)val uri = Uri.fromFile(imageFile)// Bitmapの圧縮及びファイルの保存処理
• Context#getExternalFilesDir の返り値にファイル名を組み合わせてURIを⽣成する• FileスキーマのURIが⽣成されるので、それに画像の中⾝を書き込むアプリ固有のファイルとして保存する51val externalFilesDir =context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)val imageFile = File(externalFilesDir, fileName)val uri = Uri.fromFile(imageFile)// Bitmapの圧縮及びファイルの保存処理アプリ固有のファイルの⽣成とそのファイルのFileスキーマURIの取得
• MediaScannerConnection#scanFile で⽣成したURIをMediaStoreにスキャンするよう、依頼することができる• API2 8以前はこれで外部アプリに共有することが可能だった• Context#getExternalFilesDir で得られるアプリ固有のファイルはMediaScannerConnectionのスキャン対象にならないAPI29以降のメディアのスキャン周りの制限52MediaScannerConnection.scanFile(context, paths, null) { path, uri ->Log.d(TAG, "Success to scan: $path to $uri")}
• MediaScannerConnection#scanFile で⽣成したURIをMediaStoreにスキャンするよう、依頼することができる• API2 8以前はこれで外部アプリに共有することが可能だった• Context#getExternalFilesDir で得られるアプリ固有のファイルはMediaScannerConnectionのスキャン対象にならないAPI29以降のメディアのスキャン周りの制限53MediaScannerConnection.scanFile(context, paths, null) { path, uri ->Log.d(TAG, "Success to scan: $path to $uri")}API29以降、uriがnullになるのでスキャンが失敗したことがわかるようになる
• ファイル名等、保存に必要な要素を⽤意する• 画像であればファイル名、MimeTypeなど…• ファイル保存先のURIを取得する• アプリ固有のファイルとして保存するURIを取得する• MediaStoreに登録したURIを取得する• 外部のアプリに対して共有できるメディアになる• 取得したURIにファイルの中⾝を書き込む編集後の画像の新規保存の実装54
• ContentResolver#insert メソッドによって、すでにMediaStoreに登録されたContentスキーマURIを取得する• ContentValuesを⽤意することで、ファイル名やMimeTypeといった メディアに必要なデータを⼀緒にMediaStoreに登録するMediaStoreに登録する55
MediaStoreに登録する56val contentResolver = context.contentResolverval values = ContentValues().apply {put(MediaStore.Images.Media.DISPLAY_NAME, format.fileName)put(MediaStore.Images.Media.MIME_TYPE, mimeType)}val collection = [ターゲットとなるコレクションのURI生成]val uri = contentResolver.insert(collection, values)!!// Bitmapの圧縮及びファイルの保存処理
• クエリのときと同様、ターゲットとなるコレクションのURIを指定する• Android10以降では MediaStore.VOLUME_EXTERNAL_PRIMARY から取得するのが推奨されるターゲットとなるコレクションのURI⽣成57val collection = if (Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT) {MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)} else {MediaStore.Images.Media.EXTERNAL_CONTENT_URI}
• Android10以降、VOLUME_EXTERNAL / VOLUME_EXTERNAL_PRIMARY を選択する必要がある• VOLUME_EXTERNALを⽤いたURI⽣成•content://media/external/images/media• すべての共有ストレージを指し、読み取り専⽤のものとして扱う• VOLUME_EXTERNAL_PRIMARYを⽤いたURI⽣成• content://media/external_primary/images/media になる場合がある• プライマリの共有ストレージを指し、読み書き可能なものとして扱うNOTE: VOLUME_EXTERNALとVOLUME_EXTERNAL_PRIMARY58
• ファイル名等、保存に必要な要素を⽤意する• 画像であればファイル名、MimeTypeなど…• ファイル保存先のURIを取得する• アプリ固有のストレージを使う• アプリ内のみで扱うファイルになる• MediaStoreに登録する• 外部のアプリに対して共有できるメディアになる• 取得したURIにファイルの中⾝を書き込む編集後の画像の新規保存の実装59
• 取得したURIに画像を書き込むのは共通した実装• ContentResolverからOutputStreamやParcelFileDescriptorを開くファイル保存の実装⼿法60context.contentResolver.openOutputStream(uri).use { outputStream ->BufferedOutputStream(outputStream, bufferSize).use { os ->try {bitmap.compress(format, 100, os)} catch (e: FileNotFoundException) {Log.e("saveToFile", "Not found target file", e)}}}
• Android10以降、 IS_PENDING がContentValuesとして利⽤できるNOTE: IS_PENDINGを利⽤した排他的アクセスの実現61// ファイル操作前val values = ContentValues().apply {// 他の値のContentValuesへの追加put(MediaStore.Images.Media.IS_PENDING, 1)}// ファイル操作後values.clear()values.put(MediaStore.Images.Media.IS_PENDING, 0)contentResolver.update(uri, values, null, null)
• Android10以降、 IS_PENDING がContentValuesとして利⽤できるNOTE: IS_PENDINGを利⽤した排他的アクセスの実現62// ファイル操作前val values = ContentValues().apply {// 他の値のContentValuesへの追加put(MediaStore.Images.Media.IS_PENDING, 1)}// ファイル操作後values.clear()values.put(MediaStore.Images.Media.IS_PENDING, 0)contentResolver.update(uri, values, null, null)IS_PENDING が1にすることで他のアプリからの 参照を回避することができる
• Android10以降、 IS_PENDING がContentValuesとして利⽤できるNOTE: IS_PENDINGを利⽤した排他的アクセスの実現63// ファイル操作前val values = ContentValues().apply {// 他の値のContentValuesへの追加put(MediaStore.Images.Media.IS_PENDING, 1)}// ファイル操作後values.clear()values.put(MediaStore.Images.Media.IS_PENDING, 0)contentResolver.update(uri, values, null, null)ファイル操作後は IS_PENDING を0にして更新することで他のアプリから参照できるようにする
google/modernstorageを覗き⾒る
• AndroidのDevRelチームがストレージチームと協⼒して開発している ライブラリ群• ストレージ周りに抽象化レイヤーを提供し、よりストレージアクセス周りの実装を簡単にするのが狙い• MediaStore• Storage Access Framework• 現状のバージョンは1.0.0-alpha02google/modernstorage65
• AndroidのDevRelチームがストレージチームと協⼒して開発している ライブラリ群• ストレージ周りに抽象化レイヤーを提供し、よりストレージアクセス周りの実装を簡単にするのが狙い• MediaStore• Storage Access Framework• 現状のバージョンは1.0.0-alpha02google/modernstorage66
• MediaStoreの扱いを抽象化したレイヤーを提供するライブラリ• MediaStoreRepositoryが抽象化レイヤーとなる• バージョン毎のURI⽣成などを考慮せずMediaStoreにアクセスできる• (現状)できることは以下• MediaStoreアクセスに伴うパーミッションチェック• MediaStoreに登録されたURIの⽣成• MediaStoreへのメディアの追加、変更時のスキャン• MediaStoreへの単⼀メディアの問い合わせmodernstorage-mediastore67
• Scoped Storageに伴う複雑なパーミッション周りのチェックを⾏う ヘルパーメソッドが⽤意されている•can(Read|Write)OwnEntries()• MediaStoreにあるアプリ⾃⾝が作成したメディアの読み書きの パーミッションチェック•can(Read|Write)SharedEntries()• MediaStoreにある外部アプリが作成したメディアの読み書きの パーミッションチェックMediaStoreアクセスに伴うパーミッションチェック68
• MediaStoreに登録されたURIを払い出すことができる• ACTION_IMAGE_CAPTURE Intentでカメラを起動する場合等で使⽤できるEXTRA_OUTPUT に扱うURIとして使⽤可能MediaStoreに登録されたURIの⽣成69val photoUri = mediaStoreRepository.createMediaUri(filename = "new-image.jpg",type = FileType.IMAGE,location = SharedPrimary)
• InputStreamを渡してMediaStoreにメディアを登録したり、登録済みのメディアを編集後にスキャンさせて更新させることができるMediaStoreへのメディアの追加、変更時のスキャン70val photoUri = mediaStore.addMediaFromStream(filename = "new-image.jpg",type = FileType.IMAGE,mimeType = "image/jpg",inputStream = sample,location = SharedPrimary) // メディアの追加mediaStore.scanUri(updatedPhotoUri, “image/png”) // メディアのスキャン
• URIを使ってMediaStoreに登録されたメディアを問い合わせて、ファイル名やファイルサイズといった情報を取得することができるMediaStoreへの単⼀メディアの問い合わせ71val mediaDetails = mediaStore.getResourceByUri(mediaUri)mediaDetails.uri // content://media/external/images/media/123mediaDetails.mimeType // image/jpegmediaDetails.path // ファイルの実体のパス
まとめ
• Android10以降で実装されたScoped Storageによってメディアアクセス周りの複雑性が増した• ストレージの場所からファイルの利⽤⽬的への権限の基準の変更が発⽣した• しかし、メディアアクセスにはMediaStoreとContentResolverを駆使する必要があるのは以前と変わらない• google/modernstorageは2021年の後半をターゲットに開発を進めている、と書かれており、いずれはストレージアクセスのスタンダードになりそうまとめ73
• https://developer.android.com/training/data-storage?hl=ja#scoped-storage• データ ストレージとファイル ストレージの概要|Android Developers• https://developer.android.com/training/data-storage/shared/media• 共有ストレージからメディアにアクセスする|Android Developers• https://google.github.io/modernstorage/• google/modernstorageReference74