feat: add an abstract sync interface

This commit is contained in:
ZhuJHua
2025-03-02 23:25:29 +08:00
parent b6e116418b
commit 443de5d35a
9 changed files with 659 additions and 18 deletions

View File

@@ -0,0 +1,11 @@
import 'package:moodiary/common/values/sync_status.dart';
class SyncResult<T> {
final SyncStatus status;
T? data;
SyncResult({
required this.status,
this.data,
});
}

View File

@@ -0,0 +1,32 @@
/// 同步状态枚举
enum SyncStatus {
/// 已分配任务但还未执行
pending,
/// 正在同步中
syncing,
/// 同步成功
success,
/// 同步失败
failure,
/// 验证失败
invalid,
/// 未知状态
unknown,
}
/// 连接状态枚举
enum ConnectivityStatus {
/// 未连接
disconnected,
/// 正在连接
connecting,
/// 已连接
connected,
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:moodiary/common/values/webdav.dart';
import 'package:moodiary/presentation/pref.dart';
import 'package:moodiary/presentation/secure_storage.dart';
import 'package:moodiary/utils/notice_util.dart';
import 'package:moodiary/utils/webdav_util.dart';
import 'package:refreshed/refreshed.dart';
@@ -27,6 +28,7 @@ class WebDavLogic extends GetxController {
if (state.hasOption.value) {
await checkConnectivity();
}
await checkHasUserKey();
super.onReady();
}
@@ -41,6 +43,11 @@ class WebDavLogic extends GetxController {
super.onClose();
}
Future<void> checkHasUserKey() async {
state.hasUserKey.value =
(await SecureStorageUtil.getValue('userKey')) != null;
}
Future<void> checkConnectivity() async {
state.connectivityStatus.value = WebDavConnectivityStatus.connecting;
final res = await webDav.checkConnectivity();
@@ -111,4 +118,9 @@ class WebDavLogic extends GetxController {
await PrefUtil.setValue<bool>('autoSyncAfterChange', value);
state.autoSyncAfterChange.value = value;
}
void setSyncEncryption(bool value) async {
await PrefUtil.setValue<bool>('syncEncryption', value);
state.syncEncryption.value = value;
}
}

View File

@@ -18,5 +18,8 @@ class WebDavState {
RxBool autoSyncAfterChange =
PrefUtil.getValue<bool>('autoSyncAfterChange')!.obs;
RxBool syncEncryption = PrefUtil.getValue<bool>('syncEncryption')!.obs;
RxBool hasUserKey = false.obs;
WebDavState();
}

View File

@@ -59,6 +59,14 @@ class WebDavComponent extends StatelessWidget {
subtitle: l10n.webdavSyncAfterChangeDes,
);
}),
Obx(() {
return AdaptiveSwitchListTile(
value: state.syncEncryption.value,
onChanged: state.hasUserKey.value ? logic.setSyncEncryption : null,
title: Text(l10n.webdavSyncEncryption),
subtitle: l10n.webdavSyncEncryptionDes,
);
}),
Padding(
padding: const EdgeInsets.all(16.0),
child: Form(

View File

@@ -0,0 +1,108 @@
// import 'dart:ui';
//
// import 'package:minio/minio.dart';
// import 'package:moodiary/common/models/isar/diary.dart';
// import 'package:moodiary/common/models/sync/sync.dart';
// import 'package:moodiary/services/sync/sync.dart';
//
// class MinioSyncServiceImpl implements SyncService {
// final String endPoint;
//
// final String accessKey;
//
// final String secretKey;
//
// final String bucketName;
//
// late final Minio _client;
//
// MinioSyncServiceImpl({
// required this.endPoint,
// required this.accessKey,
// required this.secretKey,
// required this.bucketName,
// });
//
// @override
// Future<void> init() async {
// _client = Minio(
// endPoint: endPoint,
// accessKey: accessKey,
// secretKey: secretKey,
// );
// }
//
// @override
// Future<bool> checkConnectivity() {
// // TODO: implement checkConnectivity
// throw UnimplementedError();
// }
//
// @override
// Future<SyncResult<Map<String, String>>> fetchServerSyncData() {
// // TODO: implement fetchServerSyncData
// throw UnimplementedError();
// }
//
// @override
// Future<SyncResult> updateServerSyncData(Map<String, String> syncData) {
// // TODO: implement updateServerSyncData
// throw UnimplementedError();
// }
//
// @override
// Future<SyncResult> deleteDiary({
// required Diary diary,
// VoidCallback? onStart,
// VoidCallback? onComplete,
// Function(int p1, int p2)? onProgress,
// }) {
// // TODO: implement deleteDiary
// throw UnimplementedError();
// }
//
// @override
// Future<SyncResult> downloadDiary({
// required String diaryId,
// VoidCallback? onStart,
// VoidCallback? onComplete,
// Function(int p1, int p2)? onProgress,
// }) {
// // TODO: implement downloadDiary
// throw UnimplementedError();
// }
//
// @override
// Future<SyncResult> updateDiary({
// required Diary oldDiary,
// required Diary newDiary,
// VoidCallback? onStart,
// VoidCallback? onComplete,
// Function(int p1, int p2)? onProgress,
// }) {
// // TODO: implement updateDiary
// throw UnimplementedError();
// }
//
// @override
// Future<SyncResult> uploadDiary({
// required Diary diary,
// VoidCallback? onStart,
// VoidCallback? onComplete,
// Function(int p1, int p2)? onProgress,
// }) {
// // TODO: implement uploadDiary
// throw UnimplementedError();
// }
//
// @override
// Future<SyncResult> syncDiary({
// required List<Diary> diaries,
// VoidCallback? onStart,
// VoidCallback? onComplete,
// Function(int p1, int p2)? onProgress,
// }) {
// // TODO: implement syncDiary
// throw UnimplementedError();
// }
// }

View File

@@ -0,0 +1,266 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:moodiary/common/models/isar/diary.dart';
import 'package:moodiary/common/models/sync/sync.dart';
import 'package:moodiary/common/values/sync_status.dart';
import 'package:moodiary/common/values/webdav.dart';
import 'package:moodiary/services/sync/sync.dart';
import 'package:moodiary/utils/log_util.dart';
import 'package:refreshed/refreshed.dart';
import 'package:webdav_client/webdav_client.dart' as webdav;
class WebdavSyncServiceImpl implements SyncService {
final String url;
final String username;
final String password;
late final webdav.Client _client;
final Rx<ConnectivityStatus> _connectivity =
ConnectivityStatus.connecting.obs;
bool get _isConnected => _connectivity.value == ConnectivityStatus.connected;
late Map<String, String> _syncStatus;
Timer? _connectivityTimer;
WebdavSyncServiceImpl({
required this.url,
required this.username,
required this.password,
});
@override
Future<void> init() async {
_client = webdav.newClient(
url,
user: username,
password: password,
debug: false,
);
await checkConnectivity();
startPolling();
}
void startPolling({Duration interval = const Duration(minutes: 1)}) {
_connectivityTimer?.cancel();
_connectivityTimer = Timer.periodic(interval, (timer) async {
await checkConnectivity();
});
}
void stopPolling() {
_connectivityTimer?.cancel();
}
@override
Future<void> checkConnectivity() async {
_connectivity.value = ConnectivityStatus.connecting;
try {
await _client.ping().timeout(const Duration(seconds: 3));
_connectivity.value = ConnectivityStatus.connected;
} catch (e) {
LogUtil.printError("WebDAV Error Check Connectivity", error: e);
_connectivity.value = ConnectivityStatus.disconnected;
}
}
Future<void> initDir() async {
if (!_isConnected) return;
final paths = [
WebDavOptions.imagePath,
WebDavOptions.videoPath,
WebDavOptions.audioPath,
WebDavOptions.diaryPath,
WebDavOptions.categoryPath,
];
for (final path in paths) {
try {
await _client.mkdirAll(path);
} catch (e) {
LogUtil.printError("创建目录失败: $path", error: e);
}
}
try {
await _client.read(WebDavOptions.syncFlagPath);
} catch (_) {
await _client.write(
WebDavOptions.syncFlagPath,
utf8.encode(jsonEncode({})),
);
}
}
@override
Future<SyncResult<Map<String, String>>> fetchServerSyncData() async {
if (!_isConnected) return SyncResult(status: SyncStatus.invalid);
try {
final response = await _client.read(WebDavOptions.syncFlagPath);
if (response.isNotEmpty) {
return SyncResult(
status: SyncStatus.success,
data: jsonDecode(utf8.decode(response)) as Map<String, String>,
);
}
} catch (e) {
LogUtil.printError("获取服务器同步数据失败", error: e);
}
return SyncResult(status: SyncStatus.failure);
}
@override
Future<SyncResult<bool>> updateServerSyncData(
Map<String, String> syncData,
) async {
if (!_isConnected) return SyncResult(status: SyncStatus.invalid);
try {
await _client.write(
WebDavOptions.syncFlagPath,
utf8.encode(jsonEncode(syncData)),
);
return SyncResult(status: SyncStatus.success, data: true);
} catch (e) {
LogUtil.printError("更新服务器同步数据失败", error: e);
return SyncResult(status: SyncStatus.failure);
}
}
@override
Future<SyncResult> deleteDiary({
required Diary diary,
VoidCallback? onStart,
VoidCallback? onComplete,
Function(int p1, int p2)? onProgress,
}) async {
if (!_isConnected) return SyncResult(status: SyncStatus.invalid);
if (!_syncStatus.containsKey(diary.id)) {
return SyncResult(status: SyncStatus.success);
}
_syncStatus[diary.id] = 'delete';
await updateServerSyncData(_syncStatus);
// 批量删除文件
try {
await Future.wait([
_deleteFile('${WebDavOptions.diaryPath}/${diary.id}.json'),
_deleteFile('${WebDavOptions.diaryPath}/${diary.id}.bin'),
_deleteFiles(
diary.imageName,
'${WebDavOptions.imagePath}/${diary.id}',
'image',
),
_deleteFiles(
diary.audioName,
'${WebDavOptions.audioPath}/${diary.id}',
'audio',
),
_deleteFiles(
diary.videoName,
'${WebDavOptions.videoPath}/${diary.id}',
'video',
),
_deleteFiles(
diary.videoName
.map(
(videoName) => 'thumbnail-${videoName.substring(6, 42)}.jpeg',
)
.toList(),
'${WebDavOptions.videoPath}/${diary.id}',
'thumbnail',
),
_deleteFile('${WebDavOptions.imagePath}/${diary.id}'),
_deleteFile('${WebDavOptions.audioPath}/${diary.id}'),
_deleteFile('${WebDavOptions.videoPath}/${diary.id}'),
]);
} catch (e) {
LogUtil.printError("删除日记失败", error: e);
return SyncResult(status: SyncStatus.failure);
}
return SyncResult(status: SyncStatus.success);
}
Future<void> _deleteFile(String path) async {
try {
await _client.remove(path);
} catch (e) {
rethrow;
}
}
Future<void> _deleteFiles(
List<String> fileNames,
String resourcePath,
String type,
) async {
for (final fileName in fileNames) {
await _deleteFile('$resourcePath/$fileName');
}
}
@override
Future<SyncResult> downloadDiary({
required String diaryId,
VoidCallback? onStart,
VoidCallback? onComplete,
Function(int p1, int p2)? onProgress,
}) async {
if (!_isConnected) return SyncResult(status: SyncStatus.invalid);
debugPrint("下载日记 $diaryId (未实现)");
return SyncResult(status: SyncStatus.failure);
}
@override
Future<SyncResult> uploadDiary({
required Diary diary,
VoidCallback? onStart,
VoidCallback? onComplete,
Function(int p1, int p2)? onProgress,
}) async {
if (!_isConnected) return SyncResult(status: SyncStatus.invalid);
debugPrint("上传日记 ${diary.id} (未实现)");
return SyncResult(status: SyncStatus.failure);
}
void dispose() {
stopPolling();
}
@override
Future<SyncResult> updateDiary({
required Diary oldDiary,
required Diary newDiary,
VoidCallback? onStart,
VoidCallback? onComplete,
Function(int p1, int p2)? onProgress,
}) {
// TODO: implement updateDiary
throw UnimplementedError();
}
@override
Future<SyncResult> syncDiary({
required List<Diary> diaries,
VoidCallback? onUpload,
VoidCallback? onDownload,
VoidCallback? onComplete,
Function(int p1, int p2)? onProgress,
}) async {
throw UnimplementedError();
}
@override
// TODO: implement hasConfig
bool get hasConfig => throw UnimplementedError();
@override
// TODO: implement connectivity
ConnectivityStatus get rxConnectivity => _connectivity.value;
}

153
lib/services/sync/sync.dart Normal file
View File

@@ -0,0 +1,153 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:moodiary/common/models/isar/diary.dart';
import 'package:moodiary/common/models/sync/sync.dart';
import 'package:moodiary/common/values/sync_status.dart';
import 'package:moodiary/services/sync/impl/webdav_impl.dart';
import 'package:refreshed/refreshed.dart';
/// 同步服务的抽象基类
abstract class SyncService {
/// 是否有配置
bool get hasConfig;
/// 连通性
/// 返回值为 [ConnectivityStatus]
/// 这是一个响应式变量,可以通过 [Obx] 进行监听
ConnectivityStatus get rxConnectivity;
/// 初始化方法
Future<void> init();
/// 连通性检查
Future<void> checkConnectivity();
/// 同步日记
/// 这个方法会自动进行增量同步
Future<SyncResult> syncDiary({
required List<Diary> diaries,
VoidCallback? onUpload,
VoidCallback? onDownload,
VoidCallback? onComplete,
Function(int, int)? onProgress,
});
/// 上传日记
/// 可选参数为加密
/// 返回值 [SyncResult] 为同步结果
/// [onStart] 为开始回调
/// [onComplete] 为完成回调
/// [onProgress] 为进度回调
Future<SyncResult> uploadDiary({
required Diary diary,
VoidCallback? onStart,
VoidCallback? onComplete,
Function(int, int)? onProgress,
});
/// 更新日记
/// 可选参数为加密
/// 返回值 [SyncResult] 为同步结果
/// [onStart] 为开始回调
/// [onComplete] 为完成回调
/// [onProgress] 为进度回调
Future<SyncResult> updateDiary({
required Diary oldDiary,
required Diary newDiary,
VoidCallback? onStart,
VoidCallback? onComplete,
Function(int, int)? onProgress,
});
/// 下载日记
/// 返回值 [SyncResult] 为同步结果
/// [onStart] 为开始回调
/// [onComplete] 为完成回调
/// [onProgress] 为进度回调
Future<SyncResult> downloadDiary({
required String diaryId,
VoidCallback? onStart,
VoidCallback? onComplete,
Function(int, int)? onProgress,
});
/// 删除日记
/// 返回值 [SyncResult] 为同步结果
/// [onStart] 为开始回调
/// [onComplete] 为完成回调
/// [onProgress] 为进度回调
Future<SyncResult> deleteDiary({
required Diary diary,
VoidCallback? onStart,
VoidCallback? onComplete,
Function(int, int)? onProgress,
});
/// 获取服务器同步数据
/// 文件名为 [sync.json]
/// 返回值 [SyncResult] 为同步结果
/// [Map] 为同步数据
Future<SyncResult<Map<String, String>>> fetchServerSyncData();
/// 更新服务器同步数据
Future<SyncResult> updateServerSyncData(Map<String, String> syncData);
}
/// 同步服务的具体实现
/// 通过 [WebdavSyncServiceImpl] 进行同步
class WebdavSyncService extends GetxService {
late final SyncService _webdavSyncService;
Future<void> init({
required String baseUrl,
required String username,
required String password,
}) async {
_webdavSyncService = WebdavSyncServiceImpl(
url: baseUrl,
username: username,
password: password,
);
await _webdavSyncService.init();
}
@override
void onInit() {
// TODO: implement onInit
super.onInit();
}
@override
void onClose() {
// TODO: implement onClose
super.onClose();
}
@override
void onReady() {
// TODO: implement onReady
super.onReady();
}
}
// /// 通过 MinIO 对象存储进行同步
// /// 通过 [MinioSyncServiceImpl] 进行同步
// class MinioSyncService extends GetxService {
// late final SyncService _minioSyncService;
//
// Future<void> init({
// required String endPoint,
// required String accessKey,
// required String secretKey,
// required String bucketName,
// }) async {
// _minioSyncService = MinioSyncServiceImpl(
// endPoint: endPoint,
// accessKey: accessKey,
// secretKey: secretKey,
// bucketName: bucketName,
// );
// await _minioSyncService.init();
// }
// }

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' as flutter;
import 'package:moodiary/common/models/isar/category.dart';
@@ -9,6 +10,8 @@ import 'package:moodiary/common/values/webdav.dart';
import 'package:moodiary/pages/home/diary/diary_logic.dart';
import 'package:moodiary/presentation/isar.dart';
import 'package:moodiary/presentation/pref.dart';
import 'package:moodiary/presentation/secure_storage.dart';
import 'package:moodiary/utils/aes_util.dart';
import 'package:moodiary/utils/file_util.dart';
import 'package:moodiary/utils/log_util.dart';
import 'package:refreshed/refreshed.dart';
@@ -40,7 +43,6 @@ class WebDavUtil {
_client = null;
}
// 尝试连接,如果失败,
try {
_client = webdav.newClient(
webDavOption[0],
@@ -134,6 +136,7 @@ class WebDavUtil {
await updateServerSyncData(serverSyncData);
// 删除日记json
await _client!.remove('${WebDavOptions.diaryPath}/${diary.id}.json');
await _client!.remove('${WebDavOptions.diaryPath}/${diary.id}.bin');
// 遍历删除日记资源文件
await _deleteFiles(
diary.imageName, '${WebDavOptions.imagePath}/${diary.id}', 'image');
@@ -341,7 +344,30 @@ class WebDavUtil {
}
}
Future<bool> _checkShouldEncrypt() async {
return PrefUtil.getValue<bool>('syncEncryption') == true &&
(await SecureStorageUtil.getValue('userKey')) != null;
}
Future<void> _uploadDiary(Diary diary) async {
Uint8List diaryData;
String diaryPath;
// 检查有没有开启加密
final shouldEncrypt = await _checkShouldEncrypt();
if (shouldEncrypt) {
// 尝试获取用户密钥
final userKey = await SecureStorageUtil.getValue('userKey');
// 生成加密密钥, 用日记 ID 和用户密钥生成
final key = await AesUtil.deriveKey(salt: diary.id, userKey: userKey!);
// 加密日记内容
diaryPath = '${WebDavOptions.diaryPath}/${diary.id}.bin';
diaryData =
await AesUtil.encrypt(key: key, data: jsonEncode(diary.toJson()));
} else {
diaryPath = '${WebDavOptions.diaryPath}/${diary.id}.json';
diaryData = utf8.encode(jsonEncode(diary.toJson()));
}
// 检查并上传分类
if (diary.categoryId != null) {
final categoryName =
@@ -350,20 +376,16 @@ class WebDavUtil {
await _uploadCategory(diary.categoryId!, categoryName);
}
}
// 上传日记 JSON 数据
final diaryPath = '${WebDavOptions.diaryPath}/${diary.id}.json';
final diaryData = jsonEncode(diary.toJson());
LogUtil.printInfo(diaryData);
try {
_client!.setHeaders({
'accept-charset': 'utf-8',
'Content-Type': 'application/json',
'Content-Type':
shouldEncrypt ? 'application/octet-stream' : 'application/json',
});
await _client!.write(diaryPath, utf8.encode(diaryData));
LogUtil.printInfo('Diary JSON uploaded: $diaryPath');
await _client!.write(diaryPath, diaryData);
LogUtil.printInfo('Diary uploaded: $diaryPath');
} catch (e) {
LogUtil.printInfo('Failed to upload diary JSON: $e');
LogUtil.printInfo('Failed to upload diary : $e');
rethrow;
}
@@ -422,17 +444,43 @@ class WebDavUtil {
Future<Diary> _downloadDiary(String diaryId) async {
// 下载日记 JSON 数据
final diaryPath = '${WebDavOptions.diaryPath}/$diaryId.json';
final normalDiaryPath = '${WebDavOptions.diaryPath}/$diaryId.json';
final encryptedDiaryPath = '${WebDavOptions.diaryPath}/$diaryId.bin';
late Diary diary;
try {
final diaryData = await _client!.read(diaryPath);
diary = await flutter.compute(Diary.fromJson,
jsonDecode(utf8.decode(diaryData)) as Map<String, dynamic>);
LogUtil.printInfo('Diary JSON downloaded: $diaryPath');
// 先尝试普通 JSON 格式
try {
final diaryData = await _client!.read(normalDiaryPath);
diary = await flutter.compute(Diary.fromJson,
jsonDecode(utf8.decode(diaryData)) as Map<String, dynamic>);
LogUtil.printInfo('Diary JSON downloaded: $normalDiaryPath');
} catch (e) {
LogUtil.printInfo('Failed to download normal JSON: $e');
// 再尝试二进制格式
try {
final encryptedDiaryData = await _client!.read(encryptedDiaryPath);
// 解密日记内容
final userKey = await SecureStorageUtil.getValue('userKey');
final shouldEncrypt = await _checkShouldEncrypt();
if (!shouldEncrypt) {
throw Exception('User key not found or encryption not enabled');
}
final key = await AesUtil.deriveKey(salt: diaryId, userKey: userKey!);
final decryptedData = await AesUtil.decrypt(
key: key,
encryptedData: Uint8List.fromList(encryptedDiaryData),
);
diary = await flutter.compute(Diary.fromJson,
jsonDecode(decryptedData) as Map<String, dynamic>);
LogUtil.printInfo('Diary binary downloaded: $encryptedDiaryPath');
} catch (e) {
LogUtil.printInfo('Failed to download binary diary: $e');
// 两种方式都失败,抛出最终异常
rethrow;
}
}
} catch (e) {
LogUtil.printInfo('Failed to download diary JSON: $e');
rethrow;
throw Exception('Failed to download diary: $e');
}
// 同步分类