import 'dart:async'; import 'dart:convert'; import 'package:background_downloader/background_downloader.dart'; import 'package:clock/clock.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'; import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:drift/drift.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:mutex/mutex.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/constants/secure_storage_keys.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/api/http/http_requests.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; import 'package:twonly/src/services/api/mediafiles/media_background.service.dart'; import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/services/flame.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; Future finishStartedPreprocessing() async { final mediaFiles = await twonlyDB.mediaFilesDao.getAllMediaFilesPendingUpload(); for (final mediaFile in mediaFiles) { if (mediaFile.isDraftMedia) { continue; } try { final service = MediaFileService(mediaFile); if (!service.originalPath.existsSync() && !service.uploadRequestPath.existsSync()) { if (service.storedPath.existsSync()) { // media files was just stored.. continue; } Log.info( 'Deleted media files, as originalPath and uploadRequestPath both do not exists', ); // the file does not exists anymore. await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId); continue; } await startBackgroundMediaUpload(service); } catch (e) { Log.warn(e); } } } Future initializeMediaUpload( MediaType type, int? displayLimitInMilliseconds, { bool isDraftMedia = false, }) async { if (displayLimitInMilliseconds != null && displayLimitInMilliseconds < 1000) { // in case the time was set in seconds... // ignore: parameter_assignments displayLimitInMilliseconds = displayLimitInMilliseconds * 1000; } final chacha20 = FlutterChacha20.poly1305Aead(); final encryptionKey = await (await chacha20.newSecretKey()).extract(); final encryptionNonce = chacha20.newNonce(); await twonlyDB.mediaFilesDao.updateAllMediaFiles( const MediaFilesCompanion(isDraftMedia: Value(false)), ); final mediaFile = await twonlyDB.mediaFilesDao.insertMedia( MediaFilesCompanion( uploadState: const Value(UploadState.initialized), displayLimitInMilliseconds: Value(displayLimitInMilliseconds), encryptionKey: Value(Uint8List.fromList(encryptionKey.bytes)), encryptionNonce: Value(Uint8List.fromList(encryptionNonce)), isDraftMedia: Value(isDraftMedia), type: Value(type), ), ); if (mediaFile == null) return null; return MediaFileService(mediaFile); } Future insertMediaFileInMessagesTable( MediaFileService mediaService, List groupIds, ) async { await twonlyDB.mediaFilesDao.updateAllMediaFiles( const MediaFilesCompanion( isDraftMedia: Value(false), ), ); for (final groupId in groupIds) { final message = await twonlyDB.messagesDao.insertMessage( MessagesCompanion( groupId: Value(groupId), mediaId: Value(mediaService.mediaFile.mediaId), type: const Value(MessageType.media), ), ); await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, clock.now()); if (message != null) { // de-archive contact when sending a new message await twonlyDB.groupsDao.updateGroup( message.groupId, const GroupsCompanion( archived: Value(false), deletedContent: Value(false), ), ); } else { Log.error('Error inserting media upload message in database.'); } } unawaited(startBackgroundMediaUpload(mediaService)); } Future startBackgroundMediaUpload(MediaFileService mediaService) async { if (mediaService.mediaFile.uploadState == UploadState.initialized || mediaService.mediaFile.uploadState == UploadState.preprocessing) { await mediaService.setUploadState(UploadState.preprocessing); if (!mediaService.tempPath.existsSync()) { await mediaService.compressMedia(); if (!mediaService.tempPath.existsSync()) { return; } } // if the user has enabled auto storing and the file // was send with unlimited counter not in twonly-Mode then store the file if (gUser.autoStoreAllSendUnlimitedMediaFiles && !mediaService.mediaFile.requiresAuthentication && !mediaService.storedPath.existsSync() && mediaService.mediaFile.displayLimitInMilliseconds == null) { await mediaService.storeMediaFile(); } if (!mediaService.encryptedPath.existsSync()) { await _encryptMediaFiles(mediaService); if (!mediaService.encryptedPath.existsSync()) { return; } } if (!mediaService.uploadRequestPath.existsSync()) { await _createUploadRequest(mediaService); } if (mediaService.uploadRequestPath.existsSync()) { await mediaService.setUploadState(UploadState.uploading); // at this point the original file is not used any more, so it can be deleted mediaService.originalPath.deleteSync(); } } if (mediaService.mediaFile.uploadState == UploadState.uploading || mediaService.mediaFile.uploadState == UploadState.uploadLimitReached) { await _uploadUploadRequest(mediaService); } } Future _encryptMediaFiles(MediaFileService mediaService) async { /// if there is a video wait until it is finished with compression if (!mediaService.tempPath.existsSync()) { Log.error('Could not encrypted image as it does not exists'); return; } final dataToEncrypt = await mediaService.tempPath.readAsBytes(); final chacha20 = FlutterChacha20.poly1305Aead(); final secretBox = await chacha20.encrypt( dataToEncrypt, secretKey: SecretKey(mediaService.mediaFile.encryptionKey!), nonce: mediaService.mediaFile.encryptionNonce, ); await mediaService.setEncryptedMac(Uint8List.fromList(secretBox.mac.bytes)); mediaService.encryptedPath .writeAsBytesSync(Uint8List.fromList(secretBox.cipherText)); } Future _createUploadRequest(MediaFileService media) async { final downloadTokens = []; final messagesOnSuccess = []; final messages = await twonlyDB.messagesDao.getMessagesByMediaId(media.mediaFile.mediaId); if (messages.isEmpty) { // There where no user selected who should receive the image, so waiting with this step... return; } for (final message in messages) { final groupMembers = await twonlyDB.groupsDao.getGroupNonLeftMembers(message.groupId); if (media.mediaFile.reuploadRequestedBy == null) { await incFlameCounter(message.groupId, false, message.createdAt); } for (final groupMember in groupMembers) { /// only send the upload to the users if (media.mediaFile.reuploadRequestedBy != null) { if (!media.mediaFile.reuploadRequestedBy! .contains(groupMember.contactId)) { continue; } } final downloadToken = getRandomUint8List(32); late EncryptedContent_Media_Type type; switch (media.mediaFile.type) { case MediaType.audio: type = EncryptedContent_Media_Type.AUDIO; case MediaType.image: type = EncryptedContent_Media_Type.IMAGE; case MediaType.gif: type = EncryptedContent_Media_Type.GIF; case MediaType.video: type = EncryptedContent_Media_Type.VIDEO; } final notEncryptedContent = EncryptedContent( groupId: message.groupId, media: EncryptedContent_Media( senderMessageId: message.messageId, type: type, requiresAuthentication: media.mediaFile.requiresAuthentication, timestamp: Int64(message.createdAt.millisecondsSinceEpoch), downloadToken: downloadToken.toList(), encryptionKey: media.mediaFile.encryptionKey, encryptionNonce: media.mediaFile.encryptionNonce, encryptionMac: media.mediaFile.encryptionMac, ), ); if (media.mediaFile.displayLimitInMilliseconds != null) { notEncryptedContent.media.displayLimitInMilliseconds = Int64(media.mediaFile.displayLimitInMilliseconds!); } final cipherText = await sendCipherText( groupMember.contactId, notEncryptedContent, onlyReturnEncryptedData: true, ); if (cipherText == null) { Log.error( 'Could not generate ciphertext message for ${groupMember.contactId}', ); } final messageOnSuccess = TextMessage() ..body = cipherText!.$1 ..userId = Int64(groupMember.contactId); if (cipherText.$2 != null) { messageOnSuccess.pushData = cipherText.$2!; } messagesOnSuccess.add(messageOnSuccess); downloadTokens.add(downloadToken); } } final bytesToUpload = await media.encryptedPath.readAsBytes(); final uploadRequest = UploadRequest( messagesOnSuccess: messagesOnSuccess, downloadTokens: downloadTokens, encryptedData: bytesToUpload, ); final uploadRequestBytes = uploadRequest.writeToBuffer(); await media.uploadRequestPath.writeAsBytes(uploadRequestBytes); } Mutex protectUpload = Mutex(); Future _uploadUploadRequest(MediaFileService media) async { await protectUpload.protect(() async { final currentMedia = await twonlyDB.mediaFilesDao.getMediaFileById(media.mediaFile.mediaId); if (currentMedia == null || currentMedia.uploadState == UploadState.backgroundUploadTaskStarted) { Log.info('Download for ${media.mediaFile.mediaId} already started.'); return null; } final apiAuthTokenRaw = await const FlutterSecureStorage() .read(key: SecureStorageKeys.apiAuthToken); if (apiAuthTokenRaw == null) { Log.error('api auth token not defined.'); return null; } final apiAuthToken = uint8ListToHex(base64Decode(apiAuthTokenRaw)); final apiUrl = 'http${apiService.apiSecure}://${apiService.apiHost}/api/upload'; // try { Log.info('Starting upload from ${media.mediaFile.mediaId}'); final task = UploadTask.fromFile( taskId: 'upload_${media.mediaFile.mediaId}', displayName: media.mediaFile.type.name, file: media.uploadRequestPath, url: apiUrl, priority: 0, retries: 10, headers: { 'x-twonly-auth-token': apiAuthToken, }, ); final connectivityResult = await Connectivity().checkConnectivity(); if (!connectivityResult.contains(ConnectivityResult.mobile) && !connectivityResult.contains(ConnectivityResult.wifi)) { // no internet, directly put it into the background... await FileDownloader().enqueue(task); await media.setUploadState(UploadState.backgroundUploadTaskStarted); Log.info('Enqueue upload task: ${task.taskId}'); } else { unawaited(uploadFileFastOrEnqueue(task, media)); } }); } Future uploadFileFastOrEnqueue( UploadTask task, MediaFileService media, ) async { final requestMultipart = http.MultipartRequest( 'POST', Uri.parse(task.url), ); requestMultipart.headers.addAll(task.headers); requestMultipart.files.add( await http.MultipartFile.fromPath( 'file', await task.filePath(), filename: 'upload', ), ); try { Log.info('Uploading fast: ${task.taskId}'); final response = await requestMultipart.send().timeout(const Duration(seconds: 4)); var status = TaskStatus.failed; if (response.statusCode == 200) { status = TaskStatus.complete; } else if (response.statusCode == 404) { status = TaskStatus.notFound; } await handleUploadStatusUpdate( TaskStatusUpdate( task, status, null, null, null, response.statusCode, ), ); } catch (e) { Log.info('Upload failed enqueuing task...'); await FileDownloader().enqueue(task); await media.setUploadState(UploadState.backgroundUploadTaskStarted); } }