Logo wizaman's blog (legacy)

AndroidのpersistentDataPathがカオス

October 10, 2016
50 min read
Table of Contents
  • 2017/12/23
    • 記憶違いの部分があったのでAndroid 2.2の説明を修正。今更。
    • 変更が多くなったので若干記述を整理。
  • 2017/05/06
    • targetSdkVersionの決定方法が間違っていたので修正。
  • 2017/02/06
    • Unity 5.3.6での仕様変更について追記。
  • 2016/11/14
    • Android 4.4でのREAD_EXTERNAL_STORAGE対応、maxSdkVersionについて追記。
    • Android 6.0でのユーザによる権限の付け外しについて追記。
    • 掲載スクリプトの読み取り可能チェック処理でREAD_EXTERNAL_STORAGEを見ないように修正し、実行結果を更新。
    • 一部、誤植の修正。

Unityで外部ファイルにデータを読み書きするとき、普通Application.persistentDataPathを使うと思うのですが、AndroidだけpersistentDataPathの返すパスが権限によって変わります。

これドキュメントに書かなきゃいけない重要事項だと思うんですが、全く触れられていない・・・。

詳しいことはこれから説明していきますが、やりたいことによっては必要な追加権限があるにも関わらず、それを設定したときには、persistentDataPathはセーブデータの保存先として信用できないという衝撃的な問題が存在します。Unityのバージョンは5.3.4f1です。

但し、Unity 5.3.6でpersistentDataPathおよびtemporaryCachePathがAndroid 4.4(コードネーム:KitKat、API Level 19)以降、「外部」を指すよう仕様変更となったそうです(リリースノート)。また混乱することを・・・。いずれにせよ、AndroidでpersistentDataPathを利用することは推奨しません。

以下、死ぬほど長いまとめ書きましたので、覚悟してください。

Write Accessとは

AndroidのPlayer Settingsで、「Write Access」がデフォルトで「Internal Only」になっています。これは書き込み権限を「内部」に限定するというものです。ここで「内部」という曖昧な言葉を使ってるのはわざとです(あとで説明します)。「内部」へのアクセスはAndroidにデフォルトで認められているため、特別な権限を要求しません。

「Write Access」を「External (SDCard)」にすると、これは「外部」への書き込み権限を要求します。実際には、READ_EXTERNAL_STORAGE(「外部」の読み込み)とWRITE_EXTERNAL_STORAGE(「外部」への読み書き)という権限(permission)が追加されます。

既に予想がついていると思いますが、WRITE_EXTERNAL_STORAGE権限が許可されているかどうかによって、Application.persistentDataPathが変わります。**persistentDataPathはデフォルトでは「内部」を指すようになっているのが、「外部」への書き込み許可により「外部」を指すようになります。**両者を区別して指定する方法はUnityからは提供されていません。

また、こちらはドキュメントにも書いてあるんですが、**開発ビルド(Development Build)では、強制的に「External (SDCard)」扱いになります。つまり、「Internal Only」にしていると、開発ビルドと本番ビルドでpersistentDataPathの返すパスが変わります。**結構、混乱しませんか、これ。

さらにAndroid 6.0以降でもややこしい問題がありますが、これは順を追って説明していきます。

外部ストレージの扱いの変遷

「内部」と「外部」の違いと扱い方を説明するために、Androidの歴史の話をします。読むの面倒でしたら、あとで必要なところだけ読み返す感じでいいです。

昔々、Androidは内蔵メモリの容量が小さく、それを補う形でSDカードが存在しました。それはそれでSDカードの利用方法が統一されておらず、アプリ開発者を混乱させました。

Android 2.2(コードネーム:Froyo、API Level 8)では、SDカード内にアプリ固有の領域が用意されました。そして、アプリ本体のデータで内蔵メモリが埋め尽くされることへの対策として、アプリ側で許可すれば、アプリ本体をSDカードに移動できるようになりました。アプリ側での設定はinstallLocationをpreferExternalにすればいいのですが、これに相当する設定はUnityのAndroid Player Settingsでも同様の名前で存在します。ちなみに、Unity 5.3.4f1がサポートしているAndroidの最小バージョンが2.3.1になります。

Android 4.0(コードネーム:Ice Cream Sandwich、API Level 14)では、アプリ本体のSDカードへの移動が廃止されました。但し、これは標準機能としての廃止であって、Android 4.0が動作している端末であっても、一部はこの機能が生きているものもあるようです。そして、**内部ストレージに実体のある領域が、External Storageのプライマリストレージとして扱われるようになりました。**プライマリストレージは、システムデータとは区別された、ユーザデータを保存するための領域、みたいな感じです。互換性のためにExternal Storage(外部ストレージ)という名前が維持されて実態と矛盾しているため、名前の意味を深読みする必要はないです。**SDカードもExternal Storageになりますが、セカンダリストレージという扱いになります。**取得したパスに「sdcard」という文字列が含まれていたとしても、これの実体が内部か外部かの区別はなく、SDカードであるとは限らないわけです。ややこしいですね。

公式ドキュメントでも、この名前の混乱について触れられています。

すべての Android 端末には、「内部」ストレージと「外部」ストレージの 2 つのファイル記憶領域があります。これらの名前は初期の Android の名残として、当時ほとんどの端末が内蔵の不揮発性メモリ(内部ストレージ)を備えていたのに加え、マイクロ SD カードのような取り外し可能な記憶媒体(外部記憶装置)も備えていたことから来ています。一部の端末では、永続的なストレージ領域を「内部」と「外部」のパーティションに分割しており、リムーバブル記憶媒体が備わっていない場合でも常に 2 つのストレージ スペースがあり、外部ストレージが取り外し可能であるか否かにかかわらず、API の動作は同じです。

Android 4.1(コードネーム:Jelly Bean、API Level 16)では、それまで外部ストレージへの読み書き権限はWRITE_EXTERNAL_STORAGEだけでしたが、外部ストレージへの読み込み権限だけを示すREAD_EXTERNAL_STORAGEが将来有効化される仕様として追加されました。どういうことかというと、ひとまず指定できるようになっただけであり、開発者オプションを設定しない限りは効力がなかった、ということです。

Android 4.2(コードネーム:Jelly Bean、API Level 17)では、タブレットに関して、マルチユーザ機能とゲストモードが追加されました。ユーザごとに異なる内部ストレージとプライマリストレージが割り当てられます(ユーザごとにパスが変わります)。

Android 4.4(コードネーム:KitKat、API Level 19)では、内蔵メモリの容量が十分に大きくなってきた背景やセキュリティ強化の流れもあり、SDカードの利用を推奨しなくなりました。その結果、**基本的にセカンダリストレージへの自由な書き込みができなくなりました。**プライマリストレージは引き続き権限さえあれば読み書き可能っぽいです。一方、プライマリ、セカンダリを問わずExternal Storage内のアプリ固有パスについては、権限なしでも読み書きができるようになりました。

とはいえ、プリインストールされている「File Commander」といったアプリ以外では、SDカード上の写真の移動などの日常的な操作すらできないことになりました。これは私が実際に困った話なんですが、USBケーブルでPCに繋いでMTPモードでデータをやりとりするときも、ファイル作成やリネームができなくなりました(コピーや上書きはできる中途半端さ)。これでは、SDカードの意味がない・・・ということで多くのユーザ、アプリ開発者から不評を買いました。

また、KitKatでは、Android 4.1で仮に追加されていたREAD_EXTERNAL_STORAGEが正式対応となりました。さらに、権限に対して有効な最大APIレベルを指定するmaxSdkVersionが追加され、特定のAPIレベル以降で不要となった権限を無駄に要求せずに済むようになりました。

Android 5.0(コードネーム:Lollipop、API Level 21)では、アプリに対してアクセスを許可する場所をユーザに任意で指定させる新たな方法が用意されました。これに対応したアプリならば、セカンダリストレージであっても、ユーザから指定された場所の中に限定して、ファイルの読み書きができるわけです。実質、SDカード書き込み制限の緩和です。また、スマートフォンにおいても、マルチユーザ/ゲストモード(4.2以降タブレットの機能)が導入されました。

Android 6.0(コードネーム:Marshmallow、API Level 23)では、**一部権限を許可するタイミングが変わりました。**詳しくは長くなるのであとでまた説明します。他には、SDカードを内部ストレージとして扱う「Adoptable Storage」が導入されました。詳しくは触れませんが、色々と問題があるため、端末のメーカーが無効にしている場合もあります。

以下、参考。

persistentDataPathの指すパス

だいぶ混乱する経緯でしたが、UnityのApplication.persistentDataPathに話を戻します。

これまで、私が「内部」と表現したのは、本来「内部ストレージ」を指していたアプリ固有のデータ領域のことで、Android内でContext.getFilesDir()で取得できるパスです。普通、この領域にはそのアプリしかアクセスできません。

が、正確にはファイルのパーミッション依存らしく、パーミッションを間違えると他アプリからアクセスできてしまうようです。Unityだと、このへんどうなってるのかちゃんと確認できてないのが不安です・・・。推奨されるパーミッションはMODE_PRIVATE(660:rw-rw----)です。他のパーミッションに関しては、MODE_WORLD_READABLE/MODE_WORLD_WRITEABLEはAndroid 4.2(API Level 17)で非推奨となっています。

一方、「外部」と表現したのは、本来「外部ストレージ」を指していたが、Android 4.0以降はプライマリストレージとして置き換わったExternal Storageのひとつのことで、Android内でContext.getExternalFilesDir(null)で取得できるアプリ固有パスです。Android 4.0以降、getExternalFilesDir()はプライマリストレージを返すので、セカンダリストレージが欲しければ、メソッド名末尾に「s」がついたgetExternalFilesDirs()でプライマリストレージを含むすべてのExternal Storageを取得する必要がありますが、Android 4.4(API Level 19)以降でないと使えないAPIです。「外部」は他のアプリから読み取ることができます。

つまり、他のアプリからデータを参照したいとき、「外部」にデータを置く必要があります。例えば、スクリーンショットを撮ってSNSに投稿したい、といったとき、スクリーンショットを「外部」に置いて、それをSNSアプリに参照してもらう必要があります。そのためにApplication.persistentDataPathを利用する場合、「外部」を参照するために「Write Access」は「External (SDCard)」にしないといけないことになります。

これも結構厄介な仕様です。何故なら、アプリのバージョンアップによって「Write Access」を変えることになったら、persistentDataPathが変わってしまうので、それでも破綻しないようにしてやらないといけないからです。つまり、「内部」に保存してしまったセーブデータをどうやって「外部」に移すのか?そもそもセーブデータは「内部」に置くべきなんじゃないの?といった議論になるわけです。しかし、**「内部」「外部」を明示的に指定する機能はUnityにはないので、AndroidのAPIを利用してやる必要があります。**めんどくさい話になってきましたね。やり方はあとで説明します。

ちなみに、persistentDataPathで取得するパスはアプリ固有パスのため、アプリをアンインストールすると「内部」「外部」のデータともに削除されます。

以下、参考。

Android 6.0での権限の仕様変更

これも困った話になります。

昔のAndroidは、アプリに設定された権限をすべて許可するという形で初めてアプリをインストールできる仕様でした。インストールするならすべて許可しろ、それが認められないならインストールするな、という極端な仕様です。アップデートによって権限が追加された場合、追加分の権限を許可しなければアップデートもできませんでした。

長いこと、この仕様のままだったんですが、Android 6.0でついに、任意のタイミングで権限許可をユーザに問うiOSに似た仕様に変更となりました。正確には、保護レベル「dangerous」(危険)とされる一部の権限について、実行時に初めて権限許可を問うダイアログが表示されて、そこで許可されれば権限を得るようになりました。

古い仕様であれば、インストールされている時点で、設定した権限がすべて許可されているという前提でアプリを実装することができました。これが、最新のAndroidでは、設定された権限が許可されているとは限らない、ということになりました。そして、やはりというかdangerousな権限のひとつにWRITE_EXTERNAL_STORAGEがあるんですね。これの許可は実行時に求められます。「設定」アプリから権限をあとから付け外しすることも自由です。

但し、Android 6.0で動作するすべてのアプリがこの仕様変更の対象となっているわけではなく、互換性のためにAndroid 6.0(API Level 23)以降をtargetSdkVersionとしたアプリにのみ適用される仕様です。とはいえ、UnityでtargetSdkVersionを指定するオプションはないです。どうも手元で試してみた感じだと、開発環境に導入されているSDKのうち最も新しいものが適用されるようです(2017/05/06 説明を修正しました)。古いSDKを使っていれば適用されない仕様変更ですが、根本解決してないのでそれは置いておきます。Android 6.0以降の端末では、targetSdkVersionが23未満のアプリから権限を剥奪する(互換モード)こともできるようですが、これも今回は無関係なので置いておきます。

それでは、Android 6.0の端末で、「Write Access」を「External (SDCard)」にしたUnityアプリを起動するとどうなるでしょうか。**起動時にWRITE_EXTERNAL_STORAGEの許可を求めるダイアログが表示され、それに答えると許可されたかどうかによらずゲームを開始します。**そして、これが厄介なのですが、**権限を許可したかどうかによってpersistentDataPathが変わってしまいます。**例えば、persistentDataPathを利用してセーブデータを作っていた場合、権限の許可設定を変えることによってパスが変わって、それまで利用していたセーブデータが参照できなくなってしまう致命的な問題が起こり得ます。恐ろしい・・・。

しかも、権限の許可を得ているかどうか調べる機能とか、Unityが提供していないので、このへん自分で用意してやって地道に対応するしかないです。めんどくせぇので、Unityから何らかの対応があればいいんですが・・・。

許可しないで始めたユーザーはゲーム中でスクリーンショットを撮影する前に権限を確認して許可するかのポップアップを表示しています。
ですがここで許可にしても”Application.persistentDataPath”は許可しないときのパスのままになっています。
(一度ゲームのプロセスを切ってはじめからすればパスは変わります。)

あまりこのへんの対応に関する情報を国内で見ないのですが、上記の人は、自前でゲーム中に権限の許可を取る対応をしたようです。しかし、persistentDataPathが起動時の状態でキャッシュされていて更新されないそうです。ますます信用ならないですね、persistentDataPath。

余談なのですが、要求される権限はいくらかグルーピングされ、そのグループに対してユーザが許可/拒否を選択するという仕様のようです。これによって要求していない権限が勝手に追加されることはなくても、READ_EXTERNAL_STORAGEとWRITE_EXTERNAL_STORAGEを区別して許可を求めることはできない、ということは知っておいてもいいかもしれません。

UnityのAndroid対応状況まとめ

ここまでを踏まえて、Unityアプリを開発するにあたって気にすべき要点を整理します。

  • Android 4.0より前のバージョンでは、
    • アプリ本体をSDカードに移動できる。つまり、Application.dataPathが変わる。一部端末は4.0でも移動できるらしいが、気にしなくていいと思う。
    • External StorageはSDカードであり、マウントされているとは限らない。つまり、Application.persistentDataPathの指す場所の存在が保証されない。
  • Android 4.0以降では、
    • 従来手法で取得するExternal Storageがプライマリストレージに置き換わっているため、SDカードではない。
  • Android 4.4以降では、
    • External Storageであっても、アプリ固有の領域であれば、ファイルの読み書きに権限を必要としない。
  • Android 6.0より前のバージョンでは、
    • 「Write Access」を「External (SDCard)」にすると、WRITE_EXTERNAL_STORAGE権限をインストール時に要求し、Application.persistentDataPathが変わる。
  • Android 6.0以降では、
    • 「Write Access」を「External (SDCard)」にすると、WRITE_EXTERNAL_STORAGE権限をアプリ起動時に要求し、そのときの許可設定によってApplication.persistentDataPathが変わる。

特に4.0前後と6.0前後での仕様の違いに気をつけねばなりません。

どうすべきか?

Androidの事情を考慮して、どのように対応すべきか、実装すべき機能は何か、ということを考えます。

Android 4.0以降対象が無難?

とりあえず、External Storageが存在する前提にはしたいので、対象とするAndroidの最小バージョン(Minimum API Level)は4.0(API Level 14)以降にしておくのが無難そうです。もうほとんどの端末が4.0以降なので問題ないでしょう。古い環境でUnityアプリを快適に動作させるのも厳しいですしね。

とすると、あとは「Write Access」まわりの問題をどう対応していくか、ですね。

Android 4.4以降対象とするか?

2016年9月現在、世界シェアでAndroid 4.3以前のユーザが20%弱。今、開発しているアプリなら、そろそろ切ること考えてもいいかもしれません。

Android 4.4以降であれば、External Storageでもアプリ固有パスに対する読み書きに権限が不要となるため、少し状況がすっきりする気もします。つまり、「Write Access」が「Internal Only」でも、「外部」にアクセスする権利があるということです。但し、開発ビルドでは「Write Access」が強制的に「External (SDCard)」になることを忘れてはいけません。

また、特別な事情がなければ、External Storageのアプリ固有パス以外にアクセスすることはないと思うのですが、その場合、Android 4.4以降ではWRITE_EXTERNAL_STORAGE権限が完全に不要になります。「Write Access」は「Internal Only」でいい、ということです。

逆に言えば、4.4より前のバージョンも対象に含め、External Storageも使用したいならば、読み書き先がアプリ固有パスであっても、「Write Access」を「External (SDCard)」にしなければいけません。セーブデータは「内部」で持つべきと思うので、「内部」パスの取得方法が欲しいですね。

以上を踏まえると、4.4以降を対象とするかどうかによらず、Application.persistentDataPathの利用を諦めて、明示的に「内部」「外部」を区別してパスを取得する手段があれば機能的には十分そうです。

Android 6.0以降の対応をどうするか?

前節の通り、明示的に「内部」「外部」を区別してパスを取得する手段を用意する前提として考えます。

とすると、結局は、External Storageのアプリ固有パス以外にアクセスしないならば、WRITE_EXTERNAL_STORAGE権限が不要なので、4.4以降とほぼ同じ状況と言えます。但し、4.4より前のバージョンを対象に含む時は、「Write Access」を「External (SDCard)」にする必要がありますので、無意味でありながらもアプリ起動時に権限の許可を問うダイアログが表示されることになります。

Android 4.4以降でWRITE_EXTERNAL_STORAGEを要求しないようにすることも可能ですが、AndroidManifest.xmlを用意する必要があります。UnityでAndroidManifest.xmlを使う」という記事を書いたので、詳細はそちらを参照してください。

もし、**External Storageのアプリ固有パス以外にアクセスしたいならば、OSバージョンのチェックをした上で、必要に応じてWRITE_EXTERNAL_STORAGE権限の許可状態をチェックする必要があります。**それは特殊なケースだと思いますが、念のため、その方法も確認したいと思います。

もっと手の込んだ対応をしたければ、上の記事が参考になると思います。

実装方法

下記の機能の実装を考えます。

  • 「内部」「外部」パスの取得
  • OSバージョンの取得
  • 特定権限の許可状態の取得

解説を先にして、あとでコードをまとめます。

Androidアプリの標準的な実装言語はJavaであり、ネイティブコードから利用するにはJNIを経由する必要があって面倒なのですが、これを上手くラップしたものとして、AndroidJavaClassAndroidJavaObjectといったクラスをUnityが提供してくれています。これらを利用して、リフレクションでAPIを呼び出しますので、ありがたいことにC#だけでコードが完結します。AndroidJavaClass、AndroidJavaObjectのオブジェクトは利用終了後に明示的に破棄すべきで、usingステートメントを使うと記述が楽です。

基本的な流れとしては、JavaのUnityPlayerクラスを取得して、そこからさらに現在のActivityオブジェクトを取得して、これを介して情報を得ることになります。Activityとは、UnityでいうSceneのようなもので画面構成の単位です。ActivityはContextを継承していて、Contextはアプリの各種情報を提供してくれます。

必要に応じて、上の公式ドキュメントも参考にしてください。

「内部」「外部」のパス取得

AndroidJavaClassやAndroidJavaObjectの使い方としても、上の記事が参考になります。AndroidのAPIを呼び出して「内部」「外部」のパスを直接取りに行きます。

Application.persistentDataPathの説明でも書きましたが、対応するAPIは**「内部」がContext.getFilesDir()「外部」がContext.getExternalFilesDir(null)**です。getExternalFilesDirには引数nullを与えることでアプリ固有パスの取得を指定したことになります。どちらもJavaのFileオブジェクトを返すので、getAbsolutePath()かgetCanonicalPath()でパスを文字列として取得します。上の記事ではgetCanonicalPath()を使っていますが、UnityのpersistentDataPathと一致するのはgetAbsolutePath()の方だったので、私はgetAbsolutePath()を採用しました。

2つのメソッドの違いは下の記事が参考になります。

それから、何故かドキュメントに書いてませんが、そもそも「外部」へのアクセス可能な状態でなければ、getExternalFilesDir(null)がnullを返す仕様であることは知っておきましょう。これは、Android 4.4より前のバージョンでは、「Write Access」を「External (SDCard)」にしないとgetExternalFilesDir(null)でアプリ固有パスが取得できないことを意味します。但し、読み取りアクセス可能な状態であっても、しっかりパスを取得できます。一度、「外部」への書き込み許可をした状態でインストールしたアプリから、権限を奪った場合、アプリ固有パスに対応するディレクトリが自動で作られたために、権限剥奪後もパスを取得できることはあるので、デバッグ時に注意してください。

そして、これが厄介なのですが、**AndroidJavaObjectやAndroidJavaClassを介してnullデータを得ようとするとExceptionが発生します。**内部でJNIを利用して型変換する過程で、プリミティブ型であろうと一度はAndroidJavaObjectで値を受け取るようなのですが、そのコンストラクタにnullが渡されるとExceptionを吐く実装になっています。そこを良い感じにして、stringやAndroidJavaObjectで受け取る時はExceptionなしでnullで返してくれればいいんですが・・・。

色々と調べましたが、このnull問題は良い解決方法がないので、nullが返らないようにする使い方が基本方針となります。どうしてもnullを回避できないときは、例外を捕捉するのが手っ取り早いですが、パフォーマンスを下げますし、何より関係ない例外まで捕捉してしまいかねないので、最終手段です。あるいは、AndroidJavaObjectやAndroidJavaClassを使わず、AndroidJNIクラスを利用してJNIを自前で扱うのも手ですが、面倒なので試してません。

ということで、getExternalFilesDir(null)がnullを返すケースを排除したいと思います。考えられるのは、WRITE_EXTERNAL_STORAGE権限を持っていないケースと、Android 4.0より前のバージョンでSDカードがマウントされていないケースの2つを想定しています。前者は「特定権限の許可状態の取得」として、あとで説明します。後者は、Environment.getExternalStorageState()でExternal Storageのマウント状態を取得することで、利用可能か判断できます。

ここまで手間かけて意図通りに動いたので、例外捕捉はしてません。

OSバージョン(API Level)の取得

**Build.VERSIONクラスの持つSDK_INTフィールドがAPI Levelを示します。**Build.VERSIONクラスを取得するとき、子クラスのシグネチャには区切り文字として$を使うことに注意です。これはJNIの仕様です。

Android 6.0以上であることを判定するには、API Level 23以上であるかどうかをチェックすればいいです。

ちなみに、アプリのtargetSdkVersionは、ApplicationInfoが持っています。ApplicationInfoはContextから取得できます。

特定権限の許可状態の取得

調べれば、android-support-v4に含まれるContextCompat.checkSelfPermission()を利用すればいい、という情報も出てきますが、それはAndroid 6.0より古い環境で動かすための手段です。android-support-v4.jarをPlugins/Androidフォルダに配置する必要があり、あんまり勧められません。何故なら、android-support-v4は、Androidサポートライブラリのバージョン4ではなくて、API Level 4以降を対象としたライブラリであり、それ自体にもバージョンが存在するため、管理対象を増やすことになるからです。名前をandroid-support-for-v4とかにすれば誤解ないのにねとか思ったり。

**Android 6.0(API Level 23)では、Context.checkSelfPermission()が使えます。**OSバージョン(API Level)が6.0(API Level 23)以上のときに呼び出してやればいいです。権限の名前を引数に渡すと、その権限を持っていればPERMISSION_GRANTED(0)、持っていなければPERMISSION_DENIED(-1)を返します。

権限取得まわりの仕様については、上の記事が詳しいです。

もし、Android 6.0より前のバージョンでも権限があるか調べたいならば、インストールされている時点で、そのアプリが要求する権限はすべて許可されているという前提があるため、要求している権限を調べにいけば十分でしょう。これは、PackageManagerのcheckPermissionでできます。こちらもアプリのパッケージ名を指定すること以外は、Context.checkSelfPermission()と同じ使い方になります。PackageManagerはContextから取得できます。

今回、調べたいのはWRITE_EXTERNAL_STORAGEになりますが、ついでにREAD_EXTERNAL_STORAGEも見て、読み取りアクセス可能か調べる機能を作ります。但し、READ_EXTERNAL_STORAGEが正式採用されたのはAndroid 4.4からであるので、それより前のバージョンではWRITE_EXTERNAL_STORAGEだけを見るようにします。

実装例

#if UNITY_EDITOR && UNITY_ANDROID
#define UNITY_ANDROID_EDITOR
#endif
 
using System;
using UnityEngine;
 
#if UNITY_ANDROID
// android.permission
public enum AndroidPermission
{
    READ_EXTERNAL_STORAGE,
    WRITE_EXTERNAL_STORAGE,
}
 
// android.os.Environment
public static class AndroidExternalStorageState
{
    public const string MEDIA_MOUNTED = "mounted";
    public const string MEDIA_MOUNTED_READ_ONLY = "mounted_ro";
}
 
public static class AndroidUtil
{
    private const int VERSION_CODE_K = 19;
    private const int VERSION_CODE_M = 23;
 
    private const int PERMISSION_GRANTED = 0;
 
    public static int apiLevel
    {
        get
        {
#if !UNITY_ANDROID_EDITOR
            if(_apiLevel == 0) {
                using(var version = new AndroidJavaClass("android.os.Build$VERSION")) {
                    _apiLevel = version.GetStatic<int>("SDK_INT");
                }
            }
#endif
            return _apiLevel;
        }
    }
 
    public static int targetSdkVersion
    {
        get
        {
#if !UNITY_ANDROID_EDITOR
            if(_targetSdkVersion == 0) {
                processActivity(activity => {
                    using(var appInfo = activity.Call<AndroidJavaObject>("getApplicationInfo")) {
                        _targetSdkVersion = appInfo.Get<int>("targetSdkVersion");
                    }
                });
            }
#endif
            return _targetSdkVersion;
        }
    }
 
    public static bool writeableExternalStorage
    {
        get
        {
#if !UNITY_ANDROID_EDITOR
            if(apiLevel >= VERSION_CODE_K) {
                // API Level 19以上では、アプリ固有パスのアクセスに権限不要
                return true;
            }
 
            if(getExternalStorageState() == AndroidExternalStorageState.MEDIA_MOUNTED
                && checkSelfPermission(AndroidPermission.WRITE_EXTERNAL_STORAGE)) {
                return true;
            }
            return false;
#else
            return true;
#endif
        }
    }
    public static bool readableExternalStorage
    {
        get
        {
#if !UNITY_ANDROID_EDITOR
            if(apiLevel >= VERSION_CODE_K) {
                // API Level 19以上では、アプリ固有パスのアクセスに権限不要
                return true;
            }
 
            var state = getExternalStorageState();
            if((state == AndroidExternalStorageState.MEDIA_MOUNTED || state == AndroidExternalStorageState.MEDIA_MOUNTED_READ_ONLY)
                && checkSelfPermission(AndroidPermission.WRITE_EXTERNAL_STORAGE)) {
                return true;
            }
            return false;
#else
            return true;
#endif
        }
    }
 
    public static string internalFilesPath
    {
        get
        {
#if !UNITY_ANDROID_EDITOR
            if(_internalFilesPath == null) {
                _internalFilesPath = getPath(activity => activity.Call<AndroidJavaObject>("getFilesDir"));
            }
            return _internalFilesPath;
#else
            return Application.persistentDataPath;
#endif
        }
    }
 
    public static string internalCachePath
    {
        get
        {
#if !UNITY_ANDROID_EDITOR
            if(_internalCachePath == null) {
                _internalCachePath = getPath(activity => activity.Call<AndroidJavaObject>("getCacheDir"));
            }
            return _internalCachePath;
#else
            return Application.temporaryCachePath;
#endif
        }
    }
 
    public static string externalFilesPath
    {
        get
        {
#if !UNITY_ANDROID_EDITOR
            if(_externalFilesPath == null && writeableExternalStorage) {
                _externalFilesPath = getPath(activity => activity.Call<AndroidJavaObject>("getExternalFilesDir", null));
            }
            return _externalFilesPath;
#else
            return Application.persistentDataPath;
#endif
        }
    }
 
    public static string externalCachePath
    {
        get
        {
#if !UNITY_ANDROID_EDITOR
            if(_externalCachePath == null && writeableExternalStorage) {
                _externalCachePath = getPath(activity => activity.Call<AndroidJavaObject>("getExternalCacheDir"));
            }
            return _externalCachePath;
#else
            return Application.temporaryCachePath;
#endif
        }
    }
 
    public static bool checkSelfPermission(AndroidPermission permission)
    {
        return checkSelfPermission("android.permission." + permission.ToString());
    }
    public static bool checkSelfPermission(string permission)
    {
#if !UNITY_ANDROID_EDITOR
        var result = false;
 
        if(apiLevel >= VERSION_CODE_M) {
            // API Level 23以上では、Context.checkSelfPermission()で実行時の権限をチェック
            processActivity(activity => {
                result = activity.Call<int>("checkSelfPermission", permission) == PERMISSION_GRANTED;
            });
        } else {
            // それより古いバージョンでは、パッケージの要求権限をチェックすれば十分
            processActivity(activity => {
                using(var packageManager = activity.Call<AndroidJavaObject>("getPackageManager")) {
                    result = packageManager.Call<int>("checkPermission", permission, Application.bundleIdentifier) == PERMISSION_GRANTED;
                }
            });
        }
 
        return result;
#else
        return true;
#endif
    }
 
    public static string getExternalStorageState()
    {
#if !UNITY_ANDROID_EDITOR
        using(var environment = new AndroidJavaClass("android.os.Environment")) {
            return environment.CallStatic<string>("getExternalStorageState");
        }
#else
        return AndroidExternalStorageState.MEDIA_MOUNTED;
#endif
    }
 
#if !UNITY_ANDROID_EDITOR
    private static void processActivity(Action<AndroidJavaObject> func)
    {
        using(var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
        using(var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) {
            func(activity);
        }
    }
 
    private static string getPath(Func<AndroidJavaObject, AndroidJavaObject> func)
    {
        string path = null;
        processActivity(activity => {
            using(var file = func(activity)) {
                if(file != null) {
                    // getAbsolutePathまたはgetCanonicalPathで絶対パスを取得
                    path = file.Call<string>("getAbsolutePath");
                }
            }
        });
        return path;
    }
 
    private static string _internalFilesPath = null;
    private static string _internalCachePath = null;
    private static string _externalFilesPath = null;
    private static string _externalCachePath = null;
#endif
 
    private static int _apiLevel = 0;
    private static int _targetSdkVersion = 0;
}
#endif

これまでの解説を踏まえた上で読めば、使い方はわかると思うので説明しませんが、いくらか補足します。

パスやバージョンはアプリ起動中に変わることはないのでキャッシュするようにしました。JNIの呼び出しコストは当然安くないので。

Android実機でのみ有効なコードを書くには、#if !UNITY_EDITOR && UNITY_ANDROIDを使えばいいのですが、それをあちこちに書くとDRY原則に反するし面倒なので、ファイルの先頭で別のシンボルUNITY_ANDROID_EDITORを定義しています。また、選択中のプラットフォームがAndroidのとき、UNITY_ANDROID_EDITORの定義をコメントアウトすれば、Android実機用コードがIDEにも現在有効なものとして認識されるので、コーディングするときにも都合がいいです。

ここで定義したAndroidUtilは、Android実機でなくても、選択中のプラットフォームがAndroidであれば、インターフェースは有効となるようにしました(返ってくる値はダミー)。こうしておけば、Unityエディタ上でテストシーンを作るのに都合がいいと思ったからです。

また、これまで触れていませんでしたが、temporaryCachePathもpersistentDataPathと同様にWRITE_EXTERNAL_STORAGE権限の有無でパスが変わるため、こちらもついでに対応しておきました。

動作結果

下記の環境で簡単に動作確認してみました。結果を表で見やすいように、それぞれ別名をつけておきます。

環境名デバイス/プラットフォームOSSDカードWrite AccessWRITE_EXTERNAL_STORAGE
UnityエディタデスクトップPC(Unityエディタ)Windows 10---
Xperia A(Internal)Xperia A SO-04EAndroid 4.2.2Internal Only
Xperia A(External)Xperia A SO-04EAndroid 4.2.2External (SDCard)許可(インストール時)
Xperia Z4(Internal)Xperia Z4 SO-03GAndroid 6.0Internal Only
Xperia Z4(External拒否)Xperia Z4 SO-03GAndroid 6.0External (SDCard)拒否
Xperia Z4(External許可)Xperia Z4 SO-03GAndroid 6.0External (SDCard)許可

Unityのバージョンは5.3.4f1です。

それでは、結果を見ていきましょう。

環境名apiLeveltargetSdkVersion
Unityエディタ00
Xperia A(Internal)1724
Xperia A(External)1724
Xperia Z4(Internal)2324
Xperia Z4(External拒否)2324
Xperia Z4(External許可)2324
環境名READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE
UnityエディタTrueTrue
Xperia A(Internal)TrueFalse
Xperia A(External)TrueTrue
Xperia Z4(Internal)FalseFalse
Xperia Z4(External拒否)FalseFalse
Xperia Z4(External許可)TrueTrue
環境名getExternalStorageState()readableExternalStoragewriteableExternalStorage
UnityエディタmountedTrueTrue
Xperia A(Internal)mountedFalseFalse
Xperia A(External)mountedTrueTrue
Xperia Z4(Internal)mountedTrueTrue
Xperia Z4(External拒否)mountedTrueTrue
Xperia Z4(External許可)mountedTrueTrue
環境名persistentDataPath
UnityエディタC:/Users/User/AppData/LocalLow/wizaman_net/Sandbox
Xperia A(Internal)/data/data/net.wizaman.Sandbox/files
Xperia A(External)/storage/emulated/0/Android/data/net.wizaman.Sandbox/files
Xperia Z4(Internal)/data/user/0/net.wizaman.Sandbox/files
Xperia Z4(External拒否)/data/user/0/net.wizaman.Sandbox/files
Xperia Z4(External許可)/storage/emulated/0/Android/data/net.wizaman.Sandbox/files
環境名temporaryCachePath
UnityエディタC:/Users/User/AppData/Local/Temp/wizaman_net/Sandbox
Xperia A(Internal)/data/data/net.wizaman.Sandbox/cache
Xperia A(External)/storage/emulated/0/Android//data/net.wizaman.Sandbox/cache
Xperia Z4(Internal)/data/user/0/net.wizaman.Sandbox/cache
Xperia Z4(External拒否)/data/user/0/net.wizaman.Sandbox/cache
Xperia Z4(External許可)/storage/emulated/0/Android//data/net.wizaman.Sandbox/cache
環境名internalFilesPath
UnityエディタC:/Users/User/AppData/LocalLow/wizaman_net/Sandbox
Xperia A(Internal)/data/data/net.wizaman.Sandbox/files
Xperia A(External)/data/data/net.wizaman.Sandbox/files
Xperia Z4(Internal)/data/user/0/net.wizaman.Sandbox/files
Xperia Z4(External拒否)/data/user/0/net.wizaman.Sandbox/files
Xperia Z4(External許可)/data/user/0/net.wizaman.Sandbox/files
環境名internalCachePath
UnityエディタC:/Users/User/AppData/Local/Temp/wizaman_net/Sandbox
Xperia A(Internal)/data/data/net.wizaman.Sandbox/cache
Xperia A(External)/data/data/net.wizaman.Sandbox/cache
Xperia Z4(Internal)/data/user/0/net.wizaman.Sandbox/cache
Xperia Z4(External拒否)/data/user/0/net.wizaman.Sandbox/cache
Xperia Z4(External許可)/data/user/0/net.wizaman.Sandbox/cache
環境名externalFilesPath
UnityエディタC:/Users/User/AppData/LocalLow/wizaman_net/Sandbox
Xperia A(Internal)null
Xperia A(External)/storage/emulated/0/Android/data/net.wizaman.Sandbox/files
Xperia Z4(Internal)/storage/emulated/0/Android/data/net.wizaman.Sandbox/files
Xperia Z4(External拒否)/storage/emulated/0/Android/data/net.wizaman.Sandbox/files
Xperia Z4(External許可)/storage/emulated/0/Android/data/net.wizaman.Sandbox/files
環境名externalCachePath
UnityエディタC:/Users/User/AppData/Local/Temp/wizaman_net/Sandbox
Xperia A(Internal)null
Xperia A(External)/storage/emulated/0/Android//data/net.wizaman.Sandbox/cache
Xperia Z4(Internal)/storage/emulated/0/Android//data/net.wizaman.Sandbox/cache
Xperia Z4(External拒否)/storage/emulated/0/Android//data/net.wizaman.Sandbox/cache
Xperia Z4(External許可)/storage/emulated/0/Android//data/net.wizaman.Sandbox/cache

Xperia A(Internal)で、External Storageの読み取り権限はある・・・だと・・・?

READ_EXTERNAL_STORAGEが正式対応され意味を持つようになったのは、Android 4.4からですが、それまではデフォルトでその権限を持っている扱いにされていたんでしょうか・・・?ともかく、4.4より前のバージョンでREAD_EXTERNAL_STORAGEをチェックすることに意味はないことがわかったので、readableExternalStorageでREAD_EXTERNAL_STORAGEを見るのはやめました。

ともかく、書き込み権限まわりはだいたい意図した通りになりました。Xperia Z4(Internal)とXperia Z4(External拒否)は全く同じ結果が出ています。Android 4.0より古い環境や、マルチユーザ時の動作検証はしてませんが、多分大丈夫でしょう。

調査・検証・まとめ、どれもめちゃくちゃ時間がかかって大変だったので、誰か褒めてください。