hishidaの開発blog

EBシリーズ(EBPocket,EBWin,EBMac,EBStudio),KWIC Finder,xdoc2txt,読書尚友の開発者ブログ

読書尚友の対象範囲別ストレージ対応について

Qiitaに上げた方がいいような技術的な内容だが、忘備録として書いておく。

Googleから警告がきた

先月から、読書尚友とEBPocet for AndroidGoogle Playコンソールに、次のようなメッセージが表示されるようになった。

5月5日より、アプリがストレージへの広範なアクセスを必要とする理由をお知らせいただく必要があります

お客様のアプリで 1 件以上の App Bundle または APK のマニフェスト ファイルに requestLegacyExternalStorage フラグが含まれていることが検出されました。

Android 11 以降が搭載されているデバイスをアプリの対象とするデベロッパーは、ユーザーがデバイス ストレージへのアクセスを適切に制御できるよう対象範囲別ストレージを使用する必要があります。5 月 5 日以降に Android 11 以降でアプリをリリースするには、次のいずれかを行う必要があります。

・Storage Access Framework API、Media Store API など、よりプライバシーに配慮したベスト プラクティスを使用するようアプリを更新する
マニフェスト ファイルですべてのファイルへのアクセス(MANAGE_EXTERNAL_STORAGE)権限を申告するようアプリを更新し、5 月 5 日以降に Google Play Console ですべてのファイルへのアクセス権限の申告を完了する
・すべてのファイルへのアクセス権限をアプリから完全に削除する

Android 11 を対象とするアプリでは、requestLegacyExternalStorage フラグは無視されます。広範なアクセスを保持するには、すべてのファイルへのアクセス権限を使用する必要があります。

許可された用途なく、すべてのファイルへのアクセス権限へのアクセスをリクエストするアプリは、Google Play から削除されます。アップデートを公開することはできません。


どういうことかというと、Android 10(APIレベル29)以降、対象範囲別ストレージが導入され、ローカルストレージへのアクセスが厳格化された。アプリがアクセスできるのは、基本的にアプリ固有領域と呼ばれるサンドボックスだけで、共有ストレージにアクセスするためには、java.io.Fileクラスに代わり、Storage Access Frameworkを使用してアクセスする必要がある。これにはプログラムの大幅な修正が必要になる。

そこで対象範囲別ストレージに対応するまでの時間かせぎとして、requestLegacyExternalStorage フラグが設けられており、これをtrueにすれば、APIレベル29を対象とするアプリでも、従来と同様にローカルストレージに自由にアクセスができる。だがAPIレベル30以降を対象にしたアプリでは、requestLegacyExternalStorage フラグは無視されるので、いずれは対象範囲別ストレージへの対応は避けられない。

もう一つの方法は、MANAGE_EXTERNAL_STORAGE権限を付けると、これまで通りにすべてのストレージにアクセスができる。これはファイルマネージャのような特別なアプリを想定しており、アプリ申請時に、MANAGE_EXTERNAL_STORAGE権限が必要な理由をGoogleに説明する必要がある。読書尚友やEBPocketのような一般的なアプリで申請が通るかどうかは不明だが、おそらくrejectされる可能性が高い。

support.google.com


これまで公開されているスケジュールでは、次のようになっていた。

  • 2021年8月 新しいアプリは、Android 11(API レベル 30)以上を対象とする必要がある
  • 2021年11月 既存のアプリの新しいアプリ アップデートは、Android 11(API レベル 30)以上を対象とする必要がある

当方の心づもりとしては、11月までに読書尚友とEBPocketの更新を終え、それ以後は開発を凍結するつもりだった。

ebstudio.hatenablog.com


ところが、今回のGoogle Playのメッセージでは、「requestLegacyExternalStorageフラグをtrueにしているアプリは、強制的に Google Playから削除される」というように読める。これはさすがに一大事だ。これまでそのようなアナウンスはなかったので、本当にそんな乱暴なことをするかどうか疑問だが、なんらかの対応は考えておかないといけない。

読書尚友の対象範囲別ストレージ対応

読書尚友は青空文庫ビューアだが、ローカルファイルのテキストを閲覧できることを特徴にしている。ファイルブラウザの機能を削除して青空文庫ビューアだけに限定すれば、requestLegacyExternalStorageフラグを外すことは簡単だが、読書尚友の存在価値がなくなってしまう。悩んだ挙句、読書尚友については、正攻法で対象範囲別ストレージ対応を行うことにした。
基本的には、次のマニュアルにある通り、ACTION_OPEN_DOCUMENT_TREE インテントを実行してディレクトリを選択し、ディレクトリ ツリーへのアクセス権を付与する。選択したディレクトリのUriが返却されるので、これを使用して、ファイル操作を行う。

developer.android.com

public void openDirectory(Uri uriToLoad) {
    // Choose a directory using the system's file picker.
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);

    // Provide read access to files and sub-directories in the user-selected
    // directory.
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);

    // Optionally, specify a URI for the directory that should be opened in
    // the system file picker when it loads.
    intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uriToLoad);

    startActivityForResult(intent, REQCODE_OPENTREE);
}

@Override
public void onActivityResult(int requestCode, int resultCode,
        Intent resultData) {
    if (requestCode == REQCODE_OPENTREE
            && resultCode == Activity.RESULT_OK) {
        // The result data contains a URI for the document or directory that
        // the user selected.
        Uri treeUri= null;
        if (resultData != null) {
            treeUri= resultData.getData();
            // Perform operations on the document using its URI.

        }
    }
}

パフォーマンス問題にはまる

よく見かけるサンプルプログラムだと、Uri を DocumentFile に変換して操作しているものが多い。ところが、うっかり下図のようなコーディングをすると、滅茶苦茶に遅い。実機で300件ぐらいのファイルの情報の取得に6秒ぐらいかかる。これではファイルブラウザとして使い物にならない。

DocumentFile documentFile = DocumentFile.fromTreeUri(getActivity(), treeUri);

for ( DocumentFile file :  documentFile.listFiles() ) {

// 悪い例。滅茶苦茶遅い!

Uri documentUri = file.getUri(); String displayName = file.getName(); long size = file.length(); long lastModified = file.lastModified(); boolean isDirectory = file.isDirectory(); ... }

遅い理由は、DocumentFileのメソッドの getName(), length(), lastModified(), isDirectory()  の呼び出し毎に、内部で毎回queryを実行しているからである。天下のGoogleとは思えないようなひどい実装である。
解決策は、DocumentFileは使わずに、次のようにContentResolver.queryを実行すれば、queryの回数は1回で済む。1000件ぐらいのファイル数でも一瞬で終わる。

String[] projection = {
    DocumentsContract.Document.COLUMN_DISPLAY_NAME,
    DocumentsContract.Document.COLUMN_SIZE,
    DocumentsContract.Document.COLUMN_LAST_MODIFIED,
    DocumentsContract.Document.COLUMN_MIME_TYPE,
    DocumentsContract.Document.COLUMN_DOCUMENT_ID,
  };

Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, DocumentsContract.getDocumentId(treeUri));

Cursor cursor = getActivity().getContentResolver()
		.query(childrenUri, projection, null, null, null, null);
try {
    if (cursor != null && cursor.moveToFirst()) {
        do {
            String displayName = "";
            boolean isDirectory = false;
            long size = 0;
            long lastModified = 0;

            int nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME);
            int sizeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE);
            int dateModifiedIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED);
            int mimeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE);
            int documentIdIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID);

            String documentId = cursor.getString(documentIdIndex);
            Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId);

            displayName = cursor.getString(nameIndex);
            size = cursor.getLong(sizeIndex);
            lastModified = cursor.getLong(dateModifiedIndex);
            String mimeType = cursor.getString(mimeIndex);
            isDirectory = mimeType.compareTo(DocumentsContract.Document.MIME_TYPE_DIR) == 0;

        } while (cursor.moveToNext());
    }
} finally {
    cursor.close();
}

 あとは、これまでjava.io.Fileを使用していた個所を、Uriベースに書き換えていけばよい(UriをFileに変換できればいいが、ACTION_OPEN_DOCUMENT_TREEで取得したUriはFileに変換できない)。UriはInputStreamに変換できるので、InputStreamに対応した外部ライブラリも使用できる。

InputStream stream = context.getContentResolver().openInputStream(uri);

これでなんとか対象範囲別ストレージ対応を終えることができた。

なお、Android 10 の実機検証は Alldocube iplay40 で行った。Android11の検証はエミュレータを用いた。

EBPocketの対象範囲別ストレージへの対応

次はEBPocketだが、EBPocketはネイティブのC++ライブラリで辞書ファイルにアクセスしているため、Srorage Access Framework を使用することができない。GoogleJavaでファイルを開いてファイル記述子をネイティブライブラリに渡す方法を推奨しているが、辞書ファイルの中には画像や動画ファイルが数千個あるような辞書もあり、とても実現不可能である。
対応としては、次の3つが考えられる。

  1. 共有ストレージの利用をあきらめて、アプリ固有領域( /storage/emulated/0/Android/data/info.ebstudio.ebpocket/files/ )に辞書をコピーして運用してもらう。問題は、EBPocketをアンインストールすると辞書も削除されてしまうこと。また、他の辞書アプリと辞書を共有できない。かなりの制限なので、きっと評価に☆1をつけられるだろう。
  2. 一か八か、MANAGE_EXTERNAL_STORAGE権限を付けて申請してみる。だが受理されるかどうかはわからない。
  3. 本当にGoogle Playからアプリが削除されるまで静観する。そもそも requestLegacyExternalStorage フラグを使うことは違反ではない。

とりあえず静観してみて、削除される可能性が出てきたら 1. か 2. で行こうと思っている。