TargetSDK29 対象範囲別ストレージ対応

# はじめに
2020/11/2から、AndroidアプリのアップデートにはTargetSDK29が必須になりました。
TargetSDK29対応の一つに対象範囲別ストレージというものがあり、ファイルへのアクセスが厳格化されました。
この対応の際にAndroidのファイルシステムや具体的な対応方法に苦労したので、知見を共有しておきます。
コード例はすべて画像を対象にしたものです。保存したいファイルに応じて、適宜読み替えてください。

対象範囲別ストレージについての公式の説明はこちら：
https://developer.android.com/training/data-storage/files/external-scoped?hl=ja


# 対応方針
具体的な対応方法に入る前に、まずはファイルの保存先について見直しましょう。
保存先が外部領域か内部領域か、アプリ専用領域か公開領域かで色々と異なります。

|  | 外部かつアプリ専用領域 | 外部かつ公開領域 | 内部かつアプリ専用領域 | 
|:-:|:-:|:-:|:-:|
| アプリ削除時にファイルが | 消える | 消えない | 消える |
| ユーザーからファイルアクセスが | 可能 | 可能 | 不可能 |
| Permissionが | 必要 | 必要 | 不要 |
| TargetSDK29からパスを指定して読み書きが | 不可能 | 不可能 | 可能 |

※内部かつ公開領域は存在しません

# 外部領域
ここで指す外部領域とは必ずSDカードとは限りません。
メディアを共有するためのストレージ、くらいの認識です。

参考：
https://developer.android.com/reference/android/os/Environment#getExternalStorageDirectory()

> Note: don't be confused by the word "external" here. This directory can better be thought as media/shared storage. It is a filesystem that can hold a relatively large amount of data and that is shared across all applications (does not enforce permissions). Traditionally this is an SD card, but it may also be implemented as built-in storage in a device that is distinct from the protected internal storage and can be mounted as a filesystem on a computer.

他のアプリからファイルを使えるようにする必要がある場合は外部領域に保存する必要があります。


## 外部かつアプリ専用領域に保存する方法
getExternalFilesDir()を使用します。
この方法を用いて保存したファイルは以下のようなパスになります。
/storage/emulated/0/Android/data/アプリのパッケージ名/files/Pictures/example.jpg
**※パスはイメージ用に示していますが、端末によって異なってくることに注意してください。**

``` kotlin:ImageUtil.kt
    /**
     * アプリ外部に画像を書き出す
     * @param fileName ファイル名
     * @param bmp 書き出したい画像のBitmap
     * @param quality 書き出すJPEG画像の品質
     */
    fun saveImageToExternal(context: Context, fileName: String, bmp: Bitmap, quality: Int) {
        val file = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), fileName)
        try {
            val outputStream = FileOutputStream(file)
            bmp.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
            outputStream.close()
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
```

## 外部かつ公開領域に保存する方法
MediaStoreを使う方法とストレージアクセスフレームワークを使う方法の2つあります。
MediaStoreを使う方法だとコードがやや煩雑ですが、UIが自由です。アプリ内のボタンを押してすぐ保存、などの処理がしたい場合はこちらです。
ストレージアクセスフレームワークを使うとコードが簡単ですが、UIがAndroid指定のものになります。

ストレージアクセスフレームワークについての公式の説明：
https://developer.android.com/guide/topics/providers/document-provider?hl=ja

### MediaStore
ContentProvider経由でUriを取得して、そのUriに書き込みます。
API29からはMediaStore.Images.ImageColumns.RELATIVE_PATHでディレクトリを指定できますが、28以下だとMediaStore.Images.ImageColumns.DATAを使う必要がありそうです。
良いコードの書き方があればぜひ教えて下さい。

パスの例
/storage/emulated/0/Pictures/HOGE/example.jpg
**※パスはイメージ用に示していますが、端末によって異なってくることに注意してください。**

``` kotlin:ImageUtil.kt
    /**
     * 外部書き出し用のURIを取得する
     * @param fileName ファイル名
     */
    private fun getUri(context: Context, fileName: String): Uri? {
        val contentResolver = context.contentResolver
        val contentValues = ContentValues().apply {
            // 相対パスのルートにはDCIMかPicturesの指定が必須
            // java.lang.IllegalArgumentException: Primary directory HOGE not allowed for content://media/external/images/media; allowed directories are [DCIM, Pictures]
            if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                put(MediaStore.Images.ImageColumns.RELATIVE_PATH, "Pictures/HOGE")
            } else {
                val path = Environment.getExternalStorageDirectory().path + "/Pictures/HOGE/"
                val dir = File(Environment.getExternalStorageDirectory().path + "/Pictures", "HOGE")
                if(!dir.exists()) {
                    dir.mkdir()
                }
                put(MediaStore.Images.ImageColumns.DATA, path + fileName)
            }
            put(MediaStore.Images.ImageColumns.DISPLAY_NAME, fileName)
            put(MediaStore.Images.ImageColumns.DATE_TAKEN, System.currentTimeMillis())
        }
        return contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
    }

    /**
     * アプリ外部に画像を書き出す
     * @param fileName ファイル名
     * @param bmp 書き出したい画像のBitmap
     * @param quality 書き出すJPEG画像の品質
     */
    fun saveImageToExternal(context: Context, fileName: String, bmp: Bitmap, quality: Int = 100) {
        val uri = getUri(context, fileName)
        uri?.let {
            try {
                val outputStream = context.contentResolver.openOutputStream(it)
                bmp.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
                outputStream?.close()
            } catch(e: FileNotFoundException) {
                e.printStackTrace()
            } catch (e: IOException) {
                e.printStackTrace()
            }
        }
    }
```

### ストレージアクセスフレームワーク
下記のスクリーンショットのような画面が開きます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133311/9e5e7e54-15f4-2439-6e29-0abcb1697ff7.png" width=40%>

intent.putExtra(Intent.EXTRA_TITLE, "")で保存するファイルのデフォルト名が設定できます。
保存先はユーザーが選べますが、デフォルトのまま保存したときのパスは以下のようになります。
/storage/emulated/0/Download/example.jpg
**※パスはイメージ用に示していますが、端末によって異なってくることに注意してください。**

``` kotlin:MainActivity.kt
    companion object {
        const val SAVE_IMAGE = 1000
    }

    var bmp: Bitmap? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        // setContentViewなど。省略

        binding.button3.setOnClickListener {
            bmp = ImageUtils.makeBitmap(128, 128, Color.YELLOW)
            val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
            intent.type = "image/*"
            intent.putExtra(Intent.EXTRA_TITLE, "example.jpg")
            startActivityForResult(intent, SAVE_IMAGE)
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        when(requestCode) {
            SAVE_IMAGE -> {
                if(resultCode == RESULT_OK) {
                    try {
                        data?.data?.let {
                            val outputStream = contentResolver.openOutputStream(it)
                            bmp?.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
                            outputStream?.close()
                        }
                    } catch(e: FileNotFoundException) {
                        e.printStackTrace()
                    } catch(e: IOException) {
                        e.printStackTrace()
                    }
                }
            }
        }
    }

```

参考：
https://akira-watson.com/android/action_create_document.html

# 内部領域に保存
内部領域はその名の通り、端末内蔵ストレージを指します。
内部領域でもキャッシュディレクトリとアプリ固有ディレクトリの2つがあります。
突然消されてもいいファイルはキャッシュディレクトリ、そうでないファイルはアプリ固有ディレクトリに保存しましょう。
キャッシュディレクトリに保存したファイルはストレージが逼迫しない限りは消されないようなので（未確認）、適宜削除処理を入れることを推奨します。

パスの例

* アプリ固有ディレクトリの場合
 * /data/user/0/アプリのパッケージ名/files/example.jpg
* キャッシュディレクトリの場合
 * data/user/0/アプリのパッケージ名/cache/example.jpg

**※パスはイメージ用に示していますが、端末によって異なってくることに注意してください。**

キャッシュディレクトリについての仕様：
https://developer.android.com/reference/android/content/Context#getCacheDir()

```kotlin:ImageUtil.kt
    /**
     * アプリ専用ディレクトリに画像を書き出す
     * @see saveImageToInternal
     */
    fun saveImageToAppFileDir(context: Context, fileName: String, bmp: Bitmap, quality: Int = 100) {
        saveImageToInternal(context.filesDir, fileName, bmp, quality)
    }

    /**
     * アプリ専用キャッシュディレクトリに画像を書き出す
     * @see saveImageToInternal
     */
    fun saveImageToAppCacheDir(context: Context, fileName: String, bmp: Bitmap, quality: Int = 100) {
        saveImageToInternal(context.cacheDir, fileName, bmp, quality)
    }

    /**
     * アプリ内部に画像を書き出す
     * @param directory 書き出したいディレクトリ
     * @param fileName ファイル名
     * @param bmp 書き出したい画像のBitmap
     * @param quality 書き出すJPEG画像の品質
     */
    private fun saveImageToInternal(directory: File, fileName: String, bmp: Bitmap, quality: Int) {
        val file = File(directory, fileName)
        try {
            val outputStream = FileOutputStream(file)
            bmp.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
            outputStream.close()
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
```

# おわりに
この記事では、対象範囲別ストレージを機にファイルの保存先の見直しが行えるようにAndroidの保存先についての説明と具体的な保存方法について示しました。
外部領域/内部領域とは何か、書くディレクトリに付与されてる権限はLinuxシステム上でどうなっているかなどについては理解が曖昧なので示しませんでした。
誤字脱字などの問題点や記事をより良くするための情報があればご指摘ください。
参考になれば幸いです。