fixing issues with media_download

This commit is contained in:
otsmr 2025-10-22 00:01:55 +02:00
parent 0207aaf074
commit f5cbcf154b
12 changed files with 332 additions and 447 deletions

View file

@ -45,6 +45,8 @@ void main() async {
apiService = ApiService();
twonlyDB = TwonlyDB();
await initFileDownloader();
// await twonlyDB.messagesDao.resetPendingDownloadState();
// await twonlyDB.messagesDao.handleMediaFilesOlderThan30Days();
// await twonlyDB.messageRetransmissionDao.purgeOldRetransmissions();
@ -56,8 +58,6 @@ void main() async {
// unawaited(performTwonlySafeBackup());
await initFileDownloader();
runApp(
MultiProvider(
providers: [

View file

@ -51,4 +51,10 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
),
);
}
Future<List<MediaFile>> getAllMediaFilesPendingDownload() async {
return (select(mediaFiles)
..where((t) => t.downloadState.equals(DownloadState.pending.name)))
.get();
}
}

View file

@ -177,7 +177,11 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
if (msg.mediaId != null) {
await (delete(mediaFiles)..where((t) => t.mediaId.equals(msg.mediaId!)))
.go();
await removeMediaFile(msg.mediaId!);
final mediaService = await MediaFileService.fromMediaId(msg.mediaId!);
if (mediaService != null) {
mediaService.fullMediaRemoval();
}
}
await (delete(messageHistories)
..where((t) => t.messageId.equals(messageId)))

View file

@ -16,7 +16,13 @@ enum UploadState {
receiverNotified,
}
enum DownloadState { pending, downloading, reuploadRequested }
enum DownloadState {
pending,
downloading,
downloaded,
ready,
reuploadRequested
}
@DataClassName('MediaFile')
class MediaFiles extends Table {
@ -31,8 +37,7 @@ class MediaFiles extends Table {
BoolColumn get reopenByContact =>
boolean().withDefault(const Constant(false))();
BoolColumn get storedByContact =>
boolean().withDefault(const Constant(false))();
BoolColumn get stored => boolean().withDefault(const Constant(false))();
TextColumn get reuploadRequestedBy =>
text().map(IntListTypeConverter()).nullable()();

View file

@ -1473,15 +1473,14 @@ class $MediaFilesTable extends MediaFiles
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("reopen_by_contact" IN (0, 1))'),
defaultValue: const Constant(false));
static const VerificationMeta _storedByContactMeta =
const VerificationMeta('storedByContact');
static const VerificationMeta _storedMeta = const VerificationMeta('stored');
@override
late final GeneratedColumn<bool> storedByContact = GeneratedColumn<bool>(
'stored_by_contact', aliasedName, false,
late final GeneratedColumn<bool> stored = GeneratedColumn<bool>(
'stored', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("stored_by_contact" IN (0, 1))'),
defaultConstraints:
GeneratedColumn.constraintIsAlways('CHECK ("stored" IN (0, 1))'),
defaultValue: const Constant(false));
@override
late final GeneratedColumnWithTypeConverter<List<int>?, String>
@ -1536,7 +1535,7 @@ class $MediaFilesTable extends MediaFiles
downloadState,
requiresAuthentication,
reopenByContact,
storedByContact,
stored,
reuploadRequestedBy,
displayLimitInMilliseconds,
downloadToken,
@ -1573,11 +1572,9 @@ class $MediaFilesTable extends MediaFiles
reopenByContact.isAcceptableOrUnknown(
data['reopen_by_contact']!, _reopenByContactMeta));
}
if (data.containsKey('stored_by_contact')) {
context.handle(
_storedByContactMeta,
storedByContact.isAcceptableOrUnknown(
data['stored_by_contact']!, _storedByContactMeta));
if (data.containsKey('stored')) {
context.handle(_storedMeta,
stored.isAcceptableOrUnknown(data['stored']!, _storedMeta));
}
if (data.containsKey('display_limit_in_milliseconds')) {
context.handle(
@ -1638,8 +1635,8 @@ class $MediaFilesTable extends MediaFiles
data['${effectivePrefix}requires_authentication'])!,
reopenByContact: attachedDatabase.typeMapping.read(
DriftSqlType.bool, data['${effectivePrefix}reopen_by_contact'])!,
storedByContact: attachedDatabase.typeMapping.read(
DriftSqlType.bool, data['${effectivePrefix}stored_by_contact'])!,
stored: attachedDatabase.typeMapping
.read(DriftSqlType.bool, data['${effectivePrefix}stored'])!,
reuploadRequestedBy: $MediaFilesTable.$converterreuploadRequestedByn
.fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string,
data['${effectivePrefix}reupload_requested_by'])),
@ -1690,7 +1687,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
final DownloadState? downloadState;
final bool requiresAuthentication;
final bool reopenByContact;
final bool storedByContact;
final bool stored;
final List<int>? reuploadRequestedBy;
final int? displayLimitInMilliseconds;
final Uint8List? downloadToken;
@ -1705,7 +1702,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
this.downloadState,
required this.requiresAuthentication,
required this.reopenByContact,
required this.storedByContact,
required this.stored,
this.reuploadRequestedBy,
this.displayLimitInMilliseconds,
this.downloadToken,
@ -1731,7 +1728,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
}
map['requires_authentication'] = Variable<bool>(requiresAuthentication);
map['reopen_by_contact'] = Variable<bool>(reopenByContact);
map['stored_by_contact'] = Variable<bool>(storedByContact);
map['stored'] = Variable<bool>(stored);
if (!nullToAbsent || reuploadRequestedBy != null) {
map['reupload_requested_by'] = Variable<String>($MediaFilesTable
.$converterreuploadRequestedByn
@ -1769,7 +1766,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
: Value(downloadState),
requiresAuthentication: Value(requiresAuthentication),
reopenByContact: Value(reopenByContact),
storedByContact: Value(storedByContact),
stored: Value(stored),
reuploadRequestedBy: reuploadRequestedBy == null && nullToAbsent
? const Value.absent()
: Value(reuploadRequestedBy),
@ -1807,7 +1804,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
requiresAuthentication:
serializer.fromJson<bool>(json['requiresAuthentication']),
reopenByContact: serializer.fromJson<bool>(json['reopenByContact']),
storedByContact: serializer.fromJson<bool>(json['storedByContact']),
stored: serializer.fromJson<bool>(json['stored']),
reuploadRequestedBy:
serializer.fromJson<List<int>?>(json['reuploadRequestedBy']),
displayLimitInMilliseconds:
@ -1832,7 +1829,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
$MediaFilesTable.$converterdownloadStaten.toJson(downloadState)),
'requiresAuthentication': serializer.toJson<bool>(requiresAuthentication),
'reopenByContact': serializer.toJson<bool>(reopenByContact),
'storedByContact': serializer.toJson<bool>(storedByContact),
'stored': serializer.toJson<bool>(stored),
'reuploadRequestedBy': serializer.toJson<List<int>?>(reuploadRequestedBy),
'displayLimitInMilliseconds':
serializer.toJson<int?>(displayLimitInMilliseconds),
@ -1851,7 +1848,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
Value<DownloadState?> downloadState = const Value.absent(),
bool? requiresAuthentication,
bool? reopenByContact,
bool? storedByContact,
bool? stored,
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
Value<int?> displayLimitInMilliseconds = const Value.absent(),
Value<Uint8List?> downloadToken = const Value.absent(),
@ -1868,7 +1865,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
requiresAuthentication:
requiresAuthentication ?? this.requiresAuthentication,
reopenByContact: reopenByContact ?? this.reopenByContact,
storedByContact: storedByContact ?? this.storedByContact,
stored: stored ?? this.stored,
reuploadRequestedBy: reuploadRequestedBy.present
? reuploadRequestedBy.value
: this.reuploadRequestedBy,
@ -1901,9 +1898,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
reopenByContact: data.reopenByContact.present
? data.reopenByContact.value
: this.reopenByContact,
storedByContact: data.storedByContact.present
? data.storedByContact.value
: this.storedByContact,
stored: data.stored.present ? data.stored.value : this.stored,
reuploadRequestedBy: data.reuploadRequestedBy.present
? data.reuploadRequestedBy.value
: this.reuploadRequestedBy,
@ -1935,7 +1930,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
..write('downloadState: $downloadState, ')
..write('requiresAuthentication: $requiresAuthentication, ')
..write('reopenByContact: $reopenByContact, ')
..write('storedByContact: $storedByContact, ')
..write('stored: $stored, ')
..write('reuploadRequestedBy: $reuploadRequestedBy, ')
..write('displayLimitInMilliseconds: $displayLimitInMilliseconds, ')
..write('downloadToken: $downloadToken, ')
@ -1955,7 +1950,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
downloadState,
requiresAuthentication,
reopenByContact,
storedByContact,
stored,
reuploadRequestedBy,
displayLimitInMilliseconds,
$driftBlobEquality.hash(downloadToken),
@ -1973,7 +1968,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
other.downloadState == this.downloadState &&
other.requiresAuthentication == this.requiresAuthentication &&
other.reopenByContact == this.reopenByContact &&
other.storedByContact == this.storedByContact &&
other.stored == this.stored &&
other.reuploadRequestedBy == this.reuploadRequestedBy &&
other.displayLimitInMilliseconds == this.displayLimitInMilliseconds &&
$driftBlobEquality.equals(other.downloadToken, this.downloadToken) &&
@ -1991,7 +1986,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
final Value<DownloadState?> downloadState;
final Value<bool> requiresAuthentication;
final Value<bool> reopenByContact;
final Value<bool> storedByContact;
final Value<bool> stored;
final Value<List<int>?> reuploadRequestedBy;
final Value<int?> displayLimitInMilliseconds;
final Value<Uint8List?> downloadToken;
@ -2007,7 +2002,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
this.downloadState = const Value.absent(),
this.requiresAuthentication = const Value.absent(),
this.reopenByContact = const Value.absent(),
this.storedByContact = const Value.absent(),
this.stored = const Value.absent(),
this.reuploadRequestedBy = const Value.absent(),
this.displayLimitInMilliseconds = const Value.absent(),
this.downloadToken = const Value.absent(),
@ -2024,7 +2019,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
this.downloadState = const Value.absent(),
required bool requiresAuthentication,
this.reopenByContact = const Value.absent(),
this.storedByContact = const Value.absent(),
this.stored = const Value.absent(),
this.reuploadRequestedBy = const Value.absent(),
this.displayLimitInMilliseconds = const Value.absent(),
this.downloadToken = const Value.absent(),
@ -2042,7 +2037,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
Expression<String>? downloadState,
Expression<bool>? requiresAuthentication,
Expression<bool>? reopenByContact,
Expression<bool>? storedByContact,
Expression<bool>? stored,
Expression<String>? reuploadRequestedBy,
Expression<int>? displayLimitInMilliseconds,
Expression<Uint8List>? downloadToken,
@ -2060,7 +2055,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
if (requiresAuthentication != null)
'requires_authentication': requiresAuthentication,
if (reopenByContact != null) 'reopen_by_contact': reopenByContact,
if (storedByContact != null) 'stored_by_contact': storedByContact,
if (stored != null) 'stored': stored,
if (reuploadRequestedBy != null)
'reupload_requested_by': reuploadRequestedBy,
if (displayLimitInMilliseconds != null)
@ -2081,7 +2076,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
Value<DownloadState?>? downloadState,
Value<bool>? requiresAuthentication,
Value<bool>? reopenByContact,
Value<bool>? storedByContact,
Value<bool>? stored,
Value<List<int>?>? reuploadRequestedBy,
Value<int?>? displayLimitInMilliseconds,
Value<Uint8List?>? downloadToken,
@ -2098,7 +2093,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
requiresAuthentication:
requiresAuthentication ?? this.requiresAuthentication,
reopenByContact: reopenByContact ?? this.reopenByContact,
storedByContact: storedByContact ?? this.storedByContact,
stored: stored ?? this.stored,
reuploadRequestedBy: reuploadRequestedBy ?? this.reuploadRequestedBy,
displayLimitInMilliseconds:
displayLimitInMilliseconds ?? this.displayLimitInMilliseconds,
@ -2136,8 +2131,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
if (reopenByContact.present) {
map['reopen_by_contact'] = Variable<bool>(reopenByContact.value);
}
if (storedByContact.present) {
map['stored_by_contact'] = Variable<bool>(storedByContact.value);
if (stored.present) {
map['stored'] = Variable<bool>(stored.value);
}
if (reuploadRequestedBy.present) {
map['reupload_requested_by'] = Variable<String>($MediaFilesTable
@ -2178,7 +2173,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
..write('downloadState: $downloadState, ')
..write('requiresAuthentication: $requiresAuthentication, ')
..write('reopenByContact: $reopenByContact, ')
..write('storedByContact: $storedByContact, ')
..write('stored: $stored, ')
..write('reuploadRequestedBy: $reuploadRequestedBy, ')
..write('displayLimitInMilliseconds: $displayLimitInMilliseconds, ')
..write('downloadToken: $downloadToken, ')
@ -7107,7 +7102,7 @@ typedef $$MediaFilesTableCreateCompanionBuilder = MediaFilesCompanion Function({
Value<DownloadState?> downloadState,
required bool requiresAuthentication,
Value<bool> reopenByContact,
Value<bool> storedByContact,
Value<bool> stored,
Value<List<int>?> reuploadRequestedBy,
Value<int?> displayLimitInMilliseconds,
Value<Uint8List?> downloadToken,
@ -7124,7 +7119,7 @@ typedef $$MediaFilesTableUpdateCompanionBuilder = MediaFilesCompanion Function({
Value<DownloadState?> downloadState,
Value<bool> requiresAuthentication,
Value<bool> reopenByContact,
Value<bool> storedByContact,
Value<bool> stored,
Value<List<int>?> reuploadRequestedBy,
Value<int?> displayLimitInMilliseconds,
Value<Uint8List?> downloadToken,
@ -7190,9 +7185,8 @@ class $$MediaFilesTableFilterComposer
column: $table.reopenByContact,
builder: (column) => ColumnFilters(column));
ColumnFilters<bool> get storedByContact => $composableBuilder(
column: $table.storedByContact,
builder: (column) => ColumnFilters(column));
ColumnFilters<bool> get stored => $composableBuilder(
column: $table.stored, builder: (column) => ColumnFilters(column));
ColumnWithTypeConverterFilters<List<int>?, List<int>, String>
get reuploadRequestedBy => $composableBuilder(
@ -7271,9 +7265,8 @@ class $$MediaFilesTableOrderingComposer
column: $table.reopenByContact,
builder: (column) => ColumnOrderings(column));
ColumnOrderings<bool> get storedByContact => $composableBuilder(
column: $table.storedByContact,
builder: (column) => ColumnOrderings(column));
ColumnOrderings<bool> get stored => $composableBuilder(
column: $table.stored, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get reuploadRequestedBy => $composableBuilder(
column: $table.reuploadRequestedBy,
@ -7332,8 +7325,8 @@ class $$MediaFilesTableAnnotationComposer
GeneratedColumn<bool> get reopenByContact => $composableBuilder(
column: $table.reopenByContact, builder: (column) => column);
GeneratedColumn<bool> get storedByContact => $composableBuilder(
column: $table.storedByContact, builder: (column) => column);
GeneratedColumn<bool> get stored =>
$composableBuilder(column: $table.stored, builder: (column) => column);
GeneratedColumnWithTypeConverter<List<int>?, String>
get reuploadRequestedBy => $composableBuilder(
@ -7408,7 +7401,7 @@ class $$MediaFilesTableTableManager extends RootTableManager<
Value<DownloadState?> downloadState = const Value.absent(),
Value<bool> requiresAuthentication = const Value.absent(),
Value<bool> reopenByContact = const Value.absent(),
Value<bool> storedByContact = const Value.absent(),
Value<bool> stored = const Value.absent(),
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
Value<int?> displayLimitInMilliseconds = const Value.absent(),
Value<Uint8List?> downloadToken = const Value.absent(),
@ -7425,7 +7418,7 @@ class $$MediaFilesTableTableManager extends RootTableManager<
downloadState: downloadState,
requiresAuthentication: requiresAuthentication,
reopenByContact: reopenByContact,
storedByContact: storedByContact,
stored: stored,
reuploadRequestedBy: reuploadRequestedBy,
displayLimitInMilliseconds: displayLimitInMilliseconds,
downloadToken: downloadToken,
@ -7442,7 +7435,7 @@ class $$MediaFilesTableTableManager extends RootTableManager<
Value<DownloadState?> downloadState = const Value.absent(),
required bool requiresAuthentication,
Value<bool> reopenByContact = const Value.absent(),
Value<bool> storedByContact = const Value.absent(),
Value<bool> stored = const Value.absent(),
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
Value<int?> displayLimitInMilliseconds = const Value.absent(),
Value<Uint8List?> downloadToken = const Value.absent(),
@ -7459,7 +7452,7 @@ class $$MediaFilesTableTableManager extends RootTableManager<
downloadState: downloadState,
requiresAuthentication: requiresAuthentication,
reopenByContact: reopenByContact,
storedByContact: storedByContact,
stored: stored,
reuploadRequestedBy: reuploadRequestedBy,
displayLimitInMilliseconds: displayLimitInMilliseconds,
downloadToken: downloadToken,

View file

@ -1,7 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
@ -10,24 +8,23 @@ import 'package:drift/drift.dart';
import 'package:http/http.dart' as http;
import 'package:mutex/mutex.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/json/message_old.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart';
import 'package:twonly/src/services/api/media_upload.dart';
import 'package:twonly/src/services/api/utils.dart';
import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/services/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/camera/share_image_editor_view.dart';
Future<void> tryDownloadAllMediaFiles({bool force = false}) async {
// This is called when WebSocket is newly connected, so allow all downloads to be restarted.
final messages =
await twonlyDB.messagesDao.getAllMessagesPendingDownloading();
final mediaFiles =
await twonlyDB.mediaFilesDao.getAllMediaFilesPendingDownload();
for (final message in messages) {
await startDownloadMedia(message, force);
for (final mediaFile in mediaFiles) {
await startDownloadMedia(mediaFile, force);
}
}
@ -44,7 +41,7 @@ Map<String, List<String>> defaultAutoDownloadOptions = {
],
};
Future<bool> isAllowedToDownload(bool isVideo) async {
Future<bool> isAllowedToDownload({required bool isVideo}) async {
final connectivityResult = await Connectivity().checkConnectivity();
final user = await getUser();
@ -76,7 +73,7 @@ Future<bool> isAllowedToDownload(bool isVideo) async {
}
Future<void> handleDownloadStatusUpdate(TaskStatusUpdate update) async {
final messageId = int.parse(update.task.taskId.replaceAll('download_', ''));
final mediaId = update.task.taskId.replaceAll('download_', '');
var failed = false;
if (update.status == TaskStatus.failed ||
@ -93,83 +90,45 @@ Future<void> handleDownloadStatusUpdate(TaskStatusUpdate update) async {
);
}
} else {
Log.info('Got ${update.status} for $messageId');
Log.info('Got ${update.status} for $mediaId');
return;
}
await handleDownloadStatusUpdateInternal(messageId, failed);
}
Future<void> handleDownloadStatusUpdateInternal(
int messageId,
bool failed,
) async {
if (failed) {
Log.error('Download failed for $messageId');
final message = await twonlyDB.messagesDao
.getMessageByMessageId(messageId)
.getSingleOrNull();
if (message != null && message.downloadState != DownloadState.downloaded) {
await handleMediaError(message);
}
await requestMediaReupload(mediaId);
} else {
Log.info('Download was successfully for $messageId');
await handleEncryptedFile(messageId);
await handleEncryptedFile(mediaId);
}
}
Mutex protectDownload = Mutex();
Future<void> startDownloadMedia(MediaFile media, bool force) async {
Log.info(
'Download blocked for ${message.messageId} because of network state.',
);
if (message.contentJson == null) {
Log.error('Content of ${message.messageId} not found.');
await handleMediaError(message);
final mediaService = await MediaFileService.fromMedia(media);
if (mediaService.encryptedPath.existsSync()) {
await handleEncryptedFile(media.mediaId);
return;
}
final content = MessageContent.fromJson(
message.kind,
jsonDecode(message.contentJson!) as Map,
);
if (content is! MediaMessageContent) {
Log.error('Content of ${message.messageId} is not media file.');
await handleMediaError(message);
return;
}
if (content.downloadToken == null) {
Log.error('Download token not defined for ${message.messageId}.');
await handleMediaError(message);
return;
}
if (!force && !await isAllowedToDownload(content.isVideo)) {
if (!force &&
!await isAllowedToDownload(isVideo: media.type == MediaType.video)) {
Log.warn(
'Download blocked for ${message.messageId} because of network state.',
'Download blocked for ${media.mediaId} because of network state.',
);
return;
}
final isBlocked = await protectDownload.protect<bool>(() async {
final msg = await twonlyDB.messagesDao
.getMessageByMessageId(message.messageId)
.getSingleOrNull();
final msg = await twonlyDB.mediaFilesDao.getMediaFileById(media.mediaId);
if (msg == null) return true;
if (msg.downloadState != DownloadState.pending) {
Log.error(
'${message.messageId} is already downloaded or is downloading.',
);
if (msg == null || msg.downloadState != DownloadState.pending) {
return true;
}
await twonlyDB.messagesDao.updateMessageByMessageId(
message.messageId,
const MessagesCompanion(
await twonlyDB.mediaFilesDao.updateMedia(
msg.mediaId,
const MediaFilesCompanion(
downloadState: Value(DownloadState.downloading),
),
);
@ -178,11 +137,16 @@ Future<void> startDownloadMedia(MediaFile media, bool force) async {
});
if (isBlocked) {
Log.info('Download for ${message.messageId} already started.');
Log.info('Download for ${media.mediaId} already started.');
return;
}
final downloadToken = uint8ListToHex(content.downloadToken!);
if (media.downloadToken == null) {
Log.info('Download token for ${media.mediaId} not found.');
return;
}
final downloadToken = uint8ListToHex(media.downloadToken!);
final apiUrl =
'http${apiService.apiSecure}://${apiService.apiHost}/api/download/$downloadToken';
@ -190,20 +154,20 @@ Future<void> startDownloadMedia(MediaFile media, bool force) async {
try {
final task = DownloadTask(
url: apiUrl,
taskId: 'download_${message.messageId}',
directory: 'media/received/',
baseDirectory: BaseDirectory.applicationSupport,
filename: '${message.messageId}.encrypted',
taskId: 'download_${media.mediaId}',
directory: mediaService.encryptedPath.parent.path,
baseDirectory: BaseDirectory.root,
filename: basename(mediaService.encryptedPath.path),
priority: 0,
retries: 10,
);
Log.info(
'Got media file. Starting download: ${downloadToken.substring(0, 10)}',
'Downloading ${media.mediaId} to ${mediaService.encryptedPath}',
);
try {
await downloadFileFast(message.messageId, apiUrl);
await downloadFileFast(media, apiUrl, mediaService.encryptedPath);
} catch (e) {
Log.error('Fast download failed: $e');
await FileDownloader().enqueue(task);
@ -214,269 +178,114 @@ Future<void> startDownloadMedia(MediaFile media, bool force) async {
}
Future<void> downloadFileFast(
int messageId,
MediaFile media,
String apiUrl,
File filePath,
) async {
final directoryPath =
'${(await getApplicationSupportDirectory()).path}/media/received/';
final filename = '$messageId.encrypted';
final directory = Directory(directoryPath);
if (!directory.existsSync()) {
await directory.create(recursive: true);
}
final filePath = '${directory.path}/$filename';
final response =
await http.get(Uri.parse(apiUrl)).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
await File(filePath).writeAsBytes(response.bodyBytes);
await filePath.writeAsBytes(response.bodyBytes);
Log.info('Fast Download successful: $filePath');
await handleDownloadStatusUpdateInternal(messageId, false);
await handleEncryptedFile(media.mediaId);
return;
} else {
if (response.statusCode == 404 || response.statusCode == 403) {
await handleDownloadStatusUpdateInternal(messageId, true);
if (response.statusCode == 404 ||
response.statusCode == 403 ||
response.statusCode == 400) {
// Message was deleted from the server. Requesting it again from the sender to upload it again...
await requestMediaReupload(media.mediaId);
return;
}
// can be tried again
// Will be tried again using the slow method...
throw Exception('Fast download failed with status: ${response.statusCode}');
}
}
Future<void> handleEncryptedFile(int messageId) async {
final msg = await twonlyDB.messagesDao
.getMessageByMessageId(messageId)
.getSingleOrNull();
if (msg == null) {
Log.error('Not message for downloaded file found: $messageId');
Future<void> requestMediaReupload(String mediaId) async {
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(mediaId);
if (messages.length != 1 || messages.first.senderId == null) {
Log.error(
'Media file has none or more than one sender. That is not possible');
return;
}
final encryptedBytes = await readMediaFile(msg.messageId, 'encrypted');
await sendCipherText(
messages.first.senderId!,
EncryptedContent(
mediaUpdate: EncryptedContent_MediaUpdate(
type: EncryptedContent_MediaUpdate_Type.DECRYPTION_ERROR,
targetMessageId: mediaId,
),
),
);
if (encryptedBytes == null) {
Log.error('encrypted bytes are not found for ${msg.messageId}');
await twonlyDB.mediaFilesDao.updateMedia(
mediaId,
const MediaFilesCompanion(
downloadState: Value(DownloadState.reuploadRequested),
),
);
}
Future<void> handleEncryptedFile(String mediaId) async {
final mediaService = await MediaFileService.fromMediaId(mediaId);
if (mediaService == null) {
Log.error('Media file $mediaId not found in database.');
return;
}
final content =
MediaMessageContent.fromJson(jsonDecode(msg.contentJson!) as Map);
await twonlyDB.mediaFilesDao.updateMedia(
mediaId,
const MediaFilesCompanion(
downloadState: Value(DownloadState.downloaded),
),
);
late Uint8List encryptedBytes;
try {
encryptedBytes = await mediaService.encryptedPath.readAsBytes();
} catch (e) {
Log.error('Could not read encrypted media file: $mediaId. $e');
await requestMediaReupload(mediaId);
return;
}
try {
final chacha20 = FlutterChacha20.poly1305Aead();
final secretKeyData = SecretKeyData(content.encryptionKey!);
final secretKeyData = SecretKeyData(mediaService.mediaFile.encryptionKey!);
final secretBox = SecretBox(
encryptedBytes,
nonce: content.encryptionNonce!,
mac: Mac(content.encryptionMac!),
nonce: mediaService.mediaFile.encryptionNonce!,
mac: Mac(mediaService.mediaFile.encryptionMac!),
);
// try {
final plaintextBytes =
await chacha20.decrypt(secretBox, secretKey: secretKeyData);
var imageBytes = Uint8List.fromList(plaintextBytes);
if (content.isVideo) {
final extractedBytes = extractUint8Lists(imageBytes);
imageBytes = extractedBytes[0];
await writeMediaFile(msg.messageId, 'mp4', extractedBytes[1]);
}
final rawMediaBytes = Uint8List.fromList(plaintextBytes);
await writeMediaFile(msg.messageId, 'png', imageBytes);
// } catch (e) {
// Log.error(
// "could not decrypt the media file in the second try. reporting error to user: $e");
// handleMediaError(msg);
// return;
// }
await mediaService.tempPath.writeAsBytes(rawMediaBytes);
} catch (e) {
Log.error('$e');
/// legacy support
final chacha20 = Xchacha20.poly1305Aead();
final secretKeyData = SecretKeyData(content.encryptionKey!);
final secretBox = SecretBox(
encryptedBytes,
nonce: content.encryptionNonce!,
mac: Mac(content.encryptionMac!),
Log.error(
'Could not decrypt the media file. Requesting a new upload.',
);
try {
final plaintextBytes =
await chacha20.decrypt(secretBox, secretKey: secretKeyData);
var imageBytes = Uint8List.fromList(plaintextBytes);
if (content.isVideo) {
final extractedBytes = extractUint8Lists(imageBytes);
imageBytes = extractedBytes[0];
await writeMediaFile(msg.messageId, 'mp4', extractedBytes[1]);
}
await writeMediaFile(msg.messageId, 'png', imageBytes);
} catch (e) {
Log.error(
'could not decrypt the media file in the second try. reporting error to user: $e',
);
await handleMediaError(msg);
return;
}
await requestMediaReupload(mediaId);
return;
}
await twonlyDB.messagesDao.updateMessageByMessageId(
msg.messageId,
const MessagesCompanion(downloadState: Value(DownloadState.downloaded)),
await twonlyDB.mediaFilesDao.updateMedia(
mediaId,
const MediaFilesCompanion(
downloadState: Value(DownloadState.ready),
),
);
Log.info('Download and decryption of ${msg.messageId} was successful');
Log.info('Decryption of $mediaId was successful');
await deleteMediaFile(msg.messageId, 'encrypted');
mediaService.encryptedPath.deleteSync();
unawaited(apiService.downloadDone(content.downloadToken!));
unawaited(apiService.downloadDone(mediaService.mediaFile.downloadToken!));
}
Future<Uint8List?> getImageBytes(int mediaId) async {
return readMediaFile(mediaId, 'png');
}
Future<File?> getVideoPath(int mediaId) async {
final basePath = await getMediaFilePath(mediaId, 'received');
return File('$basePath.mp4');
}
/// --- helper functions ---
Future<Uint8List?> readMediaFile(int mediaId, String type) async {
final basePath = await getMediaFilePath(mediaId, 'received');
final file = File('$basePath.$type');
Log.info('Reading: $file');
if (!file.existsSync()) {
return null;
}
return file.readAsBytes();
}
Future<bool> existsMediaFile(int mediaId, String type) async {
final basePath = await getMediaFilePath(mediaId, 'received');
final file = File('$basePath.$type');
return file.existsSync();
}
Future<void> writeMediaFile(int mediaId, String type, Uint8List data) async {
final basePath = await getMediaFilePath(mediaId, 'received');
final file = File('$basePath.$type');
await file.writeAsBytes(data);
}
Future<void> deleteMediaFile(int mediaId, String type) async {
final basePath = await getMediaFilePath(mediaId, 'received');
final file = File('$basePath.$type');
try {
if (file.existsSync()) {
await file.delete();
}
} catch (e) {
Log.error('Error deleting: $e');
}
}
Future<void> purgeReceivedMediaFiles() async {
final basedir = await getApplicationSupportDirectory();
final directory = Directory(join(basedir.path, 'media', 'received'));
await purgeMediaFiles(directory);
}
Future<void> purgeMediaFiles(Directory directory) async {
// Check if the directory exists
if (directory.existsSync()) {
// List all files in the directory
final files = directory.listSync();
// Iterate over each file
for (final file in files) {
// Get the filename
final filename = file.uri.pathSegments.last;
// Use a regular expression to extract the integer part
final match = RegExp(r'(\d+)').firstMatch(filename);
if (match != null) {
// Parse the integer and add it to the list
final fileId = int.parse(match.group(0)!);
try {
if (directory.path.endsWith('send')) {
final messages =
await twonlyDB.messagesDao.getMessagesByMediaUploadId(fileId);
var canBeDeleted = true;
for (final message in messages) {
try {
final content = MediaMessageContent.fromJson(
jsonDecode(message.contentJson!) as Map,
);
final oneDayAgo =
DateTime.now().subtract(const Duration(days: 1));
final twoDaysAgo =
DateTime.now().subtract(const Duration(days: 1));
if ((message.openedAt == null ||
oneDayAgo.isBefore(message.openedAt!)) &&
!message.errorWhileSending) {
canBeDeleted = false;
} else if (message.mediaStored) {
if (!file.path.contains('.original.') &&
!file.path.contains('.encrypted')) {
canBeDeleted = false;
}
}
/// In case the image is not yet opened but successfully uploaded
/// to the server preserve the image for two days in case of an receiving error will happen
/// and then delete them as well.
if (message.acknowledgeByServer &&
twoDaysAgo.isAfter(message.sendAt)) {
// Preserve images which can be stored by the other person...
if (content.maxShowTime != gMediaShowInfinite) {
canBeDeleted = true;
}
// Encrypted or upload data can be removed when acknowledgeByServer
if (file.path.contains('.upload') ||
file.path.contains('.encrypted')) {
canBeDeleted = true;
}
}
} catch (e) {
Log.error(e);
}
}
if (canBeDeleted) {
Log.info('purged media file ${file.path} ');
file.deleteSync();
}
} else {
final message = await twonlyDB.messagesDao
.getMessageByMessageId(fileId)
.getSingleOrNull();
if ((message == null) ||
(message.openedAt != null &&
!message.mediaStored &&
message.acknowledgeByServer) ||
message.errorWhileSending) {
file.deleteSync();
}
}
} catch (e) {
Log.error('$e');
}
}
}
}
}
// /data/user/0/eu.twonly.testing/files/media/received/27.encrypted
// /data/user/0/eu.twonly.testing/app_flutter/data/user/0/eu.twonly.testing/files/media/received/27.encrypted

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
@ -8,7 +7,6 @@ import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/services/api/media_download.dart';
import 'package:twonly/src/services/api/utils.dart';
import 'package:twonly/src/services/mediafile.service.dart';
import 'package:twonly/src/services/thumbnail.service.dart';
import 'package:twonly/src/utils/log.dart';
Future<void> handleMedia(
@ -33,7 +31,10 @@ Future<void> handleMedia(
}
// in case there was already a downloaded file delete it...
await removeMediaFile(message.mediaId!);
final mediaService = await MediaFileService.fromMediaId(message.mediaId!);
if (mediaService != null) {
mediaService.tempPath.deleteSync();
}
await twonlyDB.mediaFilesDao.updateMedia(
message.mediaId!,
@ -81,6 +82,7 @@ Future<void> handleMedia(
);
if (mediaFile == null) {
Log.error('Could not insert media file into database');
return;
}
@ -121,7 +123,11 @@ Future<void> handleMediaUpdate(
if (message == null || message.mediaId == null) return;
final mediaFile =
await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!);
if (mediaFile == null) return;
if (mediaFile == null) {
Log.info(
'Got media file update, but media file was not found ${message.mediaId}');
return;
}
switch (mediaUpdate.type) {
case EncryptedContent_MediaUpdate_Type.REOPENED:
@ -137,11 +143,12 @@ Future<void> handleMediaUpdate(
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(
storedByContact: Value(true),
stored: Value(true),
),
);
unawaited(createThumbnailForMediaFile(mediaFile));
final mediaService = await MediaFileService.fromMedia(mediaFile);
unawaited(mediaService.createThumbnail());
case EncryptedContent_MediaUpdate_Type.DECRYPTION_ERROR:
Log.info('Got media file decryption error ${mediaFile.mediaId}');

View file

@ -1,5 +1,124 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/thumbnail.service.dart';
import 'package:twonly/src/utils/log.dart';
Future<void> removeMediaFile(String mediaId) async {
Log.error('TODO removeMediaFile: $mediaId');
class MediaFileService {
MediaFileService(this.mediaFile, {required this.applicationSupportDirectory});
MediaFile mediaFile;
final Directory applicationSupportDirectory;
static Future<MediaFileService> fromMedia(MediaFile media) async {
return MediaFileService(
media,
applicationSupportDirectory: await getApplicationSupportDirectory(),
);
}
static Future<MediaFileService?> fromMediaId(String mediaId) async {
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(mediaId);
if (mediaFile == null) return null;
return MediaFileService(
mediaFile,
applicationSupportDirectory: await getApplicationSupportDirectory(),
);
}
Future<void> updateFromDB() async {
final updated =
await twonlyDB.mediaFilesDao.getMediaFileById(mediaFile.mediaId);
if (updated != null) {
mediaFile = updated;
}
}
Future<void> createThumbnail() async {
if (!storedPath.existsSync()) {
Log.error('Could not create Thumbnail as stored media does not exists.');
return;
}
switch (mediaFile.type) {
case MediaType.image:
await createThumbnailsForImage(storedPath, thumbnailPath);
case MediaType.video:
await createThumbnailsForVideo(storedPath, thumbnailPath);
case MediaType.gif:
Log.error('Thumbnail for .gif is not implemented yet');
}
}
void fullMediaRemoval() {
if (tempPath.existsSync()) {
tempPath.deleteSync();
}
if (encryptedPath.existsSync()) {
encryptedPath.deleteSync();
}
if (storedPath.existsSync()) {
storedPath.deleteSync();
}
if (thumbnailPath.existsSync()) {
thumbnailPath.deleteSync();
}
}
Future<void> storeMediaFile() async {
Log.info('Storing media file ${mediaFile.mediaId}');
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(
stored: Value(true),
),
);
await tempPath.copy(storedPath.path);
await updateFromDB();
}
File _buildFilePath(
String directory, {
String namePrefix = '',
String extensionParam = '',
}) {
final mediaBaseDir = Directory(join(
applicationSupportDirectory.path,
'mediafiles',
directory,
));
if (!mediaBaseDir.existsSync()) {
mediaBaseDir.createSync(recursive: true);
}
var extension = extensionParam;
if (extension == '') {
switch (mediaFile.type) {
case MediaType.image:
extension = 'webp';
case MediaType.video:
extension = 'mp4';
case MediaType.gif:
extension = 'gif';
}
}
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',
);
}

View file

@ -1,26 +1,13 @@
import 'dart:io';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:path/path.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
Future<void> createThumbnailForMediaFile(MediaFile media) async {
switch (media.type) {
case MediaType.image:
TODO
break;
default:
}
}
Future<void> createThumbnailsForImage(File file) async {
final fileExtension = file.path.split('.').last.toLowerCase();
Future<void> createThumbnailsForImage(
File sourceFile,
File destinationFile,
) async {
final fileExtension = sourceFile.path.split('.').last.toLowerCase();
if (fileExtension != 'png') {
Log.error('Could not create thumbnail for image. $fileExtension != .png');
return;
@ -30,7 +17,7 @@ Future<void> createThumbnailsForImage(File file) async {
final imageBytesCompressed = await FlutterImageCompress.compressWithFile(
minHeight: 800,
minWidth: 450,
file.path,
sourceFile.path,
format: CompressFormat.webp,
quality: 50,
);
@ -39,16 +26,17 @@ Future<void> createThumbnailsForImage(File file) async {
Log.error('Could not compress the image');
return;
}
final thumbnailFile = getThumbnailPath(file);
await thumbnailFile.writeAsBytes(imageBytesCompressed);
await destinationFile.writeAsBytes(imageBytesCompressed);
} catch (e) {
Log.error('Could not compress the image got :$e');
}
}
Future<void> createThumbnailsForVideo(File file) async {
final fileExtension = file.path.split('.').last.toLowerCase();
Future<void> createThumbnailsForVideo(
File sourceFile,
File destinationFile,
) async {
final fileExtension = sourceFile.path.split('.').last.toLowerCase();
if (fileExtension != 'mp4') {
Log.error('Could not create thumbnail for video. $fileExtension != .mp4');
return;
@ -56,8 +44,8 @@ Future<void> createThumbnailsForVideo(File file) async {
try {
await VideoThumbnail.thumbnailFile(
video: file.path,
thumbnailPath: getThumbnailPath(file).path,
video: sourceFile.path,
thumbnailPath: destinationFile.path,
maxWidth: 450,
quality: 75,
);
@ -65,15 +53,3 @@ Future<void> createThumbnailsForVideo(File file) async {
Log.error('Could not create the video thumbnail: $e');
}
}
File getThumbnailPath(File file) {
final originalFileName = file.uri.pathSegments.last;
final fileNameWithoutExtension = originalFileName.split('.').first;
var fileExtension = originalFileName.split('.').last;
if (fileExtension == 'mp4') {
fileExtension = 'png';
}
final newFileName = '$fileNameWithoutExtension.thumbnail.$fileExtension';
Directory(file.parent.path).createSync();
return File(join(file.parent.path, newFileName));
}

View file

@ -13,7 +13,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:twonly/src/constants/secure_storage_keys.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/model/protobuf/backup/backup.pb.dart';
import 'package:twonly/src/model/protobuf/client/generated/backup.pb.dart';
import 'package:twonly/src/services/api/media_upload.dart';
import 'package:twonly/src/services/twonly_safe/common.twonly_safe.dart';
import 'package:twonly/src/utils/log.dart';
@ -48,20 +48,19 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
final backupDir = Directory(join(baseDir, 'backup_twonly_safe/'));
await backupDir.create(recursive: true);
final backupDatabaseFile =
File(join(backupDir.path, 'twonly_database.backup.sqlite'));
final backupDatabaseFile = File(join(backupDir.path, 'twonly.backup.sqlite'));
final backupDatabaseFileCleaned =
File(join(backupDir.path, 'twonly_database.backup.cleaned.sqlite'));
File(join(backupDir.path, 'twonly.backup.cleaned.sqlite'));
// copy database
final originalDatabase = File(join(baseDir, 'twonly_database.sqlite'));
final originalDatabase = File(join(baseDir, 'twonly.sqlite'));
await originalDatabase.copy(backupDatabaseFile.path);
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
final backupDB = TwonlyDatabase(
final backupDB = TwonlyDB(
driftDatabase(
name: 'twonly_database.backup',
name: 'twonly.backup',
native: DriftNativeOptions(
databaseDirectory: () async {
return backupDir;

View file

@ -10,10 +10,8 @@ import 'package:http/http.dart' as http;
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:twonly/src/constants/secure_storage_keys.dart';
import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/model/protobuf/backup/backup.pb.dart';
import 'package:twonly/src/model/protobuf/client/generated/backup.pb.dart';
import 'package:twonly/src/services/twonly_safe/common.twonly_safe.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/storage.dart';
@ -90,44 +88,9 @@ Future<void> handleBackupData(
);
final baseDir = (await getApplicationSupportDirectory()).path;
final originalDatabase = File(join(baseDir, 'twonly_database.sqlite'));
final originalDatabase = File(join(baseDir, 'twonly.sqlite'));
await originalDatabase.writeAsBytes(backupContent.twonlyDatabase);
/// When restoring the last message ID must be increased otherwise
/// receivers would mark them as duplicates as they where already
/// send.
final database = TwonlyDatabase();
var lastMessageSend = 0;
int? randomUserId;
final contacts = await database.contactsDao.getAllNotBlockedContacts();
for (final contact in contacts) {
randomUserId = contact.userId;
final days = DateTime.now().difference(contact.lastMessageExchange).inDays;
if (days < lastMessageSend) {
lastMessageSend = days;
}
}
if (randomUserId != null) {
// for each day add 400 message ids
final dummyMessagesCounter = (lastMessageSend + 1) * 400;
Log.info(
'Creating $dummyMessagesCounter dummy messages to increase message counter as last message was $lastMessageSend days ago.',
);
for (var i = 0; i < dummyMessagesCounter; i++) {
await database.messagesDao.insertMessage(
MessagesCompanion(
contactId: Value(randomUserId),
kind: const Value(MessageKind.ack),
acknowledgeByServer: const Value(true),
errorWhileSending: const Value(true),
),
);
}
await database.messagesDao.deleteAllMessagesByContactId(randomUserId);
}
const storage = FlutterSecureStorage();
final secureStorage = jsonDecode(backupContent.secureStorageJson);

View file

@ -340,6 +340,10 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
Future<Uint8List?> getMergedImage() async {
Uint8List? image;
TODO: When changed then create a new mediaID!!!!!!
As storedMediaId would overwrite it....
if (layers.length > 1 || widget.videoFilePath != null) {
for (final x in layers) {
x.showCustomButtons = false;