581 lines
20 KiB
Dart
581 lines
20 KiB
Dart
import 'dart:io';
|
||
import 'dart:convert';
|
||
import 'package:archive/archive_io.dart';
|
||
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) {
|
||
print('⚠️ サイレントサインインエラー: $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) {
|
||
print('❌ 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) {
|
||
print('❌ サインインが必要です');
|
||
return false;
|
||
}
|
||
|
||
// 2. Drive APIクライアントを作成
|
||
final authClient = await _googleSignIn.authenticatedClient();
|
||
if (authClient == null) {
|
||
print('❌ 認証クライアントの取得に失敗しました');
|
||
return false;
|
||
}
|
||
|
||
final driveApi = drive.DriveApi(authClient);
|
||
|
||
// 3. バックアップZIPファイルを作成
|
||
final zipFile = await _createBackupZip();
|
||
if (zipFile == null) {
|
||
print('❌ バックアップファイルの作成に失敗しました');
|
||
return false;
|
||
}
|
||
|
||
// 4. Google Driveにアップロード
|
||
final success = await _uploadToDrive(driveApi, zipFile);
|
||
|
||
// 5. 一時ファイルを削除
|
||
await zipFile.delete();
|
||
|
||
return success;
|
||
} catch (error) {
|
||
print('❌ バックアップ作成エラー: $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.name,
|
||
'brewery': item.displayData.brewery,
|
||
'prefecture': item.displayData.prefecture,
|
||
'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));
|
||
print('📄 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);
|
||
|
||
print('✅ バックアップZIPファイル作成完了: $zipPath');
|
||
return File(zipPath);
|
||
} catch (error) {
|
||
print('❌ ZIP作成エラー: $error');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// Google DriveにZIPファイルをアップロード
|
||
Future<bool> _uploadToDrive(drive.DriveApi driveApi, File zipFile) async {
|
||
try {
|
||
// 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!);
|
||
print('🗑️ 既存のバックアップファイルを削除しました: ${file.id}');
|
||
} catch (e) {
|
||
print('⚠️ 既存ファイルの削除に失敗 (無視して続行): $e');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 新しいファイルをアップロード
|
||
final driveFile = drive.File();
|
||
driveFile.name = backupFileName;
|
||
|
||
final media = drive.Media(zipFile.openRead(), zipFile.lengthSync());
|
||
|
||
final uploadedFile = await driveApi.files.create(
|
||
driveFile,
|
||
uploadMedia: media,
|
||
);
|
||
|
||
if (uploadedFile.id == null) {
|
||
print('❌ アップロード後のID取得失敗');
|
||
return false;
|
||
}
|
||
|
||
print('✅ Google Driveにアップロードリクエスト完了 ID: ${uploadedFile.id}');
|
||
|
||
// 4. 検証ステップ:正しくアップロードされたか確認
|
||
// APIの反映ラグを考慮して少し待機してから確認
|
||
int retryCount = 0;
|
||
bool verified = false;
|
||
|
||
while (retryCount < 3 && !verified) {
|
||
await Future.delayed(Duration(milliseconds: 1000 * (retryCount + 1)));
|
||
|
||
try {
|
||
final check = await driveApi.files.get(uploadedFile.id!);
|
||
// getが成功すればファイルは存在する
|
||
verified = true;
|
||
print('✅ アップロード検証成功: ファイル存在確認済み');
|
||
} catch (e) {
|
||
print('⚠️ 検証試行 ${retryCount + 1} 失敗: $e');
|
||
}
|
||
retryCount++;
|
||
}
|
||
|
||
return verified;
|
||
} catch (error) {
|
||
print('❌ アップロードエラー: $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) {
|
||
print('❌ サインインが必要です');
|
||
return false;
|
||
}
|
||
|
||
// 2. Drive APIクライアントを作成
|
||
final authClient = await _googleSignIn.authenticatedClient();
|
||
if (authClient == null) {
|
||
print('❌ 認証クライアントの取得に失敗しました');
|
||
return false;
|
||
}
|
||
|
||
final driveApi = drive.DriveApi(authClient);
|
||
|
||
// 3. 現在のデータを退避
|
||
await _createPreRestoreBackup();
|
||
|
||
// 4. Google Driveからダウンロード
|
||
final zipFile = await _downloadFromDrive(driveApi);
|
||
if (zipFile == null) {
|
||
print('❌ ダウンロードに失敗しました');
|
||
return false;
|
||
}
|
||
|
||
// 5. データを復元
|
||
final success = await _restoreFromZip(zipFile);
|
||
|
||
// 6. 一時ファイルを削除
|
||
await zipFile.delete();
|
||
|
||
return success;
|
||
} catch (error) {
|
||
print('❌ 復元エラー: $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();
|
||
print('✅ 復元前のデータを退避しました: $backupPath');
|
||
}
|
||
} catch (error) {
|
||
print('⚠️ データ退避エラー: $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) {
|
||
print('❌ バックアップファイルが見つかりません');
|
||
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);
|
||
|
||
print('✅ ダウンロード完了: $downloadPath');
|
||
return downloadFile;
|
||
} catch (error) {
|
||
print('❌ ダウンロードエラー: $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);
|
||
print('📦 展開: $filename (${data.length} bytes)');
|
||
}
|
||
}
|
||
|
||
// デバッグ: 展開されたファイル一覧を表示
|
||
print('📂 展開ディレクトリの中身:');
|
||
extractDir.listSync(recursive: true).forEach((f) => print(' - ${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) {
|
||
// 見つからない場合
|
||
print('❌ sake_items.json が見つかりません');
|
||
}
|
||
|
||
if (sakeItemsFile != null && await sakeItemsFile.exists()) {
|
||
final sakeItemsJson = json.decode(await sakeItemsFile.readAsString()) as List;
|
||
print('🔍 復元対象データ数: ${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);
|
||
}
|
||
print('✅ 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) {
|
||
print('⚠️ 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);
|
||
}
|
||
print('✅ 設定を復元しました');
|
||
}
|
||
|
||
// 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));
|
||
}
|
||
}
|
||
print('✅ 画像ファイルを復元しました(${imageFiles.length}ファイル)');
|
||
}
|
||
}
|
||
|
||
// 5. 一時ディレクトリを削除
|
||
await extractDir.delete(recursive: true);
|
||
|
||
print('✅ データの復元が完了しました');
|
||
return true;
|
||
} catch (error) {
|
||
print('❌ 復元処理エラー: $error');
|
||
// スタックトレースも出す
|
||
print(error);
|
||
if (error is Error) {
|
||
print(error.stackTrace);
|
||
}
|
||
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) {
|
||
print('❌ バックアップ確認エラー: $error');
|
||
return false;
|
||
}
|
||
}
|
||
}
|