Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[DroidKaigi 2021] メディアアクセス古今東西 / Now and Future...

[DroidKaigi 2021] メディアアクセス古今東西 / Now and Future of Media Access

2021/10/19-21に開催されたDroidKaigi 2021のDay3にて発表した「Now and Future of Media Access 〜メディアアクセス古今東西〜」の発表資料です。

Yoshihiro WADA

October 19, 2021
Tweet

More Decks by Yoshihiro WADA

Other Decks in Programming

Transcript

  1. Now and Future of Media Access 〜メディアアクセス古今東⻄〜 DroidKaigi 20 2

    1 2 02 1 / 10 / 2 1 Yoshihiro Wada - @e 10 dokup CyberAgent, Inc.
  2. Yoshihiro Wada @ e 10 dokup CyberAgent, Inc. / Ameba

    Photography / Motorsports / Gadget
  3. • Intent経由でギャラリーアプリ等を呼び出し、結果をonActivityResult で受け取る • ACTION_GET_CONTENT • データの読み取りとインポートのみを⾏う • ACTION_OPEN_DOCUMENT等(Android 4

    . 4 〜) • ドキュメントへの永続的なアクセスを可能にする • 編集等が可能になる • Storage Access Framework(SAF)を利⽤ Intent発⾏によるギャラリーアプリ等の呼び出し 8
  4. • Android 4 . 4 以降で採⽤されたフレームワーク • 標準のシステムUIによるドキュメント選択を提供 することが可能 •

    端末内だけではなくGoogle Drive、Google Photosのようなサービスからも取得することが 可能 Storage Access Framework 9
  5. • 端末内のメディアはMediaStoreに定義されたコレクションに追加される • システムによる⾃動的なスキャンで追加されるようになっている • MediaStore.Images(画像) / MediaStore.Videos(動画) / etc

    … • ContentResolverを使⽤することで、これらのコレクションに抽象的に アクセスし、参照や更新を⾏うことができる ContentResolverとMediaStore APIを利⽤する 10
  6. Androidのストレージの区別について(Scoped Storage) 16 アプリ固有のファイル メディア ドキュメント/ファイル • アプリ内でのみ使⽤するファイル • 権限不要だが、外部/内部ストレージ

    で外部アプリへの共有可否が変わる • 共有可能なメディアファイル • MediaStore API経由でアクセス • 外部アプリのメディアにアクセスす るには READ_EXTERNAL_STORAGE か WRITE_EXTERNAL_STORAGE 権限が必要 • Android 9 以前ではすべてのメディア に対して権限が必要 • メディア以外のコンテンツ • SAF経由でアクセス • 権限不要
  7. • 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スキーマURI 17
  8. • Android 7 . 0 以降、プライベートディレクトリ周りのアクセス制限が強化 • targetSdkVersion 24 以降、File

    URIをIntentに⽤いることができなく なった • FileUriExposedExceptionがthrowされる • 回避策 • FileProvider#getUriForFile によってContent URIに変換する • 対象のファイルがAndroidManifestによって共有指定をした 
 ディレクトリか外部ストレージにあることが条件 File URIとContent URIとアプリ間の共有制限 18
  9. • AndroidManifest上でrequestLegacyExternalStorage をtrueにすること でScoped Storageをオプトアウトすることができた • targetSdkVersion 29 に併せたScoped Storageの対応が間に合わないア

    プリに対しての救済措置 • アプリがアンインストールされるまでオプトアウトが有効になる • targetSdkVersion 30 + Android 1 1 からScoped Storageの対応は必須と なったため、このオプトアウトは無効になるので注意が必要 requestLegacyExternalStorage 19
  10. • ContentResolverからContentProviderへのクエリを発⾏する • ContextからContentResolverのインスタンスを取得する • Context#getContentResolverメソッド • Projectionを指定する • Selection

    + args、SortOrderを指定する • ContentResolver#queryメソッドでクエリを発⾏する • クエリ結果からデータを取り出す • 実際に取得したデータを表⽰する ContentResolverによる端末内の画像ロードの実装 23
  11. • ContentResolverからContentProviderへのクエリを発⾏する • ContextからContentResolverのインスタンスを取得する • Context#getContentResolverメソッド • Projectionを指定する • Selection

    + args、SortOrderを指定する • ContentResolver#queryメソッドでクエリを発⾏する • クエリ結果からデータを取り出す • 実際に取得したデータを表⽰する ContentResolverによる端末内の画像ロードの実装 24
  12. • Projectionを指定することによって、ContentResolverが問い合わせて 取得するパラメータを指定することができる • ContentResolverが発⾏するクエリはSQL⽂に相当し、Projectionは 
 カラムの指定に相当する Projectionを指定する 25 val

    projection = arrayOf( MediaStore.Images.Media._ID, // ContentProvider上におけるID MediaStore.Images.Media.DISPLAY_NAME, // ファイル名 MediaStore.Images.Media.SIZE // ファイルサイズ )
  13. MediaStore.Images.MediaにおけるProjectionの例 27 カラム名 内容 _ID そのメディアが格納されているContentProvider上のID。 
 ContentスキーマURIで利⽤。 DISPLAY_NAME そのメディアファイルの実際のファイル名に相当

    BUCKET_DISPLAY_NAME そのメディアファイルが属しているバケット(= ディレクトリ)名に相当 HEIGHT/WIDTH そのメディアファイルが持っている縦幅/横幅 MIME_TYPE そのメディアファイルが持っているMimeType DATE_TAKEN そのメディアファイルが撮影された(画像だとEXIF由来)の⽇時
  14. • ContentResolverからContentProviderへのクエリを発⾏する • ContextからContentResolverのインスタンスを取得する • Context#getContentResolverメソッド • Projectionを指定する • Selection

    + args、SortOrderを指定する • ContentResolver#queryメソッドでクエリを発⾏する • クエリ結果からデータを取り出す • 実際に取得したデータを表⽰する ContentResolverによる端末内の画像ロードの実装 28
  15. • Selectionとそこに代⼊するArgsを指定することでSQLにおける 
 WHERE句に相当する条件設定が可能 • SortOrderを指定することでSQLにおけるORDER BY句に相当する順序指 定が可能 • どちらも不要な場合はnullを指定すればOK

    Selection + Args、SortOrderを指定する 29 val selection = “${MediaStore.Images.Media.WIDTH} > ?” val selectionArg = arrayOf(480) val sortOrder = “${MediaStore.Images.Media.DISPLAY_NAME} ASC”
  16. • Selectionとそこに代⼊するArgsを指定することでSQLにおける 
 WHERE句に相当する条件設定が可能 • SortOrderを指定することでSQLにおけるORDER BY句に相当する順序指 定が可能 • どちらも不要な場合はnullを指定すればOK

    Selection + Args、SortOrderを指定する 30 val selection = “${MediaStore.Images.Media.WIDTH} > ?” val selectionArg = arrayOf(480) val sortOrder = “${MediaStore.Images.Media.DISPLAY_NAME} ASC” 横幅480px以上の画像、という条件設定になる
  17. • Selectionとそこに代⼊するArgsを指定することでSQLにおける 
 WHERE句に相当する条件設定が可能 • SortOrderを指定することでSQLにおけるORDER BY句に相当する順序指 定が可能 • どちらも不要な場合はnullを指定すればOK

    Selection + Args、SortOrderを指定する 31 val selection = “${MediaStore.Images.Media.WIDTH} > ?” val selectionArg = arrayOf(480) val sortOrder = “${MediaStore.Images.Media.DISPLAY_NAME} ASC”
  18. • Selectionとそこに代⼊するArgsを指定することでSQLにおける 
 WHERE句に相当する条件設定が可能 • SortOrderを指定することでSQLにおけるORDER BY句に相当する順序指 定が可能 • どちらも不要な場合はnullを指定すればOK

    Selection + Args、SortOrderを指定する 32 val selection = “${MediaStore.Images.Media.WIDTH} > ?” val selectionArg = arrayOf(480) val sortOrder = “${MediaStore.Images.Media.DISPLAY_NAME} ASC” ファイル名の昇順での順序指定をしている
  19. • ContentResolverからContentProviderへのクエリを発⾏する • ContextからContentResolverのインスタンスを取得する • Context#getContentResolverメソッド • Projectionを指定する • Selection

    + args、SortOrderを指定する • ContentResolver#queryメソッドでクエリを発⾏する • クエリ結果からデータを取り出す • 実際に取得したデータを表⽰する ContentResolverによる端末内の画像ロードの実装 33
  20. • クエリ対象となるコレクションのURIを指定する • Android 10 以降では MediaStore.VOLUME_EXTERNAL から取得するのが推 奨される クエリ対象となるコレクションのURI⽣成

    35 val collection = if (Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT) { MediaStore .Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) } else { MediaStore.Images.Media.EXTERNAL_CONTENT_URI }
  21. • ContentResolverからContentProviderへのクエリを発⾏する • ContextからContentResolverのインスタンスを取得する • Context#getContentResolverメソッド • Projectionを指定する • Selection

    + args、SortOrderを指定する • ContentResolver#queryメソッドでクエリを発⾏する • クエリ結果からデータを取り出す • 実際に取得したデータを表⽰する ContentResolverによる端末内の画像ロードの実装 36
  22. • クエリ結果のCursorインスタンスを操作してデータを抽出していく クエリ結果からデータを取り出す 37 query?.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を行の数だけループさせて結果のリストを作る(次項目) }
  23. • クエリ結果のCursorインスタンスを操作してデータを抽出していく クエリ結果からデータを取り出す 38 query?.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を行の数だけループさせて結果のリストを作る(次項目) } ⾏ごとにループする際にこのメソッドが毎回呼ばれる のを回避するため、あらかじめキャッシュしておく
  24. クエリ結果からデータを取り出す 39 query?.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) ) // あとはデータ構造を作って、そのリストを結果として返す(省略) } }
  25. クエリ結果からデータを取り出す 40 query?.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) ) // あとはデータ構造を作って、そのリストを結果として返す(省略) } } キャッシュしておいたカラムインデックスを使って 
 実際に各⾏が持っているデータを抽出する
  26. クエリ結果からデータを取り出す 41 query?.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を⽣成する
  27. • ContentResolverからContentProviderへのクエリを発⾏する • ContextからContentResolverのインスタンスを取得する • Context#getContentResolverメソッド • Projectionを指定する • Selection

    + args、SortOrderを指定する • ContentResolver#queryメソッドでクエリを発⾏する • クエリ結果からデータを取り出す • 実際に取得したデータを表⽰する ContentResolverによる端末内の画像ロードの実装 42
  28. • クエリ結果のCursorインスタンスにある⾏の数だけ回す • 対象のファイルが多いほど結果が表⽰されるのは遅くなる • Orderをページング⽤に追加指定することで⼀回の読み込み量を減らす • eg. [もともとのOrder] limit

    100 offset 100 • targetSdkVersion 30 以降 + Android 1 1 以降ではlimit句が使⽤不可 • Selection、Orderと⼀緒にBundleで指定できるオーバーロードが API 26 からあるので、バージョン分岐させてそちらを使うようにする NOTE: クエリのページングについて(1/2) 45
  29. NOTE: クエリのページングについて(2/2) 46 contentResolver.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 )
  30. • Context#getExternalFilesDir の返り値にファイル名を組み合わせてURI を⽣成する • FileスキーマのURIが⽣成されるので、それに画像の中⾝を書き込む アプリ固有のファイルとして保存する 50 val externalFilesDir

    = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) val imageFile = File(externalFilesDir, fileName) val uri = Uri.fromFile(imageFile) // Bitmapの圧縮及びファイルの保存処理
  31. • Context#getExternalFilesDir の返り値にファイル名を組み合わせてURI を⽣成する • FileスキーマのURIが⽣成されるので、それに画像の中⾝を書き込む アプリ固有のファイルとして保存する 51 val externalFilesDir

    = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) val imageFile = File(externalFilesDir, fileName) val uri = Uri.fromFile(imageFile) // Bitmapの圧縮及びファイルの保存処理 アプリ固有のファイルの⽣成と そのファイルのFileスキーマURIの取得
  32. • MediaScannerConnection#scanFile で⽣成したURIをMediaStoreにス キャンするよう、依頼することができる • API 2 8 以前はこれで外部アプリに共有することが可能だった •

    Context#getExternalFilesDir で得られるアプリ固有のファイルは MediaScannerConnectionのスキャン対象にならない API 29 以降のメディアのスキャン周りの制限 52 MediaScannerConnection.scanFile(context, paths, null) { path, uri -> Log.d(TAG, "Success to scan: $path to $uri") }
  33. • MediaScannerConnection#scanFile で⽣成したURIをMediaStoreにス キャンするよう、依頼することができる • API 2 8 以前はこれで外部アプリに共有することが可能だった •

    Context#getExternalFilesDir で得られるアプリ固有のファイルは MediaScannerConnectionのスキャン対象にならない API 29 以降のメディアのスキャン周りの制限 53 MediaScannerConnection.scanFile(context, paths, null) { path, uri -> Log.d(TAG, "Success to scan: $path to $uri") } API 29 以降、uriがnullになるのでスキャンが 失敗したことがわかるようになる
  34. MediaStoreに登録する 56 val contentResolver = context.contentResolver val 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の圧縮及びファイルの保存処理
  35. • クエリのときと同様、ターゲットとなるコレクションのURIを指定する • Android 10 以降では MediaStore.VOLUME_EXTERNAL_PRIMARY から取得す るのが推奨される ターゲットとなるコレクションのURI⽣成

    57 val 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 }
  36. • Android 10 以降、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_PRIMARY 58
  37. • ファイル名等、保存に必要な要素を⽤意する • 画像であればファイル名、MimeTypeなど… • ファイル保存先のURIを取得する • アプリ固有のストレージを使う • アプリ内のみで扱うファイルになる

    • MediaStoreに登録する • 外部のアプリに対して共有できるメディアになる • 取得したURIにファイルの中⾝を書き込む 編集後の画像の新規保存の実装 59
  38. • 取得したURIに画像を書き込むのは共通した実装 • ContentResolverからOutputStreamやParcelFileDescriptorを開く ファイル保存の実装⼿法 60 context.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) } } }
  39. • Android 10 以降、 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)
  40. • Android 10 以降、 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にすることで他のアプリからの 
 参照を回避することができる
  41. • Android 10 以降、 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にして更新すること で他のアプリから参照できるようにする
  42. • Scoped Storageに伴う複雑なパーミッション周りのチェックを⾏う 
 ヘルパーメソッドが⽤意されている •can(Read|Write)OwnEntries() • MediaStoreにあるアプリ⾃⾝が作成したメディアの読み書きの 
 パーミッションチェック

    •can(Read|Write)SharedEntries() • MediaStoreにある外部アプリが作成したメディアの読み書きの 
 パーミッションチェック MediaStoreアクセスに伴うパーミッションチェック 68
  43. • InputStreamを渡してMediaStoreにメディアを登録したり、登録済みの メディアを編集後にスキャンさせて更新させることができる MediaStoreへのメディアの追加、変更時のスキャン 70 val photoUri = mediaStore.addMediaFromStream( filename

    = "new-image.jpg", type = FileType.IMAGE, mimeType = "image/jpg", inputStream = sample, location = SharedPrimary ) // メディアの追加 mediaStore.scanUri(updatedPhotoUri, “image/png”) // メディアのスキャン
  44. • Android 10 以降で実装されたScoped Storageによってメディアアクセス 周りの複雑性が増した • ストレージの場所からファイルの利⽤⽬的への権限の基準の変更が発 ⽣した •

    しかし、メディアアクセスにはMediaStoreとContentResolverを駆 使する必要があるのは以前と変わらない • google/modernstorageは2021年の後半をターゲットに開発を進めてい る、と書かれており、いずれはストレージアクセスのスタンダードにな りそう まとめ 73
  45. • 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/modernstorage Reference 74