Androidでファイル固有の外部ストレージに書き込む

前回は、Android Studioを使って、SAFを使ったファイル書き込みについて書きました。
alasixosaka.hatenablog.com
今回は、別の方法を使ってファイルを書き込むアプリを作ります。
Android10以上では、アプリが自由に書き込み出来る場所が指定されていて、それがアプリ固有の内部ストレージとアプリ固有の外部ストレージになります。
前回も書きましたが、アプリ固有の内部ストレージは容量が限られるので、ロングトレイルのログなどを保存するのには向いていないと思われます。ですので、今回作るアプリはアプリ固有の外部ストレージにデータを保存するアプリになります。
全体としては、こちらのサイトの記事を参考にしました。Androidアプリを作るときはよくお世話になっているnyanさんのブログです。
akira-watson.com
今回も、現在時刻を取得して、それをファイルの出力するアプリになります。前回のアプリと違うのは、前回のアプリでは、ボタンを押すたびに時刻を取得するとこまでは同じですが、前回のアプリはその時刻は一旦配列に格納しておいて、SAVEのボタンを押すと初めてファイルに出力されるというアプリでした。
今回は、ボタンを押すたびに時刻を取得し、それを即座にファイルに追記していくというアプリになります。

アプリ固有の外部ストレージ(内部ストレージも)を使う場合は、パーミッションは必要ありません。ですので、即座にボタンの入力待ちに移行するようにしています。
なお、これはAndroid12までのようで、Android13以降はまた更に制限が厳しくなり、いろいろと細かくパーミッションを取る必要があるようです。
www.jisei-firm.com
ですが、手持ちのデバイスはAndroid11なので、ターゲットのSDKを32として、Andorid12までの対応としています。
MainActivityの最初の部分です。

private String fileName = null;
private File file;
String str;

ファイル名を格納する変数fileNameとファイルを開くためのインスタンス file、時刻の受け渡しに使う変数strを宣言しています。
次にOnCreateです。

    @RequiresApi(api = Build.VERSION_CODES.N)
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Button button = findViewById(R.id.button_save);
        HelloListener listener = new HelloListener();
        button.setOnClickListener(listener);
        Context context = getApplicationContext();
        fileName = getNowDate()+".log";
        file = new File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), fileName);
        Log.d("log","path: " + file);
    }

Buttonを定義して、リスナーを定義し、ボタンが押された時のリスナーと紐づけしています。
ファイル名はOnCreateが呼び出されたとき、つまりアプリが起動したときの時刻に".log"を追加したものにしています。
そして、そのファイル名でファイルを開きます。context.getExternalFilesDir(...)のところがそうで、Environment.DIRECTORY_DOCUMENTSがファイルの保存先を指定している部分になります。
アプリ固有の外部ストレージは、ファイルの種類によって格納先が決まっていて、今回はドキュメントを保存するフォルダを指定しました。参考にしたサイトでは、画像を保存するアプリを作っていたので、フォルダはEnvironment.DIRECTORY_PICTURESを指定しています。
getNowDateは前回とほぼ同じですが、フォーマットを変更しています。前回のフォーマットのままでは、ファイル名に"/"や":"が含まれているのでエラーになってしまったので、区切りを全部取っ払っています。

    @RequiresApi(api = Build.VERSION_CODES.N)
    public static String getNowDate() {
        final DateFormat df = new SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault());
        Date date = new Date();
        
        return df.format(date);
    }

ボタンをクリックしたときの動作は同じです。showTimeを呼び出すだけです。

    private class HelloListener implements View.OnClickListener{
        @RequiresApi(api = Build.VERSION_CODES.N)
        @Override
        public void onClick(View view){
            showTime();
        }
    }

showTimeの中身です。

    @RequiresApi(api = Build.VERSION_CODES.N)
    private void showTime() {
        TextView text = (TextView)findViewById(R.id.text_view);
        str = getNowDate();
        text.setText(str);
        if(isExternalStorageWritable()){
            try
            {
                //FileOutputStream output =
                //        openFileOutput(fileName,MODE_APPEND);
                FileOutputStream output = new FileOutputStream(file,true);
                output.write(str.getBytes());
                output.flush();
                output.close();

            } catch (IOException e) {
                e.printStackTrace();
                textView.setText(R.string.error);
            }
        }
    }

テキストビューを指定して、strに現在時刻を取得して格納し、表示しています。その次からがファイルの書き込みです。まず、isExternalStorageWritable()を呼び出して、SDカードが書き込み可能な状態か確認し、書き込み可能であれば、FileOutputStreamを使って時刻を書き出します。
実はこの部分で引っかかってなかなかうまくいきませんでした。コメントアウトしている部分が参考サイトと同じようなやり方です。FileOutputStreamにoutputを定義して、openFileOutputでファイルを開こうとしています。この時の、MODE_APPENDはファイルを追記していくという指定になります。そして、output.write(str.getBytes())で時刻を書き込んでいますが、このやり方ではうまくいきませんでした。
別のサイトを参考にして、
android.keicode.com
FileOutputStream output = new FileOutputStream(file)とすると書き込めるのですが、追記ができません。いろいろ探した挙句、ようやく下記のサイトを発見しました。
motojapan.hateblo.jp
このサイトに追記のやり方が書いてあって、最後に引数trueを指定すると追記モードで書き込めます。つまり、

FileOutputStream output = new FileOutputStream(file,true);

としてやればうまくいきました。
SDカードが書き込み可能か問い合わせるルーチンは下記のとおりです。

    public boolean isExternalStorageWritable() {
        String state = Environment.getExternalStorageState();
        return (Environment.MEDIA_MOUNTED.equals(state));
    }

activity_main.xmlは下記のとおりです。今回はConstraintLayoutを使っていますが、別に深い意味はありません。デフォルトがそうなっているのでそのまま使っているだけです。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/text_view"
        android:textSize="24sp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.3" />

    <Button
        android:id="@+id/button_save"
        android:text="@string/save"
        android:layout_width="300dp"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.7" />

</androidx.constraintlayout.widget.ConstraintLayout>

今回はパーミッションが必要ないのでマニュフェストは特に何も触っていないので省略します。
アプリを動かすとこんな画面になります。

アプリを動かしたところ

ファイルの保存場所は、”storage/emulated/0/android/data/com.example.アプリ名/files/document/"になります。com.exampleの後のアプリ名と書いてあるところが、アプリの名称です。したがって、アプリの名称が変わればこの部分が変わります。
出力されたファイルはこんな感じになります。

202307081352042023070813520620230708135207

今回は区切りを全く使っていないので、数字の羅列になっていますが。

格納したファイルは外部から見れない

さて、これで、アプリ固有の外部ストレージにはデータを格納できたのですが、これを見ようとすると、エミュレータのデバイスファイルエクスプローラーでは見えるのですが、ファイルマネージャーや、実機でデバイスをPCにつないでPCのエクスプローラーから見に行っても見ることができません。

ファイルマネージャーで該当フォルダを開くと中身がない
バイスファイルエクスプローラでは見える

これでは、ログを後から取り出すことが難しくなるので、ファイルを別の場所にコピーしてPCから見れるようにします。
やり方は、onDestroyを作って、アプリが終了するときにファイルをstorage/emulated/0/documents/のフォルダにコピーします。別に場所は見える場所ならどこでもよいのですが、今回は前回のアプリと同じ場所にしてみました。このフォルダは普通に外部から見ることができるのでここに移しておけば、あとからログを見るのが簡単にできます。新しい格納場所を、nfileというストリング変数に指定して、Files.copy()コマンドでコピーしています。ダイレクトに場所を指定する方法は推奨されていないので、エラーなり、アラートなりが出るかと思いましたが、何事もなく普通にコピーできました。コピーした後は元のファイルは不要ですので削除しています。

    @RequiresApi(api = Build.VERSION_CODES.O)
    protected void onDestroy() {

        super.onDestroy();
        String nfile = "storage/emulated/0/documents/"+fileName;
        Path p1 = Paths.get(String.valueOf(file));
        Path p2 = Paths.get(nfile);

        try{
            Files.copy(p1, p2);
            file.delete();
        }catch(IOException e){
            System.out.println(e);
        }

    }
パーミッションは必要

なお、この操作をするには、やっぱりパーミッションが必要になるので、前回と同様にパーミッションを取るルーチンを実装する必要があります。やり方は前回と全く同じなので、ここでは省略します。
さて、いろいろ紆余曲折がありましたが、ファイルを保存するアプリのテストが完了したので、次は地図アプリにログを保存する機能を追加していきたいと思います。