import 'dart:async'; import 'dart:io'; import 'package:clock/clock.dart'; import 'package:drift/drift.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:image/image.dart' as img; import 'package:path/path.dart'; import 'package:twonly/globals.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/services/mediafiles/compression.service.dart'; import 'package:twonly/src/services/mediafiles/thumbnail.service.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; class MediaFileService { MediaFileService(this.mediaFile); MediaFile mediaFile; static Future fromMediaId(String mediaId) async { final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(mediaId); if (mediaFile == null) return null; return MediaFileService( mediaFile, ); } static Future purgeTempFolder() async { try { final tempDirectory = MediaFileService.buildDirectoryPath( 'tmp', AppEnvironment.supportDir, ); final files = tempDirectory.listSync(); if (files.isEmpty) return; final mediaIdToFile = >{}; for (final file in files) { final mediaId = basename(file.path).split('.').first; mediaIdToFile.putIfAbsent(mediaId, () => []).add(file); } final mediaIds = mediaIdToFile.keys.toList(); // Bulk fetch media files and messages final allMediaFiles = await twonlyDB.mediaFilesDao.getMediaFilesByIds( mediaIds, ); final allMessages = await twonlyDB.messagesDao.getMessagesByMediaIds( mediaIds, ); final mediaFileMap = {for (final m in allMediaFiles) m.mediaId: m}; final messageMap = >{}; for (final msg in allMessages) { if (msg.mediaId != null) { messageMap.putIfAbsent(msg.mediaId!, () => []).add(msg); } } for (final mediaId in mediaIds) { // in case the mediaID is unknown the file will be deleted var delete = true; final mediaFile = mediaFileMap[mediaId]; if (mediaFile != null) { if (mediaFile.isDraftMedia) { delete = false; } // Never purge temp files while an upload is still in progress. // The temp file is actively needed for encryption/upload. if (mediaFile.uploadState != UploadState.uploaded && mediaFile.uploadState != UploadState.fileLimitReached) { delete = false; } final messages = messageMap[mediaId] ?? []; // in case messages in empty the file will be deleted, as delete is true by default for (final message in messages) { if (mediaFile.type == MediaType.audio) { delete = false; // do not delete voice messages } if (message.openedAt == null) { // Message was not yet opened from all persons, so wait... delete = false; } else if (message.openedAt!.isAfter( clock.now().subtract(const Duration(minutes: 3)), )) { // When the message was opened in the last two minutes, do not purge. // Bug: When the user opens an image immediately after starting the app, there is a race condition: // The message is marked as opened, but then purgeTempFolder is run // (it is unawaited) and deletes the file. Thi gives a grace period: // The image must have been opened within the last two minutes, otherwise do not delete it. delete = false; } else if (mediaFile.requiresAuthentication || mediaFile.displayLimitInMilliseconds != null) { // Message was opened by all persons, and they can not reopen the image. } else if (message.openedAt!.isAfter( clock.now().subtract(const Duration(days: 2)), )) { // In case the image was opened, but send with unlimited time or no authentication. if (message.senderId == null) { delete = false; } else { // Check weather the image was send in a group. Then the images is preserved for two days in case another person stores the image. // This also allows to reopen this image for two days. final group = await twonlyDB.groupsDao.getGroup( message.groupId, ); if (group != null && !group.isDirectChat) { delete = false; } } // In case the app was send in a direct chat, then it can be deleted. } } } if (delete) { Log.info('Purging media file $mediaId'); final filesToPurge = mediaIdToFile[mediaId] ?? []; for (final file in filesToPurge) { try { if (file.existsSync()) { file.deleteSync(); } } catch (e) { Log.error('Error deleting file ${file.path}: $e'); } } } } } catch (e) { Log.error('Error in purgeTempFolder: $e'); } } Future updateFromDB() async { final updated = await twonlyDB.mediaFilesDao.getMediaFileById( mediaFile.mediaId, ); if (updated != null) { mediaFile = updated; } } Future setDisplayLimit(int? displayLimitInMilliseconds) async { await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, MediaFilesCompanion( displayLimitInMilliseconds: Value(displayLimitInMilliseconds), ), ); await updateFromDB(); } bool get removeAudio => mediaFile.removeAudio ?? false; Future toggleRemoveAudio() async { // var removeAudio = false; // if (mediaFile.removeAudio != null) { // removeAudio = !mediaFile.removeAudio!; // } await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, MediaFilesCompanion( removeAudio: Value(!removeAudio), ), ); await updateFromDB(); } Future setUploadState(UploadState uploadState) async { await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, MediaFilesCompanion( uploadState: Value(uploadState), ), ); await updateFromDB(); } Future setEncryptedMac(Uint8List encryptionMac) async { await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, MediaFilesCompanion( encryptionMac: Value(encryptionMac), ), ); await updateFromDB(); } Future setRequiresAuth(bool requiresAuthentication) async { await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, MediaFilesCompanion( requiresAuthentication: Value(requiresAuthentication), displayLimitInMilliseconds: requiresAuthentication ? const Value(12000) : const Value.absent(), ), ); await updateFromDB(); } Future createThumbnail() async { if (!storedPath.existsSync()) { if (mediaFile.stored && mediaFile.createdAt.isBefore( clock.now().subtract(const Duration(days: 30)), )) { // media files does not exists any more so also delete the database entry await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId); fullMediaRemoval(); } return; } var success = false; switch (mediaFile.type) { case MediaType.gif: success = await createThumbnailsForGif(storedPath, thumbnailPath); case MediaType.image: success = await createThumbnailsForImage(storedPath, thumbnailPath); case MediaType.video: success = await createThumbnailsForVideo(storedPath, thumbnailPath); case MediaType.audio: break; } if (success) { await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, const MediaFilesCompanion(hasThumbnail: Value(true)), ); await updateFromDB(); } } Future compressMedia() async { if (!originalPath.existsSync()) { Log.error('Could not compress as original media does not exists.'); return; } switch (mediaFile.type) { case MediaType.image: await compressImage(originalPath, tempPath); case MediaType.video: await compressAndOverlayVideo(this); case MediaType.audio: case MediaType.gif: originalPath.copySync(tempPath.path); } } void fullMediaRemoval() { final pathsToRemove = [ tempPath, encryptedPath, originalPath, storedPath, thumbnailPath, uploadRequestPath, ]; for (final path in pathsToRemove) { if (path.existsSync()) { path.deleteSync(); } } } // Media was send with unlimited display limit time and without auth required // and the temp media file still exists, then the media file can be reopened again... bool get canBeOpenedAgain => !mediaFile.requiresAuthentication && mediaFile.displayLimitInMilliseconds == null && tempPath.existsSync(); bool get imagePreviewAvailable => mediaFile.hasThumbnail || thumbnailPath.existsSync() || storedPath.existsSync(); Future storeMediaFile() async { Log.info('Storing media file ${mediaFile.mediaId}'); await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, const MediaFilesCompanion( stored: Value(true), ), ); if (originalPath.existsSync() && !tempPath.existsSync()) { await compressMedia(); } if (tempPath.existsSync()) { await tempPath.copy(storedPath.path); if (userService.currentUser.storeMediaFilesInGallery) { if (mediaFile.type == MediaType.video) { await saveVideoToGallery(storedPath.path); } else { await saveImageToGallery( storedPath.readAsBytesSync(), ); } } } else { Log.error( 'Could not store image neither as ${tempPath.path} does not exists.', ); } unawaited(createThumbnail()); await calculateAndSaveSize(); await hashMediaFile(); // updateFromDb is done in hashStoredMedia() } Future calculateAndSaveSize() async { if (storedPath.existsSync()) { final size = storedPath.lengthSync(); await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, MediaFilesCompanion( sizeInBytes: Value(size), ), ); await updateFromDB(); } } Future hashMediaFile() async { late final List checksum; if (storedPath.existsSync()) { checksum = await sha256File(storedPath); } else if (tempPath.existsSync()) { checksum = await sha256File(tempPath); } else { return; } await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, MediaFilesCompanion( storedFileHash: Value(Uint8List.fromList(checksum)), ), ); await updateFromDB(); } static Directory buildDirectoryPath( String directory, String applicationSupportDirectory, ) { final mediaBaseDir = Directory( join( applicationSupportDirectory, 'mediafiles', directory, ), ); if (!mediaBaseDir.existsSync()) { mediaBaseDir.createSync(recursive: true); } return mediaBaseDir; } File _buildFilePath( String directory, { String namePrefix = '', String extensionParam = '', }) { var extension = extensionParam; if (extension == '') { switch (mediaFile.type) { case MediaType.image: extension = 'webp'; case MediaType.video: extension = 'mp4'; case MediaType.gif: extension = 'gif'; case MediaType.audio: extension = 'm4a'; } } final mediaBaseDir = buildDirectoryPath( directory, AppEnvironment.supportDir, ); return File( join(mediaBaseDir.path, '${mediaFile.mediaId}$namePrefix.$extension'), ); } File get tempPath => _buildFilePath('tmp'); File get storedPath => _buildFilePath('stored'); File get thumbnailPath => _buildFilePath( 'stored', namePrefix: '.thumbnail', extensionParam: 'webp', ); File get encryptedPath => _buildFilePath( 'tmp', namePrefix: '.encrypted', ); File get uploadRequestPath => _buildFilePath( 'tmp', namePrefix: '.upload', ); File get originalPath => _buildFilePath( 'tmp', namePrefix: '.original', ); File get ffmpegOutputPath => _buildFilePath( 'tmp', namePrefix: '.ffmpeg', ); File get overlayImagePath => _buildFilePath( 'tmp', namePrefix: '.overlay', extensionParam: 'png', ); Future cropTransparentBorders() async { if (mediaFile.type != MediaType.image) { await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, const MediaFilesCompanion(hasCropAnalyzed: Value(true)), ); return; } if (!storedPath.existsSync()) { await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, const MediaFilesCompanion(hasCropAnalyzed: Value(true)), ); return; } try { final bytes = storedPath.readAsBytesSync(); final image = img.decodeImage(bytes); if (image == null) { await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, const MediaFilesCompanion(hasCropAnalyzed: Value(true)), ); return; } var minY = 0; var maxY = image.height - 1; var minX = 0; var maxX = image.width - 1; var found = false; for (var y = 0; y < image.height; y++) { for (var x = 0; x < image.width; x++) { if (image.getPixel(x, y).a > 10) { minY = y; found = true; break; } } if (found) break; } found = false; for (var y = image.height - 1; y >= minY; y--) { for (var x = 0; x < image.width; x++) { if (image.getPixel(x, y).a > 10) { maxY = y; found = true; break; } } if (found) break; } found = false; for (var x = 0; x < image.width; x++) { for (var y = minY; y <= maxY; y++) { if (image.getPixel(x, y).a > 10) { minX = x; found = true; break; } } if (found) break; } found = false; for (var x = image.width - 1; x >= minX; x--) { for (var y = minY; y <= maxY; y++) { if (image.getPixel(x, y).a > 10) { maxX = x; found = true; break; } } if (found) break; } final newWidth = maxX - minX + 1; final newHeight = maxY - minY + 1; if (minY > 0 || maxY < image.height - 1 || minX > 0 || maxX < image.width - 1) { if (newWidth > 10 && newHeight > 10) { final cropped = img.copyCrop( image, x: minX, y: minY, width: newWidth, height: newHeight, ); final pngBytes = img.encodePng(cropped); final webpBytes = await FlutterImageCompress.compressWithList( pngBytes, format: CompressFormat.webp, quality: 90, ); storedPath.writeAsBytesSync(webpBytes); if (thumbnailPath.existsSync()) { thumbnailPath.deleteSync(); } await createThumbnail(); final checksum = await sha256File(storedPath); await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, MediaFilesCompanion( hasCropAnalyzed: const Value(true), storedFileHash: Value(Uint8List.fromList(checksum)), ), ); await updateFromDB(); return; } } await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, const MediaFilesCompanion(hasCropAnalyzed: Value(true)), ); await updateFromDB(); } catch (e) { Log.error( 'Error auto-cropping transparent borders for mediaId ${mediaFile.mediaId}: $e', ); await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, const MediaFilesCompanion(hasCropAnalyzed: Value(true)), ); await updateFromDB(); } } }