import 'dart:io'; import 'dart:convert'; import 'dart:async'; // TimeoutException import 'package:archive/archive_io.dart'; import 'package:flutter/foundation.dart'; // debugPrint import 'package:googleapis/drive/v3.dart' as drive; import 'package:google_sign_in/google_sign_in.dart'; import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../models/sake_item.dart'; /// Google Driveへのバックアップ・復元を管理するサービス /// /// 【主な機能】 /// 1. Googleアカウント認証 /// 2. Hiveデータ + 画像をZIPファイルにまとめる /// 3. Google Driveへアップロード /// 4. Google Driveからダウンロード /// 5. ZIPファイルを展開してデータ復元 class BackupService { /// Google Sign Inインスタンス /// スコープ: drive.file (アプリが作成したファイルのみアクセス可能) final GoogleSignIn _googleSignIn = GoogleSignIn( scopes: [drive.DriveApi.driveFileScope], ); /// バックアップファイル名 static const String backupFileName = 'ponshu_backup.zip'; /// 現在のGoogleアカウント情報を取得 GoogleSignInAccount? get currentUser => _googleSignIn.currentUser; /// 初期化処理(サイレントサインイン試行) Future init() async { try { await _googleSignIn.signInSilently(); } catch (e) { debugPrint('⚠️ サイレントサインインエラー: $e'); } } /// Googleアカウントでサインイン /// /// 【処理フロー】 /// 1. Googleアカウント選択画面を表示 /// 2. ユーザーが許可するとアカウント情報を取得 /// 3. Google Drive APIへのアクセス権を取得 /// /// 【戻り値】 /// - 成功: GoogleSignInAccount(アカウント情報) /// - 失敗: null(キャンセルまたはエラー) Future signIn() async { try { // 既にサインイン済みの場合は現在のアカウントを返す if (_googleSignIn.currentUser != null) { return _googleSignIn.currentUser; } // サインイン画面を表示 final account = await _googleSignIn.signIn(); return account; } catch (error) { debugPrint('❌ Google Sign In エラー: $error'); return null; } } /// サインアウト Future signOut() async { await _googleSignIn.signOut(); } /// バックアップを作成してGoogle Driveにアップロード /// /// 【処理フロー】 /// 1. ローカルのHiveデータをJSONに変換 /// 2. 画像ファイルを収集 /// 3. ZIPファイルに圧縮 /// 4. Google Driveにアップロード /// 5. 一時ファイルを削除 /// /// 【戻り値】 /// - 成功: true /// - 失敗: false Future createBackup() async { try { // 1. サインイン確認 final account = _googleSignIn.currentUser; if (account == null) { debugPrint('❌ サインインが必要です'); return false; } // 2. Drive APIクライアントを作成 final authClient = await _googleSignIn.authenticatedClient(); if (authClient == null) { debugPrint('❌ 認証クライアントの取得に失敗しました'); return false; } final driveApi = drive.DriveApi(authClient); // 3. バックアップZIPファイルを作成 final zipFile = await _createBackupZip(); if (zipFile == null) { debugPrint('❌ バックアップファイルの作成に失敗しました'); return false; } // 4. Google Driveにアップロード final success = await _uploadToDrive(driveApi, zipFile); // 5. 一時ファイルを削除 await zipFile.delete(); return success; } catch (error) { debugPrint('❌ バックアップ作成エラー: $error'); return false; } } /// ローカルデータをZIPファイルにまとめる /// /// 【ファイル構造】 /// ponshu_backup.zip /// ├── sake_items.json (Hiveのデータ) /// ├── settings.json (アプリ設定) /// └── images/ /// ├── uuid1.jpg /// ├── uuid2.jpg /// └── ... Future _createBackupZip() async { try { final tempDir = await getTemporaryDirectory(); final backupDir = Directory(path.join(tempDir.path, 'backup_temp')); // バックアップ用の一時ディレクトリを作成 if (await backupDir.exists()) { await backupDir.delete(recursive: true); } await backupDir.create(recursive: true); // 1. Hiveデータ取得 final sakeBox = Hive.box('sake_items'); final settingsBox = Hive.box('settings'); // データの整合性を保つためにフラッシュ await sakeBox.flush(); await settingsBox.flush(); // 2. sake_items.jsonを作成 final sakeItems = sakeBox.values.map((item) => { 'id': item.id, 'displayData': { 'name': item.displayData.displayName, 'brewery': item.displayData.displayBrewery, 'prefecture': item.displayData.displayPrefecture, 'catchCopy': item.displayData.catchCopy, 'imagePaths': item.displayData.imagePaths, 'rating': item.displayData.rating, }, 'hiddenSpecs': { 'description': item.hiddenSpecs.description, 'tasteStats': item.hiddenSpecs.tasteStats, 'flavorTags': item.hiddenSpecs.flavorTags, 'sweetnessScore': item.hiddenSpecs.sweetnessScore, 'bodyScore': item.hiddenSpecs.bodyScore, }, 'userData': { 'isFavorite': item.userData.isFavorite, 'isUserEdited': item.userData.isUserEdited, 'price': item.userData.price, 'costPrice': item.userData.costPrice, 'markup': item.userData.markup, 'priceVariants': item.userData.priceVariants, }, 'gamification': { 'ponPoints': item.gamification.ponPoints, }, 'metadata': { 'createdAt': item.metadata.createdAt.toIso8601String(), 'aiConfidence': item.metadata.aiConfidence, }, 'itemType': item.itemType.toString().split('.').last, }).toList(); final sakeItemsFile = File(path.join(backupDir.path, 'sake_items.json')); await sakeItemsFile.writeAsString(json.encode(sakeItems)); debugPrint('📄 sake_items.json 作成: ${await sakeItemsFile.length()} bytes'); // 3. settings.jsonを作成 final settings = Map.from(settingsBox.toMap()); final settingsFile = File(path.join(backupDir.path, 'settings.json')); await settingsFile.writeAsString(json.encode(settings)); // 4. 画像ファイルをコピー final imagesDir = Directory(path.join(backupDir.path, 'images')); await imagesDir.create(); final appDir = await getApplicationDocumentsDirectory(); final imageFiles = appDir.listSync().where((file) => file.path.endsWith('.jpg') || file.path.endsWith('.jpeg') || file.path.endsWith('.png') ); for (var imageFile in imageFiles) { final fileName = path.basename(imageFile.path); await File(imageFile.path).copy(path.join(imagesDir.path, fileName)); } // 5. ZIPファイルに圧縮 final encoder = ZipFileEncoder(); final zipPath = path.join(tempDir.path, backupFileName); encoder.create(zipPath); encoder.addDirectory(backupDir); encoder.close(); // 6. 一時ディレクトリを削除 await backupDir.delete(recursive: true); debugPrint('✅ バックアップZIPファイル作成完了: $zipPath'); return File(zipPath); } catch (error) { debugPrint('❌ ZIP作成エラー: $error'); return null; } } /// Google DriveにZIPファイルをアップロード Future _uploadToDrive(drive.DriveApi driveApi, File zipFile) async { try { debugPrint('[BACKUP] 📤 アップロード開始: ${zipFile.lengthSync()} bytes'); // 1. 既存のバックアップファイルを検索 final fileList = await driveApi.files.list( q: "name = '$backupFileName' and trashed = false", spaces: 'drive', ); // 2. 既存ファイルがあれば削除(完全上書き戦略) if (fileList.files != null && fileList.files!.isNotEmpty) { for (var file in fileList.files!) { try { await driveApi.files.delete(file.id!); debugPrint('🗑️ [BACKUP] 既存ファイルを削除: ${file.id}'); } catch (e) { debugPrint('⚠️ [BACKUP] 既存ファイル削除失敗 (無視): $e'); } } } // 3. 新しいファイルをアップロード final driveFile = drive.File(); driveFile.name = backupFileName; final media = drive.Media(zipFile.openRead(), zipFile.lengthSync()); debugPrint('[BACKUP] 🚀 Driveへ送信中...'); final uploadedFile = await driveApi.files.create( driveFile, uploadMedia: media, ).timeout(const Duration(minutes: 3), onTimeout: () { throw TimeoutException('アップロードがタイムアウトしました (3分)'); }); if (uploadedFile.id == null) { debugPrint('❌ [BACKUP] ID取得失敗'); return false; } debugPrint('✅ [BACKUP] アップロード完了 ID: ${uploadedFile.id}'); // 4. 検証ステップ int retryCount = 0; bool verified = false; while (retryCount < 3 && !verified) { await Future.delayed(Duration(milliseconds: 1000 * (retryCount + 1))); try { await driveApi.files.get(uploadedFile.id!); verified = true; debugPrint('✅ [BACKUP] 検証成功'); } catch (e) { debugPrint('⚠️ [BACKUP] 検証試行 ${retryCount + 1} 失敗: $e'); } retryCount++; } return verified; } catch (error) { debugPrint('❌ [BACKUP] アップロードエラー: $error'); return false; } } /// Google Driveからバックアップを復元 /// /// 【処理フロー】 /// 1. Google Driveからバックアップファイルをダウンロード /// 2. ZIPファイルを展開 /// 3. 現在のデータを退避(pre_restore_backup.zip) /// 4. バックアップデータでHiveを上書き /// 5. 画像ファイルを復元 /// /// 【注意】 /// 現在のデータは完全に上書きされます。 /// 事前に確認ダイアログを表示することを推奨します。 Future restoreBackup() async { try { // 1. サインイン確認 final account = _googleSignIn.currentUser; if (account == null) { debugPrint('❌ サインインが必要です'); return false; } // 2. Drive APIクライアントを作成 final authClient = await _googleSignIn.authenticatedClient(); if (authClient == null) { debugPrint('❌ 認証クライアントの取得に失敗しました'); return false; } final driveApi = drive.DriveApi(authClient); // 3. 現在のデータを退避 await _createPreRestoreBackup(); // 4. Google Driveからダウンロード final zipFile = await _downloadFromDrive(driveApi); if (zipFile == null) { debugPrint('❌ ダウンロードに失敗しました'); return false; } // 5. データを復元 final success = await _restoreFromZip(zipFile); // 6. 一時ファイルを削除 await zipFile.delete(); return success; } catch (error) { debugPrint('❌ 復元エラー: $error'); return false; } } /// 復元前に現在のデータを退避 Future _createPreRestoreBackup() async { try { final tempDir = await getTemporaryDirectory(); final backupPath = path.join(tempDir.path, 'pre_restore_backup.zip'); final zipFile = await _createBackupZip(); if (zipFile != null) { await zipFile.copy(backupPath); await zipFile.delete(); debugPrint('✅ 復元前のデータを退避しました: $backupPath'); } } catch (error) { debugPrint('⚠️ データ退避エラー: $error'); } } /// Google DriveからZIPファイルをダウンロード Future _downloadFromDrive(drive.DriveApi driveApi) async { try { // 1. バックアップファイルを検索 final fileList = await driveApi.files.list( q: "name = '$backupFileName' and trashed = false", spaces: 'drive', ); if (fileList.files == null || fileList.files!.isEmpty) { debugPrint('❌ バックアップファイルが見つかりません'); return null; } final fileId = fileList.files!.first.id!; // 2. ファイルをダウンロード final media = await driveApi.files.get( fileId, downloadOptions: drive.DownloadOptions.fullMedia, ) as drive.Media; final tempDir = await getTemporaryDirectory(); final downloadPath = path.join(tempDir.path, 'downloaded_backup.zip'); final downloadFile = File(downloadPath); // 3. ストリームをファイルに書き込み final sink = downloadFile.openWrite(); await media.stream.pipe(sink); debugPrint('✅ ダウンロード完了: $downloadPath'); return downloadFile; } catch (error) { debugPrint('❌ ダウンロードエラー: $error'); return null; } } /// ZIPファイルからデータを復元 Future _restoreFromZip(File zipFile) async { try { final tempDir = await getTemporaryDirectory(); final extractDir = Directory(path.join(tempDir.path, 'restore_temp')); // 1. ZIP展開 if (await extractDir.exists()) { await extractDir.delete(recursive: true); } await extractDir.create(recursive: true); final bytes = await zipFile.readAsBytes(); final archive = ZipDecoder().decodeBytes(bytes); for (var file in archive) { final filename = file.name; final data = file.content as List; final extractPath = path.join(extractDir.path, filename); // __MACOSX などの不要なディレクトリはスキップ if (filename.startsWith('__MACOSX')) continue; if (file.isFile) { final outFile = File(extractPath); await outFile.create(recursive: true); await outFile.writeAsBytes(data); debugPrint('📦 展開: $filename (${data.length} bytes)'); } } // デバッグ: 展開されたファイル一覧を表示 debugPrint('📂 展開ディレクトリの中身:'); extractDir.listSync(recursive: true).forEach((f) => debugPrint(' - ${path.basename(f.path)}')); // 2. sake_items.jsonを検索 (ルートまたはサブディレクトリ) File? sakeItemsFile; final potentialFiles = extractDir.listSync(recursive: true).whereType(); try { sakeItemsFile = potentialFiles.firstWhere((f) => path.basename(f.path) == 'sake_items.json'); } catch (e) { // 見つからない場合 debugPrint('❌ sake_items.json が見つかりません'); } if (sakeItemsFile != null && await sakeItemsFile.exists()) { final sakeItemsJson = json.decode(await sakeItemsFile.readAsString()) as List; debugPrint('🔍 復元対象データ数: ${sakeItemsJson.length}件'); final sakeBox = Hive.box('sake_items'); await sakeBox.clear(); for (var itemData in sakeItemsJson) { final data = itemData as Map; // JSONからSakeItemオブジェクトを再構築 final item = SakeItem( id: data['id'] as String, displayData: DisplayData( name: data['displayData']['name'] as String, brewery: data['displayData']['brewery'] as String, prefecture: data['displayData']['prefecture'] as String, catchCopy: data['displayData']['catchCopy'] as String?, imagePaths: List.from(data['displayData']['imagePaths'] as List), rating: data['displayData']['rating'] as double?, ), hiddenSpecs: HiddenSpecs( description: data['hiddenSpecs']['description'] as String?, tasteStats: Map.from(data['hiddenSpecs']['tasteStats'] as Map), flavorTags: List.from(data['hiddenSpecs']['flavorTags'] as List), sweetnessScore: data['hiddenSpecs']['sweetnessScore'] as double?, bodyScore: data['hiddenSpecs']['bodyScore'] as double?, ), userData: UserData( isFavorite: data['userData']['isFavorite'] as bool, isUserEdited: data['userData']['isUserEdited'] as bool, price: data['userData']['price'] as int?, costPrice: data['userData']['costPrice'] as int?, markup: data['userData']['markup'] as double, priceVariants: data['userData']['priceVariants'] != null ? Map.from(data['userData']['priceVariants'] as Map) : null, ), gamification: Gamification( ponPoints: data['gamification']['ponPoints'] as int, ), metadata: Metadata( createdAt: DateTime.parse(data['metadata']['createdAt'] as String), aiConfidence: data['metadata']['aiConfidence'] as int?, ), itemType: data['itemType'] == 'set' ? ItemType.set : ItemType.sake, ); // IDを保持するためにput()を使用(add()は新しいキーを生成してしまう) await sakeBox.put(item.id, item); } debugPrint('✅ SakeItemsを復元しました(${sakeItemsJson.length}件)'); // UI更新のためにわずかに待機 await Future.delayed(const Duration(milliseconds: 500)); } // 3. settings.jsonを検索 (ルートまたはサブディレクトリ) File? settingsFile; try { settingsFile = potentialFiles.firstWhere((f) => path.basename(f.path) == 'settings.json'); } catch (e) { debugPrint('⚠️ settings.json が見つかりません (スキップ)'); } if (settingsFile != null && await settingsFile.exists()) { final settingsJson = json.decode(await settingsFile.readAsString()) as Map; final settingsBox = Hive.box('settings'); await settingsBox.clear(); for (var entry in settingsJson.entries) { await settingsBox.put(entry.key, entry.value); } debugPrint('✅ 設定を復元しました'); } // 4. 画像ファイルを復元 (sake_items.jsonと同じ階層のimagesフォルダを探す) if (sakeItemsFile != null) { final parentDir = sakeItemsFile.parent; final imagesDir = Directory(path.join(parentDir.path, 'images')); if (await imagesDir.exists()) { final appDir = await getApplicationDocumentsDirectory(); final imageFiles = imagesDir.listSync(); for (var imageFile in imageFiles) { if (imageFile is File) { final fileName = path.basename(imageFile.path); await imageFile.copy(path.join(appDir.path, fileName)); } } debugPrint('✅ 画像ファイルを復元しました(${imageFiles.length}ファイル)'); } } // 5. 一時ディレクトリを削除 await extractDir.delete(recursive: true); debugPrint('✅ データの復元が完了しました'); return true; } catch (error) { debugPrint('❌ 復元処理エラー: $error'); // スタックトレースも出す debugPrint(error.toString()); if (error is Error) { debugPrint(error.stackTrace.toString()); } return false; } } /// Google Driveにバックアップファイルが存在するか確認 Future hasBackupOnDrive() async { try { final account = _googleSignIn.currentUser; if (account == null) return false; final authClient = await _googleSignIn.authenticatedClient(); if (authClient == null) return false; final driveApi = drive.DriveApi(authClient); final fileList = await driveApi.files.list( q: "name = '$backupFileName' and trashed = false", spaces: 'drive', ); return fileList.files != null && fileList.files!.isNotEmpty; } catch (error) { debugPrint('❌ バックアップ確認エラー: $error'); return false; } } }