Flutterアプリで、Google Driveにデータをバックアップ(保存)したり、Google Driveからデータをインポート/リストア(読込)したりする機能の実装方法が知りたい。
という方向けの記事になります。
Flutterでユーザーデータをクラウドに保存する際、Firestoreを使うと便利ですが、アプリ運営者がユーザーデータを見れてしまうので、個人開発者としては情報管理の荷が重い印象です。
一方、Google Driveは、ユーザー個人が保有・管理するストレージのため、アプリ運営者からは見えず、管理の難易度が低いです。
また、Googleアカウントは多くのユーザーが保有しており、Google Driveは15Gまで無料で使用できるため、個人開発アプリのバックアップ先としては、リーズナブルな方法だと考えました。
しかし、いざ実装しようとすると、Flutter(Dart)による Google Drive(Google Drive API)の実装方法については、日本語情報が少なく苦労しました。。
そのため、自分の経験をもとに、FlutterからGoogle Driveにアクセスするサンプルプロジェクトを作成し、その具体的な実装方法や注意点を共有します。
サンプルプロジェクト実装の大まかな流れは以下のとおりです。
- FirebaseでFirebaseプロジェクトを作成し、Google サインインを有効化
- Google Cloud Platform(以下、GCP)でAPIのスコープ設定と同意画面を設定
- Flutterプロジェクトに必要なパッケージを導入
- Flutterのコードを書く(Google サインイン、Google Driveへの保存・読込処理)
サンプルコードのみをご覧になりたい方は、こちらをご覧ください。
また、データを端末内(ローカル内)にバックアップする方法については、下記記事に整理しましたので、よろしければご参考ください。
40代からプログラミング(Flutter)を始めて、GooglePlayとAppStoreにアプリを公開しているhalzo appdevです。
作成したアプリはこちら↓ 全てFlutterで開発したアプリです。
- 参考になる情報ソース
- 実装にあたり検討すべき選択肢
- サンプルプロジェクトの概略
- サンプルプロジェクトの実装手順
- 実装手順① Firebaseプロジェクトの作成
- 実装手順② Google Cloud Platform(GCP)での同意画面設定
- 実装手順③ パッケージの導入
- 実装手順④ コードの記述
- サンプルコードの全体像
- Google APIで使用するスコープを指定する
- Google サインインの画面をキャンセルされたときの処理を入れる
- iOSの挙動に備えて、signInSilentlyメソッドを併用する
- サインイン済でもエラーとなる場合は、認証情報を初期化する
- ステップ② バックアップ対象を「.txt」に限定
- ステップ②③ HTTPクライアントのインスタンスを作成
- ステップ② 「create」メソッドでGoogle Driveに保存
- ステップ③ Google Drive内のファイル情報リストを「list」メソッドで取得
- ステップ④ 「get」メソッドでデータをダウンロード
- ステップ④ 読込み完了数を計測するカウンタ変数を別途用意する
- ステップ⑤ forループで「delete」メソッドを回す
- ステップ⑥ 「signOut」メソッドでサインアウト処理
- その他補足
参考になる情報ソース
Google Driveへのアクセスを実装するには、まず、Googleが提供するAPIへのアクセス方法(OAuthと呼ばれる認証方式によるアクセス方法)を理解する必要がありますが、Flutterを前提としたGoogle公式の情報ソースを見つけるのにとても苦労しました。
Google Drive APIの公式サイトは以下ですが、主に、JavaScript、Java、Node.js、Pythonのコード例しかなく、Flutter(Dart)のコード例はありません。
色々探した結果、最終的に、以下3つが有力な情報ソースになりました。
①Flutter公式
これが一番参考になります。
Flutter公式のため、ネイティブアプリを前提とした内容になっています。Flutter公式サイト内のリンクからは辿れないページのため、直接ググってたどり着きました。。
②Google Developer公式
上記ページの中の「Dart」のリンクからGitHubのページに行くと、Dartで書かれたサンプルコードを見ることができます。
但し、基本的にWebアプリを想定した内容になっている点に注意が必要です。
③各パッケージの公式説明
コードを書く際に導入する下記パッケージのReadMe、Sample、ソースコード内のコメントなどが参考になります。
特に、Google Driveのメソッドは、「googleapis(googleapis/drive/v3.dart)」のソースコード内を探索すると、説明が詳しく書かれているので、情報を見つけやすいです。
また、Google Driveに対する具体的な処理を記述したコードを紹介くださっている貴重な例としては、下記の記事が大変参考になりました。ありがとうございます!
実装にあたり検討すべき選択肢
サインインの実装方法
アプリからGoogle Driveにアクセスする際、Googleアカウントによるサインイン処理を実装する必要があります。
方法としては、「google_sign_in」パッケージのみを使う方法と、「firebase_auth」パッケージも併用して、サインインしたユーザー情報をFirebase内に記録する方法とがあります。
一般的には、Firestoreとの連携などを想定して、後者の方法が紹介されていることが多いかと思います。
しかし、サインインしたユーザーのメールアドレスがFirebase上に記録されるため、個人情報の管理が発生してしまいます。
個人的には、できるだけ個人情報を取得せずに、情報管理の難易度を下げたいことと、バックアップ目的であれば、ユーザー情報を把握する必要はないことから、後述するサンプルコードでは、「google_sign_in」パッケージのみを用いた方法を採用しました。
なお、「firebase_auth」を併用する際のコードは、参考としてコメントアウトで記載しました。
ユーザー同意の取得方法
サインイン後は、同意取得画面(OAuth同意画面と呼ばれる。以下、同意画面)を表示して、Google Driveへのアクセスについて、ユーザーから同意を得る必要があります。
同意画面の表示には、アプリ上のポップアップで行う方法と、Webブラウザに飛ばしてブラウザ上で行う方法とがあります。
- 前者が、Flutter公式で紹介されている方法(「extension_google_sign_in_as_googleapis_auth」パッケージを用いる方法)
- 後者が、Google Developer公式で紹介されている方法(「googleapi_auth」パッケージを用いる方法)
になります。
開発者にとっては、後者の方が実装の仕組みが分かりやすいのですが、ユーザーにとってはアプリとブラウザの切り替えが生じるので、利便性が悪いです。
そのため、本記事では前者の方法(アプリ上のポップアップで行う方法)を採用しました。
サンプルプロジェクトの概略
具体的な実装方法を示すために、今回作成したサンプルプロジェクトの概略をご説明します。
画面イメージは下記に掲載しますが、以下の流れでGoogle Driveとの間でのバックアップ・インポートをテストできる仕様になっています。
各ステップに対応したボタンをタップすると、下記内容が実行されます。
- ステップ①
- Google Driveにバックアップするためのテキストファイル(「テスト用のテキストファイルです。」と記載)を、端末内のアプリ専用フォルダ内に生成・保存します。
- 複数ファイルをバックアップするテストをしたい場合は、ステップ①のボタンを複数回押します。※ファイル名に保存日時を入れることで、名前が重複しないようにしています。
- ステップ②
- ステップ①で、端末内に保存したファイル一式を、Google Driveのアプリ専用領域にアップロード(バックアップ)します。
- 保存する前に、Google アカウントへのサインインと、Google Driveへの利用に関する同意取得処理を行います(本処理は、②〜⑤のいずれかを最初に実行した時のみ行われます)。
- ステップ①で、端末内に保存したファイル一式を、Google Driveのアプリ専用領域にアップロード(バックアップ)します。
- ステップ③
- ステップ②で、Google Driveにバックアップしたファイル情報を取得し、ログにprint出力します。
- ステップ④
- Google Driveにバックアップされたファイル一式を、端末のアプリ専用フォルダ内にインポート(読み込み)します。
- ステップ②のバックアップ時に別名保存しているので、ステップ①で保存したファイルとは別のファイルとして保存されます。
- Google Driveにバックアップされたファイル一式を、端末のアプリ専用フォルダ内にインポート(読み込み)します。
- ステップ⑤
- Google Driveのアプリ専用領域にあるファイルを全て削除します。
- ステップ⑥
- Google アカウントからサインアウトします。
ステップ②で、Google Drive内のユーザーが見える場所(マイドライブ直下)に保存させる実装も可能ですが、個人的には、以下の理由からできれば避けた方が良いと考えています。
- 機密性の高いスコープに該当するため、GoogleからのOAuth検証の事前承認が必要になる(後述)
- 関係ないユーザーデータをアプリ側から誤って削除してしまう恐れがある
- バックアップしたファイルをユーザーが直接触れるため、アプリ側からの保存状態の制御が難しい
サンプルプロジェクトの実装手順
実装手順① Firebaseプロジェクトの作成
まず、Firebaseプロジェクトを作成し、AndroidアプリとiOSアプリの設定をしておきます。
具体的な作成・設定方法は、下記記事に整理しましたので、こちらをご参考にしてください。
なお、Google サインインの実装を、「google_sign_in」パッケージのみで実装する場合であっても、Firebase Authentication上で、Google サインインを有効化する処理は必要になります。
そのため、上記参考記事の「Firebase Authenticationの設定手順」まで(「Google Playリリース後の設定」の手前まで)は完了させてください。
同時に、Google Cloud Platform(GCP)のプロジェクトも作成されることになります。
実装手順② Google Cloud Platform(GCP)での同意画面設定
Firebaseプロジェクトを作成したら、Google Cloud Platform(GCP)で同意画面の設定をします。
まず、GCPの画面に入り、Firebaseで作成したプロジェクト名を選択したら、Google DriveのAPIを有効化します。
次に、下図の手順で同意画面等の設定(アプリ登録の編集)をします。
以上は、機密性の高いスコープを使用しないことを前提とした場合の手順です(Google Driveのアプリ専用領域のみを使用するケース)。
もし、Google Drive内のユーザーが見える場所(マイドライブ直下)に保存したい場合は、機密性の高いスコープ「.../auth/drive」を指定する必要があり、その場合は、GoogleからのOAuth検証の事前承認が必要になります。
具体的な承認手順や注意点は、下記記事に整理していますので、対応される方はご参考にしてください(結構大変です。。。)。
実装手順③ パッケージの導入
Flutterプロジェクトの「pubspec.yaml」ファイルに、以下のパッケージを記載し、「pub get」します。
- Google Drive利用のために必要なパッケージ
- google_sign_in
- googleapis
- extension_google_sign_in_as_googleapis_auth
- firebase_core ※Firebaseにユーザー情報を記録する場合のみ
- firebase_auth ※Firebaseにユーザー情報を記録する場合のみ
- 端末内保存をするために必要なパッケージ
- 日時情報を扱うのに必要なパッケージ
サンプルコードでは、上記以外にも「dart:io」、「material」、「path」を冒頭でインポートしていますが、いずれもFlutterに標準で入っているため、「pubspec.yaml」への記載は不要です。
実装手順④ コードの記述
サンプルコードの全体像
以下に、今回作成したサンプルコードの全内容を掲載します。
コードを見ただけでも分かるよう、できるだけコード内にコメントで説明をつけました。
// クラス名、メソッド名、プロパティ名(変数名)について、筆者が作成したもの(名前変更可のもの) // の名前の末尾には、大文字のオー「O」をつけています // ※ライブラリ(パッケージ)で予め決められているもの(名前の変更不可のもの)と、 // 自分で作成したもの(名前の変更可のもの)の区別をしやすくするため import 'dart:io'; import 'package:flutter/material.dart'; import 'package:google_sign_in/google_sign_in.dart' as signInO; import 'package:googleapis/drive/v3.dart' as driveO; import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart'; import 'package:path/path.dart'; // // HTTPクライアントを自作する場合に必要 ※pubspec.yamlに追記する必要なし // import 'package:http/http.dart' as httpO; // // firebase上にサインインしたユーザー情報を記録する場合は以下をインポート ※pubspec.yamlに追記が必要 // import 'package:firebase_core/firebase_core.dart'; // import 'package:firebase_auth/firebase_auth.dart'; void main() => runApp(MyApp()); // // firebase上にサインインのユーザー情報を記録する場合は、 // // firebaseの初期化処理が必要になるため、上の1文を下記に書き換える // // ※firebase_coreのインポートが必要 // Future<void> main() async{ // // // main関数内で非同期処理をするときはこれが必要 // WidgetsFlutterBinding.ensureInitialized(); // // // Firebaseの初期化処理 // await Firebase.initializeApp(); // // runApp(MyApp()); // } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: "Google Drive Test", theme: ThemeData.light(), home: SampleScreenO(), ); } } class SampleScreenO extends StatefulWidget { @override _SampleScreenOState createState() => _SampleScreenOState(); } class _SampleScreenOState extends State<SampleScreenO> { // 端末内に保存する新規ファイルの名前 // この時点ではダミーで空文字を設定 String savedFileNameO = ""; // 以下のプロパティは、メソッド間で共有したいのでクラスのトップで定義 // Google SignIn認証のためのインスタンス // ※google_sign_inパッケージのインポート文に、as signInOを付けているので、 // google_sign_inのクラスには「signInO.」を付けて表記 late signInO.GoogleSignIn googleSignInO; // GoogleSignInAccount?(ユーザー情報を保持するクラス)のインスタンス // サインインをキャンセルしたときはnullになりうるので、null許容「?」で定義する signInO.GoogleSignInAccount? accountO; // Google認証情報を入れるプロパティ // 本来はAuthClient型で宣言するプロパティだが、AuthClientを明示すると、googleapis_authパッケージの // インポートが必要になるので、varで宣言してインポートを回避する // ※googleapis_authパッケージは、extension_google_sign_in_as_googleapis_authパッケージ内で // インポート済のため、本コード内でインポートしなくても動作する late var httpClientO; // Google Drive APIのインスタンス late driveO.DriveApi googleDriveApiO; // Google Drive内のファイルのリスト情報を格納するためのプロパティ // ※googleapisパッケージのインポート文に、as driveOを付けているので、 // googleapisのクラスには「driveO.」を付けて表記 late driveO.FileList listO; // Google SignInの状態を示す文字列 ※起動時は空文字 String signInStatusO = ""; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: FittedBox( fit: BoxFit.scaleDown, child: Text("Google Drive バックアップ・インポートテスト"), ), ), body: Center( child: Column( children: <Widget>[ SizedBox( height: 20, ), ElevatedButton( onPressed: () => _saveNewFileO(), child: Text( "①テキストファイルを作成し、端末内に保存", textAlign: TextAlign.center, ), ), SizedBox( height: 30, ), ElevatedButton( onPressed: () => _backUpToGoogleDriveO(), child: Text( "②Googleドライブに全ファイルをバックアップ", textAlign: TextAlign.center, ), ), SizedBox( height: 30, ), ElevatedButton( onPressed: () => _getListFromGoogleDriveO(), child: Text( "③Googleドライブ内の全ファイル情報を取得", textAlign: TextAlign.center, ), ), SizedBox( height: 30, ), ElevatedButton( onPressed: () => _importFromGoogleDriveO(), child: Text( "④Googleドライブから全ファイルをインポート", textAlign: TextAlign.center, ), ), SizedBox( height: 30, ), ElevatedButton( onPressed: () => _deleteGoogleDriveFilesO(), child: Text( "⑤Googleドライブ内の全ファイルを削除", textAlign: TextAlign.center, ), ), SizedBox( height: 30, ), ElevatedButton( onPressed: () => _signOutFromGoogleO(), child: Text( "⑥サインアウト", textAlign: TextAlign.center, ), ),SizedBox( height: 30, ), Text(signInStatusO), ], ), ), ); } /// Google SignIn処理をするメソッド Future<bool> _googleSignInMethodO() async{ // Google SignIn認証のためのインスタンスを作成 googleSignInO = signInO.GoogleSignIn( scopes: [ // Google APIで使用したいスコープを指定 // ※ここではGoogle Drive内のアプリ専用領域へのアクセス権を要求 driveO.DriveApi.driveAppdataScope, // // Google Drive内のユーザーが見える場所に保存したい場合は以下に変更する // // ※但し、GoogleによるOAuth検証の事前承認が必要 // driveO.DriveApi.driveScope, ]); // サインイン画面や同意画面のポップアップをキャンセルした場合のエラーを回避するため、try catchを設定 // 但し、デバッグモードでは止まってしまうので、キャンセル時の挙動を確かめるには、 // releaseモードで実行する必要あり try { // サインイン済か否か確認 final checkSignInResultO = await googleSignInO.isSignedIn(); print("サインインしているか否か $checkSignInResultO"); // サインイン済の場合は、サインインのポップアップを出さずにサインイン処理 // ※iOSの場合はsignInSilentlyにしないと、毎回サインインのポップアップが出てしまうため if (checkSignInResultO) { accountO = await googleSignInO.signInSilently(); // サインイン済にもかかわらず返り値がnullの場合、 // ユーザーがGoogleアカウントの設定画面で接続解除をした可能性があるので、 // disconnectメソッドで完全サインアウトし、認証情報を初期化する if (accountO == null) { print("認証情報を初期化する必要が生じたため、もう一度ボタンを押してください。"); await googleSignInO.disconnect(); // 例外処理を投げて、下方のcatchに飛ばす throw Exception(); } } else { // サインインしていない場合は、ポップアップを出してサインイン処理 accountO = await googleSignInO.signIn(); // 返り値がnullだったら、サインインもしくは同意処理がキャンセルされたと判断し、 // 例外処理を投げて、下方のcatchに飛ばす if (accountO == null) { print("キャンセル"); throw Exception(); } } // // firebase上にサインインしたユーザー情報を記録する場合は以下を追加 // // ※firebase_auth、firebase_coreのインポートが必要 // // signInO.GoogleSignInAuthentication authO = // await accountO!.authentication; // // final OAuthCredential credentialO = // GoogleAuthProvider.credential( // idToken: authO.idToken, // accessToken: authO.accessToken, // ); // // // このユーザーデータ(userO)を必要に応じて使用する // User? userO = // (await FirebaseAuth.instance.signInWithCredential(credentialO)).user; // // // 使用する一例として、Firebase上で管理される「ユーザーUID」をログに表示 // print("ユーザーUID: ${userO!.uid}"); // サインイン表示に変更し、再描画 setState(() { signInStatusO = "サインイン中"; }); // 返り値trueを返す return true; } catch (e) { // サインアウト表示に変更し、再描画 setState(() { signInStatusO = "サインアウト中"; }); // 返り値falseを返す print("サインインできず $e"); return false; } } /// ステップ① Future<void> _saveNewFileO() async { final String savedContentO = "テスト用のテキストファイルです。"; // 保存先のパス名(ファイル名除く)をいったん空文字で設定 String savedPathO = ""; // テスト用ファイルを保存する端末内のアプリ専用フォルダのパスを取得 final savedDocumentDirectoryO = await getApplicationDocumentsDirectory(); savedPathO = savedDocumentDirectoryO.path; // 保存するファイル名を作成(ユニーク化するため、保存時点の日時を名称に入れる) savedFileNameO = "SaveTest_${(DateFormat("yyyyMMddHHmmss")).format(DateTime.now()).toString()}.txt"; String savedFullPathO = join(savedPathO,savedFileNameO); try { // 上記フルパスを用いてFileクラスのインスタンスを作成 File savedFileO = File(savedFullPathO); // 上記インスタンスにファイル内容を書き込む(テストファイルの端末内保存を完了) await savedFileO.writeAsString(savedContentO); } catch (e) { print(e); } } /// ステップ② Future<void> _backUpToGoogleDriveO() async{ /// /////////////////////////////////////// /// 端末のアプリ専用フォルダ内に保存したバックアップ対象ファイル(テキストファイル)のチェック // 保存したテキストファイルのディレクトリ情報を取得 final savedFileDirectoryO = await getApplicationDocumentsDirectory(); // ディレクトリ内にある全てのファイル情報を取得 List<FileSystemEntity> fileEntityO = Directory(savedFileDirectoryO.path).listSync(); // バックアップ対象であるテキストファイル以外をリストから削除 // ※拡張子「.txt」で終わるファイル以外を削除 fileEntityO.removeWhere((elementO) => !elementO.path.endsWith(".txt")); // 確認用にパスをログに表示 fileEntityO.forEach((elementO) {print(elementO.path);}); // バックアップ対象のファイルが存在しない場合は、何もせず早期リターン if (fileEntityO.length == 0) { print("バックアップ対象のファイル無し"); return; } /// /////////////////////////////////////// /// Google SignInの処理 final signInResultO = await _googleSignInMethodO(); if (!signInResultO) { // サインインできなかった場合は、早期リターン return; } /// /////////////////////////////////////// /// Google Driveへのアップロード(バックアップ)処理 // Googleサインインで認証済のHTTPクライアントのインスタンスを作成 // https://docs.flutter.dev/development/data-and-backend/google-apis // を参考に、extension_google_sign_in_as_googleapis_authパッケージのメソッドを使用 httpClientO = (await googleSignInO.authenticatedClient())!; // // httpパッケージで自作する場合は、上記1文をコメントアウトし、下記に変更する // // 認証情報を含むヘッダを作成 // final authHeadersO = await accountO!.authHeaders; // // HTTPクライアントのインスタンスを作成 // httpClientO = GoogleAuthClientO(authHeadersO); // Google Drive APIのインスタンスを作成 googleDriveApiO = driveO.DriveApi(httpClientO); // Google Drive APIのFile型のインスタンスを作成 final uploadedFileO = driveO.File(); // Google Drive内のアプリ専用領域に保存する事を指定 uploadedFileO.parents = ["appDataFolder"]; // ※スコープを「driveO.DriveApi.driveScope」に設定し、ユーザーが見える場所に // ファイルを保存する場合は、上記1文を削除する // 端末のアプリ専用フォルダ内のファイルを、forループで順次Google Driveに保存 for (int iO = 0; iO < fileEntityO.length; iO++) { // 端末のアプリ専用フォルダ内のiO番目のファイルをFile型で取得 final File fileO = File(fileEntityO[iO].path); // Google Driveに保存する際のファイル名を設定 // ※端末内のファイル名と区別するため、頭に「googleDrive_」をつける uploadedFileO.name = "googleDrive_${basename(fileEntityO[iO].path)}"; // Media型に変換した上で、createメソッドでGoogle Driveに保存 await googleDriveApiO.files.create( uploadedFileO, uploadMedia: driveO.Media(fileO.openRead(), fileO.lengthSync()), ); print("${iO+1}番目のファイルを保存"); } print("Google Driveに全ファイルのバックアップ完了"); } /// ステップ③ Future<void> _getListFromGoogleDriveO() async{ /// /////////////////////////////////////// /// Google SignInの処理 final signInResultO = await _googleSignInMethodO(); if (!signInResultO) { // サインインできなかった場合は、早期リターン return; } /// /////////////////////////////////////// /// Google Drive内のファイル情報の取得 // HTTPクライアントのインスタンスを作成 // ②〜⑤のボタンのうち、どれが最初に押されるか分からないので、各場所で // Google Drive APIのインスタンスを作成しておく httpClientO = (await googleSignInO.authenticatedClient())!; // // httpパッケージで自作する場合は、上の1文をコメントアウトし、下記に変更する // // 認証情報を含むヘッダを作成 // final authHeadersO = await accountO!.authHeaders; // // HTTPクライアントのインスタンスを作成 // httpClientO = GoogleAuthClientO(authHeadersO); googleDriveApiO = driveO.DriveApi(httpClientO); // Google Driveのアプリ専用領域(appDataFolder)からファイル情報を取得 // listメソッドに、$fields:の引数として、取得したい情報を指定(ここではid、ファイル名、作成日時を指定) // ※取得できる情報は、v3.dartのFileクラスにあるプロパティまたは以下で確認可能 // https://developers.google.com/drive/api/v3/reference/files await googleDriveApiO.files .list(spaces: 'appDataFolder', $fields: 'files(id, name, createdTime)') .then((value) { // ファイルのリスト情報(FileList型)を取得 ※listメソッドがFutureのため、thenメソッドを使って処理 listO = value; // データが存在する場合は、取得したデータリストをログに表示(確認のため) if (listO.files!.length != 0) { for (var iO = 0; iO < listO.files!.length; iO++) { print( "Id: ${listO.files![iO].id} File Name:${listO.files![iO].name} Created Time: ${listO.files![iO].createdTime!.toLocal()}"); } } else { print("Google Drive内にデータなし"); } }); } /// ステップ④ Future<void> _importFromGoogleDriveO() async{ /// /////////////////////////////////////// /// Google Drive内のファイル情報の取得 // ステップ③のメソッドを流用 // Google SignInの処理はこのメソッド内で実行 await _getListFromGoogleDriveO(); /// /////////////////////////////////////// /// Google Driveからのファイルインポートの処理 // ステップ③のメソッド内で作成・取得済になっている // Google Drive APIのインスタンスと、ドライブ内ファイル情報を用いて // 下記処理を実行 // 端末内のインポート先であるアプリ専用フォルダのパスを取得 final importDirectoryO = await getApplicationDocumentsDirectory(); // 個々のファイルのインポート処理(Googleドライブからのファイル読込み)の // 完了回数をカウントするための変数を定義 int importCountO = 0; // ドライブ内にファイルが存在する場合にインポート処理を実行する if (listO.files!.length > 0) { // ドライブ内のファイルを1つずつ処理 for (var iO = 0; iO < listO.files!.length; iO++) { // Google Drive内のファイル名を使って、インポート先のフルパスを作り、File型プロパティを作成 final importFilePathO = join(importDirectoryO.path, listO.files![iO].name); print("インポート先のフルパス: $importFilePathO"); final importFileO = File(importFilePathO); // Google Drive内のファイルを、idをキーにして取得(ダウンロード)する // ファイルのデータ全体を取得(ダウンロード)するため、.fullMediaとする // getメソッドはObject型を返すが、以降でデータを扱うためにはMedia?型である // 必要があるため、as driveO.Media?を付けて変換する driveO.Media? fileO = (await googleDriveApiO.files.get(listO.files![iO].id!, downloadOptions: driveO.DownloadOptions.fullMedia)) as driveO.Media?; // 取得(ダウンロード)したデータを整数の羅列として保管するための // リスト型プロパティを定義 List<int> downLoadedDataO = []; // データをダウンロードしながら(Stream処理)、順次、整数の羅列として上記プロパティに保管 fileO!.stream.listen( (dataO) { print("DataReceived: ${dataO.length}"); downLoadedDataO.insertAll(downLoadedDataO.length, dataO); }, // Stream処理の完了後に行う処理をonDone:に書く // 非同期処理が含まれるためasync awaitをつける onDone: () async { // インポート処理が完了した数をカウントアップする // ※Stream処理では、ダウンロードが順不同で実行される可能性があるので、 // forループのindex(iO)は必ずしもインポート完了数の計測には使えない // (下のprint文2つで確認可能) importCountO++; print("forループのindex値 ${iO + 1}"); print("カウンター値 $importCountO"); // ダウンロードしたデータを、端末内のアプリ専用フォルダにインポート(保存) await importFileO.writeAsBytes(downLoadedDataO); // Google Drive内の全ファイルのインポート完了後に // 実行したい処理がある場合には、以下に記述する // ※カウント変数がファイル数と一致したら完了したと判定する if (importCountO == listO.files!.length) { print("Googleドライブから全ファイルのインポート完了"); } }, // エラー発生時の処理をonError:に書く onError: (errorO) { print("エラーでインポートできず $errorO"); }, ); } } else { print("インポートするファイル無し"); } } /// ステップ⑤ Future<void> _deleteGoogleDriveFilesO() async{ /// /////////////////////////////////////// /// Google Drive内のファイル情報の取得 // ステップ③のメソッドを流用 // Google SignInの処理はこのメソッド内で実行 await _getListFromGoogleDriveO(); /// /////////////////////////////////////// /// Google Drive内のファイル削除 // 上記ステップ③のメソッド内で作成・取得済となっている // Google Drive APIのインスタンス、ドライブ内ファイル情報を用いて // 下記処理を実行 // ドライブ内にデータがある場合のみ、削除処理を実行 if (listO.files!.length > 0) { for (var iO = 0; iO < listO.files!.length; iO++) { await googleDriveApiO.files.delete(listO.files![iO].id!); } print("データ削除完了"); } } /// ステップ⑥ Future<void> _signOutFromGoogleO() async{ // サインインせず⑥を押した場合を想定し、 // ここでもGoogle SignIn認証のためのインスタンスを作成する googleSignInO = signInO.GoogleSignIn( scopes: [ driveO.DriveApi.driveAppdataScope, // Google Drive内のユーザーが見える場所に保存している場合は、以下に変更する // driveO.DriveApi.driveScope, ]); try { await googleSignInO.signOut(); // // 再サインインするときに再度、同意画面を表示させたい場合は、上記1文を以下に変更(完全サインアウト) // await googleSignInO.disconnect(); // // firebase上にサインインしたユーザー情報を記録している場合は以下を追加 // // ※firebase_auth、firebase_coreのインポートが必要 // await FirebaseAuth.instance.signOut(); // サインアウト表示に変更し、再描画 setState(() { signInStatusO = "サインアウト中"; }); } catch (e) { print("サインアウトできず $e"); } } } // // extension_google_sign_in_as_googleapis_authパッケージを使わずに、 // // HTTPクライアントを自作する場合のクラス ※httpパッケージのインポートが必要 // class GoogleAuthClientO extends httpO.BaseClient { // final Map<String, String> _headers; // // final httpO.Client _client = new httpO.Client(); // // GoogleAuthClientO(this._headers); // // Future<httpO.StreamedResponse> send(httpO.BaseRequest request) { // return _client.send(request..headers.addAll(_headers)); // } // }
実装手順①②③が完了していれば、上記コードをコピーして「main.dart」に貼り付ければ、そのまま挙動を確認できるかと思います。
なお、
- 「firebase_core」、「firebase_auth」をインポートしてFirebaseにユーザー情報を登録するケース
- 「extension_google_sign_in_as_googleapis_auth」パッケージを利用せず、HTTPクライアントを自作するケース
について、コメントアウトした状態でコードを記載しています。
コメントアウトを外すことで、これらのケースも試すことも可能です。
※一部、他の箇所をコメントアウトするなどの調整が必要になります。調整の方法はコメントに記載しています。
ただ、最もシンプルな形で実装を試したい方は、上記コメントアウト部分は無視してください。
以降では、自分が苦労した部分など、いくつかの留意点を記載します。
Google APIで使用するスコープを指定する
該当箇所は、173〜186行目です。
GoogleSignInのscopesプロパティに、Google APIで使用したいスコープ(権限範囲)を指定します。
Google Driveの中に、ユーザーがアクセスできないアプリ専用の領域を作ってデータを保管するので、「driveAppdataScope」のスコープを指定します。
これは非機密のスコープに該当するので、GoogleによるOAuth検証の事前承認は不要です。
一方、ユーザーが見える場所(マイドライブ内)にデータを保管する場合には、「driveScope」のスコープを指定すると可能になりますが、これは機密性の高いスコープに該当するので、GoogleによるOAuth検証の事前承認が必要となります。
ご参考に、コメントアウトした状態で「driveScope」スコープを指定するコードを記載しています。スコープをこちらに変更し、Googleから非承認の状態で実行すると、同意画面が出る前に、下図のようなアラート画面が表示されます。
Googleからの承認を得れば、上記アラート表示は消え、違和感なく使用できるようになりますが、前述した理由から、できる限り非機密のスコープを利用したほうが良いと考えます(少なくともバックアップ機能が目的の場合は、「driveAppdataScope」スコープで十分かと思います)。
Google サインインの画面をキャンセルされたときの処理を入れる
該当箇所は、191行目〜です。
Google サインインのポップアップが出たとき、周りの暗い部分をタップするとキャンセルできますが、その際にエラーでアプリが停止してしまいます。
※これは同意画面でキャンセルした場合も同様です。
そのため、try catch文を入れ、キャンセルにより「signIn」メソッドの返り値がnullだった場合は、例外処理を投げて、以降の処理を回避するようにします。
但し、デバッグモードで実行すると、例外処理でもアプリが止まってしまうので、キャンセル時の挙動を試したい場合は、リリースモード(flutter run --release)で実行する必要があります。
※Android Studioの場合は、右三角(▶)ボタン(Run 'main.dart')で実行しても大丈夫です。
iOSの挙動に備えて、signInSilentlyメソッドを併用する
該当箇所は、200行目です。
最初にどのボタンが押されても良いように、②〜⑤のボタン押下後のメソッドでは、それぞれ最初にGoogle サインインの処理をするメソッド(_googleSignInMethodO)を呼んでいます。
Androidの場合は、サインイン済なら「signIn」メソッドの所で何もせず通過してくれるのですが、iOSの場合には、毎回サインインのポップアップが起動してしまい、煩わしい挙動になります。
そのため、サインイン済か否かを「isSignedIn」メソッドで確認し、サインイン済の場合には、「signInSilently」メソッドに切り替え、ポップアップの起動を省略しています。
サインイン済でもエラーとなる場合は、認証情報を初期化する
該当箇所は、205〜211行目です。
「isSignedIn」メソッドでサインイン済と判定されても、「signInSilently」メソッドがnullになってしまうことがあります。
その状態で、「signOut」メソッド(後述)でサインアウトし、再度サインインを試みると、以下のようなエラーが発生します。
E/flutter (14109): [ERROR:flutter/lib/ui/ui_dart_state.cc(199)] Unhandled Exception: Access was denied (www-authenticate header was: Bearer realm="https://accounts.google.com/", error=invalid_token).
自分の経験では、この現象は、ユーザーが、Googleアカウントの設定画面から、アプリのアクセス権を手動で解除した場合に発生します(具体的な解除方法は、後述の「その他補足」をご参照ください)。
これは、アプリのGoogleアカウントに対する認証情報が更新されないまま、アクセス権が解除されてしまった事が原因と理解しています(自分の経験では、トークンの有効期間とされている1時間を経過すると、再びアクセスできるようになります)。
そのため、サインイン済と判定されるのに、サインインの結果がnullとなる場合は、「disconnect」メソッドを用いて完全サインアウトさせ、アプリの認証情報を初期化する処理を入れています。
その後で、再度サインインすれば、認証情報が再構築され、問題なく処理が継続できるようになります。
ステップ② バックアップ対象を「.txt」に限定
該当箇所は、310行目です。
端末内のアプリ専用フォルダには、ステップ①で保存した以外のファイルも存在しています。
存在するファイルは、Device File Explorerで確認できます。
そのため、いったん「listSync」メソッドでアプリ専用フォルダ内の全ファイルをリストで取得した後、「.txt」を含まないファイルをリストから削除しています。
ステップ②③ HTTPクライアントのインスタンスを作成
該当箇所は、337行目と399行目です。
Google Drive APIのインスタンスを作成するには、HTTPクライアントのインスタンスが必要ですが、本記事では、Flutter公式ページの推奨に従って、「extension_google_sign_in_as_googleapis_auth」パッケージを使う方法を採用しています。
パッケージを使うと、記述するコード量が少ないので非常に楽ですが、中身がやや分かりづらくなります。
パッケージを使わない場合は、アクセストークンを含むHTTP Authorization ヘッダーを生成してから、Flutterに標準で入っている「http」パッケージを用いて、HTTPクライアントのインスタンスを自作することができます。
後者の方法は、コード中にコメントアウト表示で記載しましたので、コメントアウトを削除することで、試していただけます。
ステップ② 「create」メソッドでGoogle Driveに保存
該当箇所は、367〜370行目です。
端末内のファイルをMedia型に変換した上で、「create」メソッドでGoogle Driveに保存します。
複数のファイルを保存することを想定し、forループで保存処理を回しています。
ユーザーには見えないGoogle Drive内のアプリ専用領域に保存するため、「parents」属性に、["appDataFolder"]を指定しています。
本記事では推奨はしていませんが、OAuth検証の事前承認を得る前提で、ユーザーが見ることのできる場所(マイドライブ直下)に保存する場合は、上記指定は不要になります。
ステップ③ Google Drive内のファイル情報リストを「list」メソッドで取得
該当箇所は、414行目です。
「list」メソッドを使い、Google Drive内のファイル情報を取得する場所(アプリ専用領域)と、取得する情報項目を指定することで、ファイル情報のリストを取得できます。
ここでは、例として、保存時にGoogle Driveによって自動採番されるファイルのIDと、ファイル名、ファイル作成日(保存日)を取得することとしました。
この他に取得できる情報については、下記公式ページに記載されています。
ユーザーには見えないアプリ専用領域の情報を取得するため、「list」メソッドの「spaces」属性に、「'appDataFolder'」を指定しています。
【注意点】
OAuth検証の事前承認を得て、ユーザーが見ることのできる場所(マイドライブ直下)に保存する場合は、この指定は不要、もしくは「'drive'」を指定することになります。
ただし、マイドライブ直下の全情報を取得して、ステップ④(インポート)やステップ⑤(削除)のボタンを押すと、マイドライブ内にある大量のデータをインポートしてしまったり、マイドライブ内の全データを削除してしまったりするため、実行する場合は、問題が生じないか十分注意が必要です(例えば、ステップ③で保存したファイル名のみを指定して情報取得する、などの処理を追加するのが安全です)。
なお、Googleの承認前であっても、アラート画面を通過すれば、処理自体はできてしまうので、上記リスクを考慮して、サンプルコード中では「'appDataFolder'」を指定するケースのみを記載しています。
ステップ④ 「get」メソッドでデータをダウンロード
該当箇所は、473行目です。
ステップ③のファイル情報リストの取得メソッドを実行後、「get」メソッドで、ファイルのIDをキーにしてデータをダウンロードします。
「get」メソッドはFuture<Object>型を返しますが、後で「stream」メソッドを使うために、「as driveO.Media?」を付けて、「Media?」型にしておきます。
ステップ④ 読込み完了数を計測するカウンタ変数を別途用意する
該当箇所は、496行目です。
データをダウンロードしながら、「stream.listen」メソッドで、データを順次数値の羅列としてリスト型のプロパティに保管していきます。
保管が終わったら、「onDone」プロパティの中で、「writeAsBytes」メソッドを使って、端末内にデータを書き込み(保存し)ます。
なお、「stream.listen」メソッドはStream型のため、必ずしもforループのインデックス順に処理が完了するとは限りません(重いファイルは完了が遅れたりします)。
そのため、全ファイルのインポート(読込み)が終わった後に、何らかの処理を実行したい場合には、forループのインデックス変数(ここでは「iO」)とは別に、ファイルの読込み数をカウントするプロパティ(ここでは「importCountO」)を作成して対応しています。
具体的には、forループが1度回るたびに「importCountO」をカウントアップし、ファイル情報リストの要素数と一致した段階で、読込み完了後の処理を実行する形としました(この点は理解不足のため、もっと上手な方法があるようでしたら、是非ご教示くださいm(_ _)m)。
ステップ⑤ forループで「delete」メソッドを回す
該当箇所は、548行目です。
ステップ③のファイル情報リストの取得メソッドを実行後、取得したリスト情報に対して、1つ1つ「delete」メソッドをforループで回して実行します。
【注意点】
前述のとおり、OAuth検証の事前承認を得て、ユーザーが見ることのできる場所(マイドライブ直下)からファイル情報リストを取得した場合には、アプリとは関係ないデータを含む全ファイルを削除してしまう恐れがあるため、十分注意が必要です。
ステップ⑥ 「signOut」メソッドでサインアウト処理
該当箇所は、570行目です。
サインアウトの処理には、「signOut」メソッドと「disconnect」メソッドの2つの選択肢があります。
「signOut」メソッドは、あくまで「サインアウト状態」にするだけで、認証状態の破棄も含めた完全なサインアウトではありません。
そのため、再サインイン時には、最初に要求された同意画面は表示されず、簡単にサインインできます。
一方、「disconnect」メソッドは、前述のとおり、認証状態の破棄も含めた完全なサインアウトになるため、再サインイン時には、改めて同意画面が表示されます。
サービス提供側としては、ユーザーに同意画面を見せるのは最初の1回だけにしたいはずなので、サンプルコードでは「signOut」メソッドを採用しています。
但し、開発中は、頻繁に同意画面の表示状態を確認したいと思われるので、開発中のみ「disconnect」メソッドを採用しておく、というのもアリかもしれません。
その他補足
Google Drive内のアプリ専用領域の情報を確認する方法
ユーザー視点の話ですが、Google Driveのアプリ専用領域の情報は、ブラウザ上のGoogle Driveの設定画面に入り、下記手順で確認することができます。
具体的なファイル情報を見ることはできませんが、どの程度の容量を占めているかが分かる他、右側のオプションボタンで、ドライブからの切断やデータの削除ができます。
※ドライブからの切断は、次項で説明する「アクセス権解除」と同じことになります。
開発中のテスト段階では、データを削除するコードを実装しなくても、この機能を使ってデータを削除できるので、便利な機能です。
一度同意した同意画面を再度表示させる方法
サインアウトには「signOut」メソッドを用いているため、一度、同意画面に同意すると、以降のサインイン時には同意画面が表示されません(サインアウトして再サインインしても表示されません)。
同意画面をもう一度表示させたい場合には、「disconnect」メソッドで完全サインアウトさせるか、該当するGoogleアカウントのページで、下記手順により、アクセス権を削除すると可能になります。
ご説明は以上になります。
FlutterからのGoogle Drive利用を検討されている方には、ご参考になれば幸いです。
最後までお読みいただき、ありがとうございました。
個人アプリ開発で役立ったもの
おすすめの学習教材
\超初心者向けでオススメな元Udemyの講座/
\キャンペーン時を狙えば安価で網羅的な内容が学べる(日本語訳あり)/
\Gitの基礎について無料で学べる/
おすすめの学習書籍
\実用的。image_pickerに関してかなり助けられた/
\Dartの基礎文法を素早くインプットできる/
コメント