fix errors found with glitchtip

This commit is contained in:
otsmr 2026-06-20 13:11:48 +02:00
parent e8829ed2c9
commit 7066e8c799
17 changed files with 275 additions and 118 deletions

View file

@ -314,9 +314,11 @@ class UserDiscoveryCallbacks {
static Future<Uint8List?> getContactPromotion(int contactId) async { static Future<Uint8List?> getContactPromotion(int contactId) async {
try { try {
final row = await (twonlyDB.select( final query = twonlyDB.select(twonlyDB.userDiscoveryOwnPromotions)
twonlyDB.userDiscoveryOwnPromotions, ..where((tbl) => tbl.contactId.equals(contactId))
)..where((tbl) => tbl.contactId.equals(contactId))).getSingleOrNull(); ..orderBy([(tbl) => OrderingTerm.desc(tbl.versionId)])
..limit(1);
final row = await query.getSingleOrNull();
return row?.promotion; return row?.promotion;
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);

View file

@ -128,7 +128,7 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
final result = await _insertGroup(insertGroup); final result = await _insertGroup(insertGroup);
if (result != null) { if (result != null) {
await into(groupMembers).insert( await into(groupMembers).insertOnConflictUpdate(
GroupMembersCompanion( GroupMembersCompanion(
groupId: Value(result.groupId), groupId: Value(result.groupId),
contactId: Value( contactId: Value(

View file

@ -73,8 +73,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
mediaFiles, mediaFiles,
mediaFiles.mediaId.equalsExp(messages.mediaId), mediaFiles.mediaId.equalsExp(messages.mediaId),
), ),
]) ])..where(
..where(
messages.openedAt.isNull() & messages.openedAt.isNull() &
messages.mediaId.isNotNull() & messages.mediaId.isNotNull() &
messages.type.equals(MessageType.media.name) & messages.type.equals(MessageType.media.name) &
@ -291,11 +290,26 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
List<String> messageIds, List<String> messageIds,
DateTime timestamp, DateTime timestamp,
) async { ) async {
if (contactId.present) {
final contactExists = await twonlyDB.contactsDao.getContactById(contactId.value);
if (contactExists == null) {
Log.info(
'handleMessagesOpened: Contact ${contactId.value} does not exist in database, ignoring messages opened action.',
);
return;
}
}
for (final messageId in messageIds) { for (final messageId in messageIds) {
try { try {
var actionTimestamp = timestamp; var actionTimestamp = timestamp;
final msg = await getMessageById(messageId).getSingleOrNull(); final msg = await getMessageById(messageId).getSingleOrNull();
if (msg != null && actionTimestamp.isBefore(msg.createdAt)) { if (msg == null) {
Log.info(
'handleMessagesOpened: Message $messageId does not exist in database, skipping.',
);
continue;
}
if (actionTimestamp.isBefore(msg.createdAt)) {
Log.warn( Log.warn(
'Receiver clock skew detected for message $messageId. ' 'Receiver clock skew detected for message $messageId. '
'Action timestamp $actionTimestamp is before message creation ${msg.createdAt}. ' 'Action timestamp $actionTimestamp is before message creation ${msg.createdAt}. '
@ -348,7 +362,11 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
'handleMessagesOpened completed for message $messageId', 'handleMessagesOpened completed for message $messageId',
); );
} catch (e) { } catch (e) {
Log.error('handleMessagesOpened failed for $messageId: $e'); Log.warn('handleMessagesOpened failed for $messageId: $e');
Log.error(
'handleMessagesOpened failed for: $e',
onlyIfSentryEnabled: true,
);
} }
} }
} }
@ -358,6 +376,20 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
String messageId, String messageId,
DateTime timestamp, DateTime timestamp,
) async { ) async {
final contactExists = await twonlyDB.contactsDao.getContactById(contactId);
if (contactExists == null) {
Log.info(
'handleMessageAckByServer: Contact $contactId does not exist in database, ignoring message ack.',
);
return;
}
final msg = await getMessageById(messageId).getSingleOrNull();
if (msg == null) {
Log.info(
'handleMessageAckByServer: Message $messageId does not exist in database, skipping.',
);
return;
}
await transaction(() async { await transaction(() async {
await into(messageActions).insertOnConflictUpdate( await into(messageActions).insertOnConflictUpdate(
MessageActionsCompanion( MessageActionsCompanion(

View file

@ -42,7 +42,9 @@ class SignalPreKeyStore extends PreKeyStore {
); );
try { try {
await twonlyDB.into(twonlyDB.signalPreKeyStores).insert(preKeyCompanion); await twonlyDB
.into(twonlyDB.signalPreKeyStores)
.insert(preKeyCompanion, mode: InsertMode.insertOrReplace);
} catch (e) { } catch (e) {
Log.error('$e'); Log.error('$e');
} }

View file

@ -439,6 +439,10 @@ Future<void> _startBackgroundMediaUploadInternal(
// Refresh the media file state inside the mutex // Refresh the media file state inside the mutex
await mediaService.updateFromDB(); await mediaService.updateFromDB();
if (mediaService.mediaFile.uploadState == UploadState.uploading) {
await _checkAndRecoverMissingUploadRequest(mediaService);
}
if (mediaService.mediaFile.uploadState == UploadState.initialized || if (mediaService.mediaFile.uploadState == UploadState.initialized ||
mediaService.mediaFile.uploadState == UploadState.preprocessing) { mediaService.mediaFile.uploadState == UploadState.preprocessing) {
Log.info( Log.info(
@ -672,7 +676,31 @@ Future<void> _createUploadRequest(MediaFileService media) async {
await media.uploadRequestPath.writeAsBytes(uploadRequestBytes); await media.uploadRequestPath.writeAsBytes(uploadRequestBytes);
} }
Future<bool> _checkAndRecoverMissingUploadRequest(
MediaFileService media, {
bool triggerBackgroundUpload = false,
}) async {
if (!media.uploadRequestPath.existsSync()) {
Log.warn(
'UploadRequestPath for media ${media.mediaFile.mediaId} does not exist. Reverting to preprocessing.',
);
await media.setUploadState(UploadState.preprocessing);
if (triggerBackgroundUpload) {
unawaited(startBackgroundMediaUpload(media));
}
return true;
}
return false;
}
Future<void> _uploadUploadRequest(MediaFileService media) async { Future<void> _uploadUploadRequest(MediaFileService media) async {
if (await _checkAndRecoverMissingUploadRequest(
media,
triggerBackgroundUpload: true,
)) {
return;
}
final currentMedia = await twonlyDB.mediaFilesDao.getMediaFileById( final currentMedia = await twonlyDB.mediaFilesDao.getMediaFileById(
media.mediaFile.mediaId, media.mediaFile.mediaId,
); );
@ -722,6 +750,13 @@ Future<void> uploadFileFastOrEnqueue(
UploadTask task, UploadTask task,
MediaFileService media, MediaFileService media,
) async { ) async {
if (await _checkAndRecoverMissingUploadRequest(
media,
triggerBackgroundUpload: true,
)) {
return;
}
final requestMultipart = http.MultipartRequest( final requestMultipart = http.MultipartRequest(
'POST', 'POST',
Uri.parse(task.url), Uri.parse(task.url),

View file

@ -101,14 +101,18 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({
if (receiptId == null && receipt == null) return null; if (receiptId == null && receipt == null) return null;
try { try {
if (receipt == null) { final targetReceiptId = receipt?.receiptId ?? receiptId!;
// ignore: parameter_assignments final loadedReceipt = await twonlyDB.receiptsDao.getReceiptById(
receipt = await twonlyDB.receiptsDao.getReceiptById(receiptId!); targetReceiptId,
if (receipt == null) { );
Log.warn('[$receiptId] Receipt not found.'); if (loadedReceipt == null) {
Log.info(
'[$targetReceiptId] Receipt not found (might have been processed or deleted).',
);
return null; return null;
} }
} // ignore: parameter_assignments
receipt = loadedReceipt;
if (receipt.retryCount >= 2) { if (receipt.retryCount >= 2) {
// After two retries, change the receiptId. This addresses a bug where the receiver received the message and marked it as received, // After two retries, change the receiptId. This addresses a bug where the receiver received the message and marked it as received,
@ -138,7 +142,7 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({
if (!onlyReturnEncryptedData && if (!onlyReturnEncryptedData &&
receipt.ackByServerAt != null && receipt.ackByServerAt != null &&
receipt.markForRetry == null) { receipt.markForRetry == null) {
Log.error('Message already uploaded and mark for retry is not set!'); Log.info('Message already uploaded and mark for retry is not set.');
return null; return null;
} }

View file

@ -33,17 +33,18 @@ Future<void> compressImage(
Log.info('Compressed images size in bytes: ${compressedBytes.length}'); Log.info('Compressed images size in bytes: ${compressedBytes.length}');
if (compressedBytes.length >= 1 * 1000 * 1000) { if (compressedBytes.length >= 2 * 1000 * 1000) {
// if the media file is over 1MB compress it with 60% // if the media file is over 1MB compress it with 60%
final tmpCompressedBytes = await FlutterImageCompress.compressWithFile( final tmpCompressedBytes = await FlutterImageCompress.compressWithFile(
sourceFile.path, sourceFile.path,
format: CompressFormat.webp, format: CompressFormat.webp,
quality: 60, quality: 60,
); );
if (tmpCompressedBytes != null) { if (tmpCompressedBytes == null) {
Log.error( Log.error(
'Could not compress media file with 60%: $sourceFile. Sending original 90% compressed file.', 'Could not compress media file with 60%: $sourceFile. Sending original 90% compressed file.',
); );
} else {
compressedBytes = tmpCompressedBytes; compressedBytes = tmpCompressedBytes;
} }
} }
@ -106,11 +107,11 @@ Future<void> compressAndOverlayVideo(MediaFileService media) async {
}, },
); );
} catch (e) { } catch (e) {
Log.error('during video compression: $e'); Log.warn('during video compression: $e');
} }
if (compressedPath == null) { if (compressedPath == null) {
Log.error('Could not compress video using original video.'); Log.warn('Could not compress video using original video.');
// as a fall back use the non compressed version // as a fall back use the non compressed version
media.ffmpegOutputPath.copySync(media.tempPath.path); media.ffmpegOutputPath.copySync(media.tempPath.path);
} }

View file

@ -252,7 +252,7 @@ class MediaFileService {
Future<void> compressMedia() async { Future<void> compressMedia() async {
if (!originalPath.existsSync()) { if (!originalPath.existsSync()) {
Log.error('Could not compress as original media does not exists.'); Log.warn('Could not compress as original media does not exists.');
return; return;
} }
@ -296,7 +296,8 @@ class MediaFileService {
(thumbnailPath.existsSync() && thumbnailPath.lengthSync() > 0) || (thumbnailPath.existsSync() && thumbnailPath.lengthSync() > 0) ||
mediaFile.type == MediaType.audio || mediaFile.type == MediaType.audio ||
((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) && ((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) &&
storedPath.existsSync() && storedPath.lengthSync() > 0); storedPath.existsSync() &&
storedPath.lengthSync() > 0);
Future<void> storeMediaFile() async { Future<void> storeMediaFile() async {
Log.info('Storing media file ${mediaFile.mediaId}'); Log.info('Storing media file ${mediaFile.mediaId}');
@ -327,8 +328,8 @@ class MediaFileService {
} }
} }
} else { } else {
Log.error( Log.warn(
'Could not store image neither as ${tempPath.path} does not exists.', 'Could not store image locally as ${tempPath.path} does not exist.',
); );
} }
unawaited(createThumbnail()); unawaited(createThumbnail());
@ -503,7 +504,7 @@ class MediaFileService {
); );
await updateFromDB(); await updateFromDB();
} catch (e) { } catch (e) {
Log.error( Log.warn(
'Error auto-cropping transparent borders for mediaId ${mediaFile.mediaId}: $e', 'Error auto-cropping transparent borders for mediaId ${mediaFile.mediaId}: $e',
); );
await twonlyDB.mediaFilesDao.updateMedia( await twonlyDB.mediaFilesDao.updateMedia(

View file

@ -20,6 +20,7 @@ class GroupContextMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final navigator = Navigator.of(context);
return ContextMenu( return ContextMenu(
items: [ items: [
if (!group.archived) if (!group.archived)
@ -27,9 +28,7 @@ class GroupContextMenu extends StatelessWidget {
title: context.lang.contextMenuArchiveUser, title: context.lang.contextMenuArchiveUser,
onTap: () async { onTap: () async {
const update = GroupsCompanion(archived: Value(true)); const update = GroupsCompanion(archived: Value(true));
if (context.mounted) {
await twonlyDB.groupsDao.updateGroup(group.groupId, update); await twonlyDB.groupsDao.updateGroup(group.groupId, update);
}
}, },
icon: Icons.archive_outlined, icon: Icons.archive_outlined,
), ),
@ -38,15 +37,13 @@ class GroupContextMenu extends StatelessWidget {
title: context.lang.contextMenuUndoArchiveUser, title: context.lang.contextMenuUndoArchiveUser,
onTap: () async { onTap: () async {
const update = GroupsCompanion(archived: Value(false)); const update = GroupsCompanion(archived: Value(false));
if (context.mounted) {
await twonlyDB.groupsDao.updateGroup(group.groupId, update); await twonlyDB.groupsDao.updateGroup(group.groupId, update);
}
}, },
icon: Icons.unarchive_outlined, icon: Icons.unarchive_outlined,
), ),
ContextMenuItem( ContextMenuItem(
title: context.lang.contextMenuOpenChat, title: context.lang.contextMenuOpenChat,
onTap: () => context.push(Routes.chatsMessages(group.groupId)), onTap: () => navigator.context.push(Routes.chatsMessages(group.groupId)),
icon: FontAwesomeIcons.comments, icon: FontAwesomeIcons.comments,
), ),
if (!group.archived) if (!group.archived)
@ -56,9 +53,7 @@ class GroupContextMenu extends StatelessWidget {
: context.lang.contextMenuPin, : context.lang.contextMenuPin,
onTap: () async { onTap: () async {
final update = GroupsCompanion(pinned: Value(!group.pinned)); final update = GroupsCompanion(pinned: Value(!group.pinned));
if (context.mounted) {
await twonlyDB.groupsDao.updateGroup(group.groupId, update); await twonlyDB.groupsDao.updateGroup(group.groupId, update);
}
}, },
icon: group.pinned icon: group.pinned
? FontAwesomeIcons.thumbtackSlash ? FontAwesomeIcons.thumbtackSlash
@ -69,9 +64,9 @@ class GroupContextMenu extends StatelessWidget {
icon: FontAwesomeIcons.trashCan, icon: FontAwesomeIcons.trashCan,
onTap: () async { onTap: () async {
final ok = await showAlertDialog( final ok = await showAlertDialog(
context, navigator.context,
context.lang.deleteTitle, navigator.context.lang.deleteTitle,
context.lang.groupContextMenuDeleteGroup, navigator.context.lang.groupContextMenuDeleteGroup,
); );
if (ok) { if (ok) {
await twonlyDB.messagesDao.deleteMessagesByGroupId(group.groupId); await twonlyDB.messagesDao.deleteMessagesByGroupId(group.groupId);

View file

@ -17,12 +17,13 @@ class UserContextMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final navigator = Navigator.of(context);
return ContextMenu( return ContextMenu(
minWidth: 150, minWidth: 150,
items: [ items: [
ContextMenuItem( ContextMenuItem(
title: context.lang.contextMenuUserProfile, title: context.lang.contextMenuUserProfile,
onTap: () => context.push(Routes.profileContact(contact.userId)), onTap: () => navigator.context.push(Routes.profileContact(contact.userId)),
icon: FontAwesomeIcons.user, icon: FontAwesomeIcons.user,
), ),
], ],

View file

@ -178,7 +178,8 @@ class MainCameraController {
selectedCameraDetails.isZoomAble = false; selectedCameraDetails.isZoomAble = false;
if (cameraController == null || !cameraController!.value.isInitialized) { final currentController = cameraController;
if (currentController == null || !currentController.value.isInitialized) {
final controllerToDispose = cameraController; final controllerToDispose = cameraController;
cameraController = null; cameraController = null;
if (controllerToDispose != null) { if (controllerToDispose != null) {
@ -188,7 +189,7 @@ class MainCameraController {
final hasMic = await micPermissionFuture; final hasMic = await micPermissionFuture;
if (sessionId != _cameraSessionId) return; if (sessionId != _cameraSessionId) return;
cameraController = CameraController( final controller = CameraController(
AppEnvironment.cameras[cameraId], AppEnvironment.cameras[cameraId],
ResolutionPreset.high, ResolutionPreset.high,
enableAudio: hasMic, enableAudio: hasMic,
@ -196,22 +197,60 @@ class MainCameraController {
? ImageFormatGroup.nv21 ? ImageFormatGroup.nv21
: ImageFormatGroup.bgra8888, : ImageFormatGroup.bgra8888,
); );
var assignedToGlobal = false;
try { try {
_initializeFuture = cameraController?.initialize(); _initializeFuture = controller.initialize();
await _initializeFuture; await _initializeFuture;
await cameraController!.startImageStream(_processCameraImage); if (sessionId != _cameraSessionId) {
await cameraController!.setZoomLevel(selectedCameraDetails.scaleFactor); unawaited(controller.dispose());
if (userService.currentUser.videoStabilizationEnabled && !kDebugMode) { return;
await cameraController!.setVideoStabilizationMode( }
VideoStabilizationMode.level1,
if (!controller.value.isInitialized) {
throw CameraException(
'Uninitialized CameraController',
'CameraController was not initialized after initialize() completed.',
); );
} }
await controller.startImageStream(_processCameraImage);
if (sessionId != _cameraSessionId) {
unawaited(controller.dispose());
return;
}
await controller.setZoomLevel(selectedCameraDetails.scaleFactor);
if (sessionId != _cameraSessionId) {
unawaited(controller.dispose());
return;
}
if (userService.currentUser.videoStabilizationEnabled && !kDebugMode) {
await controller.setVideoStabilizationMode(
VideoStabilizationMode.level1,
);
if (sessionId != _cameraSessionId) {
unawaited(controller.dispose());
return;
}
}
cameraController = controller;
assignedToGlobal = true;
} catch (e) { } catch (e) {
if (e is CameraException) {
Log.warn('Camera initialization failed (CameraException): $e');
} else {
Log.error('Error initializing camera: $e'); Log.error('Error initializing camera: $e');
final controllerToDispose = cameraController; }
if (!assignedToGlobal) {
unawaited(controller.dispose());
} else {
if (cameraController == controller) {
cameraController = null; cameraController = null;
if (controllerToDispose != null) { }
unawaited(controllerToDispose.dispose()); unawaited(controller.dispose());
} }
initCameraStarted = false; initCameraStarted = false;
return; return;
@ -219,35 +258,64 @@ class MainCameraController {
} else { } else {
try { try {
if (!isVideoRecording) { if (!isVideoRecording) {
await cameraController!.stopImageStream(); try {
await currentController.stopImageStream();
} catch (e) {
Log.warn('Could not stop image stream: $e');
} }
}
if (sessionId != _cameraSessionId) return;
selectedCameraDetails.scaleFactor = 1; selectedCameraDetails.scaleFactor = 1;
await cameraController!.setZoomLevel(1); await currentController.setZoomLevel(1);
await cameraController!.setDescription( if (sessionId != _cameraSessionId) return;
await currentController.setDescription(
AppEnvironment.cameras[cameraId], AppEnvironment.cameras[cameraId],
); );
if (sessionId != _cameraSessionId) return;
if (!isVideoRecording) { if (!isVideoRecording) {
await cameraController!.startImageStream(_processCameraImage); await currentController.startImageStream(_processCameraImage);
} }
} catch (e) { } catch (e) {
if (e is CameraException) {
Log.warn('Camera description switch failed (CameraException): $e');
} else {
Log.error('Error switching camera description: $e'); Log.error('Error switching camera description: $e');
}
initCameraStarted = false; initCameraStarted = false;
return; return;
} }
} }
if (sessionId != _cameraSessionId) return;
final controller = cameraController;
if (controller == null) {
initCameraStarted = false;
return;
}
try { try {
await cameraController!.lockCaptureOrientation( await controller.lockCaptureOrientation(
DeviceOrientation.portraitUp, DeviceOrientation.portraitUp,
); );
await cameraController!.setFlashMode( if (sessionId != _cameraSessionId) return;
await controller.setFlashMode(
selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off, selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off,
); );
selectedCameraDetails.maxAvailableZoom = await cameraController! if (sessionId != _cameraSessionId) return;
selectedCameraDetails.maxAvailableZoom = await controller
.getMaxZoomLevel(); .getMaxZoomLevel();
selectedCameraDetails.minAvailableZoom = await cameraController! if (sessionId != _cameraSessionId) return;
selectedCameraDetails.minAvailableZoom = await controller
.getMinZoomLevel(); .getMinZoomLevel();
if (sessionId != _cameraSessionId) return;
selectedCameraDetails selectedCameraDetails
..isZoomAble = ..isZoomAble =
selectedCameraDetails.maxAvailableZoom != selectedCameraDetails.maxAvailableZoom !=
@ -263,12 +331,15 @@ class MainCameraController {
initCameraStarted = false; initCameraStarted = false;
setState?.call(); setState?.call();
} catch (e) { } catch (e) {
if (e is CameraException) {
Log.warn('Camera post-initialization failed (CameraException): $e');
} else {
Log.error('Error post-initializing camera: $e'); Log.error('Error post-initializing camera: $e');
final controllerToDispose = cameraController;
cameraController = null;
if (controllerToDispose != null) {
unawaited(controllerToDispose.dispose());
} }
if (cameraController == controller) {
cameraController = null;
}
unawaited(controller.dispose());
initCameraStarted = false; initCameraStarted = false;
return; return;
} }
@ -348,8 +419,9 @@ class MainCameraController {
} }
InputImage? _inputImageFromCameraImage(CameraImage image) { InputImage? _inputImageFromCameraImage(CameraImage image) {
if (cameraController == null) return null; final controller = cameraController;
final camera = cameraController!.description; if (controller == null) return null;
final camera = controller.description;
final sensorOrientation = camera.sensorOrientation; final sensorOrientation = camera.sensorOrientation;
InputImageRotation? rotation; InputImageRotation? rotation;
@ -358,7 +430,7 @@ class MainCameraController {
rotation = InputImageRotationValue.fromRawValue(sensorOrientation); rotation = InputImageRotationValue.fromRawValue(sensorOrientation);
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
var rotationCompensation = var rotationCompensation =
_orientations[cameraController!.value.deviceOrientation]; _orientations[controller.value.deviceOrientation];
if (rotationCompensation == null) return null; if (rotationCompensation == null) return null;
if (camera.lensDirection == CameraLensDirection.front) { if (camera.lensDirection == CameraLensDirection.front) {
// front-facing // front-facing
@ -408,14 +480,15 @@ class MainCameraController {
if (_isBusy) return; if (_isBusy) return;
_isBusy = true; _isBusy = true;
final barcodes = await _barcodeScanner.processImage(inputImage); final barcodes = await _barcodeScanner.processImage(inputImage);
final controller = cameraController;
if (inputImage.metadata?.size != null && if (inputImage.metadata?.size != null &&
inputImage.metadata?.rotation != null && inputImage.metadata?.rotation != null &&
cameraController != null) { controller != null) {
final painter = BarcodeDetectorPainter( final painter = BarcodeDetectorPainter(
barcodes, barcodes,
inputImage.metadata!.size, inputImage.metadata!.size,
inputImage.metadata!.rotation, inputImage.metadata!.rotation,
cameraController!.description.lensDirection, controller.description.lensDirection,
); );
qrCodePain = CustomPaint(painter: painter); qrCodePain = CustomPaint(painter: painter);
@ -501,9 +574,10 @@ class MainCameraController {
if (_isBusyFaces) return; if (_isBusyFaces) return;
_isBusyFaces = true; _isBusyFaces = true;
final faces = await _faceDetector.processImage(inputImage); final faces = await _faceDetector.processImage(inputImage);
final controller = cameraController;
if (inputImage.metadata?.size != null && if (inputImage.metadata?.size != null &&
inputImage.metadata?.rotation != null && inputImage.metadata?.rotation != null &&
cameraController != null) { controller != null) {
if (faces.isNotEmpty) { if (faces.isNotEmpty) {
CustomPainter? painter; CustomPainter? painter;
switch (_currentFilterType) { switch (_currentFilterType) {
@ -512,7 +586,7 @@ class MainCameraController {
faces, faces,
inputImage.metadata!.size, inputImage.metadata!.size,
inputImage.metadata!.rotation, inputImage.metadata!.rotation,
cameraController!.description.lensDirection, controller.description.lensDirection,
); );
case FaceFilterType.beardUpperLipGreen: case FaceFilterType.beardUpperLipGreen:
painter = BeardFilterPainter( painter = BeardFilterPainter(
@ -520,7 +594,7 @@ class MainCameraController {
faces, faces,
inputImage.metadata!.size, inputImage.metadata!.size,
inputImage.metadata!.rotation, inputImage.metadata!.rotation,
cameraController!.description.lensDirection, controller.description.lensDirection,
); );
case FaceFilterType.none: case FaceFilterType.none:
break; break;

View file

@ -36,13 +36,13 @@ class _UserListItem extends State<GroupListItemComp> {
Message? _currentMessage; Message? _currentMessage;
List<Message> _messagesNotOpened = []; List<Message> _messagesNotOpened = [];
late StreamSubscription<List<Message>> _messagesNotOpenedStream; StreamSubscription<List<Message>>? _messagesNotOpenedStream;
Message? _lastMessage; Message? _lastMessage;
Reaction? _lastReaction; Reaction? _lastReaction;
late StreamSubscription<Message?> _lastMessageStream; StreamSubscription<Message?>? _lastMessageStream;
late StreamSubscription<Reaction?> _lastReactionStream; StreamSubscription<Reaction?>? _lastReactionStream;
late StreamSubscription<List<MediaFile>> _lastMediaFilesStream; StreamSubscription<List<MediaFile>>? _lastMediaFilesStream;
List<Message> _previewMessages = []; List<Message> _previewMessages = [];
final List<MediaFile> _previewMediaFiles = []; final List<MediaFile> _previewMediaFiles = [];
@ -57,18 +57,19 @@ class _UserListItem extends State<GroupListItemComp> {
@override @override
void dispose() { void dispose() {
_messagesNotOpenedStream.cancel(); _messagesNotOpenedStream?.cancel();
_lastReactionStream.cancel(); _lastReactionStream?.cancel();
_lastMessageStream.cancel(); _lastMessageStream?.cancel();
_lastMediaFilesStream.cancel(); _lastMediaFilesStream?.cancel();
super.dispose(); super.dispose();
} }
Future<void> initStreams() async { Future<void> initStreams() async {
_lastMessageStream = final lastMsgStream = await twonlyDB.messagesDao.watchLastMessage(
(await twonlyDB.messagesDao.watchLastMessage(
widget.group.groupId, widget.group.groupId,
)).listen((update) { );
if (!mounted) return;
_lastMessageStream = lastMsgStream.listen((update) {
protectUpdateState.protect(() async { protectUpdateState.protect(() async {
await updateState(update, _messagesNotOpened); await updateState(update, _messagesNotOpened);
}); });
@ -103,6 +104,7 @@ class _UserListItem extends State<GroupListItemComp> {
final groupContacts = await twonlyDB.groupsDao.getGroupContact( final groupContacts = await twonlyDB.groupsDao.getGroupContact(
widget.group.groupId, widget.group.groupId,
); );
if (!mounted) return;
if (groupContacts.length == 1) { if (groupContacts.length == 1) {
_receiverDeletedAccount = groupContacts.first.accountDeleted; _receiverDeletedAccount = groupContacts.first.accountDeleted;
} }

View file

@ -99,6 +99,7 @@ class MessageContextMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final navigator = Navigator.of(context);
return ContextMenu( return ContextMenu(
items: [ items: [
if (!message.isDeletedFromSender) if (!message.isDeletedFromSender)
@ -107,7 +108,7 @@ class MessageContextMenu extends StatelessWidget {
onTap: () async { onTap: () async {
final layer = final layer =
await showModalBottomSheet( await showModalBottomSheet(
context: context, context: navigator.context,
backgroundColor: Colors.black, backgroundColor: Colors.black,
builder: (context) { builder: (context) {
return const EmojiPickerBottom(); return const EmojiPickerBottom();
@ -138,7 +139,7 @@ class MessageContextMenu extends StatelessWidget {
if (mediaFileService?.canBeOpenedAgain ?? false) if (mediaFileService?.canBeOpenedAgain ?? false)
ContextMenuItem( ContextMenuItem(
title: context.lang.contextMenuViewAgain, title: context.lang.contextMenuViewAgain,
onTap: () => reopenMediaFile(context), onTap: () => reopenMediaFile(navigator.context),
icon: FontAwesomeIcons.clockRotateLeft, icon: FontAwesomeIcons.clockRotateLeft,
), ),
if (!message.isDeletedFromSender) if (!message.isDeletedFromSender)
@ -155,7 +156,7 @@ class MessageContextMenu extends StatelessWidget {
ContextMenuItem( ContextMenuItem(
title: context.lang.edit, title: context.lang.edit,
onTap: () async { onTap: () async {
await editTextMessage(context, message); await editTextMessage(navigator.context, message);
}, },
icon: FontAwesomeIcons.pencil, icon: FontAwesomeIcons.pencil,
), ),
@ -172,13 +173,13 @@ class MessageContextMenu extends StatelessWidget {
title: context.lang.delete, title: context.lang.delete,
onTap: () async { onTap: () async {
final delete = await showAlertDialog( final delete = await showAlertDialog(
context, navigator.context,
context.lang.deleteTitle, navigator.context.lang.deleteTitle,
null, null,
customOk: customOk:
(message.senderId == null && !message.isDeletedFromSender) (message.senderId == null && !message.isDeletedFromSender)
? context.lang.deleteOkBtnForAll ? navigator.context.lang.deleteOkBtnForAll
: context.lang.deleteOkBtnForMe, : navigator.context.lang.deleteOkBtnForMe,
); );
if (delete) { if (delete) {
if (message.senderId == null && !message.isDeletedFromSender) { if (message.senderId == null && !message.isDeletedFromSender) {
@ -209,8 +210,7 @@ class MessageContextMenu extends StatelessWidget {
ContextMenuItem( ContextMenuItem(
title: context.lang.info, title: context.lang.info,
onTap: () async { onTap: () async {
await Navigator.push( await navigator.push(
context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) { builder: (context) {
return MessageInfoView( return MessageInfoView(

View file

@ -137,6 +137,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
final Mutex _messageUpdateLock = Mutex(); final Mutex _messageUpdateLock = Mutex();
bool _isViewActive() { bool _isViewActive() {
if (!mounted) return false;
return !AppState.isAppInBackground && return !AppState.isAppInBackground &&
(ModalRoute.of(context)?.isCurrent ?? false); (ModalRoute.of(context)?.isCurrent ?? false);
} }
@ -358,7 +359,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (!mounted) return; if (!mounted) return;
if (!currentMediaLocal.tempPath.existsSync()) { if (!currentMediaLocal.tempPath.existsSync()) {
Log.error('Temp media file not found...'); Log.warn('Temp media file not found for media ID: ${currentMediaLocal.mediaFile.mediaId}');
await handleMediaError(currentMediaLocal.mediaFile); await handleMediaError(currentMediaLocal.mediaFile);
return advanceToNextMediaOrExit(); return advanceToNextMediaOrExit();
} }
@ -468,7 +469,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
..play(); ..play();
}) })
.catchError((Object err, StackTrace st) { .catchError((Object err, StackTrace st) {
Log.error('Video player initialization error', err, st); Log.error('Video player initialization error', error: err, stackTrace: st);
return null; return null;
}); });
} }
@ -511,12 +512,16 @@ class _MediaViewerViewState extends State<MediaViewerView> {
} }
Future<void> onPressedSaveToGallery() async { Future<void> onPressedSaveToGallery() async {
final media = currentMedia;
final msg = currentMessage;
if (media == null || msg == null) return;
setState(() { setState(() {
imageSaving = true; imageSaving = true;
}); });
await currentMedia!.storeMediaFile(); await media.storeMediaFile();
await twonlyDB.messagesDao.updateMessageId( await twonlyDB.messagesDao.updateMessageId(
currentMessage!.messageId, msg.messageId,
const MessagesCompanion( const MessagesCompanion(
mediaStored: Value(true), mediaStored: Value(true),
), ),
@ -526,7 +531,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
pb.EncryptedContent( pb.EncryptedContent(
mediaUpdate: pb.EncryptedContent_MediaUpdate( mediaUpdate: pb.EncryptedContent_MediaUpdate(
type: pb.EncryptedContent_MediaUpdate_Type.STORED, type: pb.EncryptedContent_MediaUpdate_Type.STORED,
targetMessageId: currentMessage!.messageId, targetMessageId: msg.messageId,
), ),
), ),
); );
@ -553,11 +558,12 @@ class _MediaViewerViewState extends State<MediaViewerView> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (currentMedia != null && if (currentMedia != null &&
currentMessage != null &&
!currentMedia!.mediaFile.requiresAuthentication && !currentMedia!.mediaFile.requiresAuthentication &&
currentMedia!.mediaFile.displayLimitInMilliseconds == null) currentMedia!.mediaFile.displayLimitInMilliseconds == null)
MyIconButton( MyIconButton(
variant: MyIconButtonVariant.secondary, variant: MyIconButtonVariant.secondary,
onPressed: (currentMedia == null) ? null : onPressedSaveToGallery, onPressed: (currentMedia == null || currentMessage == null) ? null : onPressedSaveToGallery,
icon: imageSaving icon: imageSaving
? const SizedBox( ? const SizedBox(
width: 16, width: 16,

View file

@ -118,6 +118,7 @@ class GroupMemberContextMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final navigator = Navigator.of(context);
return ContextMenu( return ContextMenu(
items: [ items: [
if (contact.accepted) if (contact.accepted)
@ -131,15 +132,15 @@ class GroupMemberContextMenu extends StatelessWidget {
// create // create
return; return;
} }
if (!context.mounted) return; if (!navigator.mounted) return;
await context.push(Routes.chatsMessages(directChat.groupId)); await navigator.context.push(Routes.chatsMessages(directChat.groupId));
}, },
icon: FontAwesomeIcons.message, icon: FontAwesomeIcons.message,
), ),
if (!contact.accepted) if (!contact.accepted)
ContextMenuItem( ContextMenuItem(
title: context.lang.createContactRequest, title: context.lang.createContactRequest,
onTap: () => _makeContactRequest(context), onTap: () => _makeContactRequest(navigator.context),
icon: FontAwesomeIcons.userPlus, icon: FontAwesomeIcons.userPlus,
), ),
if (member.groupPublicKey != null && if (member.groupPublicKey != null &&
@ -147,7 +148,7 @@ class GroupMemberContextMenu extends StatelessWidget {
member.memberState == MemberState.normal) member.memberState == MemberState.normal)
ContextMenuItem( ContextMenuItem(
title: context.lang.makeAdmin, title: context.lang.makeAdmin,
onTap: () => _makeContactAdmin(context), onTap: () => _makeContactAdmin(navigator.context),
icon: FontAwesomeIcons.key, icon: FontAwesomeIcons.key,
), ),
if (member.groupPublicKey != null && if (member.groupPublicKey != null &&
@ -155,13 +156,13 @@ class GroupMemberContextMenu extends StatelessWidget {
member.memberState == MemberState.admin) member.memberState == MemberState.admin)
ContextMenuItem( ContextMenuItem(
title: context.lang.removeAdmin, title: context.lang.removeAdmin,
onTap: () => _removeContactAsAdmin(context), onTap: () => _removeContactAsAdmin(navigator.context),
icon: FontAwesomeIcons.key, icon: FontAwesomeIcons.key,
), ),
if (group.isGroupAdmin && member.groupPublicKey != null) if (group.isGroupAdmin && member.groupPublicKey != null)
ContextMenuItem( ContextMenuItem(
title: context.lang.removeFromGroup, title: context.lang.removeFromGroup,
onTap: () => _removeContactFromGroup(context), onTap: () => _removeContactFromGroup(navigator.context),
icon: FontAwesomeIcons.rightFromBracket, icon: FontAwesomeIcons.rightFromBracket,
), ),
], ],

View file

@ -57,7 +57,7 @@ impl BackupArchive {
if is_db { if is_db {
// To avoid write-lock conflicts with Dart (which has the live database open in write mode), // To avoid write-lock conflicts with Dart (which has the live database open in write mode),
// we copy the database file first, then open the copy in write mode to perform the backup. // we copy the database file first, then open the copy to perform the backup.
let temp_copy_path = backup_data_dir.join(format!("{}.temp_copy", file_name)); let temp_copy_path = backup_data_dir.join(format!("{}.temp_copy", file_name));
std::fs::copy(&file_path, &temp_copy_path)?; std::fs::copy(&file_path, &temp_copy_path)?;
@ -72,13 +72,14 @@ impl BackupArchive {
.await?; .await?;
// Close database connection to release file lock before removing it // Close database connection to release file lock before removing it
drop(db); db.pool.close().await;
remove_file(&temp_copy_path)?; remove_file(&temp_copy_path)?;
// Perform integrity check of the new database file // Perform integrity check of the new database file
let backup_db = let backup_db =
Database::new(&backup_database_file, encryption_key.as_deref(), false).await?; Database::new(&backup_database_file, encryption_key.as_deref(), true).await?;
backup_db.check_integrity().await?; backup_db.check_integrity().await?;
backup_db.pool.close().await;
} else { } else {
let file_backup = backup_data_dir.join(file_name); let file_backup = backup_data_dir.join(file_name);
std::fs::copy(file_path, file_backup)?; std::fs::copy(file_path, file_backup)?;

View file

@ -29,7 +29,7 @@ impl Database {
.journal_mode(sqlx::sqlite::SqliteJournalMode::Delete) .journal_mode(sqlx::sqlite::SqliteJournalMode::Delete)
.foreign_keys(true) .foreign_keys(true)
.read_only(read_only) .read_only(read_only)
.busy_timeout(Duration::from_millis(5000)) .busy_timeout(Duration::from_secs(30))
.pragma("synchronous", "FULL") .pragma("synchronous", "FULL")
.pragma("recursive_triggers", "ON") .pragma("recursive_triggers", "ON")
.log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500)); .log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500));
@ -39,7 +39,7 @@ impl Database {
} }
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.acquire_timeout(Duration::from_secs(5)) .acquire_timeout(Duration::from_secs(30))
.max_connections(10) .max_connections(10)
.connect_with(connect_options) .connect_with(connect_options)
.await?; .await?;