Merge pull request #394 from twonlyapp/dev
Some checks failed
Publish on Github / build_and_publish (push) Has been cancelled

- New: Groups can now collect flames as well
- New: Background execution to pre-load messages 
- New: Adds a link if the image contains a QR code
- Improve: Video compression with progress updates
- Improve: Show message "Flames restored"
- Improve: Show toast message if user was added via QR 
- Fix: Media file appears as a white square and is not listed.
- Fix: Issue with media files required to be reuploaded
- Fix: Problem during contact requests
- Fix: Problem with deleting a contact
- Fix: Problem with restoring from backup
- Fix: Issue with the log file
This commit is contained in:
Tobi 2026-03-16 00:05:10 +01:00 committed by GitHub
commit e953d1ffd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 180 additions and 137 deletions

View file

@ -1,6 +1,6 @@
# Changelog # Changelog
## 0.0.99 ## 0.1.1
- New: Groups can now collect flames as well - New: Groups can now collect flames as well
- New: Background execution to pre-load messages - New: Background execution to pre-load messages

View file

@ -26,8 +26,9 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
final rowId = await into(mediaFiles).insert(insertMediaFile); final rowId = await into(mediaFiles).insert(insertMediaFile);
return await (select(mediaFiles)..where((t) => t.rowId.equals(rowId))) return await (select(
.getSingle(); mediaFiles,
)..where((t) => t.rowId.equals(rowId))).getSingle();
} catch (e) { } catch (e) {
Log.error('Could not insert media file: $e'); Log.error('Could not insert media file: $e');
return null; return null;
@ -35,8 +36,7 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
} }
Future<void> deleteMediaFile(String mediaId) async { Future<void> deleteMediaFile(String mediaId) async {
await (delete(mediaFiles) await (delete(mediaFiles)..where(
..where(
(t) => t.mediaId.equals(mediaId), (t) => t.mediaId.equals(mediaId),
)) ))
.go(); .go();
@ -46,8 +46,9 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
String mediaId, String mediaId,
MediaFilesCompanion updates, MediaFilesCompanion updates,
) async { ) async {
await (update(mediaFiles)..where((c) => c.mediaId.equals(mediaId))) await (update(
.write(updates); mediaFiles,
)..where((c) => c.mediaId.equals(mediaId))).write(updates);
} }
Future<void> updateAllMediaFiles( Future<void> updateAllMediaFiles(
@ -57,14 +58,15 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
} }
Future<MediaFile?> getMediaFileById(String mediaId) async { Future<MediaFile?> getMediaFileById(String mediaId) async {
return (select(mediaFiles)..where((t) => t.mediaId.equals(mediaId))) return (select(
.getSingleOrNull(); mediaFiles,
)..where((t) => t.mediaId.equals(mediaId))).getSingleOrNull();
} }
Future<MediaFile?> getDraftMediaFile() async { Future<MediaFile?> getDraftMediaFile() async {
final medias = await (select(mediaFiles) final medias = await (select(
..where((t) => t.isDraftMedia.equals(true))) mediaFiles,
.get(); )..where((t) => t.isDraftMedia.equals(true))).get();
if (medias.isEmpty) { if (medias.isEmpty) {
return null; return null;
} }
@ -72,13 +74,13 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
} }
Stream<MediaFile?> watchMedia(String mediaId) { Stream<MediaFile?> watchMedia(String mediaId) {
return (select(mediaFiles)..where((t) => t.mediaId.equals(mediaId))) return (select(
.watchSingleOrNull(); mediaFiles,
)..where((t) => t.mediaId.equals(mediaId))).watchSingleOrNull();
} }
Future<void> resetPendingDownloadState() async { Future<void> resetPendingDownloadState() async {
await (update(mediaFiles) await (update(mediaFiles)..where(
..where(
(c) => c.downloadState.equals( (c) => c.downloadState.equals(
DownloadState.downloading.name, DownloadState.downloading.name,
), ),
@ -91,8 +93,7 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
} }
Future<List<MediaFile>> getAllMediaFilesPendingDownload() async { Future<List<MediaFile>> getAllMediaFilesPendingDownload() async {
return (select(mediaFiles) return (select(mediaFiles)..where(
..where(
(t) => (t) =>
t.downloadState.equals(DownloadState.pending.name) | t.downloadState.equals(DownloadState.pending.name) |
t.downloadState.equals(DownloadState.downloading.name), t.downloadState.equals(DownloadState.downloading.name),
@ -101,25 +102,23 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
} }
Future<List<MediaFile>> getAllMediaFilesReuploadRequested() async { Future<List<MediaFile>> getAllMediaFilesReuploadRequested() async {
return (select(mediaFiles) return (select(mediaFiles)..where(
..where(
(t) => t.downloadState.equals(DownloadState.reuploadRequested.name), (t) => t.downloadState.equals(DownloadState.reuploadRequested.name),
)) ))
.get(); .get();
} }
Future<List<MediaFile>> getAllNonHashedStoredMediaFiles() async { Future<List<MediaFile>> getAllNonHashedStoredMediaFiles() async {
return (select(mediaFiles) return (select(mediaFiles)..where(
..where(
(t) => t.stored.equals(true) & t.storedFileHash.isNull(), (t) => t.stored.equals(true) & t.storedFileHash.isNull(),
)) ))
.get(); .get();
} }
Future<List<MediaFile>> getAllMediaFilesPendingUpload() async { Future<List<MediaFile>> getAllMediaFilesPendingUpload() async {
return (select(mediaFiles) return (select(mediaFiles)..where(
..where( (t) =>
(t) => (t.uploadState.equals(UploadState.initialized.name) | (t.uploadState.equals(UploadState.initialized.name) |
t.uploadState.equals(UploadState.uploadLimitReached.name) | t.uploadState.equals(UploadState.uploadLimitReached.name) |
t.uploadState.equals(UploadState.uploading.name) | t.uploadState.equals(UploadState.uploading.name) |
t.uploadState.equals(UploadState.preprocessing.name)), t.uploadState.equals(UploadState.preprocessing.name)),
@ -128,8 +127,8 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
} }
Stream<List<MediaFile>> watchAllStoredMediaFiles() { Stream<List<MediaFile>> watchAllStoredMediaFiles() {
final query = (select(mediaFiles)..where((t) => t.stored.equals(true))) final query =
.join([]) (select(mediaFiles)..where((t) => t.stored.equals(true))).join([])
..groupBy([mediaFiles.storedFileHash]); ..groupBy([mediaFiles.storedFileHash]);
return query.map((row) => row.readTable(mediaFiles)).watch(); return query.map((row) => row.readTable(mediaFiles)).watch();
} }
@ -142,8 +141,7 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
} }
Future<void> updateAllRetransmissionUploadingState() async { Future<void> updateAllRetransmissionUploadingState() async {
await (update(mediaFiles) await (update(mediaFiles)..where(
..where(
(t) => (t) =>
t.uploadState.equals(UploadState.uploading.name) & t.uploadState.equals(UploadState.uploading.name) &
t.reuploadRequestedBy.isNotNull(), t.reuploadRequestedBy.isNotNull(),

View file

@ -124,14 +124,15 @@ Future<void> handleContactUpdate(
await twonlyDB.contactsDao.updateContact( await twonlyDB.contactsDao.updateContact(
fromUserId, fromUserId,
ContactsCompanion( ContactsCompanion(
avatarSvgCompressed: avatarSvgCompressed: Value(
Value(Uint8List.fromList(contactUpdate.avatarSvgCompressed)), Uint8List.fromList(contactUpdate.avatarSvgCompressed),
),
displayName: Value(contactUpdate.displayName), displayName: Value(contactUpdate.displayName),
username: Value(contactUpdate.username), username: Value(contactUpdate.username),
senderProfileCounter: Value(senderProfileCounter), senderProfileCounter: Value(senderProfileCounter),
), ),
); );
unawaited(createPushAvatars()); unawaited(createPushAvatars(forceForUserId: fromUserId));
} }
} }
} }

View file

@ -30,10 +30,9 @@ Future<void> finishStartedPreprocessing() async {
final mediaFiles = await twonlyDB.mediaFilesDao final mediaFiles = await twonlyDB.mediaFilesDao
.getAllMediaFilesPendingUpload(); .getAllMediaFilesPendingUpload();
Log.info('There are ${mediaFiles.length} media files pending');
for (final mediaFile in mediaFiles) { for (final mediaFile in mediaFiles) {
if (mediaFile.isDraftMedia) { if (mediaFile.isDraftMedia) {
Log.info('Ignoring media files as it is a draft');
continue; continue;
} }
try { try {
@ -51,6 +50,9 @@ Future<void> finishStartedPreprocessing() async {
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId); await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId);
continue; continue;
} }
Log.info(
'Finishing started preprocessing of ${mediaFile.mediaId} in state ${mediaFile.uploadState}.',
);
await startBackgroundMediaUpload(service); await startBackgroundMediaUpload(service);
} catch (e) { } catch (e) {
Log.warn(e); Log.warn(e);

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:mutex/mutex.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
@ -6,6 +7,7 @@ import 'package:twonly/src/constants/keyvalue.keys.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api.service.dart'; import 'package:twonly/src/services/api.service.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart'; import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/utils/exclusive_access.dart';
import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
@ -14,12 +16,14 @@ import 'package:workmanager/workmanager.dart';
// ignore: unreachable_from_main // ignore: unreachable_from_main
Future<void> initializeBackgroundTaskManager() async { Future<void> initializeBackgroundTaskManager() async {
await Workmanager().initialize(callbackDispatcher); await Workmanager().initialize(callbackDispatcher);
await Workmanager().cancelByUniqueName('fetch_data_from_server');
await Workmanager().registerPeriodicTask( await Workmanager().registerPeriodicTask(
'fetch_data_from_server', 'fetch_data_from_server',
'eu.twonly.periodic_task', 'eu.twonly.periodic_task',
frequency: const Duration(minutes: 20), frequency: const Duration(minutes: 20),
initialDelay: const Duration(minutes: 5), initialDelay: const Duration(minutes: 5),
existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
constraints: Constraints( constraints: Constraints(
networkType: NetworkType.connected, networkType: NetworkType.connected,
), ),
@ -47,16 +51,16 @@ void callbackDispatcher() {
Future<bool> initBackgroundExecution() async { Future<bool> initBackgroundExecution() async {
SentryWidgetsFlutterBinding.ensureInitialized(); SentryWidgetsFlutterBinding.ensureInitialized();
globalApplicationCacheDirectory = (await getApplicationCacheDirectory()).path;
globalApplicationSupportDirectory =
(await getApplicationSupportDirectory()).path;
initLogger(); initLogger();
final user = await getUser(); final user = await getUser();
if (user == null) return false; if (user == null) return false;
gUser = user; gUser = user;
globalApplicationCacheDirectory = (await getApplicationCacheDirectory()).path;
globalApplicationSupportDirectory =
(await getApplicationSupportDirectory()).path;
twonlyDB = TwonlyDB(); twonlyDB = TwonlyDB();
apiService = ApiService(); apiService = ApiService();
globalIsInBackgroundTask = true; globalIsInBackgroundTask = true;
@ -64,26 +68,35 @@ Future<bool> initBackgroundExecution() async {
return true; return true;
} }
Future<bool> handlePeriodicTask() async { final Mutex _keyValueMutex = Mutex();
final lastExecution =
await KeyValueStore.get(KeyValueKeys.lastPeriodicTaskExecution);
if (lastExecution == null || !lastExecution.containsKey('timestamp')) {
final lastExecutionTime = lastExecution?['timestamp'] as int?;
if (lastExecutionTime != null) {
final lastExecution =
DateTime.fromMillisecondsSinceEpoch(lastExecutionTime);
if (DateTime.now().difference(lastExecution).inMinutes < 2) {
Log.info(
'eu.twonly.periodic_task not executed as last execution was within the last two minutes.',
);
return true;
}
}
}
Future<void> handlePeriodicTask() async {
final shouldBeExecuted = await exclusiveAccess(
lockName: 'periodic_task',
mutex: _keyValueMutex,
action: () async {
final lastExecution = await KeyValueStore.get(
KeyValueKeys.lastPeriodicTaskExecution,
);
if (lastExecution != null && lastExecution.containsKey('timestamp')) {
final lastExecutionTime = lastExecution['timestamp'] as int?;
if (lastExecutionTime != null) {
final lastExecutionDate = DateTime.fromMillisecondsSinceEpoch(
lastExecutionTime,
);
if (DateTime.now().difference(lastExecutionDate).inMinutes < 2) {
return false;
}
}
}
await KeyValueStore.put(KeyValueKeys.lastPeriodicTaskExecution, { await KeyValueStore.put(KeyValueKeys.lastPeriodicTaskExecution, {
'timestamp': DateTime.now().millisecondsSinceEpoch, 'timestamp': DateTime.now().millisecondsSinceEpoch,
}); });
return true;
},
);
if (!shouldBeExecuted) return;
Log.info('eu.twonly.periodic_task was called.'); Log.info('eu.twonly.periodic_task was called.');
@ -91,12 +104,12 @@ Future<bool> handlePeriodicTask() async {
if (!await apiService.connect()) { if (!await apiService.connect()) {
Log.info('Could not connect to the api. Returning early.'); Log.info('Could not connect to the api. Returning early.');
return false; return;
} }
if (!apiService.isAuthenticated) { if (!apiService.isAuthenticated) {
Log.info('Api is not authenticated. Returning early.'); Log.info('Api is not authenticated. Returning early.');
return false; return;
} }
while (!globalGotMessageFromServer) { while (!globalGotMessageFromServer) {
@ -119,7 +132,7 @@ Future<bool> handlePeriodicTask() async {
stopwatch.stop(); stopwatch.stop();
Log.info('eu.twonly.periodic_task finished after ${stopwatch.elapsed}.'); Log.info('eu.twonly.periodic_task finished after ${stopwatch.elapsed}.');
return true; return;
} }
Future<void> handleProcessingTask() async { Future<void> handleProcessingTask() async {

View file

@ -6,12 +6,21 @@ import 'package:flutter_svg/svg.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
Future<void> createPushAvatars() async { Future<void> createPushAvatars({int? forceForUserId}) async {
final contacts = await twonlyDB.contactsDao.getAllContacts(); final contacts = await twonlyDB.contactsDao.getAllContacts();
for (final contact in contacts) { for (final contact in contacts) {
if (contact.avatarSvgCompressed == null) continue; if (contact.avatarSvgCompressed == null) continue;
if (forceForUserId == null) {
if (avatarPNGFile(contact.userId).existsSync()) {
continue; // only create the avatar in case no avatar exists yet fot this user
}
} else if (contact.userId != forceForUserId) {
// only update the avatar for this specified contact
continue;
}
final avatarSvg = getAvatarSvg(contact.avatarSvgCompressed!); final avatarSvg = getAvatarSvg(contact.avatarSvgCompressed!);
final pictureInfo = await vg.loadPicture(SvgStringLoader(avatarSvg), null); final pictureInfo = await vg.loadPicture(SvgStringLoader(avatarSvg), null);
@ -27,8 +36,9 @@ Future<void> createPushAvatars() async {
} }
File avatarPNGFile(int contactId) { File avatarPNGFile(int contactId) {
final avatarsDirectory = final avatarsDirectory = Directory(
Directory('$globalApplicationCacheDirectory/avatars'); '$globalApplicationCacheDirectory/avatars',
);
if (!avatarsDirectory.existsSync()) { if (!avatarsDirectory.existsSync()) {
avatarsDirectory.createSync(recursive: true); avatarsDirectory.createSync(recursive: true);
@ -42,8 +52,10 @@ Future<Uint8List> getUserAvatar() async {
return data.buffer.asUint8List(); return data.buffer.asUint8List();
} }
final pictureInfo = final pictureInfo = await vg.loadPicture(
await vg.loadPicture(SvgStringLoader(gUser.avatarSvg!), null); SvgStringLoader(gUser.avatarSvg!),
null,
);
final image = await pictureInfo.picture.toImage(270, 300); final image = await pictureInfo.picture.toImage(270, 300);

View file

@ -0,0 +1,51 @@
import 'dart:async';
import 'dart:io';
import 'package:mutex/mutex.dart';
import 'package:twonly/globals.dart';
Future<T> exclusiveAccess<T>({
required String lockName,
required Future<T> Function() action,
required Mutex mutex,
}) async {
final lockFile = File('$globalApplicationSupportDirectory/$lockName.lock');
return mutex.protect(() async {
var lockAcquired = false;
while (!lockAcquired) {
try {
lockFile.createSync(exclusive: true);
lockAcquired = true;
} on FileSystemException catch (e) {
final isExists = e is PathExistsException || e.osError?.errorCode == 17;
if (!isExists) {
break;
}
try {
final stat = lockFile.statSync();
if (stat.type != FileSystemEntityType.notFound) {
final age = DateTime.now().difference(stat.modified).inMilliseconds;
if (age > 1000) {
lockFile.deleteSync();
continue;
}
}
} catch (_) {}
await Future.delayed(const Duration(milliseconds: 50));
} catch (_) {
break;
}
}
try {
return await action();
} finally {
if (lockAcquired) {
try {
if (lockFile.existsSync()) {
lockFile.deleteSync();
}
} catch (_) {}
}
}
});
}

View file

@ -6,6 +6,7 @@ import 'package:logging/logging.dart';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/utils/exclusive_access.dart';
void initLogger() { void initLogger() {
// Logger.root.level = kReleaseMode ? Level.INFO : Level.ALL; // Logger.root.level = kReleaseMode ? Level.INFO : Level.ALL;
@ -91,46 +92,11 @@ Future<String> readLast1000Lines() async {
final Mutex _logMutex = Mutex(); final Mutex _logMutex = Mutex();
Future<T> _protectFileAccess<T>(Future<T> Function() action) async { Future<T> _protectFileAccess<T>(Future<T> Function() action) async {
return _logMutex.protect(() async { return exclusiveAccess(
final lockFile = File('$globalApplicationSupportDirectory/app.log.lock'); lockName: 'app.log',
var lockAcquired = false; action: action,
mutex: _logMutex,
while (!lockAcquired) { );
try {
lockFile.createSync(exclusive: true);
lockAcquired = true;
} on FileSystemException catch (e) {
final isExists = e is PathExistsException || e.osError?.errorCode == 17;
if (!isExists) {
break;
}
try {
final stat = lockFile.statSync();
if (stat.type != FileSystemEntityType.notFound) {
final age = DateTime.now().difference(stat.modified).inMilliseconds;
if (age > 1000) {
lockFile.deleteSync();
continue;
}
}
} catch (_) {}
await Future.delayed(const Duration(milliseconds: 50));
} catch (_) {
break;
}
}
try {
return await action();
} finally {
if (lockAcquired) {
try {
if (lockFile.existsSync()) {
lockFile.deleteSync();
}
} catch (_) {}
}
}
});
} }
Future<void> _writeLogToFile(LogRecord record) async { Future<void> _writeLogToFile(LogRecord record) async {

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none' publish_to: 'none'
version: 0.0.99+99 version: 0.1.1+101
environment: environment:
sdk: ^3.11.0 sdk: ^3.11.0