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

584 lines
21 KiB
Dart
Raw Permalink Normal View History

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;
}
}
}