twonly-app/lib/src/services/memories/memories_cloud.service.dart
otsmr 96f8bc39d0
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
start with cloud backup
2026-07-19 20:24:14 +02:00

394 lines
11 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
as server;
import 'package:twonly/src/model/protobuf/client/generated/backup.pb.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
class MemoriesBackupProgress {
MemoriesBackupProgress({
required this.totalPending,
required this.currentUploaded,
required this.currentUploadProgress,
this.currentMediaId,
});
final int totalPending;
final int currentUploaded;
final double currentUploadProgress;
final String? currentMediaId;
}
class ProgressMultipartRequest extends http.MultipartRequest {
ProgressMultipartRequest(super.method, super.url, {this.onProgress});
final void Function(int bytes, int totalBytes)? onProgress;
@override
http.ByteStream finalize() {
final byteStream = super.finalize();
if (onProgress == null) return byteStream;
final total = contentLength;
var bytes = 0;
final transformer = StreamTransformer<List<int>, List<int>>.fromHandlers(
handleData: (data, sink) {
bytes += data.length;
onProgress!(bytes, total);
sink.add(data);
},
);
return http.ByteStream(byteStream.transform(transformer));
}
}
class MemoriesCloudService {
Timer? _timer;
bool _isProcessing = false;
final _progressController =
StreamController<MemoriesBackupProgress>.broadcast();
Stream<MemoriesBackupProgress> get progressStream =>
_progressController.stream;
MemoriesBackupProgress? _currentProgress;
MemoriesBackupProgress? get currentProgress => _currentProgress;
void init() {
_timer = Timer.periodic(const Duration(minutes: 5), (_) {
checkUploads();
});
// Run immediately
Future.delayed(const Duration(seconds: 10), checkUploads);
}
void dispose() {
_timer?.cancel();
_progressController.close();
}
void _updateProgress({
required int totalPending,
required int currentUploaded,
required double currentUploadProgress,
String? currentMediaId,
}) {
_currentProgress = MemoriesBackupProgress(
totalPending: totalPending,
currentUploaded: currentUploaded,
currentUploadProgress: currentUploadProgress,
currentMediaId: currentMediaId,
);
_progressController.add(_currentProgress!);
}
Future<void> checkUploads() async {
if (_isProcessing || !userService.currentUser.isBackupEnabled) return;
try {
final memories = await twonlyDB.mediaFilesDao.getMemoriesToBackup();
if (memories.isEmpty) {
return;
}
_isProcessing = true;
final total = memories.length;
var uploaded = 0;
_updateProgress(
totalPending: total,
currentUploaded: uploaded,
currentUploadProgress: 0,
);
for (final mediaFile in memories) {
_updateProgress(
totalPending: total,
currentUploaded: uploaded,
currentUploadProgress: 0,
currentMediaId: mediaFile.mediaId,
);
final success = await _backupMemory(mediaFile, (progress) {
_updateProgress(
totalPending: total,
currentUploaded: uploaded,
currentUploadProgress: progress,
currentMediaId: mediaFile.mediaId,
);
});
if (success) {
uploaded++;
}
_updateProgress(
totalPending: total,
currentUploaded: uploaded,
currentUploadProgress: 0,
);
}
} catch (e) {
Log.error('Error in MemoriesCloudService.checkUploads: $e');
} finally {
_currentProgress = null;
_isProcessing = false;
}
}
static Future<bool> downloadThumbnail(MediaFileService media) async {
final urls = await apiService.getMemoriesUrl(media.mediaFile.mediaId, true);
if (urls == null || !urls.hasFullDownloadUrl()) return false;
try {
final response = await http.get(Uri.parse(urls.fullDownloadUrl));
if (response.statusCode == 200) {
return await _decryptFile(response.bodyBytes, media.thumbnailPath);
} else {
Log.warn(
'Failed to download thumbnai statuscode ${response.statusCode}',
);
}
} catch (e) {
Log.warn(e);
}
return false;
}
Future<bool> _backupMemory(
MediaFile mediaFile,
void Function(double progress) onProgress,
) async {
try {
final ms = MediaFileService(mediaFile);
if (!ms.storedPath.existsSync()) return false;
if (!mediaFile.hasThumbnail) {
await MediaFileService(mediaFile).createThumbnail();
}
final sizeBytes = ms.storedPath.lengthSync();
final urls = await apiService.requestMemoriesUpload(
sizeBytes,
mediaFile.createdAt,
mediaFile.mediaId,
);
if (urls == null) {
Log.error('Could not get upload URLs for memory ${mediaFile.mediaId}');
return false;
}
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(
cloudState: Value(CloudState.pending),
),
);
final mediaKey = getRandomUint8List(32);
final encryptedMediaKey = await RustKeyManager.encryptCloudMediaKey(
mediaKey: mediaKey,
addition: 'app',
);
final tempDir = await getTemporaryDirectory();
// 1. Upload thumbnail if exists
if (ms.thumbnailPath.existsSync() && urls.hasThumbnailUpload()) {
final thumbFile = await _encryptFile(
ms.thumbnailPath,
mediaKey,
encryptedMediaKey,
tempDir,
'thumb_${mediaFile.mediaId}',
);
try {
await _uploadToS3(urls.thumbnailUpload, thumbFile, (_) {});
} finally {
if (thumbFile.existsSync()) {
thumbFile.deleteSync();
}
}
}
// 2. Upload full media if exists
if (urls.hasFullUpload()) {
final fullFile = await _encryptFile(
ms.storedPath,
mediaKey,
encryptedMediaKey,
tempDir,
'full_${mediaFile.mediaId}',
);
try {
await _uploadToS3(urls.fullUpload, fullFile, onProgress);
} finally {
if (fullFile.existsSync()) {
fullFile.deleteSync();
}
}
}
// 3. Confirm upload
final confirmRes = await apiService.confirmMemoriesUpload(
mediaFile.mediaId,
);
if (confirmRes.isSuccess) {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(
cloudState: Value(CloudState.uploaded),
),
);
Log.info(
'Cloud backup complete and confirmed for ${mediaFile.mediaId}',
);
return true;
} else {
throw Exception('Server confirmation failed: ${confirmRes.error}');
}
} catch (e) {
Log.error('Error backing up memory ${mediaFile.mediaId}: $e');
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(
cloudState: Value(CloudState.none),
),
);
return false;
}
}
Future<void> _uploadToS3(
server.Response_PresignedPost presignedPost,
File file,
void Function(double progress) onProgress,
) async {
final request = ProgressMultipartRequest(
'POST',
Uri.parse(presignedPost.url),
onProgress: (bytes, total) {
if (total > 0) {
onProgress(bytes / total);
}
},
);
request.fields.addAll(presignedPost.fields);
request.files.add(
await http.MultipartFile.fromPath(
'file',
file.path,
filename: 'file',
),
);
final streamedResponse = await request.send();
final response = await http.Response.fromStream(streamedResponse);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(
'S3 upload failed: ${response.statusCode} - ${response.body}',
);
}
}
Future<File> _encryptFile(
File inputFile,
Uint8List mediaKey,
List<int> encryptedMediaKey,
Directory tempDir,
String prefix,
) async {
final dataToEncrypt = await inputFile.readAsBytes();
final chacha20 = FlutterChacha20.poly1305Aead();
final nonce = chacha20.newNonce();
final secretBox = await chacha20.encrypt(
dataToEncrypt,
secretKey: SecretKey(mediaKey),
nonce: nonce,
);
final cipherTextWithMac = Uint8List.fromList(
secretBox.cipherText + secretBox.mac.bytes,
);
final payload = CloudMediaBackupEncrypted()
..addition = 'app'
..encryptedMediaKey = encryptedMediaKey
..mediaNonce = nonce
..mediaCiphertext = cipherTextWithMac;
final outFile = File('${tempDir.path}/$prefix.encrypted');
await outFile.writeAsBytes(payload.writeToBuffer());
return outFile;
}
static Future<bool> _decryptFile(
Uint8List encryptedData,
File outFile,
) async {
try {
// 2. Parse the protobuf payload FIRST to access the key and addition
final payload = CloudMediaBackupEncrypted.fromBuffer(encryptedData);
// 3. Get the media key using the encrypted key and addition from the payload
final mediaKey = await RustKeyManager.decryptCloudMediaKey(
encryptedMediaKey: payload.encryptedMediaKey,
addition: payload.addition,
);
// 4. Extract the concatenated ciphertext + MAC, and the nonce
final nonce = payload.mediaNonce;
final cipherTextWithMac = payload.mediaCiphertext;
// 5. Separate the ciphertext and the MAC (Poly1305 MAC is always 16 bytes)
const macLength = 16;
final cipherTextLength = cipherTextWithMac.length - macLength;
final cipherText = cipherTextWithMac.sublist(0, cipherTextLength);
final macBytes = cipherTextWithMac.sublist(cipherTextLength);
// 6. Reconstruct the SecretBox
final secretBox = SecretBox(
cipherText,
nonce: nonce,
mac: Mac(macBytes),
);
// 7. Decrypt the data using the newly retrieved media key
final chacha20 = FlutterChacha20.poly1305Aead();
final decryptedBytes = await chacha20.decrypt(
secretBox,
secretKey: SecretKey(mediaKey),
);
await outFile.writeAsBytes(decryptedBytes);
return true;
} catch (e) {
Log.error(e);
return false;
}
}
}
final memoriesCloudService = MemoriesCloudService();