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>.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.broadcast(); Stream 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 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 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 _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 _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 _encryptFile( File inputFile, Uint8List mediaKey, List 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 _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();