Android 10になってファイルが保存できなくなった

私が作成したAndroid用のあるアプリにはデータのインポート及びエクスポートの機能が付いています。
開発当時はAndroid 9が最新でそれに合わせた実装をしていて特に問題なく動いていました。

2022年1月、私がメインで使っているスマホはAndroid 12で、サブのスマホはAndroid 11です。
これらのスマホだと上記のインポート及びエクスポート機能が正常に動きませんでした。
Fireタブレット(Fire HD 10)はAndroid 9ベースのFire OS 7で、こちらだと問題なく動くのでAndroid 10以降に何かの仕様が変わったのではないかと思われます。

今まで気が付かなかったくらいなので当該機能の使用頻度は極めて低く、別になくてもいいような機能ではありますがデータのバックアップ・リストアを行う際には絶対に必要になるので修正をすることにしました。

結論から言うと「Environment.getExternalStorageDirectory」メソッドで取得したパスを使ってのファイル入出力ができなくなったことが原因です。

Android 10におけるプライバシーに関する主な変更点

公式サイトにも記述がある通り、Android 10になって「対象範囲別ストレージ」というものが導入されました。
対象範囲別ストレージには3種類あります。

  • アプリ固有のストレージ
  • メディアストレージ
  • その他のストレージ

対象範囲別ストレージの詳細は割愛しますが、簡単にいうとアプリが好き勝手に「その他のストレージ」にアクセスできなくなってしまいました。

Android Developers

Keep your users safe by accessing personal and sensitive dat…

対応策

上記の「対象範囲別ストレージ」のうち、アプリ固有のストレージとメディアストレージには比較的自由にアプリ側からアクセスすることが可能です。
しかし、インポート及びエクスポートの機能をイメージしていただくと分かるとおり、ユーザーが作成したインポート用ファイルを読み込んだり、エクスポートしたファイルをユーザーが自分の好きなところに保存できたりすることは必須ですし、テキストファイルでの入出力を想定しているのでメディアストレージは使えない状況です。
どうにかして「その他のストレージ」にアクセスする必要があります。

そこで使うのが「ストレージアクセスフレームワーク」(SAF)です。

簡単にいうと、ユーザーに保存するファイルや読み込むファイルを指定してもらってアクセスする方法です。
Environment.getExternalStorageDirectoryメソッドでパスを取得する代わりに、ユーザーに指定してもらう感じになります。

サンプルソース(Kotlin)

エクスポート時(ファイル保存時)に保存ファイル名と保存先を設定する場合はこのような感じになります。
(パーミッション取得やチェックのロジックは割愛します)

fun onExportButtonClick(view: View) {
    val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
        addCategory(Intent.CATEGORY_OPENABLE)
        type = "text/plain"
        putExtra(Intent.EXTRA_TITLE, getString(R.string.data_file_name))
    }
    startActivityForResult(intent, resources.getInteger(R.integer.data_export_request))
}

「エクスポート」ボタンをタップするとこのメソッドに入ってきてファイル保存の画面が開きます。

出力ファイルの指定

いわゆる「名前を付けて保存」的な画面です。

上記の画面にて、ディレクトリやファイル名を設定して「保存」をタップするとプログラム的には「onActivityResult」が呼び出されます。
overrideしてファイルに書き出す処理を実装します。
こちらも詳細は割愛しますが、openOutputStreamメソッドでユーザーが指定したファイルに対して出力していく感じになります。

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    // エクスポート処理
    if ((requestCode == resources.getInteger(R.integer.data_export_request)) && (resultCode == Activity.RESULT_OK)) {
        if (resultData?.data != null) {
            try {
                val uri = resultData.data as Uri
                val out = contentResolver.openOutputStream(uri)
                val writer = OutputStreamWriter(out)

                // これはDBからデータを読み込んでArrayListを作る処理。
                // その結果がallRecordsに入ってくる。
                getAllData()

                for (record in allRecords) {
                    writer.write(record)
                    writer.write("\n")
                }

                writer.close()
            } catch (e: FileNotFoundException) {
                e.printStackTrace()
            } catch (e: IOException) {
                e.printStackTrace()
            }
        }

        return
    }
}

このような感じで指定した場所に指定した名前でファイルが出力されます。

出力結果

まとめ

セキュリティやプライバシーに関しては頻繁にいろいろとアップデートされます。
アプリの実装も適宜アップデートしていく必要があることを痛感しました。

インポート機能(ファイル読込)についても同様の問題が発生します。
その修正についてはこちらの記事をご覧ください。

関連記事

私が作成したAndroid用のあるアプリにはデータのインポート及びエクスポートの機能が付いているのですが、その機能がAndroid 10になってうまく動かなくなってしまいました。 エクスポート機能(ファイル保存)については解決したのでイン[…]