ponshu-room-lite/lib/services/backup_service.dart

584 lines
21 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void> init() async {
try {
await _googleSignIn.signInSilently();
} catch (e) {
debugPrint('⚠️ サイレントサインインエラー: $e');
}
}
/// Googleアカウントでサインイン
///
/// 【処理フロー】
/// 1. Googleアカウント選択画面を表示
/// 2. ユーザーが許可するとアカウント情報を取得
/// 3. Google Drive APIへのアクセス権を取得
///
/// 【戻り値】
/// - 成功: GoogleSignInAccountアカウント情報
/// - 失敗: nullキャンセルまたはエラー
Future<GoogleSignInAccount?> 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<void> signOut() async {
await _googleSignIn.signOut();
}
/// バックアップを作成してGoogle Driveにアップロード
///
/// 【処理フロー】
/// 1. ローカルのHiveデータをJSONに変換
/// 2. 画像ファイルを収集
/// 3. ZIPファイルに圧縮
/// 4. Google Driveにアップロード
/// 5. 一時ファイルを削除
///
/// 【戻り値】
/// - 成功: true
/// - 失敗: false
Future<bool> 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<File?> _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<SakeItem>('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<String, dynamic>.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<bool> _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<bool> 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<void> _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<File?> _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<bool> _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<int>;
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<File>();
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<SakeItem>('sake_items');
await sakeBox.clear();
for (var itemData in sakeItemsJson) {
final data = itemData as Map<String, dynamic>;
// 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<String>.from(data['displayData']['imagePaths'] as List),
rating: data['displayData']['rating'] as double?,
),
hiddenSpecs: HiddenSpecs(
description: data['hiddenSpecs']['description'] as String?,
tasteStats: Map<String, int>.from(data['hiddenSpecs']['tasteStats'] as Map),
flavorTags: List<String>.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<String, int>.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<String, dynamic>;
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<bool> 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;
}
}
}