From 7066e8c79961980c3814995bb8d3ed8079c8a1aa Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 20 Jun 2026 13:11:48 +0200 Subject: [PATCH] fix errors found with glitchtip --- .../callbacks/user_discovery.callbacks.dart | 8 +- lib/src/database/daos/groups.dao.dart | 2 +- lib/src/database/daos/messages.dao.dart | 40 ++++- .../database/signal/signal_pre_key_store.dart | 4 +- .../services/api/mediafiles/upload.api.dart | 35 +++++ lib/src/services/api/messages.api.dart | 20 ++- .../mediafiles/compression.service.dart | 9 +- .../mediafiles/mediafile.service.dart | 13 +- .../context_menu/group.context_menu.dart | 21 +-- .../context_menu/user.context_menu.dart | 3 +- .../main_camera_controller.dart | 144 +++++++++++++----- .../group_list_item.comp.dart | 34 +++-- .../message_context_menu.dart | 18 +-- .../visual/views/chats/media_viewer.view.dart | 18 ++- .../views/groups/group_member.context.dart | 13 +- rust/src/backup/backup_archive.rs | 7 +- rust/src/database/mod.rs | 4 +- 17 files changed, 275 insertions(+), 118 deletions(-) diff --git a/lib/src/callbacks/user_discovery.callbacks.dart b/lib/src/callbacks/user_discovery.callbacks.dart index 86bfc4df..1c0753b2 100644 --- a/lib/src/callbacks/user_discovery.callbacks.dart +++ b/lib/src/callbacks/user_discovery.callbacks.dart @@ -314,9 +314,11 @@ class UserDiscoveryCallbacks { static Future getContactPromotion(int contactId) async { try { - final row = await (twonlyDB.select( - twonlyDB.userDiscoveryOwnPromotions, - )..where((tbl) => tbl.contactId.equals(contactId))).getSingleOrNull(); + final query = twonlyDB.select(twonlyDB.userDiscoveryOwnPromotions) + ..where((tbl) => tbl.contactId.equals(contactId)) + ..orderBy([(tbl) => OrderingTerm.desc(tbl.versionId)]) + ..limit(1); + final row = await query.getSingleOrNull(); return row?.promotion; } catch (e) { Log.error(e); diff --git a/lib/src/database/daos/groups.dao.dart b/lib/src/database/daos/groups.dao.dart index 929dc2e0..aa5d69c4 100644 --- a/lib/src/database/daos/groups.dao.dart +++ b/lib/src/database/daos/groups.dao.dart @@ -128,7 +128,7 @@ class GroupsDao extends DatabaseAccessor with _$GroupsDaoMixin { final result = await _insertGroup(insertGroup); if (result != null) { - await into(groupMembers).insert( + await into(groupMembers).insertOnConflictUpdate( GroupMembersCompanion( groupId: Value(result.groupId), contactId: Value( diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index 532ddd92..fdf8b0b8 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -73,8 +73,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { mediaFiles, mediaFiles.mediaId.equalsExp(messages.mediaId), ), - ]) - ..where( + ])..where( messages.openedAt.isNull() & messages.mediaId.isNotNull() & messages.type.equals(MessageType.media.name) & @@ -291,11 +290,26 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { List messageIds, DateTime timestamp, ) 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) { try { var actionTimestamp = timestamp; 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( 'Receiver clock skew detected for message $messageId. ' 'Action timestamp $actionTimestamp is before message creation ${msg.createdAt}. ' @@ -348,7 +362,11 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { 'handleMessagesOpened completed for message $messageId', ); } 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 with _$MessagesDaoMixin { String messageId, DateTime timestamp, ) 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 into(messageActions).insertOnConflictUpdate( MessageActionsCompanion( diff --git a/lib/src/database/signal/signal_pre_key_store.dart b/lib/src/database/signal/signal_pre_key_store.dart index 10ae75c7..dc7cac75 100644 --- a/lib/src/database/signal/signal_pre_key_store.dart +++ b/lib/src/database/signal/signal_pre_key_store.dart @@ -42,7 +42,9 @@ class SignalPreKeyStore extends PreKeyStore { ); try { - await twonlyDB.into(twonlyDB.signalPreKeyStores).insert(preKeyCompanion); + await twonlyDB + .into(twonlyDB.signalPreKeyStores) + .insert(preKeyCompanion, mode: InsertMode.insertOrReplace); } catch (e) { Log.error('$e'); } diff --git a/lib/src/services/api/mediafiles/upload.api.dart b/lib/src/services/api/mediafiles/upload.api.dart index ea4cc518..205e04ab 100644 --- a/lib/src/services/api/mediafiles/upload.api.dart +++ b/lib/src/services/api/mediafiles/upload.api.dart @@ -439,6 +439,10 @@ Future _startBackgroundMediaUploadInternal( // Refresh the media file state inside the mutex await mediaService.updateFromDB(); + if (mediaService.mediaFile.uploadState == UploadState.uploading) { + await _checkAndRecoverMissingUploadRequest(mediaService); + } + if (mediaService.mediaFile.uploadState == UploadState.initialized || mediaService.mediaFile.uploadState == UploadState.preprocessing) { Log.info( @@ -672,7 +676,31 @@ Future _createUploadRequest(MediaFileService media) async { await media.uploadRequestPath.writeAsBytes(uploadRequestBytes); } +Future _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 _uploadUploadRequest(MediaFileService media) async { + if (await _checkAndRecoverMissingUploadRequest( + media, + triggerBackgroundUpload: true, + )) { + return; + } + final currentMedia = await twonlyDB.mediaFilesDao.getMediaFileById( media.mediaFile.mediaId, ); @@ -722,6 +750,13 @@ Future uploadFileFastOrEnqueue( UploadTask task, MediaFileService media, ) async { + if (await _checkAndRecoverMissingUploadRequest( + media, + triggerBackgroundUpload: true, + )) { + return; + } + final requestMultipart = http.MultipartRequest( 'POST', Uri.parse(task.url), diff --git a/lib/src/services/api/messages.api.dart b/lib/src/services/api/messages.api.dart index 19333cc5..c180451b 100644 --- a/lib/src/services/api/messages.api.dart +++ b/lib/src/services/api/messages.api.dart @@ -101,14 +101,18 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({ if (receiptId == null && receipt == null) return null; try { - if (receipt == null) { - // ignore: parameter_assignments - receipt = await twonlyDB.receiptsDao.getReceiptById(receiptId!); - if (receipt == null) { - Log.warn('[$receiptId] Receipt not found.'); - return null; - } + final targetReceiptId = receipt?.receiptId ?? receiptId!; + final loadedReceipt = await twonlyDB.receiptsDao.getReceiptById( + targetReceiptId, + ); + if (loadedReceipt == null) { + Log.info( + '[$targetReceiptId] Receipt not found (might have been processed or deleted).', + ); + return null; } + // ignore: parameter_assignments + receipt = loadedReceipt; 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, @@ -138,7 +142,7 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({ if (!onlyReturnEncryptedData && receipt.ackByServerAt != 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; } diff --git a/lib/src/services/mediafiles/compression.service.dart b/lib/src/services/mediafiles/compression.service.dart index 9f2796a2..f6398425 100644 --- a/lib/src/services/mediafiles/compression.service.dart +++ b/lib/src/services/mediafiles/compression.service.dart @@ -33,17 +33,18 @@ Future compressImage( 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% final tmpCompressedBytes = await FlutterImageCompress.compressWithFile( sourceFile.path, format: CompressFormat.webp, quality: 60, ); - if (tmpCompressedBytes != null) { + if (tmpCompressedBytes == null) { Log.error( 'Could not compress media file with 60%: $sourceFile. Sending original 90% compressed file.', ); + } else { compressedBytes = tmpCompressedBytes; } } @@ -106,11 +107,11 @@ Future compressAndOverlayVideo(MediaFileService media) async { }, ); } catch (e) { - Log.error('during video compression: $e'); + Log.warn('during video compression: $e'); } 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 media.ffmpegOutputPath.copySync(media.tempPath.path); } diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart index e0b9c585..cbe1039e 100644 --- a/lib/src/services/mediafiles/mediafile.service.dart +++ b/lib/src/services/mediafiles/mediafile.service.dart @@ -252,7 +252,7 @@ class MediaFileService { Future compressMedia() async { 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; } @@ -296,7 +296,8 @@ class MediaFileService { (thumbnailPath.existsSync() && thumbnailPath.lengthSync() > 0) || mediaFile.type == MediaType.audio || ((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) && - storedPath.existsSync() && storedPath.lengthSync() > 0); + storedPath.existsSync() && + storedPath.lengthSync() > 0); Future storeMediaFile() async { Log.info('Storing media file ${mediaFile.mediaId}'); @@ -327,8 +328,8 @@ class MediaFileService { } } } else { - Log.error( - 'Could not store image neither as ${tempPath.path} does not exists.', + Log.warn( + 'Could not store image locally as ${tempPath.path} does not exist.', ); } unawaited(createThumbnail()); @@ -469,7 +470,7 @@ class MediaFileService { format: CompressFormat.webp, quality: 90, ); - + if (webpBytes.isNotEmpty) { await storedPath.writeAsBytes(webpBytes); } else { @@ -503,7 +504,7 @@ class MediaFileService { ); await updateFromDB(); } catch (e) { - Log.error( + Log.warn( 'Error auto-cropping transparent borders for mediaId ${mediaFile.mediaId}: $e', ); await twonlyDB.mediaFilesDao.updateMedia( diff --git a/lib/src/visual/context_menu/group.context_menu.dart b/lib/src/visual/context_menu/group.context_menu.dart index 02fe6a17..63c8d881 100644 --- a/lib/src/visual/context_menu/group.context_menu.dart +++ b/lib/src/visual/context_menu/group.context_menu.dart @@ -20,6 +20,7 @@ class GroupContextMenu extends StatelessWidget { @override Widget build(BuildContext context) { + final navigator = Navigator.of(context); return ContextMenu( items: [ if (!group.archived) @@ -27,9 +28,7 @@ class GroupContextMenu extends StatelessWidget { title: context.lang.contextMenuArchiveUser, onTap: () async { 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, ), @@ -38,15 +37,13 @@ class GroupContextMenu extends StatelessWidget { title: context.lang.contextMenuUndoArchiveUser, onTap: () async { 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, ), ContextMenuItem( title: context.lang.contextMenuOpenChat, - onTap: () => context.push(Routes.chatsMessages(group.groupId)), + onTap: () => navigator.context.push(Routes.chatsMessages(group.groupId)), icon: FontAwesomeIcons.comments, ), if (!group.archived) @@ -56,9 +53,7 @@ class GroupContextMenu extends StatelessWidget { : context.lang.contextMenuPin, onTap: () async { 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 ? FontAwesomeIcons.thumbtackSlash @@ -69,9 +64,9 @@ class GroupContextMenu extends StatelessWidget { icon: FontAwesomeIcons.trashCan, onTap: () async { final ok = await showAlertDialog( - context, - context.lang.deleteTitle, - context.lang.groupContextMenuDeleteGroup, + navigator.context, + navigator.context.lang.deleteTitle, + navigator.context.lang.groupContextMenuDeleteGroup, ); if (ok) { await twonlyDB.messagesDao.deleteMessagesByGroupId(group.groupId); diff --git a/lib/src/visual/context_menu/user.context_menu.dart b/lib/src/visual/context_menu/user.context_menu.dart index 1a35873d..59278a86 100644 --- a/lib/src/visual/context_menu/user.context_menu.dart +++ b/lib/src/visual/context_menu/user.context_menu.dart @@ -17,12 +17,13 @@ class UserContextMenu extends StatelessWidget { @override Widget build(BuildContext context) { + final navigator = Navigator.of(context); return ContextMenu( minWidth: 150, items: [ ContextMenuItem( title: context.lang.contextMenuUserProfile, - onTap: () => context.push(Routes.profileContact(contact.userId)), + onTap: () => navigator.context.push(Routes.profileContact(contact.userId)), icon: FontAwesomeIcons.user, ), ], diff --git a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart index 8f722fc4..2b33309d 100644 --- a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart +++ b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart @@ -178,7 +178,8 @@ class MainCameraController { selectedCameraDetails.isZoomAble = false; - if (cameraController == null || !cameraController!.value.isInitialized) { + final currentController = cameraController; + if (currentController == null || !currentController.value.isInitialized) { final controllerToDispose = cameraController; cameraController = null; if (controllerToDispose != null) { @@ -188,7 +189,7 @@ class MainCameraController { final hasMic = await micPermissionFuture; if (sessionId != _cameraSessionId) return; - cameraController = CameraController( + final controller = CameraController( AppEnvironment.cameras[cameraId], ResolutionPreset.high, enableAudio: hasMic, @@ -196,22 +197,60 @@ class MainCameraController { ? ImageFormatGroup.nv21 : ImageFormatGroup.bgra8888, ); + + var assignedToGlobal = false; try { - _initializeFuture = cameraController?.initialize(); + _initializeFuture = controller.initialize(); await _initializeFuture; - await cameraController!.startImageStream(_processCameraImage); - await cameraController!.setZoomLevel(selectedCameraDetails.scaleFactor); - if (userService.currentUser.videoStabilizationEnabled && !kDebugMode) { - await cameraController!.setVideoStabilizationMode( - VideoStabilizationMode.level1, + if (sessionId != _cameraSessionId) { + unawaited(controller.dispose()); + return; + } + + 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) { - Log.error('Error initializing camera: $e'); - final controllerToDispose = cameraController; - cameraController = null; - if (controllerToDispose != null) { - unawaited(controllerToDispose.dispose()); + if (e is CameraException) { + Log.warn('Camera initialization failed (CameraException): $e'); + } else { + Log.error('Error initializing camera: $e'); + } + if (!assignedToGlobal) { + unawaited(controller.dispose()); + } else { + if (cameraController == controller) { + cameraController = null; + } + unawaited(controller.dispose()); } initCameraStarted = false; return; @@ -219,35 +258,64 @@ class MainCameraController { } else { try { 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; - await cameraController!.setZoomLevel(1); - await cameraController!.setDescription( + await currentController.setZoomLevel(1); + if (sessionId != _cameraSessionId) return; + + await currentController.setDescription( AppEnvironment.cameras[cameraId], ); + if (sessionId != _cameraSessionId) return; + if (!isVideoRecording) { - await cameraController!.startImageStream(_processCameraImage); + await currentController.startImageStream(_processCameraImage); } } catch (e) { - Log.error('Error switching camera description: $e'); + if (e is CameraException) { + Log.warn('Camera description switch failed (CameraException): $e'); + } else { + Log.error('Error switching camera description: $e'); + } initCameraStarted = false; return; } } + if (sessionId != _cameraSessionId) return; + final controller = cameraController; + if (controller == null) { + initCameraStarted = false; + return; + } + try { - await cameraController!.lockCaptureOrientation( + await controller.lockCaptureOrientation( DeviceOrientation.portraitUp, ); - await cameraController!.setFlashMode( + if (sessionId != _cameraSessionId) return; + + await controller.setFlashMode( selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off, ); - selectedCameraDetails.maxAvailableZoom = await cameraController! + if (sessionId != _cameraSessionId) return; + + selectedCameraDetails.maxAvailableZoom = await controller .getMaxZoomLevel(); - selectedCameraDetails.minAvailableZoom = await cameraController! + if (sessionId != _cameraSessionId) return; + + selectedCameraDetails.minAvailableZoom = await controller .getMinZoomLevel(); + if (sessionId != _cameraSessionId) return; + selectedCameraDetails ..isZoomAble = selectedCameraDetails.maxAvailableZoom != @@ -263,12 +331,15 @@ class MainCameraController { initCameraStarted = false; setState?.call(); } catch (e) { - Log.error('Error post-initializing camera: $e'); - final controllerToDispose = cameraController; - cameraController = null; - if (controllerToDispose != null) { - unawaited(controllerToDispose.dispose()); + if (e is CameraException) { + Log.warn('Camera post-initialization failed (CameraException): $e'); + } else { + Log.error('Error post-initializing camera: $e'); } + if (cameraController == controller) { + cameraController = null; + } + unawaited(controller.dispose()); initCameraStarted = false; return; } @@ -348,8 +419,9 @@ class MainCameraController { } InputImage? _inputImageFromCameraImage(CameraImage image) { - if (cameraController == null) return null; - final camera = cameraController!.description; + final controller = cameraController; + if (controller == null) return null; + final camera = controller.description; final sensorOrientation = camera.sensorOrientation; InputImageRotation? rotation; @@ -358,7 +430,7 @@ class MainCameraController { rotation = InputImageRotationValue.fromRawValue(sensorOrientation); } else if (Platform.isAndroid) { var rotationCompensation = - _orientations[cameraController!.value.deviceOrientation]; + _orientations[controller.value.deviceOrientation]; if (rotationCompensation == null) return null; if (camera.lensDirection == CameraLensDirection.front) { // front-facing @@ -408,14 +480,15 @@ class MainCameraController { if (_isBusy) return; _isBusy = true; final barcodes = await _barcodeScanner.processImage(inputImage); + final controller = cameraController; if (inputImage.metadata?.size != null && inputImage.metadata?.rotation != null && - cameraController != null) { + controller != null) { final painter = BarcodeDetectorPainter( barcodes, inputImage.metadata!.size, inputImage.metadata!.rotation, - cameraController!.description.lensDirection, + controller.description.lensDirection, ); qrCodePain = CustomPaint(painter: painter); @@ -501,9 +574,10 @@ class MainCameraController { if (_isBusyFaces) return; _isBusyFaces = true; final faces = await _faceDetector.processImage(inputImage); + final controller = cameraController; if (inputImage.metadata?.size != null && inputImage.metadata?.rotation != null && - cameraController != null) { + controller != null) { if (faces.isNotEmpty) { CustomPainter? painter; switch (_currentFilterType) { @@ -512,7 +586,7 @@ class MainCameraController { faces, inputImage.metadata!.size, inputImage.metadata!.rotation, - cameraController!.description.lensDirection, + controller.description.lensDirection, ); case FaceFilterType.beardUpperLipGreen: painter = BeardFilterPainter( @@ -520,7 +594,7 @@ class MainCameraController { faces, inputImage.metadata!.size, inputImage.metadata!.rotation, - cameraController!.description.lensDirection, + controller.description.lensDirection, ); case FaceFilterType.none: break; diff --git a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart index 77a19625..c9d85db6 100644 --- a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart +++ b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart @@ -36,13 +36,13 @@ class _UserListItem extends State { Message? _currentMessage; List _messagesNotOpened = []; - late StreamSubscription> _messagesNotOpenedStream; + StreamSubscription>? _messagesNotOpenedStream; Message? _lastMessage; Reaction? _lastReaction; - late StreamSubscription _lastMessageStream; - late StreamSubscription _lastReactionStream; - late StreamSubscription> _lastMediaFilesStream; + StreamSubscription? _lastMessageStream; + StreamSubscription? _lastReactionStream; + StreamSubscription>? _lastMediaFilesStream; List _previewMessages = []; final List _previewMediaFiles = []; @@ -57,22 +57,23 @@ class _UserListItem extends State { @override void dispose() { - _messagesNotOpenedStream.cancel(); - _lastReactionStream.cancel(); - _lastMessageStream.cancel(); - _lastMediaFilesStream.cancel(); + _messagesNotOpenedStream?.cancel(); + _lastReactionStream?.cancel(); + _lastMessageStream?.cancel(); + _lastMediaFilesStream?.cancel(); super.dispose(); } Future initStreams() async { - _lastMessageStream = - (await twonlyDB.messagesDao.watchLastMessage( - widget.group.groupId, - )).listen((update) { - protectUpdateState.protect(() async { - await updateState(update, _messagesNotOpened); - }); - }); + final lastMsgStream = await twonlyDB.messagesDao.watchLastMessage( + widget.group.groupId, + ); + if (!mounted) return; + _lastMessageStream = lastMsgStream.listen((update) { + protectUpdateState.protect(() async { + await updateState(update, _messagesNotOpened); + }); + }); _lastReactionStream = twonlyDB.reactionsDao.watchLastReactions(widget.group.groupId).listen((update) { if (!mounted) return; @@ -103,6 +104,7 @@ class _UserListItem extends State { final groupContacts = await twonlyDB.groupsDao.getGroupContact( widget.group.groupId, ); + if (!mounted) return; if (groupContacts.length == 1) { _receiverDeletedAccount = groupContacts.first.accountDeleted; } diff --git a/lib/src/visual/views/chats/chat_messages_components/message_context_menu.dart b/lib/src/visual/views/chats/chat_messages_components/message_context_menu.dart index 829d405c..58febb11 100644 --- a/lib/src/visual/views/chats/chat_messages_components/message_context_menu.dart +++ b/lib/src/visual/views/chats/chat_messages_components/message_context_menu.dart @@ -99,6 +99,7 @@ class MessageContextMenu extends StatelessWidget { @override Widget build(BuildContext context) { + final navigator = Navigator.of(context); return ContextMenu( items: [ if (!message.isDeletedFromSender) @@ -107,7 +108,7 @@ class MessageContextMenu extends StatelessWidget { onTap: () async { final layer = await showModalBottomSheet( - context: context, + context: navigator.context, backgroundColor: Colors.black, builder: (context) { return const EmojiPickerBottom(); @@ -138,7 +139,7 @@ class MessageContextMenu extends StatelessWidget { if (mediaFileService?.canBeOpenedAgain ?? false) ContextMenuItem( title: context.lang.contextMenuViewAgain, - onTap: () => reopenMediaFile(context), + onTap: () => reopenMediaFile(navigator.context), icon: FontAwesomeIcons.clockRotateLeft, ), if (!message.isDeletedFromSender) @@ -155,7 +156,7 @@ class MessageContextMenu extends StatelessWidget { ContextMenuItem( title: context.lang.edit, onTap: () async { - await editTextMessage(context, message); + await editTextMessage(navigator.context, message); }, icon: FontAwesomeIcons.pencil, ), @@ -172,13 +173,13 @@ class MessageContextMenu extends StatelessWidget { title: context.lang.delete, onTap: () async { final delete = await showAlertDialog( - context, - context.lang.deleteTitle, + navigator.context, + navigator.context.lang.deleteTitle, null, customOk: (message.senderId == null && !message.isDeletedFromSender) - ? context.lang.deleteOkBtnForAll - : context.lang.deleteOkBtnForMe, + ? navigator.context.lang.deleteOkBtnForAll + : navigator.context.lang.deleteOkBtnForMe, ); if (delete) { if (message.senderId == null && !message.isDeletedFromSender) { @@ -209,8 +210,7 @@ class MessageContextMenu extends StatelessWidget { ContextMenuItem( title: context.lang.info, onTap: () async { - await Navigator.push( - context, + await navigator.push( MaterialPageRoute( builder: (context) { return MessageInfoView( diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index 403f0fe4..3ea80c5b 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -137,6 +137,7 @@ class _MediaViewerViewState extends State { final Mutex _messageUpdateLock = Mutex(); bool _isViewActive() { + if (!mounted) return false; return !AppState.isAppInBackground && (ModalRoute.of(context)?.isCurrent ?? false); } @@ -358,7 +359,7 @@ class _MediaViewerViewState extends State { if (!mounted) return; 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); return advanceToNextMediaOrExit(); } @@ -468,7 +469,7 @@ class _MediaViewerViewState extends State { ..play(); }) .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; }); } @@ -511,12 +512,16 @@ class _MediaViewerViewState extends State { } Future onPressedSaveToGallery() async { + final media = currentMedia; + final msg = currentMessage; + if (media == null || msg == null) return; + setState(() { imageSaving = true; }); - await currentMedia!.storeMediaFile(); + await media.storeMediaFile(); await twonlyDB.messagesDao.updateMessageId( - currentMessage!.messageId, + msg.messageId, const MessagesCompanion( mediaStored: Value(true), ), @@ -526,7 +531,7 @@ class _MediaViewerViewState extends State { pb.EncryptedContent( mediaUpdate: pb.EncryptedContent_MediaUpdate( type: pb.EncryptedContent_MediaUpdate_Type.STORED, - targetMessageId: currentMessage!.messageId, + targetMessageId: msg.messageId, ), ), ); @@ -553,11 +558,12 @@ class _MediaViewerViewState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ if (currentMedia != null && + currentMessage != null && !currentMedia!.mediaFile.requiresAuthentication && currentMedia!.mediaFile.displayLimitInMilliseconds == null) MyIconButton( variant: MyIconButtonVariant.secondary, - onPressed: (currentMedia == null) ? null : onPressedSaveToGallery, + onPressed: (currentMedia == null || currentMessage == null) ? null : onPressedSaveToGallery, icon: imageSaving ? const SizedBox( width: 16, diff --git a/lib/src/visual/views/groups/group_member.context.dart b/lib/src/visual/views/groups/group_member.context.dart index a8a820d3..251a9010 100644 --- a/lib/src/visual/views/groups/group_member.context.dart +++ b/lib/src/visual/views/groups/group_member.context.dart @@ -118,6 +118,7 @@ class GroupMemberContextMenu extends StatelessWidget { @override Widget build(BuildContext context) { + final navigator = Navigator.of(context); return ContextMenu( items: [ if (contact.accepted) @@ -131,15 +132,15 @@ class GroupMemberContextMenu extends StatelessWidget { // create return; } - if (!context.mounted) return; - await context.push(Routes.chatsMessages(directChat.groupId)); + if (!navigator.mounted) return; + await navigator.context.push(Routes.chatsMessages(directChat.groupId)); }, icon: FontAwesomeIcons.message, ), if (!contact.accepted) ContextMenuItem( title: context.lang.createContactRequest, - onTap: () => _makeContactRequest(context), + onTap: () => _makeContactRequest(navigator.context), icon: FontAwesomeIcons.userPlus, ), if (member.groupPublicKey != null && @@ -147,7 +148,7 @@ class GroupMemberContextMenu extends StatelessWidget { member.memberState == MemberState.normal) ContextMenuItem( title: context.lang.makeAdmin, - onTap: () => _makeContactAdmin(context), + onTap: () => _makeContactAdmin(navigator.context), icon: FontAwesomeIcons.key, ), if (member.groupPublicKey != null && @@ -155,13 +156,13 @@ class GroupMemberContextMenu extends StatelessWidget { member.memberState == MemberState.admin) ContextMenuItem( title: context.lang.removeAdmin, - onTap: () => _removeContactAsAdmin(context), + onTap: () => _removeContactAsAdmin(navigator.context), icon: FontAwesomeIcons.key, ), if (group.isGroupAdmin && member.groupPublicKey != null) ContextMenuItem( title: context.lang.removeFromGroup, - onTap: () => _removeContactFromGroup(context), + onTap: () => _removeContactFromGroup(navigator.context), icon: FontAwesomeIcons.rightFromBracket, ), ], diff --git a/rust/src/backup/backup_archive.rs b/rust/src/backup/backup_archive.rs index 86d748e4..ca631223 100644 --- a/rust/src/backup/backup_archive.rs +++ b/rust/src/backup/backup_archive.rs @@ -57,7 +57,7 @@ impl BackupArchive { if is_db { // 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)); std::fs::copy(&file_path, &temp_copy_path)?; @@ -72,13 +72,14 @@ impl BackupArchive { .await?; // Close database connection to release file lock before removing it - drop(db); + db.pool.close().await; remove_file(&temp_copy_path)?; // Perform integrity check of the new database file 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.pool.close().await; } else { let file_backup = backup_data_dir.join(file_name); std::fs::copy(file_path, file_backup)?; diff --git a/rust/src/database/mod.rs b/rust/src/database/mod.rs index 665459dc..03084689 100644 --- a/rust/src/database/mod.rs +++ b/rust/src/database/mod.rs @@ -29,7 +29,7 @@ impl Database { .journal_mode(sqlx::sqlite::SqliteJournalMode::Delete) .foreign_keys(true) .read_only(read_only) - .busy_timeout(Duration::from_millis(5000)) + .busy_timeout(Duration::from_secs(30)) .pragma("synchronous", "FULL") .pragma("recursive_triggers", "ON") .log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500)); @@ -39,7 +39,7 @@ impl Database { } let pool = SqlitePoolOptions::new() - .acquire_timeout(Duration::from_secs(5)) + .acquire_timeout(Duration::from_secs(30)) .max_connections(10) .connect_with(connect_options) .await?;