19
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Android 10から13までのメディアファイルへのアクセス

Last updated at Posted at 2023-07-14

経緯

Androidアプリで久しぶりに画像・動画・音声などのメディアファイルへのアクセスを実装する機会があり、かなり昔と変わったなと感じたので整理してみます。
主に対象範囲別ストレージが実装されたAndroid 10以降からが大きな変更かと思いますので、Android 10以降のお話をまとめます。

結局今どうやって実装すれば良いのかを早く知りたい!という方はこちらからご覧ください。

Android10から13までのメディアファイルアクセスに関する変更点

Android 10 (APIレベル 29)

Android 10で対象範囲別ストレージが実装されました。
メディアファイルに限った話を端的にまとめると、システム側でアプリと各メディアファイルが紐付けられるようになりました。

各メディアファイルは1つのアプリにのみ紐付けられ、アプリは自身が作成したメディアファイルに追加の権限なしでアクセス/変更することができるようになりました。

ただ、アプリを一度アンインストールして再度インストールした場合、
アンインストール前にアプリが作成したファイルにアクセスするには READ_EXTERNAL_STORAGE パーミッションが必要になります。

またこの影響で、メディアファイルへは MediaStore APIStorage Access Framework を通してしかアクセスできないようになりました。

ただ、この変更はアプリによってはかなり影響が大きかった(遠い目)ため、
一時的な措置として AndroidManifest.xmlrequestLegacyExternalStorage を指定することでAndroid 9以前のアクセス方法でも外部ファイルにアクセスできるような救済措置が取られました。
※ 後述しますが、今は使えません。

「Android 10からメディアファイルに追加の権限なしでアクセス/変更することができるようになった」のに、WRITE_EXTERNAL_STORAGEパーミッションのandroid:maxSdkVersionが29に設定されているケースが多くあるのは、この救済措置があったためではないかと推測しています。

AndroidManifest.xml
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
                 android:maxSdkVersion="29" />

Android 11 (APIレベル 30)

targetSdkVersionが30(Android 11)以上のアプリでrequestLegacyExternalStorage="true"が無視されるようになりました。

また、アプリの運用的なお話をすると、このあたりから Google Play にリリースするアプリにtargetSdkVersionのAPIレベルの最低要件が追加されました(本題ではなく詳しくは調べてないので、正確にいつから始まったかは曖昧です)。

なので、これ以前は
targetSdkVersion上げるとうまく動かないからとりあえずそのままにしとこうぜ」
ということができたものがそれすらもできなくなり、対応に追われた方も多いのではないかと思います。

また、メディアファイルへのアクセスについてですが、このバージョンから外部ファイルの書き込みに WRITE_EXTERNAL_STORAGE パーミッションが不要になりました。

Android 12 (APIレベル 31)

メディアファイル関連でいえば MANAGE_MEDIA というパーミッションが追加されました。

例えばフォトギャラリーアプリのようにメディアファイルへのアクセス/編集が頻繁に必要なアプリに適した権限ですが、今回の本筋と外れるので割愛します。

Android 13 (APIレベル 33)

他のアプリが作成したメディアファイルにアクセスするために、下記3つの権限から選択して必要な権限をリクエストするようになりました。

メディアの種類 該当する権限
画像や写真 READ_MEDIA_IMAGES
動画 READ_MEDIA_VIDEO
音声ファイル READ_MEDIA_AUDIO

また、これまでのREAD_EXTERNAL_STORAGE権限からの移行や新しく権限をもらう必要は特になく、自動的にこちらのREAD_MEDIA_XXXXX権限が付与されるようです。

ユーザーが以前にアプリに READ_EXTERNAL_STORAGE 権限を付与していた場合は、きめ細かいメディアの権限がアプリに自動的に付与されます。それ以外の場合は、アプリが前の表に示すいずれかの権限をリクエストすると、ユーザー向けダイアログが表示されます。

実装

サンプルの実装をGithubにアップロードしました。

実装の概要としては、アプリ内でカメラを利用して写真を撮影し、撮影した写真をアプリ内で確認する、という実装になっています。

写真の撮影処理はこのあたり。

app/src/main/java/com/example/mediastore/MainActivity.kt
    private fun capture() {
        if (!::imageCapture.isInitialized) {
            return
        }

        val fileName = "${System.currentTimeMillis()}.jpg"
        val contentValues = ContentValues().apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
            put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
        }

        val outputFileOptions = ImageCapture.OutputFileOptions.Builder(
            contentResolver,
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            contentValues
        ).build()

        imageCapture.takePicture(outputFileOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback {
            override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
                Toast.makeText(this@MainActivity, "Image saved", Toast.LENGTH_SHORT).show()
            }

            override fun onError(exception: ImageCaptureException) {
                Toast.makeText(this@MainActivity, "Error saving image", Toast.LENGTH_SHORT).show()
            }
        })
    }

ImageCapture.OutputFileOptions.Builderの第二引数saveCollection: UriMediaStore.Images.Media.EXTERNAL_CONTENT_URI を指定しており、この撮影時点で外部ストレージへのアクセスが発生します。
が、このアプリ自身が作成したメディアファイルであるため、追加のパーミッションリクエストなどは必要ありません。

続けて読み取りの方はこのあたり。

app/src/main/java/com/example/mediastore/MainActivity.kt

    companion object {
        private const val MEDIA_IMAGE_PERMISSION = android.Manifest.permission.READ_EXTERNAL_STORAGE
        @RequiresApi(Build.VERSION_CODES.TIRAMISU)
        private const val MEDIA_IMAGE_PERMISSION_TIRAMISU = android.Manifest.permission.READ_MEDIA_IMAGES
    }

    private val mediaImagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        MEDIA_IMAGE_PERMISSION_TIRAMISU
    } else {
        MEDIA_IMAGE_PERMISSION
    }

    private fun confirmShowImages() {
        AlertDialog.Builder(this)
            .setTitle("画像一覧を表示")
            .setSingleChoiceItems(arrayOf("このアプリで撮影した画像", "ライブラリの画像も含める"), checkedItem) { _, which ->
                checkedItem = which
            }
            .setPositiveButton("OK") { _, _ ->
                when (checkedItem) {
                    0 -> listImages()
                    1 -> checkMediaImagePermission()
                }
                checkedItem = 0
            }
            .setNegativeButton("キャンセル") { _, _ ->
                checkedItem = 0
            }
            .show()
    }

    // ...

    private fun isMediaImagePermissionGranted(): Boolean {
        return checkSelfPermission(mediaImagePermission) == PackageManager.PERMISSION_GRANTED
    }

    private fun checkMediaImagePermission() {
        if (isMediaImagePermissionGranted()) {
            listImages()
        } else {
            requestPermissions(arrayOf(mediaImagePermission), MEDIA_IMAGE_PERMISSION_REQUEST_CODE)
        }
    }

    // ...

    private fun listImages() {
        startActivity(Intent(this, ImageListActivity::class.java))
    }
app/src/main/java/com/example/mediastore/ImageListActivity.kt
    // ...

    private fun setData() {
        val items = mutableListOf<ImageItem>()
        val projection = arrayOf(
            MediaStore.Images.Media._ID,
            MediaStore.Images.Media.DISPLAY_NAME,
            MediaStore.Images.Media.MIME_TYPE,
        )
        val selection = "${MediaStore.Images.Media.MIME_TYPE} = ?"
        val selectionArgs = arrayOf("image/jpeg")
        val sortOrder = "${MediaStore.Images.Media.DISPLAY_NAME} ASC"
        val query = contentResolver.query(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            projection,
            selection,
            selectionArgs,
            sortOrder
        )
        query?.use { cursor ->
            val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
            val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
            while (cursor.moveToNext()) {
                val id = cursor.getLong(idColumn)
                val name = cursor.getString(nameColumn)
                items.add(ImageItem(ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id), name))
            }
        }

        adapter.setItems(items)
    }

AlertDialogで「このアプリで撮影した画像」を選択した場合、パーミッション要求なしでImageListActivityに遷移します。
表示されるのはダイアログで選択した通り、このアプリで撮影した画像のみです。
「ライブラリの画像も含める」を選択すると、Android 13以降ではメディアファイルのうち、画像ファイルにのみアクセスが可能になります。

最後に

もうAndroid 14が最終テスト用のほぼ最終版のビルドなのに今さら13の話?という感じですが、このあたりの知見ってアプリによっては全然使わなくて、久々に使ってみたらナニコレとなりがちだと思ってます。(まさに自分がそうだった)
同じような状況に陥ってしまった方のご参考になれば幸いです。

参考にさせていただいたサイト

19
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?