Androidストアに登録するアプリはAPIレベル33以上に対応することが必須となった。SAFに対応したファイルアクセスが義務付けられたということである。SAFは結構昔からある仕組みだけど面倒なので避けてきたのだが、もう逃げられないようだ。FlutterでSAFを使うにはどうすればいいのだろうか。
Strage Access Framework (SAF)
SAFとは何か。ダウンロードしてきたスマホのアプリが、勝手に写真フォルダの中を漁って知らないサーバーにアップしたり、いろんな設定ファイルやデータを書き換えたりしたら大変なことになるが、Androidでスマホのアプリが勝手にファイルを読んだり書いたりするのを防ぐための仕組みが、Strage Access Framework 略してSAFである。
SAFは次のルールを守って安全にファイルを処理させようというものだ。
・基本的にアプリは自分専用の領域でしかファイルの読み書きができない。
・他アプリと共有するファイル、例えばカメラで撮った写真などにアクセスするには、ユーザーの許諾を得る必要がある。
・許諾を得たファイルも通常のファイルアクセス関数ではアクセスできず、特別なメソッドでアクセスする。
ユーザーの許諾は実際にはファイル選択ダイアログでユーザーがアクセスするファイルを選ぶということで行われる。
これまではファイルパスが分かっていればアプリケーションは読み書きできたが、SAFを遵守すると、何かファイルにアクセスするときには、必ずファイルダイアログを使ってユーザーに許諾を得なければならない。通常のファイルアクセス関数が使えないので、いったん自分専用領域にファイルをコピーしてから通常のファイルとしてアクセスするという手順を踏む必要もある。
FlutterでSAFに対応する方法について、プラグインを利用することもできるが、共有領域への書き込みまで行うにはMethodChannelによってOSの直接呼出しが必要となる。以下、順に説明する。
file_pickerプラグイン
file_pickerはFlutterでファイル選択ダイアログを表示する際の定番プラグインだと思うが、ファイル選択メソッドpickFiles()は、選択したファイルをアプリケーション専用のディレクトリにコピーしてキャッシュし、元のパスではなくキャッシュのパスを返す。なのでもらったファイルパスはそのままオープンできる。
いろいろなファイルを使っているとキャッシュが溜まっていく心配があるが、キャッシュは適度なタイミングで自動的に削除される。大量のファイルを使うなどパフォーマンスに不安があるときは、ClearTemporaryFiles()というメソッドで強制的に削除することもできる。
ディレクトリを選択するメソッドgetDirectoryPath()もあるが、返ってくるのは外部ディレクトリのパスそのままである。APIレベル33のアプリケーションではそのディレクトリの内容一覧を取得することはできないので役にたたない。
saveFile()メソッドはAndroidではサポートされていないので、file_pickerプラグインを使ってファイルを書き込むことはできない。
使い方
詳しくはプラグインのサイトを見てほしいが、一個のファイルを参照するにはスタティックなメソッドFilePicker.platform.pickFiles()を使って次のようにする。選択ダイアログをキャンセルした場合はnullが返ってくる。何か選択した場合は、result.files.single.pathにキャッシュされたファイルパスが返ってくる。
FilePickerResult? result = await FilePicker.platform.pickFiles();
if (result != null) {
File file = File(result.files.single.path!);
} else {
// User canceled the picker
}
Saf プラグイン
そのものずばりのプラグインもあった。ディレクトリの使用許諾を取ったら、そこにあるファイルをまとめて全部キャッシュにコピーするという豪快な仕様である。ひとつづつダイアログで許諾を取らなくてもよいので、ディレクトリのサムネ一覧を表示するなど、処理したいファイルがディレクトリにまとまって格納されているときに便利である。
メソッド一覧を見ると、saf.getDirectoryPermission()で許諾を取り、saf.cache()でキャッシュにまとめてコピーし、ファイルに変更を加えたら、saf.sync()で元のディレクトリに書き戻してくれる、というような動作が期待されるのだが、サンプルを動かしたところそうは機能しなかった。まとめてコピーしてくれるとこまではOKだが、saf.sync()を呼び出しても元のファイルに変更が反映されない。こちらの使用法が間違ってるのか、プラグインのバグなのかはよく分からない。
Safプラグインでファイルダイアログでディレクトリの使用許諾をとると、許諾の永続化も行うので、アプリを閉じても許諾内容は残っている。アプリを再起動して同じディレクトリを使うときに、新たにファイルダイアログで選択する必要はない。キャッシュしたファイルもそのまま残っているので、スムースに作業を継続できる。
使い方
主なメソッドは次のとおり。許諾の永続化や解放などのメソッドもあるが割愛した。詳しくはプラグインのドキュメント参照のこと。
インスタンスで Saf を開始する
Saf saf = Saf("~/some/path")
ディレクトリ許可リクエスト
bool? isGranted = await saf.getDirectoryPermission(isDynamic: false);
if (isGranted != null && isGranted) {
// Perform some file operations
} else {
// failed to get the permission
}
現在のディレクトリをキャッシュする
bool? isCached = await saf.cache();
if (isCached != null && isCached) {
// Perform some file operations
} else {
// failed to cache
}
現在のディレクトリのキャッシュされたファイルのパスを取得する
List<String>? cachedFilesPath = await saf.getCachedFilesPath();
現在のディレクトリをキャッシュされたディレクトリと同期する
bool? isSynced = await saf.sync();
MethodChannel
ここから先は非常に面倒なので、自アプリ専用領域以外にファイルを書き込む必要がなければ読み飛ばしてもらってかまわない。
アプリケーション専用領域外のファイルを参照するだけならプラグインの使用だけでまかなえるが、そこにファイルを書き込むには、結局最終手段であるMethodChannelでAndroidOSを呼び出すしかないようだ。
MethodChannelはネイティブコードとFlutterの通信手段で、双方同じ名前でMethodChannelオブジェクトを作っておくと、invokeMethod()を実行すると相手側で登録されたハンドラが呼び出されるというものだ。
MethodChannelの作成
Javaでの処理
古い人間なのでサンプルがJavaで申し訳ない。FlutterのプロジェクトのAndroidOS部分はデフォルトでKotlinのソースになっているが、次のようにするとJavaに置き換えることができる。
1.プロジェクトルート\android\app\src\main\kotlin\の下にあるMainActivity.ktを削除する。
2.Flutterプロジェクトのルートで flutter create -a java . のコマンドを実行する。
Flutterプロジェクトのルート> flutter create -a java .
Recreating project ....
(略)
Flutterプロジェクトのルート>
これでMainActivity.javaが生成される。package定義はそのままにして、以下を次のように書き換える。
1.FlutterEngine, MethodChannel, MethodChannel.Resultなどのインポートを追加する。
2.MethodChannelオブジェクト変数を定義する。
3.configureFlutterEngine()をオーバーライドして、MethodChannelオブジェクト変数の作成と、ハンドラの設定を行う。
結果は次のようになる。
import io.flutter.embedding.android.FlutterActivity;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.Result;
public class MainActivity extends FlutterActivity {
private static final String CHANNEL = "jp.picpie.saftest/saf";
static MethodChannel mChannel; // MethodChannelオブジェクト変数の定義
@Override
public void configureFlutterEngine( FlutterEngine flutterEngine) {
super.configureFlutterEngine(flutterEngine);
// MethodChannelオブジェクトの作成
mChannel =
new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL);
// ハンドラの設定
mChannel.setMethodCallHandler(
(call, result) -> {
// call.methodで切り分けを行う
if(call.method.equals("selectDirectory")){
....
}
}
);
}
}
以上がMethodChannelの基本的なコードで、mChannel.setMethodCallHandler()に必要な処理を加えていくことになる。
mChannel.setMethodCallHandler() がハンドラの設定だが、ハンドラのパラメータは(call, result)である。callは呼び元からの引数が入っており、resultは値を返すためのオブジェクトである。
call.methodにどの関数を呼び出すかを示し、call.argument(引数名)でそれぞれの引数の値が取得できる。
result.success(返す文字列) で正常終了として値を返すことができる。エラー時はresult.error(エラーコード, エラーメッセージ, エラー詳細)で返す。返すときのパメメータはそれぞれnullでもよい。
Flutterでの処理
次のライブラリをインポートする。
import 'package:flutter/services.dart';
MethodChannelオブジェクトを作成する。ここで設定したチャネル名と同じものをJava側でも設定する。
static const String CHANNEL = "jp.picpie.saftest/saf";
static const platform = MethodChannel(CHANNEL);
MethodChannelを呼び出す前のどこかのタイミングで、コールバック関数の設定を行う。
static void init(){
platform.setMethodCallHandler(_platformCallHandler);
}
コールバックハンドラは次のようなものである。引数callにネイティブコードから呼び出されたパラメータが入っている。call.methodで呼び出し先を切り分ける。転送されたデータなどはcall.argumentsに入っている。ネイティブコードで時間がかかる処理が終了したときのタイミングなどで呼び出される。
static Future<void> _platformCallHandler(MethodCall call) async {
switch (call.method) {
case 'dirlistOK':
dirlist = call.arguments.toString();
...
return;
default:
print('Unknowm method ${call.method}');
throw MissingPluginException();
}
}
ディレクトリ選択
ファイルダイアログを表示して使用するディレクトリを選択し、アプリケーションに許諾する。選択したディレクトリのSAFアクセスに必要なUriを返す。SAF環境ではファイルに関する一切の処理がUriを指定して行われる。このディレクトリの一覧取得や、ファイルの作成などに、Uriが必要となるので、ディレクトリ選択で得られたUriはどこかに保存しておくこと。
Javaでの処理
以下のインポートを追加する。
import android.util.Log;
import android.os.Handler;
import android.os.Looper;
import android.content.Intent;
import android.app.Activity;
import android.content.Context;
import android.net.Uri;
import androidx.documentfile.provider.DocumentFile;
プロジェクトルート/android/app/build.gradleの最後に以下のように書き換える。
dependencies {
implementation 'androidx.documentfile:documentfile:1.0.1' // documentfileの最新版の数値に合わせること
}
MainActivityにresultオブジェクトを保存する領域を確保しておく。
private Result pendingResult;
ハンドラの設定で、call.methodが"selectDirectory"のときselectDirectory()に分岐するようにする。
mChannel.setMethodCallHandler(
(call, result) -> {
if(call.method.equals("selectDirectory")){
selectDirectory(result);
}
ディレクトリ選択インテントを発行する。応答はonActivityResult()で受けるので、Flutterへ値を返すためのresultオブジェクトはクラス変数に退避する。
private void selectDirectory(Result result) {
pendingResult = result;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, DIRECTORY_PICKER_REQUEST);
}
ユーザーがディレクトリを選択すると、onActivityResult()が呼ばれる。リクエストコードがディレクトリ選択で、結果がOKならディレクトリUriを取得してresultを退避したpendingResultオブジェクトからsuccess()メソッドでFlutterに値を返す。pendingResultを最後nullにしているのは二重起動のチェックのためだが、試したところでは二重起動の心配はなさそうだ。ついでに言うと、Pixel8ではちゃんと選択完了するまでダイアログが抜けられない。選択してはいけないディレクトリは選択できないし、ディレクトリを選ばずキャンセルすることもできないので、resultCodeがNGになることもなさそうだ。
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
Log.d("onActivityResult", "data.getData(): " + data.getData().toString());
if (requestCode == DIRECTORY_PICKER_REQUEST) {
if (pendingResult != null) {
if (resultCode == Activity.RESULT_OK && data != null) {
String uri="";
if(data.getData()!=null){
uri = data.getData().toString();
}
pendingResult.success(uri);
} else {
pendingResult.success("");
}
pendingResult = null;
}
}
}
Flutterでの処理
Flutterからの呼び出しは次のとおりである。実行するとディレクトリ選択画面が表示され、選択したディレクトリのUriが返ってくる。
String diruri = await platform.invokeMethod('selectDirectory');
ディレクトリ一覧読み出し
許諾されたディレクトリ上のファイル一覧をJSON文字列として返す処理を行う。Uriで指定されたディレクトリの一覧を取得し、ファイルそれぞれについてファイル名やサイズなどの属性をJSON文字列として表し、それを"\n"で区切って足し合わせた一つの文字列として返す。
ファイル一覧から文字列を作成する処理は意外と時間がかかったので、処理自体は別スレッドで行い、文字列が完成するとFlutter側のコールバック関数を起動して通知する。実行すると、ファイル選択ダイアログを閉じて数秒たってから画面のファイル一覧内容が書き変わるという動作になる。
Javaでの処理
ハンドラの設定で、call.methodが"makeDirectoryList"のときmakeDirectoryList()に分岐するようにする。Flutterから指定されているパラメータuriはcall.argument("uri")で取得できる。
mChannel.setMethodCallHandler(
(call, result) -> {
...略...
if(call.method.equals("makeDirectoryList")){
String uri = call.argument("uri");
makeDirectoryList(this,uri);
}
与えられたUriを元にDocumentFileオブジェクトpickedDirを作成する。
pickedDir.listFiles()で求められた個別のDocumentFileオブジェクトについて、ファイル名などの情報を取得し、"\n"区切りで一つの文字列として返す。DocumentFileオブジェクトからは以下の属性が求められるが、ファイルの複数の情報が必要なら、JSONObjectを作成してそれら属性を追加して文字列化するとよい。
属性 | JSON アイテム | 取得メソッド | |
---|---|---|---|
ファイル名 | name | file.getName() | |
ファイルサイズ | size | file.length() | |
最終更新日 | lastModified | file.lastModified() | 1970 年 1 月 1 日の午前 0 時からのミリ秒単位 |
mime type | type | file.getType() | |
Uri | uri | file.getUri() |
private void makeDirectoryList(Context context, String uri) {
new Thread(() -> {
DocumentFile pickedDir = DocumentFile.fromTreeUri(context, Uri.parse(uri));
StringBuilder flist = new StringBuilder();
if (pickedDir != null && pickedDir.isDirectory()) {
for (DocumentFile file : pickedDir.listFiles()) {
if (file.isFile()) {
JSONObject jsonObject = new JSONObject();
try{
jsonObject.put("name", file.getName());
jsonObject.put("size", file.length());
jsonObject.put("lastModified", file.lastModified());
jsonObject.put("type", file.getType());
jsonObject.put("uri", file.getUri());
String jsonString = jsonObject.toString();
flist.append(jsonString).append("\n");
}catch(Exception e){
flist.append(e.toString()).append("\n");
}
}
}
}
// メインスレッドで結果を送信
new Handler(Looper.getMainLooper()).post(() -> mChannel.invokeMethod("dirlistOK", flist.toString() ));
}).start();
}
Flutterでの処理
javaからのコールバック関数を次のように定義する。call.arguments.toString()でjavaから送られた文字列を取得する。改行で区切って一個づつをjsonDecode()して中身を取り出す。この場合は、fnamelistという文字列リストを画面表示に使用している前提なので、fnamelistを書き換えてsetState()を呼び出すと画面が書き変わる。
List<String> fnamelist = [];
static Future<void> _platformCallHandler(MethodCall call) async {
switch (call.method) {
case 'dirlistOK':
List<String> dirstrs = call.arguments.toString().split("\n");
fnamelist.clear();
for( String fe in dirstrs){
Map<String, dynamic> user = jsonDecode(fe);
fnamelist.add(user['name']);
}
setState((){});
default:
print('Unknowm method ${call.method}');
throw MissingPluginException();
}
}
起動するときは、ディレクトリ選択で得られたdiruriを引数に次のように呼び出す。
await platform.invokeMethod('makeDirectoryList',{'uri':diruri});
ファイル読み出し
許諾されたディレクトリ上のテキストファイルを丸ごと読み出して文字列として返す処理を行う。読み出すファイルの指定は親ディレクトリのUriと対象のファイル名で行う。SAFでファイルを読みだすには対象ファイルのUriが必要で、直接ファイルのUriを指定すればいいのだが、使い勝手を考えて親ディレクトリUriとファイル名をパラメータにしている。
対象ファイルのUriを求めるのに親ディレクトリのファイル一覧を求めて、ファイル名と一致するファイルのUriを求めている。二度手間なことをやっているので、直接ファイルのUriを引数にとるファイル読み出しメソッドもあるといいかもしれない。
Javaでの処理
追加で以下のライブラリをインボートする。
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.IOException;
ハンドラの設定で、call.methodが"getFileText"のときgetFileText()に分岐するようにする。Flutterから指定されているパラメータuriはcall.argument("uri"), call.argument("filename")で取得できる。
mChannel.setMethodCallHandler(
(call, result) -> {
...略...
}else
if(call.method.equals("getFileText")){
String uri = call.argument("uri");
String filename = call.argument("filename");
getFileText(this,uri,filename,result);
}
親ディレクトリのUriとファイル名から、目的ファイルのUriを求めるメソッドを用意する。親ディレクトリのファイル一覧から一致するファイルを探している。
Uri findFileUri(Context context, String directoryUriStr, String filename){
Uri treeUri = Uri.parse(directoryUriStr);
DocumentFile pickedDir = DocumentFile.fromTreeUri(context, treeUri);
if (pickedDir != null && pickedDir.isDirectory()) {
for (DocumentFile file : pickedDir.listFiles()) {
if (file.isFile()) {
if(file.getName().equals(filename)){
return file.getUri();
}
}
}
}
return null;
}
読み出しメソッドの本体はこれ。context.getContentResolver().openInputStream()でファイルUriからInputStreamを求めている。
private void getFileText(Context context, String dirUriStr, String filename, Result result) {
Uri treeUri = findFileUri(context, dirUriStr, filename);
StringBuilder stringBuilder = new StringBuilder();
try (InputStream inputStream = context.getContentResolver().openInputStream(treeUri);
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
String line;
while ((line = reader.readLine()) != null) {
stringBuilder.append(line).append("\n");
}
result.success(stringBuilder.toString());
}catch(IOException e){
result.error("ERROR", "File read failed: " + e.getMessage(), null);
}
}
Flutterでの処理
Flutterからファイルを読みだすには以下のようにする。diruriはディレクトリ選択で得られたUri。fnameは対象のファイル名。
String text = await platform.invokeMethod('getFileText',{'uri':diruri,'filename':fname});
ファイル書き込み
許諾されたディレクトリ上のテキストファイルに与えられた文字列を丸ごと書き込む。対応するファイルがない場合は新規作成する。読み出すファイルの指定は親ディレクトリのUriと対象のファイル名で行い、書き込む内容はouttext文字列で指定する。書き込むファイルタイプは "text/plain" のみである。
Javaでの処理
追加で以下のライブラリをインボートする。
import java.io.OutputStream;
import java.io.OutputStreamWriter;
ハンドラの設定で、call.methodが"putFileText"のときputFileText()に分岐するようにする。Flutterから指定されているパラメータuriはcall.argument("uri"), call.argument("filename"), call.argument("outtext")で取得できる。
mChannel.setMethodCallHandler(
(call, result) -> {
...略...
}else
if(call.method.equals("putFileText")){
String uri = call.argument("uri");
String filename = call.argument("filename");
String outtext = call.argument("outtext");
putFileText(this,uri,filename,outtext,result);
}
読み出しメソッドの本体はこれ。context.getContentResolver().openOutputStream()でファイルUriからOutputStreamを求めている。まず指定されたファイルがあるか確認し、なければ指定されたファイル名で新たに作成する。
private void putFileText(Context context, String dirUriStr, String filename, String outtext, Result result) {
Uri uri = findFileUri( context, dirUriStr, filename);
if(uri == null){
Uri treeUri = Uri.parse(dirUriStr);
DocumentFile pickedDir = DocumentFile.fromTreeUri(context, treeUri);
DocumentFile newFile = pickedDir.createFile("text/plain", fileName);
uri = newFile.getUri();
}
try (OutputStream outputStream = context.getContentResolver().openOutputStream(uri);
OutputStreamWriter writer = new OutputStreamWriter(outputStream)) {
writer.write(outtext);
writer.flush();
result.success("OK");
}catch(IOException e){
result.error("ERROR", "File write failed: " + e.getMessage(), null);
}
}
Flutterでの処理
Flutterからファイルを書き込むには以下のようにする。diruriはディレクトリ選択で得られたUri。fnameは対象のファイル名。outtextは書き込む内容。
await platform.invokeMethod('putFileText',{'uri':diruri,'filename':fname, 'outtext':outtext});
キャッシュ領域へのファイルコピー
画像ファイルなどをFlutter標準widgetが処理できるように、共有領域からアプリ専用領域にコピーする。
Javaでの処理
追加で以下のライブラリをインボートする。
import java.io.File;
import java.io.FileOutputStream;
ハンドラの設定で、call.methodが"copyToPrivate"のときcopyToPrivate()に分岐するようにする。Flutterから指定されているパラメータuriはcall.argument("uri"), call.argument("filename"), call.argument("targetpath")で取得できる。
mChannel.setMethodCallHandler(
(call, result) -> {
...略...
}else
if(call.method.equals("copyToPrivate")){
String uri = call.argument("uri");
String filename = call.argument("filename");
String targetpath = call.argument("targetpath");
copyToPrivate(this,uri,filename,targetpath,result);
}
キャッシュ領域へのファイルコピーメソッドの本体はこれ。
private void copyToPrivate(Context context, String dirUriStr, String filename, String targetpath, Result result){
Uri sourceUri = findFileUri( context, dirUriStr, filename);
DocumentFile sourceFile = DocumentFile.fromSingleUri(context, sourceUri);
if (sourceFile != null && sourceFile.canRead()) {
try (InputStream inputStream = context.getContentResolver().openInputStream(sourceUri); ) {
File file = new java.io.File(targetpath);
OutputStream outputStream = new FileOutputStream(file);
byte[] buffer = new byte[1024*1024];
int length;
while ((length = inputStream.read(buffer)) > 0) {
outputStream.write(buffer, 0, length);
}
result.success(file.getAbsolutePath());
} catch (IOException e) {
e.printStackTrace();
result.error("ERROR", "File write failed: " + e.getMessage(), null);
}
} else {
// ファイルにアクセスできない場合の処理
result.error("ERROR", "File read failed: " + filename, null);
}
}
Flutterでの処理
Flutterからファイルを書き込むには以下のようにする。diruriはディレクトリ選択で得られたUri。fnameは対象のファイル名。targetpathはアプリ専用領域へのフルパス。
await platform.invokeMethod('copyToPrivate',{'uri':diruri,'filename':fname, 'targetpath':targetpath});
まとめ
MethodChannelについて長々と書いてしまったが、結局のところMethodChannelは使わないで、自アプリ専用領域以外にファイルを作らないようにする、というのが正解である。Safプラグインの共有ディレクトリ全コピーというのも、巨大なカメラロールを指定されたらとんでもないことになるので、せいぜいfile_pickerでひとつづつ参照するというあたりで抑えておくのが無難である。不特定多数が使うツールはそういうことはするな、というのがAndroid OSの方針なのだと思う。
Googleストアに登録しなければSAFを使う必要はないので、個人的なプロジェクトでどうしてもそういう機能が必要であれば、APIレベル30未満に設定して普通のファイルアクセスをするという手はある。