twonly-app/lib/src/services/api/mediafiles/upload.service.dart
otsmr 4f27bfba01
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled
fixing video issue for pixel 10
2025-12-16 01:03:31 +01:00

386 lines
12 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:background_downloader/background_downloader.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/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
Future<void> 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<MediaFileService?> 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<void> insertMediaFileInMessagesTable(
MediaFileService mediaService,
List<String> 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, DateTime.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<void> 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 (!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<void> _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<void> _createUploadRequest(MediaFileService media) async {
final downloadTokens = <Uint8List>[];
final messagesOnSuccess = <TextMessage>[];
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 twonlyDB.groupsDao.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<void> _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<void> 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);
}
}