twonly-app/lib/src/services/mediafiles/mediafile.service.dart
otsmr 872592af21
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
fix: database error and some ui improvements
2026-05-28 00:09:12 +02:00

568 lines
16 KiB
Dart

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<MediaFileService?> fromMediaId(String mediaId) async {
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(mediaId);
if (mediaFile == null) return null;
return MediaFileService(
mediaFile,
);
}
static Future<void> purgeTempFolder() async {
try {
final tempDirectory = MediaFileService.buildDirectoryPath(
'tmp',
AppEnvironment.supportDir,
);
final files = tempDirectory.listSync();
if (files.isEmpty) return;
final mediaIdToFile = <String, List<FileSystemEntity>>{};
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 = <String, List<Message>>{};
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<void> updateFromDB() async {
final updated = await twonlyDB.mediaFilesDao.getMediaFileById(
mediaFile.mediaId,
);
if (updated != null) {
mediaFile = updated;
}
}
Future<void> setDisplayLimit(int? displayLimitInMilliseconds) async {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
displayLimitInMilliseconds: Value(displayLimitInMilliseconds),
),
);
await updateFromDB();
}
bool get removeAudio => mediaFile.removeAudio ?? false;
Future<void> 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<void> setUploadState(UploadState uploadState) async {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
uploadState: Value(uploadState),
),
);
await updateFromDB();
}
Future<void> setEncryptedMac(Uint8List encryptionMac) async {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
encryptionMac: Value(encryptionMac),
),
);
await updateFromDB();
}
Future<void> 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<void> 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<void> 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<void> 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<void> calculateAndSaveSize() async {
if (storedPath.existsSync()) {
final size = storedPath.lengthSync();
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
sizeInBytes: Value(size),
),
);
await updateFromDB();
}
}
Future<void> hashMediaFile() async {
late final List<int> 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<void> 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();
}
}
}