출시 이후, 굿위시 데이터를 백업하고 복원하는 기능이 있으면 좋겠다는 판단을 했습니다.
원래는 '로컬 스토리지'를 이용해서 구현했는데, 출시과정에서 권한 문제르 반려를 당한 탓에 해결 방법을 다시 고민했습니다.
그때 '구글 드라이브'가 떠올랐고, 바로 착수했습니다.
백업과 복원 페이지
메인페이지에서 프로필 버튼을 누르면 프로필 페이지로 이동합니다.
프로필 페이지에 '백업 & 복원' 버튼이 있습니다. 이걸 클릭하면,
백업과 복원을 지원하는 버튼이 있습니다.
구글 드라이브를 통해 진행됩니다.
사전 설치 패키지
google_sign_in:
googleapis:
googleapis_auth:
http:
path:
pubspec.yaml에 위 패키지들을 입력한 후 설치합니다.
구글 드라이브 계정을 담는 모델 : GoogleAuthClient
// google_drive_auth_client.dart
import 'package:http/http.dart' as http;
class GoogleAuthClient extends http.BaseClient {
final Map<String, String> _headers;
final _client = http.Client();
GoogleAuthClient(this._headers);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
request.headers.addAll(_headers);
return _client.send(request);
}
}
http.BaseClient를 상속받아서, Google 계정 인증 헤더를 포함한 HTTP 요청을 보낼 수 있는 커스텀 클라이언트 모델입니다.
구글 드라이브를 비롯한 Google API는 항상 구글 계정 인증이 필요합니다. 인증 토큰을 자동으로 포함해서 요청해줍니다.
클래스 기본 속성과 생성자
_headers : 구글 계정 인증 토근이 포함된 HTTP 헤더를 저장하는 Map입니다.
_client : 실제 HTTP 요청을 처리하는 http.Client의 인스턴스입니다.
GoogleAuthClient(this._headers);
인스턴스를 생성할 때, 구글 인증 토큰이 담긴 헤더를 입력받습니다.
Send 메서드
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
request.headers.addAll(_headers);
return _client.send(request);
}
1. send 메서드
원래 send는 http.BaseClient의 추상 메서드로, HTTP 요청을 전송하는 역할을 맡습니다.
2. 헤더 추가 - request.headers.addAll(_headers)
send 메서드가 인수로 전달받은 request object에 _headers에 저장된 인증 토큰 헤더를 추가합니다.
3. 요청 전송 - _client.send(request)
http.Client의 send 메서드를 호출하여 HTTP 요청을 처리하고, 결과를 반환합니다.
구글 드라이브 API를 사용하는 클래스 : GoogleDriveService
// google_drive_service.dart
import 'dart:io';
import 'package:path/path.dart' as path;
import 'package:flutter/material.dart';
import 'package:goodwishes/Functions/google_drive_auth_client.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis/drive/v3.dart' as drive;
class GoogleDriveService {
final GoogleSignIn _googleSignIn = GoogleSignIn(
scopes: [drive.DriveApi.driveAppdataScope],
);
GoogleSignInAccount? _currentUser;
drive.DriveApi? _driveApi;
GoogleSignInAccount? get currentUser => _currentUser;
Future<GoogleSignInAccount?> signInGoogle() async {
try {
_currentUser =
await _googleSignIn.signInSilently() ?? await _googleSignIn.signIn();
if (_currentUser != null) {
_driveApi = await getDriveApi(_currentUser!);
}
} catch (e) {
debugPrint('Error during Google Sign-In: $e');
}
return _currentUser;
}
Future<void> signOut() async {
await _googleSignIn.signOut();
_currentUser = null;
_driveApi = null;
}
Future<drive.DriveApi?> getDriveApi(GoogleSignInAccount googleUser) async {
drive.DriveApi? driveApi;
try {
Map<String, String> headers = await googleUser.authHeaders;
GoogleAuthClient client = GoogleAuthClient(headers);
driveApi = drive.DriveApi(client);
} catch (e) {
debugPrint(e.toString());
}
return driveApi;
}
Future<drive.File?> uploadFile(
{required File file, String? driveFileId}) async {
if (_driveApi == null) return null;
drive.File driveFile = drive.File();
driveFile.name = path.basename(file.absolute.path);
late final response;
if (driveFileId != null) {
response = await _driveApi!.files.update(driveFile, driveFileId,
uploadMedia: drive.Media(file.openRead(), file.lengthSync()));
} else {
driveFile.parents = ["appDataFolder"];
response = await _driveApi!.files.create(driveFile,
uploadMedia: drive.Media(file.openRead(), file.lengthSync()));
}
return response;
}
Future<void> deleteFile(String driveFileId) async {
if (_driveApi == null) return;
try {
await _driveApi!.files.delete(driveFileId);
} catch (e) {
debugPrint('Error deleting file with ID $driveFileId: $e');
}
}
Future<File?> downloadFile(
{required String driveFileId, required String localPath}) async {
if (_driveApi == null) return null;
drive.Media media = await _driveApi!.files.get(driveFileId,
downloadOptions: drive.DownloadOptions.fullMedia) as drive.Media;
List<int> data = [];
await media.stream.forEach((element) {
data.addAll(element);
});
File file = File(localPath);
file.writeAsBytesSync(data);
return file;
}
Future<drive.File?> getDriveFile({required String filename}) async {
if (_driveApi == null) return null;
drive.FileList fileList = await _driveApi!.files
.list(spaces: "appDataFolder", $fields: "files(id,name,modifiedTime)");
List<drive.File>? files = fileList.files;
try {
drive.File? driveFile = files
?.firstWhere((element) => element.name == filename.toLowerCase());
return driveFile;
} catch (e) {
debugPrint('File not found: $filename');
return null;
}
}
}
GoogleDriveService는 구글 드라이브 API를 사용해서 직접 구글 드라이브 저장소와 상호작용하는 클래스입니다.
클래스 기본 속성과 생성자
class GoogleDriveService {
final GoogleSignIn _googleSignIn = GoogleSignIn(
scopes: [drive.DriveApi.driveAppdataScope],
);
_googleSignIn:
구글 인증관리 패키지인 google_sign_in - GoogleSignIn의 인스턴스를 관리하는 객체입니다.
scopes는 애플리케이션이 접근할 권한을 정의합니다.
driveAppdataScope는 앱 데이터 폴더(appDataFolder)에 대한 읽기 및 쓰기 권한을 제공합니다. 이 폴더는 실제 구글 드라이브의 탐색기에서 보이는 파일이 아니라, 사용자 파일과 별도로 관리되는 앱 전용 저장소입니다.
사용자 인증 및 API 초기화
GoogleSignInAccount? _currentUser;
drive.DriveApi? _driveApi;
_currentUser : 현재 로그인한 사용자 정보를 저장합니다.
_driveApi : Google Drive API가 담긴 객체입니다.
Google Drive API 인스턴스 생성 : getDriveApi()
Future<drive.DriveApi?> getDriveApi(GoogleSignInAccount googleUser) async {
try {
Map<String, String> headers = await googleUser.authHeaders;
GoogleAuthClient client = GoogleAuthClient(headers);
driveApi = drive.DriveApi(client);
} catch (e) {
debugPrint(e.toString());
}
return driveApi;
}
getDriveApi 메서드는 구글 계정 정보(GoogleSignInAccount)가 담긴 googleUser를 인수로 받습니다.
GoogleSignInAccount의 authHeaders 속성을 이용해서, Google 사용자 인증 헤더를 가져옵니다.
맨 처음에 직접 생성했던 클래스 모델인 GoogleAuthClient를 사용하여 HTTP 요청에 인증 헤더를 자동으로 추가합니다.
drive.DriveApi()를 통해, 인증된 DriveApi 인스턴스인 driveApi를 반환합니다.
로그인 여부 확인 / 로그인 : signInGoogle()
Future<GoogleSignInAccount?> signInGoogle() async {
try {
_currentUser = await _googleSignIn.signInSilently() ?? await _googleSignIn.signIn();
if (_currentUser != null) {
_driveApi = await getDriveApi(_currentUser!);
}
} catch (e) {
debugPrint('Error during Google Sign-In: $e');
}
return _currentUser;
}
Google 계정으로 로그인하거나, 이전에 로그인한 적이 있다면 signInSilently()을 이용해서 자동으로 로그인합니다.
로그인 성공 시 getDriveApi()를 호출하여 DriveApi 인스턴스인 _driveApi에 로그인 인증을 받은 driveApi를 재설정합니다.
이제 _driveApi로 각종 구글 드라이브 작업을 진행할 수 있습니다.
파일 업로드 : uploadFile
Future<drive.File?> uploadFile({required File file, String? driveFileId}) async {
if (_driveApi == null) return null;
drive.File driveFile = drive.File();
driveFile.name = path.basename(file.absolute.path);
late final response;
if (driveFileId != null) {
response = await _driveApi!.files.update(
driveFile,
driveFileId,
uploadMedia: drive.Media(file.openRead(), file.lengthSync()),
);
} else {
driveFile.parents = ["appDataFolder"];
response = await _driveApi!.files.create(
driveFile,
uploadMedia: drive.Media(file.openRead(), file.lengthSync()),
);
}
return response;
}
DriveFileId(굿위시에서는 HiveBox의 이름으로 지정)를 인수로 받습니다. 현재까지 저장된 굿위시 데이터들을 각각의 DriveFileId에 맞춰서 저장하게 해주는 메소드입니다.
driveFileId에 이미 파일이 있을 경우 새 데이터로 업데이트, 없을 경우 새로 업로드합니다.
파일이 새로 업로드될 경우 appDataFolder에 저장됩니다. (아까 권한 허용했던 특별 저장공간)
drive.Media 객체를 사용하여 파일 데이터를 스트림 형식으로 업로드합니다.
파일 삭제 : deleteFile
Future<void> deleteFile(String driveFileId) async {
if (_driveApi == null) return;
try {
await _driveApi!.files.delete(driveFileId);
} catch (e) {
debugPrint('Error deleting file with ID $driveFileId: $e');
}
}
인수로 전달받은 driveFileId의 파일을 삭제합니다.
파일 다운 : downloadFile
Future<File?> downloadFile({required String driveFileId, required String localPath}) async {
if (_driveApi == null) return null;
drive.Media media = await _driveApi!.files.get(
driveFileId,
downloadOptions: drive.DownloadOptions.fullMedia,
) as drive.Media;
List<int> data = [];
await media.stream.forEach((element) {
data.addAll(element);
});
File file = File(localPath);
file.writeAsBytesSync(data);
return file;
}
driveFileId에 해당하는 파일을 앱에 다운로드합니다.
다운받은 데이터는 List<int> 타입으로 수집한 뒤, 앱의 로컬 파일에 저장하고, 해당 파일의 위치를 반환합니다.
파일 검색 : getDriveFile
Future<drive.File?> getDriveFile({required String filename}) async {
if (_driveApi == null) return null;
drive.FileList fileList = await _driveApi!.files.list(
spaces: "appDataFolder",
$fields: "files(id,name,modifiedTime)",
);
List<drive.File>? files = fileList.files;
try {
drive.File? driveFile = files?.firstWhere(
(element) => element.name == filename.toLowerCase(),
);
return driveFile;
} catch (e) {
debugPrint('File not found: $filename');
return null;
}
}
appDataFolder에 저장된 파일 목록을 검색합니다.
filename과 이름이 일치하는 파일을 반환합니다.
파일이 없을 경우 null을 반환하며 오류를 출력합니다.
구글 드라이브로 받아온 데이터를 HiveBox에 저장하는 클래스 : HiveBackupRestore
import 'dart:io';
import 'package:goodwishes/Functions/google_drive_service.dart';
import 'package:hive/hive.dart';
class HiveBackupRestore {
final GoogleDriveService _googleDriveService = GoogleDriveService();
Future<void> backupHiveBox<T>(String boxName) async {
final box = Hive.isBoxOpen(boxName)
? Hive.box<T>(boxName)
: await Hive.openBox<T>(boxName);
final boxPath = box.path!;
final backupFile = File(boxPath);
if (_googleDriveService.currentUser == null) {
await _googleDriveService.signInGoogle();
}
final existingFile = await _googleDriveService.getDriveFile(
filename: '${boxName}.hive'.toLowerCase());
if (existingFile != null) {
await _googleDriveService.deleteFile(existingFile.id!);
}
final driveFile = await _googleDriveService.uploadFile(file: backupFile);
print('Backup completed for $boxName: ${driveFile?.name}');
}
Future<void> restoreHiveBox<T>(String boxName) async {
if (_googleDriveService.currentUser == null) {
await _googleDriveService.signInGoogle();
}
final box = Hive.isBoxOpen(boxName)
? Hive.box<T>(boxName)
: await Hive.openBox<T>(boxName);
final boxPath = box.path!;
final driveFile =
await _googleDriveService.getDriveFile(filename: '$boxName.hive');
print('Drive file for $boxName: $driveFile');
if (driveFile != null) {
final downloadedFile = await _googleDriveService.downloadFile(
driveFileId: driveFile.id!, localPath: boxPath);
print('Restore completed for $boxName');
} else {
print('No file found for $boxName in Google Drive');
}
}
}
Hive 데이터베이스를 Google Drive에 백업하거나 복원하는 코드입니다. Hive는 Flutter의 <키-값> 데이터베이스이고, Google Drive는 이 데이터를 클라우드에 저장하고 복구하는 저장소로 사용됩니다. 둘의 특성에 맞춰서 데이터를 저장하고 불러오게끔 합니다.
클래스 기본 속성과 생성자
class HiveBackupRestore {
final GoogleDriveService _googleDriveService = GoogleDriveService();
_googleDriveService : 방금 만든 클래스인 GoogleDriveService 인스턴스를 사용합니다.
이걸 가지고 Google 인증 및 파일 관리(업로드, 다운로드, 삭제)를 합니다.
Hive Box 백업 : backupHiveBox
Future<void> backupHiveBox<T>(String boxName) async {
final box = Hive.isBoxOpen(boxName)
? Hive.box<T>(boxName)
: await Hive.openBox<T>(boxName);
final boxPath = box.path!;
final backupFile = File(boxPath);
if (_googleDriveService.currentUser == null) {
await _googleDriveService.signInGoogle();
}
final existingFile = await _googleDriveService.getDriveFile(
filename: '${boxName}.hive'.toLowerCase());
if (existingFile != null) {
await _googleDriveService.deleteFile(existingFile.id!);
}
final driveFile = await _googleDriveService.uploadFile(file: backupFile);
print('Backup completed for $boxName: ${driveFile?.name}');
}
1. Box 경로 확인
Hive.isBoxOpen으로 Box가 열려 있는지 확인합니다.
Box가 열려 있지 않으면 Hive.openBox()로 열고 경로(boxpath = box.path!)를 가져옵니다.
2. Google 인증 확인
_googleDriveService.currentUser가 null이면 signInGoogle()을 호출해 로그인합니다.
3. 기존 백업 삭제
백업 방식은 '기존 데이터를 모두 삭제하고, 현재 데이터를 넣기'입니다.
getDriveFile()로 동일한 이름의 파일(boxName.hive)이 Google Drive에 존재하는지 확인합니다.
파일이 존재하면 deleteFile()로 삭제합니다.
4.새로운 파일 업로드
Hive Box의 파일 경로가 담긴 backupFile을 인수로 전달해서 Google Drive에 새 파일을 업로드합니다.
Hive Box 복원 : restoreHiveBox
Future<void> restoreHiveBox<T>(String boxName) async {
if (_googleDriveService.currentUser == null) {
await _googleDriveService.signInGoogle();
}
final box = Hive.isBoxOpen(boxName)
? Hive.box<T>(boxName)
: await Hive.openBox<T>(boxName);
final boxPath = box.path!;
final driveFile =
await _googleDriveService.getDriveFile(filename: '$boxName.hive');
print('Drive file for $boxName: $driveFile');
if (driveFile != null) {
final downloadedFile = await _googleDriveService.downloadFile(
driveFileId: driveFile.id!, localPath: boxPath);
print('Restore completed for $boxName');
} else {
print('No file found for $boxName in Google Drive');
}
}
1. Google 인증 확인
_googleDriveService.currentUser가 null이면 signInGoogle()을 호출해 로그인합니다.
2. Box 열기
Box가 열려 있지 않으면 Hive.openBox()로 열고 경로(boxpath = box.path!)를 가져옵니다.
3. Drive 파일 검색
getDriveFile()로 '$boxName.hive' 이름의 백업 파일을 Google Drive에서 검색합니다.
4. 파일 다운로드
구글 드라이브에 백업 파일이 존재하면, downloadFile()로 다운로드하여 Hive Box 경로에 저장합니다.
출력 페이지 : BackupRestorePage
// backup_restore_page.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:goodwishes/Functions/google_backup_restore.dart';
import 'package:goodwishes/Functions/show_info_dialog.dart';
import 'package:goodwishes/Functions/show_loading_dialog.dart';
import 'package:goodwishes/Models/category_model.dart';
import 'package:goodwishes/Models/goods_model.dart';
import 'package:goodwishes/Models/profile_model.dart';
import 'package:goodwishes/Models/wish_model.dart';
import 'package:goodwishes/widgets/section_title.dart';
class BackupRestorePage extends StatelessWidget {
final HiveBackupRestore _hiveBackupRestore = HiveBackupRestore();
BackupRestorePage({super.key});
@override
Widget build(BuildContext context) {
Future<void> backupAllData() async {
await showInfoDialog(
context,
'경고',
'백업을 진행하면, 기존의 백업데이터는 사라집니다.',
onCancled: true,
onPressed: () async {
Navigator.pop(context);
showLoadingDialog(context);
await _hiveBackupRestore.backupHiveBox<Goods>('goodsListBox');
await _hiveBackupRestore.backupHiveBox<Wish>('wishListBox');
await _hiveBackupRestore.backupHiveBox<Category>('categoryListBox');
await _hiveBackupRestore.backupHiveBox<Profile>('profileBox');
await _hiveBackupRestore
.backupHiveBox<Category>('wishCategoryListBox');
Navigator.pop(context);
await showInfoDialog(
context,
'알림',
'백업이 완료됐습니다.',
);
},
);
}
Future<void> restoreAllData() async {
await showInfoDialog(
context,
'경고',
'복원을 진행하면, 앱이 재시작됩니다.',
onCancled: true,
onPressed: () async {
Navigator.pop(context);
showLoadingDialog(context);
await _hiveBackupRestore.restoreHiveBox<Goods>('goodsListBox');
await _hiveBackupRestore.restoreHiveBox<Wish>('wishListBox');
await _hiveBackupRestore.restoreHiveBox<Category>('categoryListBox');
await _hiveBackupRestore.restoreHiveBox<Profile>('profileBox');
await _hiveBackupRestore
.restoreHiveBox<Category>('wishCategoryListBox');
Navigator.pop(context);
showInfoDialog(
context,
'알림',
'앱이 재시작됩니다.',
onPressed: () {
// 앱 종료
if (Platform.isIOS) {
exit(0);
} else {
SystemNavigator.pop();
}
},
);
},
);
}
return Scaffold(
appBar: AppBar(
title: const SectionTitle(titleText: 'Backup & Restore'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const SectionTitle(
titleText: '구글 드라이브 계정이 필요합니다',
),
const SizedBox(
height: 40,
),
ElevatedButton(
onPressed: () async {
await backupAllData();
},
child: const Text('구글 드라이브에 백업파일 저장하기'),
),
ElevatedButton(
onPressed: () async {
await restoreAllData();
},
child: const Text('구글 드라이브에 백업파일 복원하기'),
),
],
),
),
);
}
}
맨 처음에 보여드린 백업 & 복원 페이지의 소스 코드입니다.
복원 진행방식
앱을 아예 초기화 한 채로, 복원을 진행합니다. (사전에 구글 드라이브에 데이터를 백업해놨습니다.)
파일 백업과 복원 방식이 기존의 데이터를 다 지우는 방식이기 때문에 경고문구를 표시합니다.
백업과 복원이 진행될 때 로딩창을 표시합니다.
! - 만약 구글드라이브 첫 로그인일 경우, 자동으로 로그인 팝업이 뜹니다.
복원이 끝나면 앱이 종료되고, 다시 들어가면 모든 데이터가 복원된 것을 확인할 수 있습니다.
백업 진행방식
파일 백업과 복원 방식이 기존의 데이터를 다 지우는 방식이기 때문에 경고문구를 표시합니다.
백업과 복원이 진행될 때 로딩창을 표시합니다.
완료되면 완료 문구를 표시합니다.
플레이스토어 업데이트 확인
구글 플레이스토어에 업데이트 파일을 제출하니, 로컬스토리지와는 다르게 구글 드라이브는 반려되지 않고 잘 통과됐습니다.
끝.
'개발일지 > GoodWishes' 카테고리의 다른 글
[굿위시 제작기] 19. 구글 플레이스토어 출시과정 - 테스터 20명 모집하고 14일 기다리기 / 최종 스토어 출시 (0) | 2024.11.17 |
---|---|
[굿위시 제작기] 18. 구글 플레이스토어 출시과정 - 본인확인, 비공개 테스트 버전 앱 번들 제작하기 (0) | 2024.11.16 |
[굿위시 제작기] 17. 구글 플레이스토어 출시과정 - 스토어 기본 정보 등록 (0) | 2024.11.15 |
[굿위시 제작기] 16. 구글 플레이스토어 출시과정 - 회원가입과 앱 콘텐츠 정보 등록 (6) | 2024.11.14 |
[굿위시 제작기] 15. 프로필 페이지 제작하기 (0) | 2024.11.08 |