Flutter  端末内のユーザーがアクセス可能な場所にファイルを保存する方法(バックアップ機能の実装)

Flutter

(2022/7/3更新)

Flutterで、端末内のユーザーがアクセスできる場所に、ファイルを保存するにはどうすればいいの?

という方向けの内容です。

 

自分が、機種変更に対応できるアプリのバックアップ機能を作る際、実装に苦労したので、その方法を整理・共有したいと思います。

 

端末内ではなく、Google Driveにバックアップする方法については、下記記事に整理したので、よろしければこちらもご覧ください。

 

 

Google DriveやCloud FireStoreへの保存ではなく、端末内への保存を検討した理由は、ユーザーログインの手間や、ユーザーデータ管理が生じるのを避けたかったためです。

 

調べたところ、最近のAndroid・iOSのバージョンでは、セキュリティの観点から、端末内への保存には制限があることが分かり、色々と試行錯誤が必要でした。。

 

結果、自分なりに整理した結論は、以下のとおりです。

 

  • Androidの場合:
    • 保存できるのは、アプリ専用フォルダ内のユーザーがアクセスできるフォルダ端末直下のダウンロードフォルダの2つのみ
    • アプリ専用フォルダ内に保存する場合:
      • Flutter標準の「path_provider」パッケージを使う
      • 「AndroidManifest.xml」への追記は不要
    • 端末直下のダウンロードフォルダに保存する場合:
      • 「path_provider」に加え、「external_path」パッケージと「permission_handler」パッケージを使う
        • 以前使用していた「ext_storage」は、動作はするものの、警告が出るため「external_path」パッケージに変更
      • 「AndroidManifest.xml」に2つ設定を追記する
  • iOSの場合:
    • 保存できるのは、アプリ専用フォルダのみ
    • アプリ専用フォルダにユーザーアクセスを可能にするため、「info.plist」に設定を追記する
  • 保存したいファイルが画像・動画ファイルのみの場合
    • 「gallery_saver」パッケージを使えば、画像・写真フォルダに保存可能(Android・iOS両方)

 

恐らく「MethodChannel」を使って、ネイティブのコードまで操作できれば、もっと良い方法があるのだと思うのですが、、、未熟ゆえ上記レベルで精一杯でした。

 

以下、詳細になります。

 

※なお、この記事をベースに、外部ストレージからのインポート機能の実装方法について、下記記事にまとめましたので、よろしければこちらもご参考ください。

 

   

(2021/10/18追記)

また、バックアップしたいファイルが画像・動画ファイルのみの場合は、gallery_saver」というパッケージを使うと簡単にできると分かりました(ちょっと注意点もありますが)。

  

こちらの記事に、注意点を含め、メモ程度に書いていますので、よろしければご参考ください。

 


40代からプログラミング(Flutter)を始めて、GooglePlayAppStoreにアプリを公開しているhalzo appdevです。

 

作成したアプリはこちら↓ 全てFlutterで開発したアプリです。

 

超即ToDo –最短2タップで通知登録できるタスク管理アプリ

Google Play で手に入れよう
Download on the App Store

 

かんたんプリント管理:アラート・OCR文字認識・検索機能を搭載

Google Play で手に入れよう
Download on the App Store

 

シンプルメモ帳「BasicMemo」 – 文字カウント、ワンタッチ入力、タグ管理等の機能を搭載

Google Play で手に入れよう
Download on the App Store

 

自由な場所には保存できなくなった

以前は、端末内でユーザーが指定したフォルダにデータを保存できたようですが、セキュリティ上の問題から、iOS端末、Android端末(8.0以降)のいずれも、できなくなったようです(下記ご参照)。

 

  

Android公式サイトでも、以下のような記述があり、簡単には端末内にアクセスできなくなっていることが分かります。

 

・Android 11(API レベル 30)では、プラットフォームをさらに強化し、外部ストレージ上のアプリとユーザーのデータの保護を改善しています。

・Android 11 以降が実行されているデバイスでは、ユーザーのプライバシーを保護するために、アプリから他のアプリのプライベート ディレクトリへのアクセスを制限しています。

・Android 11 では、アプリは外部ストレージにある別のどのアプリの専用ディレクトリにもアクセスできなくなりました。

出典: https://developer.android.com/about/versions/11/privacy/storage?hl=ja

 

Androidの対応方針

方法(1)「path_provider」を使う

Flutterの公式サイトでは、「path_provider」というパッケージを用いた方法が説明されています。

 

 

「path_provider」には、主に以下3つの端末保存メソッド(正確には保存先のパスを取得するメソッド)が用意されています。

 

  • getTemporaryDirectory
    • システム側で消去できる「一時ディレクトリ(キャッシュ)」のパスを取得します。
  • getApplicationDocumentsDirectory
    • 当該アプリのみがアクセスできるアプリ専用のフォルダのパスを取得します。アプリが削除さると、本フォルダも削除されてしまいます。
  • getExternalStorageDirectory
    • Androidだけが利用できるメソッドです。
    • パッケージ内の説明では、「アプリがトップレベルのストレージにアクセスできるディレクトリへのパス」を取得すると書かれています。実際に試すと、アプリ専用フォルダ内に、ユーザーがアクセス可能なフォルダを作り、そのパスを取得しています。

 

1点目の「getTemporaryDirectory」は、キャッシュ保存のため、バックアップの目的に合いません。

 

また、2点目の「getApplicationDocumentsDirectory」は、アプリだけがアクセスできる(ユーザーには見えない)フォルダへの保存であり、アプリを削除するとファイルも消えるため、これもバックアップの目的に合いません。

 

そこで、3点目の「getExternalStorageDirectory」を使うことになりますが、Androidしか対応していないため、OS判定をして使い分ける必要があります。

 

また、あくまでもアプリ専用フォルダ内への保存のため、ユーザーからはアクセスしにくい場所にあり、ユーザビリティは低いです。

 

たどり着くまでの階層が深く、ユーザビリティが低い。。

 

「path_provider」を使った保存方法は、こちらの記事が大変わかりやすく、参考にさせていただきました。ありがとうございます!

 

 

方法(2)「external_path」と「permission_handler」を追加する

より良い手段が無いか、探して出会ったのがこの方法でした。

 

前述の「path_provider」と一緒に使うことが前提です。

 

「permission_handler」を使い、ポップアップを表示して、ユーザーに端末内へのアクセス許可を得た後、「external_path」のメソッドを使って、ダウンロードフォルダに保存する、という流れです。

 

 


(2022/7/3修正)

以前は、以下の記事を参考にさせていただき、「ext_storage」というパッケージを使っていました。

 

 

null safety対応がされなかったものの、GitJournalにて使用可能なバージョンが公開されており、使用を継続していました。

 

しかし、ビルドするたびに警告が表示されており、Flutter3になり、不具合の恐れも高まったので、代替案を探したところ、「external_path」というパッケージで完全に代替可能であることが分かりました(2021.6にはリリースされていたようで、気づきませんでした。。)。

 


 

「external_path」には、「ExternalPath.getExternalStoragePublicDirectory」というメソッドがあり、引数に「ExternalPath.DIRECTORY_DOWNLOADS」を指定することで、端末直下の「ダウンロード」フォルダに保存するパスを取得できます。

 

ダウンロードフォルダは、ファイル閲覧アプリですぐにアクセスできるため、ユーザビリティも高いです。

 

簡単にアクセスできるため、ユーザービリティが高い

 

※引数には、「DIRECTORY_PICTURES」や「DIRECTORY_DOCUMENTS」なども指定でき、ダウンロード以外のフォルダにも保存することができます。

 

なお、このメソッドもAndroid限定となるため、OS判定をして使い分ける必要があります。

 

iOSの対応方針

iOSは、「getApplicationDocumentsDirectory」メソッドを使って、アプリ専用フォルダ内に保存するしか方法がありません。

 

しかし、幸いなことに、iOSは「info.plist」に設定を加えることで(後述)、アプリ専用フォルダ全体にユーザーがアクセスすることが可能なので、この方法で対処します。

 

こちらも簡単にアクセスできるため、ユーザービリティは高い

 

以下の情報が参考になりました。ありがとうございます!

 

 

(2021/10/18追記)

前述のとおり、バックアップしたいファイルが画像・動画ファイルのみの場合は、gallery_saver」というパッケージを使うと簡単にできると分かりました(ちょっと注意点もありますが)。

 

iOSの場合も、プリインの写真フォルダに画像をエクスポートすることができます。

  

こちらの記事に、注意点を含め、メモ程度に書いていますので、よろしければご参考ください。

 

具体的な実装例

以下に、具体的な実装例をご説明します。

 

(1)「path_provider」だけを使った方法(アプリ専用フォルダ内への保存)と、

 

(2)「external_path」と「permission_handler」を追加した方法(ダウンロードフォルダへの保存)

 

の2通りをご紹介します。

 

なお、android/app/フォルダにある「build.gradle」内のバージョン設定は、

 

  • minSdkVersion 21
  • targetSdkVersion 30

 

を前提としています。

 

方法(1)「path_provider」のみの場合の実装例

パッケージのインポート

以下の通り、「pubspec.yaml」ファイルのdependenciesの所に追記して、「pub get」します。

 

dependencies:
  path_provider: ^2.0.2

 

 

「AndroidManifest.xml」への設定追記(Android用)

特段の追記は不要でした。

 

targetSdkVersionが29のときは、applicationタグの中(<application ・・・>)に、後述の方法(2)と同じ内容を追記する必要がある、という情報もありました。

 

ただ、自分の場合は29に変更しても、記述不要で問題なく動作しました。

 

「info.plist」への設定追記(iOS用)

以下の4行を追記します。

 

<key>UISupportsDocumentBrowser</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>

 

追記する場所は、「info.plist」の上から4行目あたり(何行目かは人によって若干違うかもしれません)にある<dict>と、下から2行目にある</dict>の間であれば、どこでも大丈夫です。

 

作成したコードのアウトプットイメージ

以下2つのケースをテストできるコードを作成しました。

 

  • テスト①(①のボタンを押す)
    • アプリ内で生成したデータを、ユーザーがアクセス可能なフォルダ(アプリ専用フォルダ内)に直接保存するケース
  • テスト②(②Step1のボタン→②Step2のボタンを押す)
    • アプリ内で生成したデータを、いったんユーザーがアクセスできないアプリ専用フォルダ内に保存し、それをユーザーがアクセス可能なフォルダ(アプリ専用フォルダ内)に再保存(バックアップ)するケース

  

テスト②が、ファイルのバックアップ機能に相当するイメージです。

 

Androidの例(テスト①→テスト②の順に実行した例)

 

iOSの例(テスト①→テスト②の順に実行した例)

 

テスト①、②とも、ユーザーが最終的にアクセス可能な場所に保存されますが、Androidの場合は、アクセスするのが面倒です。

 

作成したコードの全体像

詳細な説明は、コード中にコメントで記載しました。

 

クラス名、メソッド名、プロパティ名(変数名)は、ライブラリ(パッケージ)で予め決められているもの(名前の変更不可のもの)と、自分で作成したもの(名前の変更可のもの)の区別がつきやすいよう、

後者(自分で作成したもの)の名前の末尾には、大文字のオー「O」(Originalの略のつもりです)

を付けていますので、ご了承ください。

※自分がサンプルコードを参考にする際、元々ライブラリに規定されている名前なのか、自作した名前なのか、が分かりやすいと助かるので、そうしてみました。

 

// データ入出力関連のクラス・メソッド等を利用するために必要。pubspec.yamlへの追記は不要
import 'dart:io';

// Uint8List型を使用するために必要。pubspec.yamlへの追記は不要
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "Test",
      theme: ThemeData.light(),
      home: SampleScreenO(),
    );
  }
}

class SampleScreenO extends StatefulWidget {
  @override
  _SampleScreenOState createState() => _SampleScreenOState();
}

class _SampleScreenOState extends State<SampleScreenO> {

  // 生成したデータを直接保存するときのファイル名
  final String savedFileNameO = "SaveTest.txt";

  // 生成したデータを、いったんアプリ内専用フォルダに保存するときのファイル名
  final String inAppFolderFileNameO = "AppFolderTest.txt";

  // いったん保存したデータを、再保存(バックアップ)するときのファイル名
  final String backUppedFileNameO = "BackUpTest.txt";


  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("ファイル保存テスト"),
      ),
      body: Center(
        child: Column(
          children: <Widget>[
            SizedBox(
              height: 20,
            ),

            // テスト①の実行ボタン
            ElevatedButton(
              onPressed: () => _saveNewFileO(),
              child: Text(
                "テスト①\n新規ファイルを、アプリ専用フォルダ内の\nアクセスできる場所に保存",
                textAlign: TextAlign.center,
              ),
            ),
            SizedBox(
              height: 60,
            ),

            // テスト② Step1の実行ボタン
            ElevatedButton(
              onPressed: () => _makeFileInAppFolderO(),
              child: Text(
                "テスト② Step1\n新規ファイルを、いったんアプリ専用フォルダ内の\nユーザーがアクセスできない場所に保存\n(iOSの場合はアクセスできる場所に保存)",
                textAlign: TextAlign.center,
              ),
            ),
            SizedBox(
              height: 20,
            ),

            // テスト② Step2の実行ボタン
            ElevatedButton(
              onPressed: () => _backUpExistingFileO(),
              child: Text(
                "テスト② Step2\nStep1で保存したファイルを、アプリ専用フォルダ内の\nアクセスできる場所に再保存(バックアップ)",
                textAlign: TextAlign.center,
              ),
            ),
          ],
        ),
      ),
    );
  }


  // テスト① 実行メソッド
  Future<void> _saveNewFileO() async {
    final String savedContentO = '''テキストファイルです。
このファイルを端末内のアプリ専用フォルダに
保存します。''';

    // 保存先のパス名(ファイル名除く)をいったん空文字で設定
    String savedPathO = "";

    // AndroidとiOSでは用いるメソッドが異なるため、OS判定をして分岐

    // Androidの場合
    if (Platform.isAndroid) {

      // アプリ専用フォルダ内のユーザーがアクセス可能なフォルダ情報を取得
      Directory? savedPathDirectoryO = await getExternalStorageDirectory();

      // 上記フォルダ情報(Directory?型)からパス名を取得
      savedPathO = savedPathDirectoryO!.path;
      print("check $savedPathO");

      // iOSの場合
    } else {

      // アプリ専用フォルダ内のフォルダ情報を取得
      final savedDocumentDirectoryO = await getApplicationDocumentsDirectory();

      // 上記フォルダ情報(Directory型)からパス名を取得
      savedPathO = savedDocumentDirectoryO.path;
    }

    // 取得したパス名とファイル名を連結してフルパスを作成
    String savedFullPathO = "$savedPathO/$savedFileNameO";

    try {
      // 上記フルパスにFileクラスのインスタンスを設定
      File savedFileO = File(savedFullPathO);
      print("$savedFullPathO");

      // 上記インスタンスにファイル内容を書き込む(ここで初めてファイルが保存される)
      await savedFileO.writeAsString(savedContentO);
    } catch (e) {
      print(e);
    }
  }

  // テスト② Step1 実行メソッド
  Future<void> _makeFileInAppFolderO() async{
    final String inAppFolderContentO = '''テキストファイルです。
このファイルをいったん端末内のアクセスできない場所
(iOSの場合はアクセスできる場所)に保存し、
後でアプリ専用フォルダに再保存(バックアップ)します。''';

    // アプリ専用フォルダ内のフォルダ情報(ユーザーアクセス不可)を取得
    final inAppFolderDocumentDirectoryO = await getApplicationDocumentsDirectory();

    // 上記フォルダ情報(Directory型)からパス名を取得
    final inAppFolderPathO = inAppFolderDocumentDirectoryO.path;

    // 取得したパス名とファイル名を連結してフルパスを作成
    String inAppFolderFullPathO = "$inAppFolderPathO/$inAppFolderFileNameO";
    print("$inAppFolderFullPathO");

    try {
      // 上記フルパスにFileクラスのインスタンスを設定
      File inAppFolderFileO = File(inAppFolderFullPathO);
      print("$inAppFolderFullPathO");

      // 上記インスタンスにファイル内容を書き込む(ここで初めてファイルが保存される)
      await inAppFolderFileO.writeAsString(inAppFolderContentO);
    } catch (e) {
      print(e);
    }
  }

  // テスト② Step2 実行メソッド
  Future<void> _backUpExistingFileO() async{

    // Step1で保存したファイル(バックアップしたいファイル)のフォルダ情報を取得
    final inAppFolderDocumentDirectoryO = await getApplicationDocumentsDirectory();

    // 上記フォルダ情報(Directory型)からパス名を取得
    final inAppFolderPathO = inAppFolderDocumentDirectoryO.path;

    // Step1で保存したファイル(バックアップしたいファイル)のフルパスを取得
    String inAppFolderFullPathO = "$inAppFolderPathO/$inAppFolderFileNameO";

    // 上記フルパスにFileクラスのインスタンスを設定
    final backUppedFileO = File(inAppFolderFullPathO);

    // 元ファイルがテキスト以外の形式(画像、その他)であっても対応できるよう、
    // 数字の羅列で表現されるバイト型データ(Uint8List型)に変換
    Uint8List convertedBuckUppedFileO = await backUppedFileO.readAsBytes();


    // 再保存先(バックアップ先)のパス名(ファイル名除く)をいったん空文字で設定
    String backUppedPathO = "";

    // 以降はテスト①と同じロジック

    if (Platform.isAndroid) {
      Directory? backUppedPathDirectoryO = await getExternalStorageDirectory();
      backUppedPathO = backUppedPathDirectoryO!.path;
      print("check $backUppedPathO");
    } else {
      final backUppedDocumentDirectoryO = await getApplicationDocumentsDirectory();
      backUppedPathO = backUppedDocumentDirectoryO.path;
    }

    String backUppedFullPathO = "$backUppedPathO/$backUppedFileNameO";

    try {
      File backUppedFileO = File(backUppedFullPathO);
      print("$backUppedFullPathO");

      // 上記インスタンスにファイル内容を書き込む(バイト型データとして保存)
      await backUppedFileO.writeAsBytes(convertedBuckUppedFileO);
    } catch (e) {
      print(e);
    }
  }
}

 

方法(2)「external_path」と「permission_handler」を追加する場合の実装例

パッケージのインポート(null safety対応注意)

以下の通り、「pubspec.yaml」ファイルのdependenciesの所に追記して、「pub get」します。

 

dependencies:
  path_provider: ^2.0.2
  external_path: ^1.0.1

 

(2022/7/3修正)

前述した「ext_storage」というパッケージは、現時点でも動作はしますが、ビルドする度に、

 

The plugin ext_storage uses a deprecated version of the Android embedding.
To avoid unexpected runtime failures, or future build failures, try to see if this plugin supports the Android V2 embedding. Otherwise, consider removing it since a future release of Flutter will remove these deprecated APIs.

 

のような警告が出るので、使用しないほうが無難かと思います。

 

一応ご参考までに、以前の説明内容を残しておきます。

 


(以下は、「ext_storage」を用いる場合の参考)

 

 

dependencies:
  path_provider: ^2.0.2
  ext_storage:
    git:
      url: git://github.com/GitJournal/ext_storage.git
  permission_handler: ^8.1.4+2

 

(2022/3/27追記)

「git://github.com/GitJournal/ext_storage.git」の部分は、後述の注意点をご参照ください。

  

【注意】

「ext_storage」は、本記事の作成時点では、最終更新が2020年4月のため、null safety対応されていません。

 

そのため、普通にバージョン番号を指定して「pub get」すると、null safety対応後のFlutterでは、エラーになってしまいます。

 

困って調べたところ、なんとnull safety対応版を作成し、GitJournalで公開してくれている方がいらっしゃいました。

 

 

こちらの記事に従い、

 

  ext_storage:
    git:
      url: git://github.com/GitJournal/ext_storage.git

 

のような書き方をすることで、無事、「pub get」することができました。大変ありがたいです!

 

(2022/3/27追記)

Flutter2.10.3にアップグレードすると、「pub get」した際に、

 

Resolving dependencies…
Git error. Command: git fetch
stdout:
stderr: fatal: remote error:
  The unauthenticated git protocol on port 9418 is no longer supported.
Please see https://github.blog/2021-09-01-improving-git-protocol-security-github/ for more information.
exit code: 128
pub finished with exit code 69

 

というエラーが出る可能性があります(自分の場合、出るプロジェクトと、出ないプロジェクトがありました)。

 

こちらにイシューが上がっており、

 

 

この情報を参考に、「url: git://github.com/GitJournal/ext_storage.git」の部分を、

 

url: https://github.com/GitJournal/ext_storage

 

に修正(httpsに変え、.gitをトル)して「pub get」したら解消しましたので、同じ状況に遭遇したらお試しください。

 

(「ext_storage」を用いる場合の参考 ここまで)


 

「AndroidManifest.xml」への設定追記(Android用)

まず、「permission_handler」を起動させるために、manifestタグ(<manifest ・・・>)の次に、以下のようなuses-permissionの1文を追記します。

 

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.xxxx_app">

    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />     <!--  ←追記した部分  -->

 

※これを追記しないで実行すると、以下のようなエラーが出て、保存できませんでした。

 

D/permissions_handler(22531): No permissions found in manifest for: []15

 

次に、「external_path」が持つメソッドを機能させるために、applicationタグ(<application ・・・>)の中に、「requestLegacyExternalStorage」に関する1文を、以下のように追記します。

 

   <application
        android:label="xxxx_app"
        android:icon="@mipmap/ic_launcher"
        android:requestLegacyExternalStorage="true"> <!--  ←追記した部分  -->

 

※これを追記しないで実行すると、今度は以下のようなエラーが出て、保存できませんでした。

 

FileSystemException: Cannot open file, path = '/storage/emulated/0/Download/SaveTest.txt' (OS Error: Permission denied, errno = 13)

 

「info.plist」への設定追記(iOS用)

iOSの対応は、方法(1)と全く同じなので、以下の4行を追記します。

 

<key>UISupportsDocumentBrowser</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>

 

作成したコードのアウトプットイメージ

以下2つのケースをテストできるコードを作成しました。

 

  • テスト③(③のボタンを押す)
    • アプリ内で生成したデータを、アプリのダウンロードフォルダに直接保存するケース(iOSはアプリ専用フォルダに保存)
  • テスト④(④Step1のボタン→④Step2のボタンを押す)
    • アプリ内で生成したデータを、いったんユーザーがアクセスできないアプリ専用フォルダ内に保存し、それをアプリのダウンロードフォルダに再保存(バックアップ)するケース(iOSはアプリ専用フォルダに再保存)

  

テスト④が、ファイルのバックアップ機能に相当するイメージです。

 

Androidの例 (テスト③→テスト④の順に実行した例)※iOSは方法(1)と変わらないため省略

 

方法(1)に比べ、ユーザーが簡単にアクセスできるダウンロードフォルダに保存されるため、ユーザビリティは高いです。

 

作成したコードの全体像

こちらも詳細な説明は、コード中にコメントで記載しました。

 

【再掲】

クラス名、メソッド名、プロパティ名(変数名)は、ライブラリ(パッケージ)で予め決められているもの(名前の変更不可のもの)と、自分で作成したもの(名前の変更可のもの)の区別がつきやすいよう、

後者(自分で作成したもの)の名前の末尾には、大文字のオー「O」(Originalの略のつもりです)

を付けていますので、ご了承ください。

※自分がサンプルコードを参考にする際、元々ライブラリに規定されている名前なのか、自作した名前なのか、が分かりやすいと助かるので、そうしてみました。

 

// データ入出力関連のクラス・メソッド等を利用するために必要。pubspec.yamlへの追記は不要
import 'dart:io';

// Uint8List型を使用するために必要。pubspec.yamlへの追記は不要
import 'dart:typed_data';

// import 'package:ext_storage/ext_storage.dart'; // 警告が出るため、下記 external_path の使用に変更
import 'package:external_path/external_path.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';

import 'package:permission_handler/permission_handler.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "Test",
      theme: ThemeData.light(),
      home: SampleScreenO(),
    );
  }
}

class SampleScreenO extends StatefulWidget {
  @override
  _SampleScreenOState createState() => _SampleScreenOState();
}

class _SampleScreenOState extends State<SampleScreenO> {

  // 生成したデータを直接保存するときのファイル名
  final String savedFileNameO = "SaveTest.txt";

  // 生成したデータを、いったんアプリ内専用フォルダに保存するときのファイル名
  final String inAppFolderFileNameO = "AppFolderTest.txt";

  // いったん保存したデータを、再保存(バックアップ)するときのファイル名
  final String backUppedFileNameO = "BackUpTest.txt";


  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("ファイル保存テスト"),
      ),
      body: Center(
        child: Column(
          children: <Widget>[
            SizedBox(
              height: 20,
            ),

            // テスト③の実行ボタン
            ElevatedButton(
              onPressed: () => _saveNewFileO(),
              child: Text(
                "テスト③\n新規ファイルを、\nダウンロードフォルダに保存",
                textAlign: TextAlign.center,
              ),
            ),
            SizedBox(
              height: 60,
            ),

            // テスト④ Step1の実行ボタン
            ElevatedButton(
              onPressed: () => _makeFileInAppFolderO(),
              child: Text(
                "テスト④ Step1\n新規ファイルを、いったんアプリ専用フォルダ内の\nユーザーがアクセスできない場所に保存\n(iOSの場合はアクセスできる場所に保存)",
                textAlign: TextAlign.center,
              ),
            ),
            SizedBox(
              height: 20,
            ),

            // テスト④ Step2の実行ボタン
            ElevatedButton(
              onPressed: () => _backUpExistingFileO(),
              child: Text(
                "テスト④ Step2\nStep1で保存したファイルを\nダウンロードフォルダ\n(iOSの場合はアプリ専用フォルダ)に\n再保存(バックアップ)",
                textAlign: TextAlign.center,
              ),
            ),
          ],
        ),
      ),
    );
  }

  // テスト③ 実行メソッド
  Future<void> _saveNewFileO() async {
    final String savedContentO = '''テキストファイルです。
このファイルを端末内のダウンロードフォルダ
(iOSの場合はアプリ専用フォルダ)に保存します。''';

    // 端末内ストレージへのアクセスについて、ユーザー同意を得る画面を表示
    // ※テスト④を先にやって同意済の場合は表示されない
    await [Permission.storage].request();

    // 保存先のパス名(ファイル名除く)をいったん空文字で設定
    String savedPathO = "";

    // AndroidとiOSでは用いるメソッドが異なるため、OS判定をして分岐

    // Androidの場合
    if (Platform.isAndroid) {

      // ダウンロードフォルダのパスを取得
      savedPathO = await ExternalPath.getExternalStoragePublicDirectory(
          ExternalPath.DIRECTORY_DOWNLOADS);

      // 「ext_storage」を使う場合は以下の書き方になる(ただし、使用しない方が無難)
      // savedPathO = (await ExtStorage.getExternalStoragePublicDirectory(
         //  ExtStorage.DIRECTORY_DOWNLOADS))!;

      // iOSの場合
    } else {

      // アプリ専用フォルダ内のフォルダ情報を取得
      final savedDocumentDirectoryO = await getApplicationDocumentsDirectory();

      // 上記フォルダ情報(Directory型)からパス名を取得
      savedPathO = savedDocumentDirectoryO.path;
    }

    // 取得したパス名とファイル名を連結してフルパスを作成
    String savedFullPathO = "$savedPathO/$savedFileNameO";

    try {
      // 上記フルパスにFileクラスのインスタンスを設定
      File savedFileO = File(savedFullPathO);
      print("$savedFullPathO");

      // 上記インスタンスにファイル内容を書き込む(ここで初めてファイルが保存される)
      await savedFileO.writeAsString(savedContentO);
    } catch (e) {
      print(e);
    }
  }

  // テスト④ Step1 実行メソッド
  Future<void> _makeFileInAppFolderO() async{
    final String inAppFolderContentO = '''テキストファイルです。
このファイルをいったん端末内のアクセスできない場所
(iOSの場合はアクセスできる場所)に保存し、
後でダウンロードフォルダ(iOSの場合はアプリ専用フォルダ)
に再保存(バックアップ)します。''';

    // アプリ専用フォルダ内のフォルダ情報(ユーザーアクセス不可)を取得
    final inAppFolderDocumentDirectoryO = await getApplicationDocumentsDirectory();

    // 上記フォルダ情報(Directory型)からパス名を取得
    final inAppFolderPathO = inAppFolderDocumentDirectoryO.path;

    // 取得したパス名とファイル名を連結してフルパスを作成
    String inAppFolderFullPathO = "$inAppFolderPathO/$inAppFolderFileNameO";
    print("$inAppFolderFullPathO");

    try {
      // 上記フルパスにFileクラスのインスタンスを設定
      File inAppFolderFileO = File(inAppFolderFullPathO);
      print("$inAppFolderFullPathO");

      // 上記インスタンスにファイル内容を書き込む(ここで初めてファイルが保存される)
      await inAppFolderFileO.writeAsString(inAppFolderContentO);
    } catch (e) {
      print(e);
    }
  }

  // テスト④ Step2 実行メソッド
  Future<void> _backUpExistingFileO() async{

    // 端末内ストレージへのアクセスについて、ユーザー同意を得る画面を表示
    // ※テスト③を先にやって同意済の場合は表示されない
    await [Permission.storage].request();

    // Step1で保存したファイル(バックアップしたいファイル)のフォルダ情報を取得
    final inAppFolderDocumentDirectoryO = await getApplicationDocumentsDirectory();

    // 上記フォルダ情報(Directory型)からパス名を取得
    final inAppFolderPathO = inAppFolderDocumentDirectoryO.path;

    // Step1で保存したファイル(バックアップしたいファイル)のフルパスを取得
    String inAppFolderFullPathO = "$inAppFolderPathO/$inAppFolderFileNameO";

    // 上記フルパスにFileクラスのインスタンスを設定
    final backUppedFileO = File(inAppFolderFullPathO);

    // 元ファイルがテキスト以外の形式(画像、その他)であっても対応できるよう、
    // 数字の羅列で表現されるバイト型データ(Uint8List型)に変換
    Uint8List convertedBuckUppedFileO = await backUppedFileO.readAsBytes();

    // 再保存先(バックアップ先)のパス名(ファイル名除く)をいったん空文字で設定
    String backUppedPathO = "";

    // 以降は基本、テスト③と同じロジック

    if (Platform.isAndroid) {
      backUppedPathO = await ExternalPath.getExternalStoragePublicDirectory(
          ExternalPath.DIRECTORY_DOWNLOADS);

      // 「ext_storage」を使う場合は以下の書き方になる(ただし、使用しない方が無難)
      // backUppedPathO = (await ExtStorage.getExternalStoragePublicDirectory(
         // ExtStorage.DIRECTORY_DOWNLOADS))!;
    } else {
      final backUppedDocumentDirectoryO = await getApplicationDocumentsDirectory();
      backUppedPathO = backUppedDocumentDirectoryO.path;
    }

    String backUppedFullPathO = "$backUppedPathO/$backUppedFileNameO";

    try {
      File backUppedFileO = File(backUppedFullPathO);
      print("$backUppedFullPathO");

      // 上記インスタンスにファイル内容を書き込む(バイト型データとして保存)
      await backUppedFileO.writeAsBytes(convertedBuckUppedFileO);
    } catch (e) {
      print(e);
    }
  }
}

 


以上になります。

 

null safety対応の問題や、「AndroidManifest.xml」への追記方法が諸説あったりと、自分なりに理解するのにかなり苦労しました。。

 

iOSは選択の余地がないですが、Androidについては、個人的には実装の手間はあるものの、ユーザー目線を考えると、方法(2):端末直下のダウンロードフォルダに保存できる方法(「path_provider」、「external_path」、「permission_handler」を用いる方法)が良いと思っています。

 

以上、ご参考になれば幸いです。

 

なお、外部ストレージからのインポート機能の実装方法について、下記記事にまとめましたので、よろしければこちらもご参考ください。

 

 

最後までお読みいただき、ありがとうございました。

 

\ Flutterの学習で役立ったコンテンツ・書籍 /

The Complete 2021 Flutter Development Bootcamp with Dart

 

 


Dart入門 – Dartの要点をつかむためのクイックツアー

コメント

タイトルとURLをコピーしました