のらぬこの日常を描く

ノージャンルのお役立ち情報やアニメとゲームの話、ソフトウェア開発に関する話などを中心としたブログです。

android:SAF、RuntimePermission等、年毎に複雑化するファイルアクセスAPI

どうものらぬこです。

今回は、androidアプリから端末内のファイルの扱いについての記事です。

端末内ストレージ(内臓ストレージ、外部SDカード)のファイル操作(編集したり、コピーしたり、削除したり)機能の実装方法は、androidOSのファイルアクセス系APIの仕組みが、OS更新ごとに仕様が複雑怪奇化、API機能の制約が増加されたなどの理由により、以前(Kitkat以前)と比べると格段に面倒くさくなっています。

今回は、その変革の軌跡をたどりながら、googleマジいい加減にしろよ●すぞ!っていう殺意にも似た感情を書いてきます。

Kitkat以前(~4.3)

アプリにPermissonさえ付与しておけば、内蔵ストレージ内のファイルもSDカード内のファイルは自由に読み書きできた時代。あの頃は良かった。

Kitkat(4.4)

一般アプリからの外部ストレージ(SDカード)への書き込みが大きく制限される /sdcard/Android/<自アプリのパッケージ名> 以外ディレクトリへの書き込みが実質不可能になる。

「実質」と書きましたが、全く不可能だったわけではないようで、実は、LOLIPOPで話題に上がったストレージアクセスフレームワーク(SAF)という仕組み、実はAPIレベル19、つまりKitkatですでに実装されていたらしいのだが、機能が中途半端なせいで外部ストレージのファイルを自由に読み書きする仕組みとしては使い物にならなかったようだ。

このバージョンから、外部SDカードの書き込み操作に限ってはAndroidManifest.xml内の Permissionの設定の効力は及ばなくなる。 たとえ記載があったとしても、アプリから上記ディレクトリ以外に書き込みを行なおうとした場合、無情にもSecurityExceptionが発生する。

なお、内蔵ストレージ内のファイル操作は、androidManifest.xml内にPermission設定をすれば従来のバージョンと同等の方法で読み書き可能。

ちなみに、システムアプリ(端末標準搭載の削除できないアプリ)からはSDカード内のファイルも自由に書き込みが可能だった。 Kitkat搭載端末には、ESファイルエクスプローラ等の優秀なファイル管理アプリよりも遥かに使いづらいアプリが標準添付されていたり。

なにとは言わないが。

この制限、root権限で /etc配下に置かれているPermission関連の設定ファイルを手動で書き換えることで、回避可能。

この回避策を実行すれば、旧バージョンと同等のことが 、何の制約もなくKitkatでも行えるようになる。

制限を回避するためにroot化した人も結構いたみたい。

まるで、セキュリティーを向上するためとかいう大義名分を嘲笑うかのよう。

昔、恒久的にroot化することなく、この制限を回避するためのツールを公開してた(消えてなければファイルも残っていると思う)。

noranuk0.hatenablog.com

かなり後発で公開したのだが、意外とダウンロード数もあり、需要はあったんでしょうね。

Lolipop(5.0)

Lolipopで新たに実装された SAF(Storage Access Framework) というAPIを利用することで、SDカード内のファイルも、ユーザが明示的に許可を出せば、自由に読み書きできるようになる。

さらに「ユーザが明示的に許可を出せば」というのがポイントで、アプリから書き込操作を行ってもよいSDカードのディレクトリを、「androidOSが用意しているディレクトリ選択画面」を使ってユーザが自分で設定しなければならない。

このandroidが用意したディレクトリ選択画面、いきなりこれを出してもエンドユーザさんは何の事かサッパリ分からないような画面のため、アプリ側で操作方法の説明を記載したり、その辺の解説サイトに説明を丸投げしたりと、色々苦労があったみたい(あった)。

エンドユーザにとっては大きな改善ではあったと思うが、新しく実装されたAPIは、従来のAPIとは全く互換性がなく、対応するにはアプリの再実装が必要で、開発側にとっては良いことばかりでもなかった。

private DocumentFile getTargetDocumentFile(String path, String treeUri) {
    String[] parts = path.replaceFirst("^/", "").split("/");
    DocumentFile target = DocumentFile.fromTreeUri(context, Uri.parse(treeUri));
    for (String part : parts) {
        target = target.findFile(part);
        if (target == null) {
            return null;
        }
    }
    return target;
}

SAFにはファイルの移動APIが存在しなかったため、ファイルの移動はコピーして元ファイルを削除という MOVEコマンドがなかったころのMS-DOSMS-DOS 3.3 みたいなことをやらないといけない。

どう考えても仕様バグみたいなこの制限は、Android7.0でようやく解消される。

// android7.0以上でのみ動作する
DocumentsContract.moveDocument(
                        contentResolver,
                        documentFile.getUri(),
                        oldPathDocument.getUri(),
                        newPathDocument.getUri());

M(6.0)

Runtime Permission といって、インストール時にアプリが使う可能性のあるすべての権限をユーザに強制承認させるのではなく、アプリインストール後、いくつかの権限については、必要になったときにユーザに承認を求めるという方式に変更される。

例えば、電話を掛ける操作や、カメラを使う操作、端末内ストレージを読み書きする操作などは、機能の利用前にユーザの明示的な承認が個別に必要となる。

これは、AndroidManifest.xmlに Permission設定を書いておくだけではだめで、権限が必要な操作の実行前に、権限を使用するためのリクエストをエンドユーザに承認してもらうためのダイアログを表示する必要があり、この部分はアプリ側で追加の実装が必要。

上でも書いたが、内蔵ストレージ・SDカード内のファイル読み書きもRuntime Permission の対象のため、ひと手間かけて、そのための処理を追加実装する必要がある。

private void requestExternalSDCardPermission() {
    if (IntentUtil.checkSelfPermission(this)) {
        new AlertDialog.Builder(this).
                setTitle(
                        getResources().getString(R.string.app_name)).
                setMessage(
                        getResources().getString(R.string.external_storage_permission_message,
                                getResources().getString(R.string.app_name))).
                setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialog, int which) {
                        if (shouldShowRequestPermissionRationale() ||  !Config.instance().requestSdCardPermission()) {
                            ActivityCompat.requestPermissions(LaunchActivity.this,
                                    new String[]{
                                            Manifest.permission.READ_EXTERNAL_STORAGE},
                                    REQUEST_PERMISSIONS_EDITING);
                        } else {
                            Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
                            Uri uri = Uri.fromParts(
                                    "package",
                                    LaunchActivity.this.getPackageName(),
                                    null); //Fragmentの場合はgetContext().getPackageName()
                            intent.setData(uri);
                            LaunchActivity.this.startActivity(intent);
                        }
                    }
                }).show();
    }
}

エンドユーザにとっては、カメラや音声の録音、通話の発信等の悪用さえるとちょっと危険な香りのする権限を、アプリごとに個別に拒否することが出来るようになるというメリットがあり、世間的には割と歓迎された。

LOLIPOPで追加されたSAFの仕様はそのまま残っており、外部ストレージへの書き込みは相変わらず SAFを使う必要がある。

LOLIPOPで実装されたSAFは本当に黒歴史だと心底思う。

N(7.0)

SAFの仕様バグ、ファイルの移動ができない不具合がようやく解消される。

ただし、android7.0が出てから2年ほど経つが、対応アプリはそんなに多くないのが実情のようで(僕が知らないだけかもしれないけど)。

そしてもう一つの大きな変更。

端末内のファイルを選択し、それを他のアプリで開くような操作に対応する場合(例えば、ファイル管理アプリで動画ファイルを選択して、それを動画再生アプリを開くときなど)、対象ファイルのパス名を指定することがこのバージョンから不可能になった。 Intentの data として、file:// スキーマを設定して、他のアプリに渡そうとすると SecurityException が発生する。

代わりに、呼び出し元アプリでContentProviderを実装して、そいつのスキーマを使えという事になっている。

エンドユーザにとってはメリットなど一つもない、場合によってはデメリットしかない気がするのだけど、なんでこんなことしたのか理解不能

デメリットとしては、例えば以下のようなことが考えられる。

ContentProviderで指定するパス名は呼び出し元アプリが自由に決められるようになっていて、ファイルシステム上同一のファイルに、毎回別のパスを割り当てることも、逆にファイルシステム上は別のファイルに、同一のパスを割り当てることも可能になっている。

したがって、呼び出し元アプリの実装方法にもよるが、例えば、アプリから動画ファイルを開く場合、動画アプリ側のレジューム再生機能などに支障をきたす場合もある。

アプリ独自にContentProvider実装しなくても、今のところはcontent:// スキーマが利用可能なのが唯一の救い。

MediaScannerConnection.scanFile(LaunchActivity.this,
        new String[]{uri.substring("file://".length())}, null,
        new MediaScannerConnection.OnScanCompletedListener() {
            @Override
            public void onScanCompleted(String path, Uri uri) {
                if (uri == null) {
                    uri = Uri.fromFile(new File(path));
                }
                Intent intent = new Intent(Intent.ACTION_VIEW);
                intent.setData(uri);
                context.startActivity(intent);
            }
        });

今回の話はここでおしまいだが、android 8.0がリリースされていて、その次は android 9.0 とかが待ち構えている。

最近はandroidの最新情報を追いかけていないので、8.0でまた仕様が変わったとか9.0でさらに制約が増えるとかいうことがあるのかないのかとかは分からない。

が、これ以上中途半端な仕様・制約の追加変更はやらんでいただきたいとは切に願う。

最後にひとつ。記事内に埋め込まれたコードは、過去実装したアプリコードの一部をそのまま貼り付けているため、掲載したコード単独では不完全な部分が多くあり、そのままではおそらく動作しません。

参考にされる場合は、不足していると思われる部分を各自補ってください。

というわけで、今回の記事は以上となります。

お読みいただいてありがとうございました。