diff --git a/CHANGELOG.md b/CHANGELOG.md index fa4742a..81e7ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.1.3 + +- New: Video stabilization +- New: Crop or rotate images before sharing them. +- New: Clicking on “Text Notifications” will now open the chat directly (Android only) +- New: Developer settings to reduce flames +- Improve: Improved troubleshooting for issues with push notifications +- Improve: A message appears if someone has deleted their account. +- Improve: Make the verification badge more visible. +- Fix: Flash not activated when starting a video recording +- Fix: Problem sending media when a recipient has deleted their account. +- Fix: Receive push notifications without receiving an in-app message (Android) +- Fix: Issue with sending GIFs from Memories +- Fix: Incorrect processing of messages that have already been fetched from the server causes the UI to freeze + ## 0.1.1 - New: Groups can now collect flames as well diff --git a/assets/icons/verified_badge_green.svg b/assets/icons/verified_badge_green.svg index 98c69ac..db73051 100644 --- a/assets/icons/verified_badge_green.svg +++ b/assets/icons/verified_badge_green.svg @@ -1,4 +1,3 @@ - - - - \ No newline at end of file + + + diff --git a/assets/icons/verified_badge_red.svg b/assets/icons/verified_badge_red.svg index 6e70a82..2b03833 100644 --- a/assets/icons/verified_badge_red.svg +++ b/assets/icons/verified_badge_red.svg @@ -1,4 +1,3 @@ - - - - \ No newline at end of file + + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e3d6f23..d8c25ba 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -56,13 +56,20 @@ PODS: - FirebaseAnalytics (~> 12.9.0) - Firebase/CoreOnly (12.9.0): - FirebaseCore (~> 12.9.0) + - Firebase/Installations (12.9.0): + - Firebase/CoreOnly + - FirebaseInstallations (~> 12.9.0) - Firebase/Messaging (12.9.0): - Firebase/CoreOnly - FirebaseMessaging (~> 12.9.0) - - firebase_core (4.5.0): + - firebase_app_installations (0.4.1): + - Firebase/Installations (= 12.9.0) + - firebase_core + - Flutter + - firebase_core (4.6.0): - Firebase/CoreOnly (= 12.9.0) - Flutter - - firebase_messaging (16.1.2): + - firebase_messaging (16.1.3): - Firebase/Messaging (= 12.9.0) - firebase_core - Flutter @@ -278,17 +285,17 @@ PODS: - PromisesObjC (2.4.0) - restart_app (1.7.3): - Flutter - - SDWebImage (5.21.6): - - SDWebImage/Core (= 5.21.6) - - SDWebImage/Core (5.21.6) + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) - SDWebImageWebPCoder (0.15.0): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - Sentry/HybridSDK (8.56.2) - - sentry_flutter (9.14.0): + - Sentry/HybridSDK (8.58.0) + - sentry_flutter (9.16.0): - Flutter - FlutterMacOS - - Sentry/HybridSDK (= 8.56.2) + - Sentry/HybridSDK (= 8.58.0) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -297,32 +304,32 @@ PODS: - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - - sqlite3 (3.51.1): - - sqlite3/common (= 3.51.1) - - sqlite3/common (3.51.1) - - sqlite3/dbstatvtab (3.51.1): + - sqlite3 (3.52.0): + - sqlite3/common (= 3.52.0) + - sqlite3/common (3.52.0) + - sqlite3/dbstatvtab (3.52.0): - sqlite3/common - - sqlite3/fts5 (3.51.1): + - sqlite3/fts5 (3.52.0): - sqlite3/common - - sqlite3/math (3.51.1): + - sqlite3/math (3.52.0): - sqlite3/common - - sqlite3/perf-threadsafe (3.51.1): + - sqlite3/perf-threadsafe (3.52.0): - sqlite3/common - - sqlite3/rtree (3.51.1): + - sqlite3/rtree (3.52.0): - sqlite3/common - - sqlite3/session (3.51.1): + - sqlite3/session (3.52.0): - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (~> 3.51.1) + - sqlite3 (~> 3.52.0) - sqlite3/dbstatvtab - sqlite3/fts5 - sqlite3/math - sqlite3/perf-threadsafe - sqlite3/rtree - sqlite3/session - - SwiftProtobuf (1.34.1) + - SwiftProtobuf (1.36.1) - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter @@ -343,6 +350,7 @@ DEPENDENCIES: - emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Firebase + - firebase_app_installations (from `.symlinks/plugins/firebase_app_installations/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - FirebaseCore @@ -430,6 +438,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/emoji_picker_flutter/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" + firebase_app_installations: + :path: ".symlinks/plugins/firebase_app_installations/ios" firebase_core: :path: ".symlinks/plugins/firebase_core/ios" firebase_messaging: @@ -493,7 +503,7 @@ SPEC CHECKSUMS: app_links: a754cbec3c255bd4bbb4d236ecc06f28cd9a7ce8 audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad - camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741 + camera_avfoundation: 968a9a5323c79a99c166ad9d7866bfd2047b5a9b connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd cryptography_flutter_plus: 44f4e9e4079395fcbb3e7809c0ac2c6ae2d9576f device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe @@ -502,8 +512,9 @@ SPEC CHECKSUMS: emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Firebase: 065f2bb395062046623036d8e6dc857bc2521d56 - firebase_core: afac1aac13c931e0401c7e74ed1276112030efab - firebase_messaging: 7cb2727feb789751fc6936bcc8e08408970e2820 + firebase_app_installations: 1abd8d071ea2022d7888f7a9713710c37136ff91 + firebase_core: 8e6f58412ca227827c366b92e7cee047a2148c60 + firebase_messaging: c3aa897e0d40109cfb7927c40dc0dea799863f3b FirebaseAnalytics: cd7d01d352f3c237c9a0e31552c257cd0b0c0352 FirebaseCore: 428912f751178b06bef0a1793effeb4a5e09a9b8 FirebaseCoreInternal: b321eafae5362113bc182956fafc9922cfc77b72 @@ -544,16 +555,16 @@ SPEC CHECKSUMS: pro_video_editor: 44ef9a6d48dbd757ed428cf35396dd05f35c7830 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 restart_app: 0714144901e260eae68f7afc2fc4aacc1a323ad2 - SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477 + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 - Sentry: b53951377b78e21a734f5dc8318e333dbfc682d7 - sentry_flutter: 841fa2fe08dc72eb95e2320b76e3f751f3400cf5 + Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be + sentry_flutter: 31101687061fb85211ebab09ce6eb8db4e9ba74f share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b - sqlite3_flutter_libs: d13b8b3003f18f596e542bcb9482d105577eff41 - SwiftProtobuf: c901f00a3e125dc33cac9b16824da85682ee47da + sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 + sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab + SwiftProtobuf: 9e106a71456f4d3f6a3b0c8fd87ef0be085efc38 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a diff --git a/lib/src/constants/routes.keys.dart b/lib/src/constants/routes.keys.dart index 29ed8af..157c27d 100644 --- a/lib/src/constants/routes.keys.dart +++ b/lib/src/constants/routes.keys.dart @@ -6,7 +6,8 @@ class Routes { static const String chatsStartNewChat = '/chats/start_new_chat'; static const String chatsCameraSendTo = '/chats/camera_send_to'; static const String chatsMediaViewer = '/chats/media_viewer'; - static const String chatsMessages = '/chats/messages'; + + static String chatsMessages(String groupId) => '/chats/messages/$groupId'; static String groupCreateSelectMember(String? groupId) => '/group/create/select_member${groupId == null ? '' : '/$groupId'}'; @@ -53,5 +54,7 @@ class Routes { '/settings/developer/retransmission_database'; static const String settingsDeveloperAutomatedTesting = '/settings/developer/automated_testing'; + static const String settingsDeveloperReduceFlames = + '/settings/developer/reduce_flames'; static const String settingsInvite = '/settings/invite'; } diff --git a/lib/src/database/daos/reactions.dao.dart b/lib/src/database/daos/reactions.dao.dart index 286fc68..a581990 100644 --- a/lib/src/database/daos/reactions.dao.dart +++ b/lib/src/database/daos/reactions.dao.dart @@ -26,19 +26,19 @@ class ReactionsDao extends DatabaseAccessor with _$ReactionsDaoMixin { Log.error('Did not update reaction as it is not an emoji!'); return; } - final msg = - await twonlyDB.messagesDao.getMessageById(messageId).getSingleOrNull(); + final msg = await twonlyDB.messagesDao + .getMessageById(messageId) + .getSingleOrNull(); if (msg == null || msg.groupId != groupId) return; try { if (remove) { - await (delete(reactions) - ..where( - (t) => - t.senderId.equals(contactId) & - t.messageId.equals(messageId) & - t.emoji.equals(emoji), - )) + await (delete(reactions)..where( + (t) => + t.senderId.equals(contactId) & + t.messageId.equals(messageId) & + t.emoji.equals(emoji), + )) .go(); } else { await into(reactions).insertOnConflictUpdate( @@ -63,18 +63,18 @@ class ReactionsDao extends DatabaseAccessor with _$ReactionsDaoMixin { Log.error('Did not update reaction as it is not an emoji!'); return; } - final msg = - await twonlyDB.messagesDao.getMessageById(messageId).getSingleOrNull(); + final msg = await twonlyDB.messagesDao + .getMessageById(messageId) + .getSingleOrNull(); if (msg == null) return; try { - await (delete(reactions) - ..where( - (t) => - t.senderId.isNull() & - t.messageId.equals(messageId) & - t.emoji.equals(emoji), - )) + await (delete(reactions)..where( + (t) => + t.senderId.isNull() & + t.messageId.equals(messageId) & + t.emoji.equals(emoji), + )) .go(); if (!remove) { await into(reactions).insert( @@ -98,20 +98,19 @@ class ReactionsDao extends DatabaseAccessor with _$ReactionsDaoMixin { } Stream watchLastReactions(String groupId) { - final query = (select(reactions) - ..orderBy([(t) => OrderingTerm.desc(t.createdAt)])) - .join( - [ - innerJoin( - messages, - messages.messageId.equalsExp(reactions.messageId), - useColumns: false, - ), - ], - ) - ..where(messages.groupId.equals(groupId)) - // ..orderBy([(t) => OrderingTerm.asc(t.createdAt)])) - ..limit(1); + final query = + (select(reactions)).join( + [ + innerJoin( + messages, + messages.messageId.equalsExp(reactions.messageId), + useColumns: false, + ), + ], + ) + ..where(messages.groupId.equals(groupId)) + ..orderBy([OrderingTerm.desc(messages.createdAt)]) + ..limit(1); return query.map((row) => row.readTable(reactions)).watchSingleOrNull(); } diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 471dfb4..5f28518 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -733,9 +733,33 @@ abstract class AppLocalizations { /// No description provided for @settingsNotifyTroubleshootingNoProblemDesc. /// /// In en, this message translates to: - /// **'Press OK to receive a test notification. When you receive no message even after waiting for 10 minutes, please send us your debug log in Settings > Help > Debug log, so we can look at that issue.'** + /// **'Press OK to receive a test notification. If you do not receive the test notification, please click on the new menu item that appears after you click “OK”.'** String get settingsNotifyTroubleshootingNoProblemDesc; + /// No description provided for @settingsNotifyResetTitle. + /// + /// In en, this message translates to: + /// **'Didn\'t receive a test notification?'** + String get settingsNotifyResetTitle; + + /// No description provided for @settingsNotifyResetTitleSubtitle. + /// + /// In en, this message translates to: + /// **'If you haven\'t received any test notifications, click here to reset your notification tokens.'** + String get settingsNotifyResetTitleSubtitle; + + /// No description provided for @settingsNotifyResetTitleReset. + /// + /// In en, this message translates to: + /// **'Your notification tokens have been reset.'** + String get settingsNotifyResetTitleReset; + + /// No description provided for @settingsNotifyResetTitleResetDesc. + /// + /// In en, this message translates to: + /// **'If the problem persists, please send us your debug log via Settings > Help so we can investigate the issue.'** + String get settingsNotifyResetTitleResetDesc; + /// No description provided for @settingsHelp. /// /// In en, this message translates to: @@ -2818,6 +2842,12 @@ abstract class AppLocalizations { /// **'Scan other profile'** String get scanOtherProfile; + /// No description provided for @openYourOwnQRcode. + /// + /// In en, this message translates to: + /// **'Open your own QR code'** + String get openYourOwnQRcode; + /// No description provided for @skipForNow. /// /// In en, this message translates to: diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index ee28bb2..f6bafbd 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -356,7 +356,22 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settingsNotifyTroubleshootingNoProblemDesc => - 'Klicke auf OK, um eine Testbenachrichtigung zu erhalten. Wenn du auch nach 10 Minuten warten keine Nachricht erhältst, sende uns bitte dein Diagnoseprotokoll unter Einstellungen > Hilfe > Diagnoseprotokoll, damit wir uns das Problem ansehen können.'; + 'Um eine Testbenachrichtigung zu erhalten, klicke auf OK. Falls du die Testbenachrichtigung nicht erhältst, klicke bitte auf den neuen Menüpunkt, der nach dem Klicken auf „OK“ angezeigt wird.'; + + @override + String get settingsNotifyResetTitle => 'Keine Testbenachrichtigung erhalten?'; + + @override + String get settingsNotifyResetTitleSubtitle => + 'Falls du keine Testbenachrichtigungen erhalten hast, klicke hier, um deine Benachrichtigungstoken zurückzusetzen.'; + + @override + String get settingsNotifyResetTitleReset => + 'Deine Benachrichtigungstoken wurden zurückgesetzt.'; + + @override + String get settingsNotifyResetTitleResetDesc => + 'Sollte das Problem weiterhin bestehen, sende uns bitte dein Debug-Protokoll über „Einstellungen“ > „Hilfe“, damit wir das Problem untersuchen können.'; @override String get settingsHelp => 'Hilfe'; @@ -1553,6 +1568,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get scanOtherProfile => 'Scanne ein anderes Profil'; + @override + String get openYourOwnQRcode => 'Eigenen QR-Code öffnen'; + @override String get skipForNow => 'Vorerst überspringen'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 9ba5811..535dcaa 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -351,7 +351,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsNotifyTroubleshootingNoProblemDesc => - 'Press OK to receive a test notification. When you receive no message even after waiting for 10 minutes, please send us your debug log in Settings > Help > Debug log, so we can look at that issue.'; + 'Press OK to receive a test notification. If you do not receive the test notification, please click on the new menu item that appears after you click “OK”.'; + + @override + String get settingsNotifyResetTitle => 'Didn\'t receive a test notification?'; + + @override + String get settingsNotifyResetTitleSubtitle => + 'If you haven\'t received any test notifications, click here to reset your notification tokens.'; + + @override + String get settingsNotifyResetTitleReset => + 'Your notification tokens have been reset.'; + + @override + String get settingsNotifyResetTitleResetDesc => + 'If the problem persists, please send us your debug log via Settings > Help so we can investigate the issue.'; @override String get settingsHelp => 'Help'; @@ -1543,6 +1558,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get scanOtherProfile => 'Scan other profile'; + @override + String get openYourOwnQRcode => 'Open your own QR code'; + @override String get skipForNow => 'Skip for now'; diff --git a/lib/src/localization/generated/app_localizations_sv.dart b/lib/src/localization/generated/app_localizations_sv.dart index 3b2e90e..a41fb8b 100644 --- a/lib/src/localization/generated/app_localizations_sv.dart +++ b/lib/src/localization/generated/app_localizations_sv.dart @@ -351,7 +351,22 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settingsNotifyTroubleshootingNoProblemDesc => - 'Press OK to receive a test notification. When you receive no message even after waiting for 10 minutes, please send us your debug log in Settings > Help > Debug log, so we can look at that issue.'; + 'Press OK to receive a test notification. If you do not receive the test notification, please click on the new menu item that appears after you click “OK”.'; + + @override + String get settingsNotifyResetTitle => 'Didn\'t receive a test notification?'; + + @override + String get settingsNotifyResetTitleSubtitle => + 'If you haven\'t received any test notifications, click here to reset your notification tokens.'; + + @override + String get settingsNotifyResetTitleReset => + 'Your notification tokens have been reset.'; + + @override + String get settingsNotifyResetTitleResetDesc => + 'If the problem persists, please send us your debug log via Settings > Help so we can investigate the issue.'; @override String get settingsHelp => 'Help'; @@ -1543,6 +1558,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get scanOtherProfile => 'Scan other profile'; + @override + String get openYourOwnQRcode => 'Open your own QR code'; + @override String get skipForNow => 'Skip for now'; diff --git a/lib/src/localization/translations b/lib/src/localization/translations index 284c602..662b8dd 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit 284c602b507e77addc8f21c4fc8a321f237cac1b +Subproject commit 662b8ddafcbf1c789f54c93da51ebb0514ba1f81 diff --git a/lib/src/model/json/userdata.dart b/lib/src/model/json/userdata.dart index 39bd078..40dccb3 100644 --- a/lib/src/model/json/userdata.dart +++ b/lib/src/model/json/userdata.dart @@ -53,6 +53,9 @@ class UserData { @JsonKey(defaultValue: false) bool requestedAudioPermission = false; + @JsonKey(defaultValue: true) + bool videoStabilizationEnabled = true; + @JsonKey(defaultValue: true) bool showFeedbackShortcut = true; diff --git a/lib/src/model/json/userdata.g.dart b/lib/src/model/json/userdata.g.dart index 98fd874..1ce21d6 100644 --- a/lib/src/model/json/userdata.g.dart +++ b/lib/src/model/json/userdata.g.dart @@ -30,6 +30,8 @@ UserData _$UserDataFromJson(Map json) => ..defaultShowTime = (json['defaultShowTime'] as num?)?.toInt() ..requestedAudioPermission = json['requestedAudioPermission'] as bool? ?? false + ..videoStabilizationEnabled = + json['videoStabilizationEnabled'] as bool? ?? false ..showFeedbackShortcut = json['showFeedbackShortcut'] as bool? ?? true ..showShowImagePreviewWhenSending = json['showShowImagePreviewWhenSending'] as bool? ?? false @@ -105,6 +107,7 @@ Map _$UserDataToJson(UserData instance) => { 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, 'defaultShowTime': instance.defaultShowTime, 'requestedAudioPermission': instance.requestedAudioPermission, + 'videoStabilizationEnabled': instance.videoStabilizationEnabled, 'showFeedbackShortcut': instance.showFeedbackShortcut, 'showShowImagePreviewWhenSending': instance.showShowImagePreviewWhenSending, 'startWithCameraOpen': instance.startWithCameraOpen, diff --git a/lib/src/providers/routing.provider.dart b/lib/src/providers/routing.provider.dart index 87f7c4b..d1eb1b0 100644 --- a/lib/src/providers/routing.provider.dart +++ b/lib/src/providers/routing.provider.dart @@ -26,6 +26,7 @@ import 'package:twonly/src/views/settings/data_and_storage/export_media.view.dar import 'package:twonly/src/views/settings/data_and_storage/import_media.view.dart'; import 'package:twonly/src/views/settings/developer/automated_testing.view.dart'; import 'package:twonly/src/views/settings/developer/developer.view.dart'; +import 'package:twonly/src/views/settings/developer/reduce_flames.view.dart'; import 'package:twonly/src/views/settings/developer/retransmission_data.view.dart'; import 'package:twonly/src/views/settings/help/changelog.view.dart'; import 'package:twonly/src/views/settings/help/contact_us.view.dart'; @@ -84,10 +85,10 @@ final routerProvider = GoRouter( }, ), GoRoute( - path: 'messages', + path: 'messages/:groupId', builder: (context, state) { - final group = state.extra! as Group; - return ChatMessagesView(group); + final groupId = state.pathParameters['groupId']!; + return ChatMessagesView(groupId); }, ), ], @@ -280,6 +281,10 @@ final routerProvider = GoRouter( path: 'automated_testing', builder: (context, state) => const AutomatedTestingView(), ), + GoRoute( + path: 'reduce_flames', + builder: (context, state) => const ReduceFlamesView(), + ), ], ), GoRoute( diff --git a/lib/src/services/api.service.dart b/lib/src/services/api.service.dart index cac8dd2..4b65f32 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -133,11 +133,12 @@ class ApiService { return; } reconnectionTimer?.cancel(); - Log.info('Starting reconnection timer with $_reconnectionDelay s delay'); reconnectionTimer = Timer(Duration(seconds: _reconnectionDelay), () async { - Log.info('Reconnection timer triggered'); reconnectionTimer = null; - await connect(); + // only try to reconnect in case the app is in the foreground + if (!globalIsAppInBackground) { + await connect(); + } }); _reconnectionDelay = 3; } diff --git a/lib/src/services/api/mediafiles/upload.service.dart b/lib/src/services/api/mediafiles/upload.service.dart index c15ae6c..16f745e 100644 --- a/lib/src/services/api/mediafiles/upload.service.dart +++ b/lib/src/services/api/mediafiles/upload.service.dart @@ -147,6 +147,16 @@ Future insertMediaFileInMessagesTable( ), ); for (final groupId in groupIds) { + final groupMembers = await twonlyDB.groupsDao.getGroupContact(groupId); + if (groupMembers.length == 1) { + if (groupMembers.first.accountDeleted) { + Log.warn( + 'Did not send media file to $groupId because the only account has deleted his account.', + ); + continue; + } + } + final message = await twonlyDB.messagesDao.insertMessage( MessagesCompanion( groupId: Value(groupId), @@ -280,6 +290,14 @@ Future _createUploadRequest(MediaFileService media) async { } } + final contact = await twonlyDB.contactsDao.getContactById( + groupMember.contactId, + ); + + if (contact == null || contact.accountDeleted) { + continue; + } + final downloadToken = getRandomUint8List(32); late EncryptedContent_Media_Type type; @@ -329,10 +347,11 @@ Future _createUploadRequest(MediaFileService media) async { Log.error( 'Could not generate ciphertext message for ${groupMember.contactId}', ); + continue; } final messageOnSuccess = TextMessage() - ..body = cipherText!.$1 + ..body = cipherText.$1 ..userId = Int64(groupMember.contactId); if (cipherText.$2 != null) { diff --git a/lib/src/services/api/messages.dart b/lib/src/services/api/messages.dart index 857b0d1..7addc79 100644 --- a/lib/src/services/api/messages.dart +++ b/lib/src/services/api/messages.dart @@ -111,8 +111,8 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({ ); Uint8List? pushData; - if (pushNotification != null && receipt.retryCount <= 3) { - /// In case the message has to be resend more than three times, do not show a notification again... + if (pushNotification != null && receipt.retryCount <= 1) { + // Only show the push notification the first two time. pushData = await encryptPushNotification( receipt.contactId, pushNotification, diff --git a/lib/src/services/api/server_messages.dart b/lib/src/services/api/server_messages.dart index 69b767a..6502ba4 100644 --- a/lib/src/services/api/server_messages.dart +++ b/lib/src/services/api/server_messages.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:clock/clock.dart'; import 'package:drift/drift.dart'; import 'package:hashlib/random.dart'; @@ -25,6 +26,7 @@ import 'package:twonly/src/services/api/client2client/reaction.c2c.dart'; import 'package:twonly/src/services/api/client2client/text_message.c2c.dart'; import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/services/group.services.dart'; +import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/services/signal/encryption.signal.dart'; import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/utils/log.dart'; @@ -79,14 +81,19 @@ Future handleClient2ClientMessage(NewMessage newMessage) async { final message = Message.fromBuffer(body); final receiptId = message.receiptId; - await protectReceiptCheck.protect(() async { + final isDuplicated = await protectReceiptCheck.protect(() async { if (await twonlyDB.receiptsDao.isDuplicated(receiptId)) { Log.warn('Got duplicated message from the server.'); - return; + return true; } await twonlyDB.receiptsDao.gotReceipt(receiptId); + return false; }); + if (isDuplicated) { + return; + } + switch (message.type) { case Message_Type.SENDER_DELIVERY_RECEIPT: Log.info('Got delivery receipt for $receiptId!'); @@ -131,8 +138,9 @@ Future handleClient2ClientMessage(NewMessage newMessage) async { if (message.hasEncryptedContent()) { Value? receiptIdDB; - final encryptedContentRaw = - Uint8List.fromList(message.encryptedContent); + final encryptedContentRaw = Uint8List.fromList( + message.encryptedContent, + ); Message? response; @@ -155,8 +163,10 @@ Future handleClient2ClientMessage(NewMessage newMessage) async { } if (response == null) { - final (encryptedContent, plainTextContent) = - await handleEncryptedMessage( + final ( + encryptedContent, + plainTextContent, + ) = await handleEncryptedMessageRaw( fromUserId, encryptedContentRaw, message.type, @@ -174,6 +184,9 @@ Future handleClient2ClientMessage(NewMessage newMessage) async { encryptedContent: encryptedContent.writeToBuffer(), ); receiptIdDB = const Value.absent(); + } else { + // Message was successful processed + // } } @@ -198,27 +211,48 @@ Future handleClient2ClientMessage(NewMessage newMessage) async { } } -Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage( +Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessageRaw( int fromUserId, Uint8List encryptedContentRaw, Message_Type messageType, String receiptId, ) async { - final (content, decryptionErrorType) = await signalDecryptMessage( + final (encryptedContent, decryptionErrorType) = await signalDecryptMessage( fromUserId, encryptedContentRaw, messageType.value, ); - if (content == null) { + if (encryptedContent == null) { return ( null, PlaintextContent() ..decryptionErrorMessage = (PlaintextContent_DecryptionErrorMessage() - ..type = decryptionErrorType!) + ..type = decryptionErrorType!), ); } + final (a, b) = await handleEncryptedMessage( + fromUserId, + encryptedContent, + messageType, + receiptId, + ); + + if (Platform.isAndroid && a == null && b == null) { + // Message was handled without any error -> Show push notification to the user. + await showPushNotificationFromServerMessages(fromUserId, encryptedContent); + } + + return (a, b); +} + +Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage( + int fromUserId, + EncryptedContent content, + Message_Type messageType, + String receiptId, +) async { // We got a valid message fromUserId, so mark all messages which where // send to the user but not yet ACK for retransmission. All marked messages // will be either transmitted again after a new server connection (minimum 20 seconds). @@ -235,7 +269,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage( return ( null, PlaintextContent() - ..retryControlError = PlaintextContent_RetryErrorMessage() + ..retryControlError = PlaintextContent_RetryErrorMessage(), ); } return (null, null); @@ -312,7 +346,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage( relatedReceiptId: receiptId, ), ), - null + null, ); } Log.info( @@ -333,7 +367,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage( return ( null, PlaintextContent() - ..retryControlError = PlaintextContent_RetryErrorMessage() + ..retryControlError = PlaintextContent_RetryErrorMessage(), ); } @@ -365,7 +399,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage( return ( null, PlaintextContent() - ..retryControlError = PlaintextContent_RetryErrorMessage() + ..retryControlError = PlaintextContent_RetryErrorMessage(), ); } return (null, null); diff --git a/lib/src/services/background/callback_dispatcher.background.dart b/lib/src/services/background/callback_dispatcher.background.dart index 94980df..a146026 100644 --- a/lib/src/services/background/callback_dispatcher.background.dart +++ b/lib/src/services/background/callback_dispatcher.background.dart @@ -49,7 +49,18 @@ void callbackDispatcher() { }); } +bool _isInitialized = false; + Future initBackgroundExecution() async { + if (_isInitialized) { + // Reload the users, as on Android the background isolate can + // stay alive for multiple hours between task executions + final user = await getUser(); + if (user == null) return false; + gUser = user; + return true; + } + SentryWidgetsFlutterBinding.ensureInitialized(); globalApplicationCacheDirectory = (await getApplicationCacheDirectory()).path; globalApplicationSupportDirectory = @@ -65,12 +76,13 @@ Future initBackgroundExecution() async { apiService = ApiService(); globalIsInBackgroundTask = true; + _isInitialized = true; return true; } final Mutex _keyValueMutex = Mutex(); -Future handlePeriodicTask() async { +Future handlePeriodicTask({int lastExecutionInSecondsLimit = 120}) async { final shouldBeExecuted = await exclusiveAccess( lockName: 'periodic_task', mutex: _keyValueMutex, @@ -84,7 +96,8 @@ Future handlePeriodicTask() async { final lastExecutionDate = DateTime.fromMillisecondsSinceEpoch( lastExecutionTime, ); - if (DateTime.now().difference(lastExecutionDate).inMinutes < 2) { + if (DateTime.now().difference(lastExecutionDate).inSeconds < + lastExecutionInSecondsLimit) { return false; } } diff --git a/lib/src/services/flame.service.dart b/lib/src/services/flame.service.dart index 9ef17b8..f25a5df 100644 --- a/lib/src/services/flame.service.dart +++ b/lib/src/services/flame.service.dart @@ -13,8 +13,9 @@ Future syncFlameCounters({String? forceForGroup}) async { final groups = await twonlyDB.groupsDao.getAllGroups(); if (groups.isEmpty) return; final maxMessageCounter = groups.map((x) => x.totalMediaCounter).max; - final bestFriend = - groups.firstWhere((x) => x.totalMediaCounter == maxMessageCounter); + final bestFriend = groups.firstWhere( + (x) => x.totalMediaCounter == maxMessageCounter, + ); if (gUser.myBestFriendGroupId != bestFriend.groupId) { await updateUserdata((user) { @@ -42,8 +43,9 @@ Future syncFlameCounters({String? forceForGroup}) async { EncryptedContent( flameSync: EncryptedContent_FlameSync( flameCounter: Int64(flameCounter), - lastFlameCounterChange: - Int64(group.lastFlameCounterChange!.millisecondsSinceEpoch), + lastFlameCounterChange: Int64( + group.lastFlameCounterChange!.millisecondsSinceEpoch, + ), bestFriend: group.groupId == bestFriend.groupId, forceUpdate: group.groupId == forceForGroup, ), @@ -134,8 +136,9 @@ Future incFlameCounter( // Overwrite max flame counter either the current is bigger or the the max flame counter is older then 4 days if (flameCounter >= maxFlameCounter || maxFlameCounterFrom == null || - maxFlameCounterFrom - .isBefore(clock.now().subtract(const Duration(days: 5)))) { + maxFlameCounterFrom.isBefore( + clock.now().subtract(const Duration(days: 5)), + )) { maxFlameCounter = flameCounter; maxFlameCounterFrom = clock.now(); } @@ -172,6 +175,7 @@ bool isItPossibleToRestoreFlames(Group group) { final flameCounter = getFlameCounterFromGroup(group); return group.maxFlameCounter > 2 && flameCounter < group.maxFlameCounter && - group.maxFlameCounterFrom! - .isAfter(clock.now().subtract(const Duration(days: 5))); + group.maxFlameCounterFrom!.isAfter( + clock.now().subtract(const Duration(days: 7)), + ); } diff --git a/lib/src/services/mediafiles/compression.service.dart b/lib/src/services/mediafiles/compression.service.dart index 7aee32c..f79a4e1 100644 --- a/lib/src/services/mediafiles/compression.service.dart +++ b/lib/src/services/mediafiles/compression.service.dart @@ -72,13 +72,19 @@ Future compressAndOverlayVideo(MediaFileService media) async { try { final task = VideoRenderData( - video: EditorVideo.file(media.originalPath), - imageBytes: media.overlayImagePath.readAsBytesSync(), + videoSegments: [ + VideoSegment(video: EditorVideo.file(media.originalPath)), + ], + imageLayers: [ + ImageLayer(image: EditorLayerImage.file(media.overlayImagePath)), + ], enableAudio: !media.removeAudio, ); - await ProVideoEditor.instance - .renderVideoToFile(media.ffmpegOutputPath.path, task); + await ProVideoEditor.instance.renderVideoToFile( + media.ffmpegOutputPath.path, + task, + ); if (Platform.isIOS || media.ffmpegOutputPath.statSync().size >= 10_000_000 || @@ -115,8 +121,8 @@ Future compressAndOverlayVideo(MediaFileService media) async { final sizeFrom = (media.ffmpegOutputPath.statSync().size / 1024 / 1024) .toStringAsFixed(2); - final sizeTo = - (media.tempPath.statSync().size / 1024 / 1024).toStringAsFixed(2); + final sizeTo = (media.tempPath.statSync().size / 1024 / 1024) + .toStringAsFixed(2); Log.info( 'It took ${stopwatch.elapsedMilliseconds}ms to compress the video. Reduced from $sizeFrom to $sizeTo bytes.', diff --git a/lib/src/services/notifications/background.notifications.dart b/lib/src/services/notifications/background.notifications.dart index f7eaacc..50d7820 100644 --- a/lib/src/services/notifications/background.notifications.dart +++ b/lib/src/services/notifications/background.notifications.dart @@ -6,10 +6,12 @@ import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'; import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/secure_storage_keys.dart'; import 'package:twonly/src/localization/generated/app_localizations.dart'; import 'package:twonly/src/localization/generated/app_localizations_de.dart'; import 'package:twonly/src/localization/generated/app_localizations_en.dart'; +import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/push_notification.pb.dart'; import 'package:twonly/src/services/notifications/pushkeys.notifications.dart'; import 'package:twonly/src/utils/log.dart'; @@ -45,10 +47,34 @@ Future customLocalPushNotification(String title, String msg) async { ); } +Future showPushNotificationFromServerMessages( + int fromUserId, + EncryptedContent encryptedContent, +) async { + final pushData = await getPushNotificationFromEncryptedContent( + null, // this is the toUserID which must be null as this means that the targetMessageId was send from this user. + null, + encryptedContent, + ); + if (pushData != null) { + final pushUsers = await getPushKeys(SecureStorageKeys.receivingPushKeys); + for (final pushUser in pushUsers) { + if (pushUser.userId.toInt() == fromUserId) { + String? groupId; + if (encryptedContent.hasGroupId()) { + groupId = encryptedContent.groupId; + } + return showLocalPushNotification(pushUser, pushData, groupId: groupId); + } + } + } +} + Future handlePushData(String pushDataB64) async { try { - final pushData = - EncryptedPushNotification.fromBuffer(base64.decode(pushDataB64)); + final pushData = EncryptedPushNotification.fromBuffer( + base64.decode(pushDataB64), + ); PushNotification? pushNotification; PushUser? foundPushUser; @@ -121,8 +147,10 @@ Future tryDecryptMessage( mac: Mac(push.mac), ); - final plaintext = - await chacha20.decrypt(secretBox, secretKey: secretKeyData); + final plaintext = await chacha20.decrypt( + secretBox, + secretKey: secretKeyData, + ); return PushNotification.fromBuffer(plaintext); } catch (e) { // this error is allowed to happen... @@ -132,8 +160,9 @@ Future tryDecryptMessage( Future showLocalPushNotification( PushUser pushUser, - PushNotification pushNotification, -) async { + PushNotification pushNotification, { + String? groupId, +}) async { String? title; String? body; @@ -174,13 +203,26 @@ Future showLocalPushNotification( iOS: darwinNotificationDetails, ); + String? payload; + + if (groupId != null && + (pushNotification.kind == PushKind.text || + pushNotification.kind == PushKind.response || + pushNotification.kind == PushKind.reactionToAudio || + pushNotification.kind == PushKind.storedMediaFile || + pushNotification.kind == PushKind.reactionToImage || + pushNotification.kind == PushKind.reactionToText || + pushNotification.kind == PushKind.reactionToAudio)) { + payload = Routes.chatsMessages(groupId); + } + await flutterLocalNotificationsPlugin.show( - pushUser.userId.toInt() % - 2147483647, // Invalid argument (id): must fit within the size of a 32-bit integer + // Invalid argument (id): must fit within the size of a 32-bit integer + pushUser.userId.toInt() % 2147483647, title, body, notificationDetails, - // payload: pushNotification.kind.name, + payload: payload, ); } @@ -259,17 +301,22 @@ String getPushNotificationText(PushNotification pushNotification) { PushKind.storedMediaFile.name: lang.notificationStoredMediaFile, PushKind.reaction.name: lang.notificationReaction, PushKind.reopenedMedia.name: lang.notificationReopenedMedia, - PushKind.reactionToVideo.name: - lang.notificationReactionToVideo(pushNotification.additionalContent), - PushKind.reactionToAudio.name: - lang.notificationReactionToAudio(pushNotification.additionalContent), - PushKind.reactionToText.name: - lang.notificationReactionToText(pushNotification.additionalContent), - PushKind.reactionToImage.name: - lang.notificationReactionToImage(pushNotification.additionalContent), + PushKind.reactionToVideo.name: lang.notificationReactionToVideo( + pushNotification.additionalContent, + ), + PushKind.reactionToAudio.name: lang.notificationReactionToAudio( + pushNotification.additionalContent, + ), + PushKind.reactionToText.name: lang.notificationReactionToText( + pushNotification.additionalContent, + ), + PushKind.reactionToImage.name: lang.notificationReactionToImage( + pushNotification.additionalContent, + ), PushKind.response.name: lang.notificationResponse(inGroup), - PushKind.addedToGroup.name: - lang.notificationAddedToGroup(pushNotification.additionalContent), + PushKind.addedToGroup.name: lang.notificationAddedToGroup( + pushNotification.additionalContent, + ), }; return pushNotificationText[pushNotification.kind.name] ?? ''; diff --git a/lib/src/services/notifications/fcm.notifications.dart b/lib/src/services/notifications/fcm.notifications.dart index f202747..89b70e4 100644 --- a/lib/src/services/notifications/fcm.notifications.dart +++ b/lib/src/services/notifications/fcm.notifications.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'dart:io' show Platform; +import 'package:firebase_app_installations/firebase_app_installations.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -41,81 +42,92 @@ Future checkForTokenUpdates() async { Log.error('Could not get fcm token'); return; } - Log.info('Loaded fcm token'); + + Log.info('Loaded FCM token.'); + if (storedToken == null || fcmToken != storedToken) { + Log.info('Got new FCM TOKEN.'); + await storage.write(key: SecureStorageKeys.googleFcm, value: fcmToken); await updateUserdata((u) { u.updateFCMToken = true; return u; }); - await storage.write(key: SecureStorageKeys.googleFcm, value: fcmToken); } - FirebaseMessaging.instance.onTokenRefresh.listen((fcmToken) async { - await updateUserdata((u) { - u.updateFCMToken = true; - return u; - }); - await storage.write(key: SecureStorageKeys.googleFcm, value: fcmToken); - }).onError((err) { - Log.error('could not listen on token refresh'); - }); + FirebaseMessaging.instance.onTokenRefresh + .listen((fcmToken) async { + Log.info('Got new FCM TOKEN.'); + await storage.write( + key: SecureStorageKeys.googleFcm, + value: fcmToken, + ); + await updateUserdata((u) { + u.updateFCMToken = true; + return u; + }); + }) + .onError((err) { + Log.error('could not listen on token refresh'); + }); } catch (e) { Log.error('could not load fcm token: $e'); } } -Future initFCMAfterAuthenticated() async { - if (gUser.updateFCMToken) { +Future initFCMAfterAuthenticated({bool force = false}) async { + if (gUser.updateFCMToken || force) { const storage = FlutterSecureStorage(); final storedToken = await storage.read(key: SecureStorageKeys.googleFcm); if (storedToken != null) { final res = await apiService.updateFCMToken(storedToken); if (res.isSuccess) { - Log.info('Uploaded new fmt token!'); + Log.info('Uploaded new FCM token!'); await updateUserdata((u) { u.updateFCMToken = false; return u; }); + } else { + Log.error('Could not update FCM token!'); } + } else { + Log.error('Could not send FCM update to server as token is empty.'); } } } +Future resetFCMTokens() async { + await FirebaseInstallations.instance.delete(); + Log.info('Firebase Installation successfully deleted.'); + await FirebaseMessaging.instance.deleteToken(); + Log.info('Old FCM deleted.'); + await const FlutterSecureStorage().delete(key: SecureStorageKeys.googleFcm); + await checkForTokenUpdates(); + await initFCMAfterAuthenticated(force: true); +} + Future initFCMService() async { await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); - unawaited(checkForTokenUpdates()); + await checkForTokenUpdates(); FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); - // You may set the permission requests to "provisional" which allows the user to choose what type - // of notifications they would like to receive once the user receives a notification. - // final notificationSettings = - // await FirebaseMessaging.instance.requestPermission(provisional: true); await FirebaseMessaging.instance.requestPermission(); - // For apple platforms, ensure the APNS token is available before making any FCM plugin API calls - // if (Platform.isIOS) { - // final apnsToken = await FirebaseMessaging.instance.getAPNSToken(); - // if (apnsToken == null) { - // return; - // } - // } - FirebaseMessaging.onMessage.listen(handleRemoteMessage); } @pragma('vm:entry-point') Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { - initLogger(); - // Log.info('Handling a background message: ${message.messageId}'); + final isInitialized = await initBackgroundExecution(); + Log.info('Handling a background message: ${message.messageId}'); await handleRemoteMessage(message); if (Platform.isAndroid) { - if (await initBackgroundExecution()) { - await handlePeriodicTask(); + if (isInitialized) { + await handlePeriodicTask(lastExecutionInSecondsLimit: 10); } } else { // make sure every thing run... @@ -140,7 +152,11 @@ Future handleRemoteMessage(RemoteMessage message) async { final body = message.notification?.body ?? message.data['body'] as String? ?? ''; await customLocalPushNotification(title, body); - } else if (message.data['push_data'] != null) { - await handlePushData(message.data['push_data'] as String); } + + // On Android the push notification is now shown in the server_message.dart. This ensures + // that the messages was successfully decrypted before showing the push notification + // else if (message.data['push_data'] != null) { + // await handlePushData(message.data['push_data'] as String); + // } } diff --git a/lib/src/services/notifications/pushkeys.notifications.dart b/lib/src/services/notifications/pushkeys.notifications.dart index 2df0f34..4d00549 100644 --- a/lib/src/services/notifications/pushkeys.notifications.dart +++ b/lib/src/services/notifications/pushkeys.notifications.dart @@ -49,13 +49,15 @@ Future setupNotificationWithUsers({ final contacts = await twonlyDB.contactsDao.getAllContacts(); for (final contact in contacts) { - final pushUser = - pushUsers.firstWhereOrNull((x) => x.userId == contact.userId); + final pushUser = pushUsers.firstWhereOrNull( + (x) => x.userId == contact.userId, + ); if (pushUser != null && pushUser.pushKeys.isNotEmpty) { // make it harder to predict the change of the key - final timeBefore = - clock.now().subtract(Duration(days: 10 + random.nextInt(5))); + final timeBefore = clock.now().subtract( + Duration(days: 10 + random.nextInt(5)), + ); final lastKey = pushUser.pushKeys.last; final createdAt = DateTime.fromMillisecondsSinceEpoch( lastKey.createdAtUnixTimestamp.toInt(), @@ -197,7 +199,7 @@ Future updateLastMessageId(int fromUserId, String messageId) async { } Future getPushNotificationFromEncryptedContent( - int toUserId, + int? toUserId, String? messageId, EncryptedContent content, ) async { @@ -210,7 +212,7 @@ Future getPushNotificationFromEncryptedContent( final msg = await twonlyDB.messagesDao .getMessageById(content.reaction.targetMessageId) .getSingleOrNull(); - if (msg == null || msg.senderId == null || msg.senderId != toUserId) { + if (msg == null || msg.senderId != toUserId) { return null; } if (msg.content != null) { @@ -285,7 +287,7 @@ Future getPushNotificationFromEncryptedContent( .getMessageById(content.reaction.targetMessageId) .getSingleOrNull(); // These notifications should only be send to the original sender. - if (msg == null || msg.senderId == null || msg.senderId != toUserId) { + if (msg == null || msg.senderId != toUserId) { return null; } switch (content.mediaUpdate.type) { diff --git a/lib/src/utils/log.dart b/lib/src/utils/log.dart index 84b5009..29281db 100644 --- a/lib/src/utils/log.dart +++ b/lib/src/utils/log.dart @@ -8,8 +8,11 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/utils/exclusive_access.dart'; +bool _isInitialized = false; + void initLogger() { - // Logger.root.level = kReleaseMode ? Level.INFO : Level.ALL; + if (_isInitialized) return; + _isInitialized = true; Logger.root.level = Level.ALL; Logger.root.onRecord.listen((record) async { unawaited(_writeLogToFile(record)); @@ -126,17 +129,34 @@ Future cleanLogFile() async { return _protectFileAccess(() async { final logFile = File('$globalApplicationSupportDirectory/app.log'); - if (logFile.existsSync()) { - final lines = await logFile.readAsLines(); - - if (lines.length <= 10000) return; - - final removeCount = lines.length - 10000; - final remaining = lines.sublist(removeCount, lines.length); - - final sink = logFile.openWrite()..writeAll(remaining, '\n'); - await sink.close(); + if (!logFile.existsSync()) { + return; } + final lines = await logFile.readAsLines(); + + final twoWeekAgo = clock.now().subtract(const Duration(days: 14)); + var keepStartIndex = -1; + + for (var i = 0; i < lines.length; i += 100) { + if (lines[i].length >= 19) { + final date = DateTime.tryParse(lines[i].substring(0, 19)); + if (date != null && date.isAfter(twoWeekAgo)) { + keepStartIndex = i; + break; + } + } + } + + if (keepStartIndex == 0) return; + + if (keepStartIndex == -1) { + await logFile.writeAsString(''); + return; + } + + final remaining = lines.sublist(keepStartIndex); + final sink = logFile.openWrite()..writeAll(remaining, '\n'); + await sink.close(); }); } diff --git a/lib/src/views/camera/camera_preview_components/camera_preview_controller_view.dart b/lib/src/views/camera/camera_preview_components/camera_preview_controller_view.dart index 5e48ce4..2a439bb 100644 --- a/lib/src/views/camera/camera_preview_components/camera_preview_controller_view.dart +++ b/lib/src/views/camera/camera_preview_components/camera_preview_controller_view.dart @@ -181,8 +181,9 @@ class _CameraPreviewViewState extends State { // Maybe this is the reason? return; } else { - androidVolumeDownSub = - FlutterAndroidVolumeKeydown.stream.listen((event) { + androidVolumeDownSub = FlutterAndroidVolumeKeydown.stream.listen(( + event, + ) { if (widget.isVisible) { takePicture(); } else { @@ -297,8 +298,9 @@ class _CameraPreviewViewState extends State { return; } - final image = await mc.screenshotController - .capture(pixelRatio: MediaQuery.of(context).devicePixelRatio); + final image = await mc.screenshotController.capture( + pixelRatio: MediaQuery.of(context).devicePixelRatio, + ); if (await pushMediaEditor(image, null)) { return; @@ -314,7 +316,8 @@ class _CameraPreviewViewState extends State { bool sharedFromGallery = false, MediaType? mediaType, }) async { - final type = mediaType ?? + final type = + mediaType ?? ((videoFilePath != null) ? MediaType.video : MediaType.image); final mediaFileService = await initializeMediaUpload( type, @@ -340,25 +343,28 @@ class _CameraPreviewViewState extends State { await deInitVolumeControl(); if (!mounted) return true; - final shouldReturn = await Navigator.push( - context, - PageRouteBuilder( - opaque: false, - pageBuilder: (context, a1, a2) => ShareImageEditorView( - screenshotImage: screenshotImage, - sharedFromGallery: sharedFromGallery, - sendToGroup: widget.sendToGroup, - mediaFileService: mediaFileService, - mainCameraController: mc, - previewLink: mc.sharedLinkForPreview, - ), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - return child; - }, - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - ), - ) as bool?; + final shouldReturn = + await Navigator.push( + context, + PageRouteBuilder( + opaque: false, + pageBuilder: (context, a1, a2) => ShareImageEditorView( + screenshotImage: screenshotImage, + sharedFromGallery: sharedFromGallery, + sendToGroup: widget.sendToGroup, + mediaFileService: mediaFileService, + mainCameraController: mc, + previewLink: mc.sharedLinkForPreview, + ), + transitionsBuilder: + (context, animation, secondaryAnimation, child) { + return child; + }, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + ), + ) + as bool?; if (mounted) { setState(() { mc.isSharePreviewIsShown = false; @@ -396,13 +402,15 @@ class _CameraPreviewViewState extends State { return; } - mc.selectedCameraDetails.scaleFactor = (_baseScaleFactor + - // ignore: avoid_dynamic_calls - (_basePanY - (details.localPosition.dy as double)) / 30) - .clamp(1, mc.selectedCameraDetails.maxAvailableZoom); + mc.selectedCameraDetails.scaleFactor = + (_baseScaleFactor + + // ignore: avoid_dynamic_calls + (_basePanY - (details.localPosition.dy as double)) / 30) + .clamp(1, mc.selectedCameraDetails.maxAvailableZoom); - await mc.cameraController! - .setZoomLevel(mc.selectedCameraDetails.scaleFactor); + await mc.cameraController!.setZoomLevel( + mc.selectedCameraDetails.scaleFactor, + ); if (mounted) { setState(() {}); } @@ -434,8 +442,9 @@ class _CameraPreviewViewState extends State { ScreenshotImage? image; MediaType? mediaType; - final isImage = - imageExtensions.any((ext) => pickedFile.name.contains(ext)); + final isImage = imageExtensions.any( + (ext) => pickedFile.name.contains(ext), + ); if (isImage) { if (pickedFile.name.contains('.gif')) { mediaType = MediaType.gif; @@ -497,10 +506,15 @@ class _CameraPreviewViewState extends State { mc.isVideoRecording = true; }); + if (mc.selectedCameraDetails.isFlashOn) { + await mc.cameraController?.setFlashMode(FlashMode.torch); + } + try { await mc.cameraController?.startVideoRecording(); - _videoRecordingTimer = - Timer.periodic(const Duration(milliseconds: 15), (timer) { + _videoRecordingTimer = Timer.periodic(const Duration(milliseconds: 15), ( + timer, + ) { setState(() { _currentTime = clock.now(); }); @@ -521,6 +535,7 @@ class _CameraPreviewViewState extends State { mc.isVideoRecording = false; }); _showCameraException(e); + await mc.cameraController?.setFlashMode(FlashMode.off); return; } } @@ -531,6 +546,8 @@ class _CameraPreviewViewState extends State { _videoRecordingTimer = null; } + await mc.cameraController?.setFlashMode(FlashMode.off); + setState(() { _videoRecordingStarted = null; mc.isVideoRecording = false; @@ -601,8 +618,12 @@ class _CameraPreviewViewState extends State { keyTriggerButton.currentContext!.findRenderObject()! as RenderBox; final localPosition = renderBox.globalToLocal(details.globalPosition); - final containerRect = - Rect.fromLTWH(0, 0, renderBox.size.width, renderBox.size.height); + final containerRect = Rect.fromLTWH( + 0, + 0, + renderBox.size.width, + renderBox.size.height, + ); if (containerRect.contains(localPosition)) { startVideoRecording(); @@ -676,12 +697,14 @@ class _CameraPreviewViewState extends State { : Colors.white.withAlpha(160), onPressed: () async { if (mc.selectedCameraDetails.isFlashOn) { - await mc.cameraController - ?.setFlashMode(FlashMode.off); + await mc.cameraController?.setFlashMode( + FlashMode.off, + ); mc.selectedCameraDetails.isFlashOn = false; } else { - await mc.cameraController - ?.setFlashMode(FlashMode.always); + await mc.cameraController?.setFlashMode( + FlashMode.always, + ); mc.selectedCameraDetails.isFlashOn = true; } setState(() {}); @@ -739,8 +762,8 @@ class _CameraPreviewViewState extends State { child: FaIcon( mc.isSelectingFaceFilters ? mc.currentFilterType.index == 1 - ? FontAwesomeIcons.xmark - : FontAwesomeIcons.arrowLeft + ? FontAwesomeIcons.xmark + : FontAwesomeIcons.arrowLeft : FontAwesomeIcons.photoFilm, color: Colors.white, size: 25, @@ -785,13 +808,14 @@ class _CameraPreviewViewState extends State { child: FaIcon( mc.isSelectingFaceFilters ? mc.currentFilterType.index == - FaceFilterType - .values.length - - 1 - ? FontAwesomeIcons.xmark - : FontAwesomeIcons.arrowRight + FaceFilterType + .values + .length - + 1 + ? FontAwesomeIcons.xmark + : FontAwesomeIcons.arrowRight : FontAwesomeIcons - .faceGrinTongueSquint, + .faceGrinTongueSquint, color: Colors.white, size: 25, ), @@ -843,64 +867,64 @@ class _CameraPreviewViewState extends State { children: [ ...widget.mainCameraController.scannedNewProfiles.values .map( - (c) { - if (c.isLoading) return Container(); - return GestureDetector( - onTap: () async { - c.isLoading = true; - widget.mainCameraController.setState(); - if (await addNewContactFromPublicProfile( - c.profile, - ) && - context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.lang.requestedUserToastText( - c.profile.username, + (c) { + if (c.isLoading) return Container(); + return GestureDetector( + onTap: () async { + c.isLoading = true; + widget.mainCameraController.setState(); + if (await addNewContactFromPublicProfile( + c.profile, + ) && + context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.lang.requestedUserToastText( + c.profile.username, + ), + ), + duration: const Duration(seconds: 8), ), - ), - duration: const Duration(seconds: 8), + ); + } + }, + child: Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: context.color.surfaceContainer, ), - ); - } + child: Row( + children: [ + Text(c.profile.username), + Expanded(child: Container()), + if (c.isLoading) + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + else + ColoredBox( + color: Colors.transparent, + child: FaIcon( + FontAwesomeIcons.userPlus, + color: isDarkMode(context) + ? Colors.white + : Colors.black, + size: 17, + ), + ), + ], + ), + ), + ); }, - child: Container( - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.only(bottom: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: context.color.surfaceContainer, - ), - child: Row( - children: [ - Text(c.profile.username), - Expanded(child: Container()), - if (c.isLoading) - const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - else - ColoredBox( - color: Colors.transparent, - child: FaIcon( - FontAwesomeIcons.userPlus, - color: isDarkMode(context) - ? Colors.white - : Colors.black, - size: 17, - ), - ), - ], - ), - ), - ); - }, - ), + ), ...widget.mainCameraController.contactsVerified.values.map( (c) { return Container( @@ -936,10 +960,13 @@ class _CameraPreviewViewState extends State { : 'assets/animations/failed.lottie', repeat: false, onLoaded: (p0) { - Future.delayed(const Duration(seconds: 4), - () { - widget.mainCameraController.setState(); - }); + Future.delayed( + const Duration(seconds: 4), + () { + widget.mainCameraController + .setState(); + }, + ); }, ), ), diff --git a/lib/src/views/camera/camera_preview_components/main_camera_controller.dart b/lib/src/views/camera/camera_preview_components/main_camera_controller.dart index 1aedc31..2cc6357 100644 --- a/lib/src/views/camera/camera_preview_components/main_camera_controller.dart +++ b/lib/src/views/camera/camera_preview_components/main_camera_controller.dart @@ -4,6 +4,7 @@ import 'package:camera/camera.dart'; import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; import 'package:drift/drift.dart' show Value; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_mlkit_barcode_scanning/google_mlkit_barcode_scanning.dart'; @@ -133,6 +134,11 @@ class MainCameraController { await cameraController?.initialize(); await cameraController?.startImageStream(_processCameraImage); await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor); + if (gUser.videoStabilizationEnabled && !kDebugMode) { + await cameraController?.setVideoStabilizationMode( + VideoStabilizationMode.level1, + ); + } } else { try { if (!isVideoRecording) { diff --git a/lib/src/views/camera/share_image_editor.view.dart b/lib/src/views/camera/share_image_editor.view.dart index 4e31b88..6f9e96e 100644 --- a/lib/src/views/camera/share_image_editor.view.dart +++ b/lib/src/views/camera/share_image_editor.view.dart @@ -109,13 +109,21 @@ class _ShareImageEditorView extends State { sendingOrLoadingImage = false; loadingImage = false; }); - videoController = VideoPlayerController.file(mediaService.originalPath); + videoController = VideoPlayerController.file( + mediaService.originalPath, + videoPlayerOptions: VideoPlayerOptions( + mixWithOthers: true, + ), + ); videoController?.setLooping(true); - videoController?.initialize().then((_) async { - await videoController!.play(); - setState(() {}); - // ignore: invalid_return_type_for_catch_error, argument_type_not_assignable_to_error_handler - }).catchError(Log.error); + videoController + ?.initialize() + .then((_) async { + await videoController!.play(); + setState(() {}); + }) + // ignore: argument_type_not_assignable_to_error_handler, invalid_return_type_for_catch_error + .catchError(Log.error); } } @@ -205,8 +213,8 @@ class _ShareImageEditorView extends State { List get actionsAtTheRight { if (layers.isNotEmpty && - layers.last.isEditing && - layers.last.hasCustomActionButtons) { + (layers.first.isEditing || + (layers.last.isEditing && layers.last.hasCustomActionButtons))) { return []; } return [ @@ -246,13 +254,15 @@ class _ShareImageEditorView extends State { Icons.add_reaction_outlined, tooltipText: context.lang.addEmoji, onPressed: () async { - final layer = await showModalBottomSheet( - context: context, - backgroundColor: Colors.black, - builder: (context) { - return const EmojiPickerBottom(); - }, - ) as Layer?; + final layer = + await showModalBottomSheet( + context: context, + backgroundColor: Colors.black, + builder: (context) { + return const EmojiPickerBottom(); + }, + ) + as Layer?; if (layer == null) return; undoLayers.clear(); removedLayers.clear(); @@ -265,19 +275,20 @@ class _ShareImageEditorView extends State { count: (media.type == MediaType.video) ? '0' : media.displayLimitInMilliseconds == null - ? '∞' - : (media.displayLimitInMilliseconds! ~/ 1000).toString(), + ? '∞' + : (media.displayLimitInMilliseconds! ~/ 1000).toString(), child: ActionButton( (media.type == MediaType.video) ? media.displayLimitInMilliseconds == null - ? Icons.repeat_rounded - : Icons.repeat_one_rounded + ? Icons.repeat_rounded + : Icons.repeat_one_rounded : Icons.timer_outlined, tooltipText: context.lang.protectAsARealTwonly, onPressed: _setImageDisplayTime, ), ), - if (media.type == MediaType.video) + if (media.type == MediaType.video) ...[ + const SizedBox(height: 8), ActionButton( (mediaService.removeAudio) ? Icons.volume_off_rounded @@ -296,6 +307,29 @@ class _ShareImageEditorView extends State { if (mounted) setState(() {}); }, ), + ], + if (media.type == MediaType.image) ...[ + const SizedBox(height: 8), + ActionButton( + Icons.crop_rotate_outlined, + tooltipText: 'Crop or rotate image', + color: Colors.white, + onPressed: () async { + final first = layers.first; + if (first is BackgroundLayerData) { + first.isEditing = !first.isEditing; + } + setState(() {}); + // await mediaService.toggleRemoveAudio(); + // if (mediaService.removeAudio) { + // await videoController?.setVolume(0); + // } else { + // await videoController?.setVolume(100); + // } + // if (mounted) setState(() {}); + }, + ), + ], const SizedBox(height: 8), ActionButton( FontAwesomeIcons.shieldHeart, @@ -348,8 +382,8 @@ class _ShareImageEditorView extends State { List get actionsAtTheTop { if (layers.isNotEmpty && - layers.last.isEditing && - layers.last.hasCustomActionButtons) { + (layers.first.isEditing || + (layers.last.isEditing && layers.last.hasCustomActionButtons))) { return []; } return [ @@ -411,18 +445,20 @@ class _ShareImageEditorView extends State { await videoController?.pause(); if (isDisposed || !mounted) return; - final wasSend = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ShareImageView( - selectedGroupIds: selectedGroupIds, - updateSelectedGroupIds: updateSelectedGroupIds, - mediaStoreFuture: mediaStoreFuture, - mediaFileService: mediaService, - additionalData: getAdditionalData(), - ), - ), - ) as bool?; + final wasSend = + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ShareImageView( + selectedGroupIds: selectedGroupIds, + updateSelectedGroupIds: updateSelectedGroupIds, + mediaStoreFuture: mediaStoreFuture, + mediaFileService: mediaService, + additionalData: getAdditionalData(), + ), + ), + ) + as bool?; if (wasSend != null && wasSend && mounted) { widget.mainCameraController?.onImageSend(); Navigator.pop(context, true); @@ -471,7 +507,7 @@ class _ShareImageEditorView extends State { mediaService.tempPath.deleteSync(); } if (mediaService.originalPath.existsSync()) { - if (media.type != MediaType.video) { + if (media.type == MediaType.image) { mediaService.originalPath.deleteSync(); } } @@ -480,8 +516,6 @@ class _ShareImageEditorView extends State { if (media.type == MediaType.gif) { if (bytes != null) { mediaService.originalPath.writeAsBytesSync(bytes.toList()); - } else { - Log.error('Could not load image bytes for gif!'); } } else { image = await getEditedImageBytes(); @@ -552,8 +586,9 @@ class _ShareImageEditorView extends State { }); // It is important that the user can sending the image only when the image is fully loaded otherwise if the user // will click on send before the image is painted the screenshot will be transparent.. - _imageLoadingTimer = - Timer.periodic(const Duration(milliseconds: 10), (timer) { + _imageLoadingTimer = Timer.periodic(const Duration(milliseconds: 10), ( + timer, + ) { final imageLayer = layers.first; if (imageLayer is BackgroundLayerData) { if (imageLayer.imageLoaded) { @@ -619,8 +654,9 @@ class _ShareImageEditorView extends State { await askToCloseThenClose(); }, child: Scaffold( - backgroundColor: - widget.sharedFromGallery ? null : Colors.white.withAlpha(0), + backgroundColor: widget.sharedFromGallery + ? null + : Colors.white.withAlpha(0), resizeToAvoidBottomInset: false, body: Stack( fit: StackFit.expand, @@ -667,8 +703,9 @@ class _ShareImageEditorView extends State { OutlinedButton( style: OutlinedButton.styleFrom( iconColor: Theme.of(context).colorScheme.primary, - foregroundColor: - Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of( + context, + ).colorScheme.primary, ), onPressed: pushShareImageView, child: const FaIcon(FontAwesomeIcons.userPlus), @@ -681,9 +718,9 @@ class _ShareImageEditorView extends State { width: 12, child: CircularProgressIndicator( strokeWidth: 2, - color: Theme.of(context) - .colorScheme - .inversePrimary, + color: Theme.of( + context, + ).colorScheme.inversePrimary, ), ) : const FaIcon(FontAwesomeIcons.solidPaperPlane), diff --git a/lib/src/views/camera/share_image_editor/layers/background.layer.dart b/lib/src/views/camera/share_image_editor/layers/background.layer.dart index ab0408f..789f716 100755 --- a/lib/src/views/camera/share_image_editor/layers/background.layer.dart +++ b/lib/src/views/camera/share_image_editor/layers/background.layer.dart @@ -1,6 +1,10 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/views/camera/share_image_editor/action_button.dart'; import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; class BackgroundLayer extends StatefulWidget { @@ -29,14 +33,47 @@ class _BackgroundLayerState extends State { Widget build(BuildContext context) { final scImage = widget.layerData.image.image; if (scImage == null || scImage.image == null) return Container(); - return Container( - width: widget.layerData.image.width.toDouble(), - height: widget.layerData.image.height.toDouble(), - padding: EdgeInsets.zero, - color: Colors.transparent, - child: CustomPaint( - painter: UiImagePainter(scImage.image!), - ), + return Stack( + children: [ + Positioned.fill( + child: PhotoView.customChild( + enableRotation: true, + initialScale: PhotoViewComputedScale.contained, + minScale: PhotoViewComputedScale.contained, + backgroundDecoration: const BoxDecoration( + color: Colors.transparent, + ), + child: Container( + width: widget.layerData.image.width.toDouble(), + height: widget.layerData.image.height.toDouble(), + padding: EdgeInsets.zero, + color: Colors.transparent, + child: CustomPaint( + painter: UiImagePainter(scImage.image!), + ), + ), + ), + ), + if (widget.layerData.isEditing) + Positioned( + top: 5, + left: 5, + right: 50, + child: Row( + children: [ + ActionButton( + FontAwesomeIcons.check, + tooltipText: context.lang.imageEditorDrawOk, + onPressed: () async { + widget.layerData.isEditing = false; + widget.onUpdate!(); + setState(() {}); + }, + ), + ], + ), + ), + ], ); } } diff --git a/lib/src/views/camera/share_image_editor/layers_viewer.dart b/lib/src/views/camera/share_image_editor/layers_viewer.dart index e53e3a8..390dcab 100644 --- a/lib/src/views/camera/share_image_editor/layers_viewer.dart +++ b/lib/src/views/camera/share_image_editor/layers_viewer.dart @@ -23,11 +23,15 @@ class LayersViewer extends StatelessWidget { alignment: Alignment.center, children: [ ...layers.whereType().map((layerItem) { - return BackgroundLayer( - key: layerItem.key, - layerData: layerItem, - onUpdate: onUpdate, - ); + if (!layerItem.isEditing) { + return BackgroundLayer( + key: layerItem.key, + layerData: layerItem, + onUpdate: onUpdate, + ); + } else { + return Container(); + } }), ...layers.whereType().map((layerItem) { return FilterLayer( @@ -37,39 +41,50 @@ class LayersViewer extends StatelessWidget { }), ...layers .where( - (layerItem) => - layerItem is EmojiLayerData || - layerItem is DrawLayerData || - layerItem is LinkPreviewLayerData || - layerItem is TextLayerData, - ) + (layerItem) => + layerItem is EmojiLayerData || + layerItem is DrawLayerData || + layerItem is LinkPreviewLayerData || + layerItem is TextLayerData, + ) .map((layerItem) { - if (layerItem is EmojiLayerData) { - return EmojiLayer( - key: layerItem.key, - layerData: layerItem, - onUpdate: onUpdate, - ); - } else if (layerItem is DrawLayerData) { - return DrawLayer( - key: layerItem.key, - layerData: layerItem, - onUpdate: onUpdate, - ); - } else if (layerItem is TextLayerData) { - return TextLayer( - key: layerItem.key, - layerData: layerItem, - onUpdate: onUpdate, - ); - } else if (layerItem is LinkPreviewLayerData) { - return LinkPreviewLayer( + if (layerItem is EmojiLayerData) { + return EmojiLayer( + key: layerItem.key, + layerData: layerItem, + onUpdate: onUpdate, + ); + } else if (layerItem is DrawLayerData) { + return DrawLayer( + key: layerItem.key, + layerData: layerItem, + onUpdate: onUpdate, + ); + } else if (layerItem is TextLayerData) { + return TextLayer( + key: layerItem.key, + layerData: layerItem, + onUpdate: onUpdate, + ); + } else if (layerItem is LinkPreviewLayerData) { + return LinkPreviewLayer( + key: layerItem.key, + layerData: layerItem, + onUpdate: onUpdate, + ); + } + return Container(); + }), + ...layers.whereType().map((layerItem) { + if (layerItem.isEditing) { + return BackgroundLayer( key: layerItem.key, layerData: layerItem, onUpdate: onUpdate, ); + } else { + return Container(); } - return Container(); }), ], ); diff --git a/lib/src/views/chats/chat_list_components/group_list_item.dart b/lib/src/views/chats/chat_list_components/group_list_item.dart index fa7a22f..361a476 100644 --- a/lib/src/views/chats/chat_list_components/group_list_item.dart +++ b/lib/src/views/chats/chat_list_components/group_list_item.dart @@ -17,6 +17,7 @@ import 'package:twonly/src/views/chats/chat_messages_components/message_send_sta import 'package:twonly/src/views/components/avatar_icon.component.dart'; import 'package:twonly/src/views/components/flame.dart'; import 'package:twonly/src/views/components/group_context_menu.component.dart'; +import 'package:twonly/src/views/components/verified_shield.dart'; class GroupListItem extends StatefulWidget { const GroupListItem({ @@ -44,6 +45,7 @@ class _UserListItem extends State { List _previewMessages = []; final List _previewMediaFiles = []; bool _hasNonOpenedMediaFile = false; + bool _receiverDeletedAccount = false; @override void initState() { @@ -60,45 +62,51 @@ class _UserListItem extends State { super.dispose(); } - void initStreams() { + Future initStreams() async { _lastMessageStream = twonlyDB.messagesDao .watchLastMessage(widget.group.groupId) .listen((update) { - protectUpdateState.protect(() async { - await updateState(update, _messagesNotOpened); - }); - }); + protectUpdateState.protect(() async { + await updateState(update, _messagesNotOpened); + }); + }); _lastReactionStream = twonlyDB.reactionsDao .watchLastReactions(widget.group.groupId) .listen((update) { - setState(() { - _lastReaction = update; - }); - // protectUpdateState.protect(() async { - // await updateState(lastMessage, update, messagesNotOpened); - // }); - }); + setState(() { + _lastReaction = update; + }); + }); _messagesNotOpenedStream = twonlyDB.messagesDao .watchMessageNotOpened(widget.group.groupId) .listen((update) { - protectUpdateState.protect(() async { - await updateState(_lastMessage, update); - }); - }); + protectUpdateState.protect(() async { + await updateState(_lastMessage, update); + }); + }); - _lastMediaFilesStream = - twonlyDB.mediaFilesDao.watchNewestMediaFiles().listen((mediaFiles) { - for (final mediaFile in mediaFiles) { - final index = _previewMediaFiles - .indexWhere((t) => t.mediaId == mediaFile.mediaId); - if (index >= 0) { - _previewMediaFiles[index] = mediaFile; - } - } - setState(() {}); - }); + _lastMediaFilesStream = twonlyDB.mediaFilesDao + .watchNewestMediaFiles() + .listen((mediaFiles) { + for (final mediaFile in mediaFiles) { + final index = _previewMediaFiles.indexWhere( + (t) => t.mediaId == mediaFile.mediaId, + ); + if (index >= 0) { + _previewMediaFiles[index] = mediaFile; + } + } + setState(() {}); + }); + + final groupContacts = await twonlyDB.groupsDao.getGroupContact( + widget.group.groupId, + ); + if (groupContacts.length == 1) { + _receiverDeletedAccount = groupContacts.first.accountDeleted; + } } Mutex protectUpdateState = Mutex(); @@ -113,8 +121,9 @@ class _UserListItem extends State { _previewMessages = []; } else if (newMessagesNotOpened.isNotEmpty) { // Filter for the preview non opened messages. First messages which where send but not yet opened by the other side. - final receivedMessages = - newMessagesNotOpened.where((x) => x.senderId != null).toList(); + final receivedMessages = newMessagesNotOpened + .where((x) => x.senderId != null) + .toList(); if (receivedMessages.isNotEmpty) { _previewMessages = receivedMessages; @@ -125,8 +134,17 @@ class _UserListItem extends State { } } else { // there are no not opened messages show just the last message in the table - _currentMessage = newLastMessage; - _previewMessages = [newLastMessage]; + // only shows the last message in case there was no newer messages which already got deleted + // This prevents, that it will show that a images got stored 10 days ago... + if (newLastMessage.createdAt.isAfter( + widget.group.lastMessageExchange.subtract(const Duration(days: 2)), + )) { + _currentMessage = newLastMessage; + _previewMessages = [newLastMessage]; + } else { + _currentMessage = null; + _previewMessages = []; + } } final msgs = _previewMessages @@ -145,8 +163,9 @@ class _UserListItem extends State { for (final message in _previewMessages) { if (message.mediaId != null && !_previewMediaFiles.any((t) => t.mediaId == message.mediaId)) { - final mediaFile = - await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!); + final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById( + message.mediaId!, + ); if (mediaFile != null) { _previewMediaFiles.add(mediaFile); } @@ -171,8 +190,9 @@ class _UserListItem extends State { final msgs = _previewMessages .where((x) => x.type == MessageType.media.name) .toList(); - final mediaFile = - await twonlyDB.mediaFilesDao.getMediaFileById(msgs.first.mediaId!); + final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById( + msgs.first.mediaId!, + ); if (mediaFile?.type != MediaType.audio) { if (mediaFile?.downloadState == null) return; if (mediaFile!.downloadState! == DownloadState.pending) { @@ -190,10 +210,7 @@ class _UserListItem extends State { } } if (!mounted) return; - await context.push( - Routes.chatsMessages, - extra: widget.group, - ); + await context.push(Routes.chatsMessages(widget.group.groupId)); } @override @@ -201,23 +218,35 @@ class _UserListItem extends State { return GroupContextMenu( group: widget.group, child: ListTile( - title: Text( - substringBy(widget.group.groupName, 30), + title: Row( + children: [ + Text( + substringBy(widget.group.groupName, 30), + ), + const SizedBox(width: 3), + VerifiedShield( + group: widget.group, + showOnlyIfVerified: true, + size: 12, + ), + ], ), - subtitle: (_currentMessage == null) + subtitle: _receiverDeletedAccount + ? Text(context.lang.userDeletedAccount) + : (_currentMessage == null) ? (widget.group.totalMediaCounter == 0) - ? Text(context.lang.chatsTapToSend) - : Row( - children: [ - LastMessageTime( - dateTime: widget.group.lastMessageExchange, - ), - FlameCounterWidget( - groupId: widget.group.groupId, - prefix: true, - ), - ], - ) + ? Text(context.lang.chatsTapToSend) + : Row( + children: [ + LastMessageTime( + dateTime: widget.group.lastMessageExchange, + ), + FlameCounterWidget( + groupId: widget.group.groupId, + prefix: true, + ), + ], + ) : Row( children: [ MessageSendStateIcon( @@ -239,8 +268,9 @@ class _UserListItem extends State { leading: GestureDetector( onTap: () async { if (widget.group.isDirectChat) { - final contacts = await twonlyDB.groupsDao - .getGroupContact(widget.group.groupId); + final contacts = await twonlyDB.groupsDao.getGroupContact( + widget.group.groupId, + ); if (!context.mounted) return; await context.push(Routes.profileContact(contacts.first.userId)); return; @@ -250,15 +280,19 @@ class _UserListItem extends State { }, child: AvatarIcon(group: widget.group), ), - trailing: (widget.group.leftGroup) + trailing: (widget.group.leftGroup || _receiverDeletedAccount) ? null : IconButton( - onPressed: () => context.push( - _hasNonOpenedMediaFile - ? Routes.chatsMessages - : Routes.chatsCameraSendTo, - extra: widget.group, - ), + onPressed: () { + if (_hasNonOpenedMediaFile) { + context.push(Routes.chatsMessages(widget.group.groupId)); + } else { + context.push( + Routes.chatsCameraSendTo, + extra: widget.group, + ); + } + }, icon: FaIcon( _hasNonOpenedMediaFile ? FontAwesomeIcons.solidComments diff --git a/lib/src/views/chats/chat_messages.view.dart b/lib/src/views/chats/chat_messages.view.dart index df66b41..928796a 100644 --- a/lib/src/views/chats/chat_messages.view.dart +++ b/lib/src/views/chats/chat_messages.view.dart @@ -13,6 +13,7 @@ import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/memory_item.model.dart'; import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; +import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/chats/chat_messages_components/chat_group_action.dart'; import 'package:twonly/src/views/chats/chat_messages_components/chat_list_entry.dart'; import 'package:twonly/src/views/chats/chat_messages_components/entries/chat_date_chip.dart'; @@ -23,40 +24,11 @@ import 'package:twonly/src/views/components/blink.component.dart'; import 'package:twonly/src/views/components/flame.dart'; import 'package:twonly/src/views/components/verified_shield.dart'; -Color getMessageColor(Message message) { - return (message.senderId == null) - ? const Color.fromARGB(255, 58, 136, 102) - : const Color.fromARGB(233, 68, 137, 255); -} - -class ChatItem { - const ChatItem._({ - this.message, - this.date, - this.groupAction, - }); - factory ChatItem.date(DateTime date) { - return ChatItem._(date: date); - } - factory ChatItem.message(Message message) { - return ChatItem._(message: message); - } - factory ChatItem.groupAction(GroupHistory groupAction) { - return ChatItem._(groupAction: groupAction); - } - final GroupHistory? groupAction; - final Message? message; - final DateTime? date; - bool get isMessage => message != null; - bool get isDate => date != null; - bool get isGroupAction => groupAction != null; -} - /// Displays detailed information about a SampleItem. class ChatMessagesView extends StatefulWidget { - const ChatMessagesView(this.group, {super.key}); + const ChatMessagesView(this.groupId, {super.key}); - final Group group; + final String groupId; @override State createState() => _ChatMessagesViewState(); @@ -64,12 +36,13 @@ class ChatMessagesView extends StatefulWidget { class _ChatMessagesViewState extends State { HashSet alreadyReportedOpened = HashSet(); - late Group group; late StreamSubscription userSub; late StreamSubscription> messageSub; StreamSubscription>? groupActionsSub; StreamSubscription>? contactSub; + Group? _group; + Map userIdToContact = {}; List messages = []; @@ -81,11 +54,11 @@ class _ChatMessagesViewState extends State { late FocusNode textFieldFocus; final ItemScrollController itemScrollController = ItemScrollController(); int? focusedScrollItem; + bool _receiverDeletedAccount = false; @override void initState() { super.initState(); - group = widget.group; textFieldFocus = FocusNode(); initStreams(); } @@ -102,36 +75,47 @@ class _ChatMessagesViewState extends State { Mutex protectMessageUpdating = Mutex(); Future initStreams() async { - final groupStream = twonlyDB.groupsDao.watchGroup(group.groupId); + final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId); userSub = groupStream.listen((newGroup) { if (newGroup == null) return; setState(() { - group = newGroup; + _group = newGroup; + }); + + protectMessageUpdating.protect(() async { + if (groupActionsSub == null && !newGroup.isDirectChat) { + final actionsStream = twonlyDB.groupsDao.watchGroupActions( + newGroup.groupId, + ); + groupActionsSub = actionsStream.listen((update) async { + groupActions = update; + await setMessages(allMessages, update); + }); + + final contactsStream = twonlyDB.contactsDao.watchAllContacts(); + contactSub = contactsStream.listen((contacts) { + for (final contact in contacts) { + userIdToContact[contact.userId] = contact; + } + }); + } }); }); - if (!widget.group.isDirectChat) { - final actionsStream = twonlyDB.groupsDao.watchGroupActions(group.groupId); - groupActionsSub = actionsStream.listen((update) async { - groupActions = update; - await setMessages(allMessages, update); - }); - - final contactsStream = twonlyDB.contactsDao.watchAllContacts(); - contactSub = contactsStream.listen((contacts) { - for (final contact in contacts) { - userIdToContact[contact.userId] = contact; - } - }); - } - - final msgStream = twonlyDB.messagesDao.watchByGroupId(group.groupId); + final msgStream = twonlyDB.messagesDao.watchByGroupId(widget.groupId); messageSub = msgStream.listen((update) async { allMessages = update; await protectMessageUpdating.protect(() async { await setMessages(update, groupActions); }); }); + + final groupContacts = await twonlyDB.groupsDao.getGroupContact( + widget.groupId, + ); + if (groupContacts.length == 1) { + _receiverDeletedAccount = groupContacts.first.accountDeleted; + } } Future setMessages( @@ -153,8 +137,9 @@ class _ChatMessagesViewState extends State { if (groupHistoryIndex < groupActions.length) { for (; groupHistoryIndex < groupActions.length; groupHistoryIndex++) { if (msg.createdAt.isAfter(groupActions[groupHistoryIndex].actionAt)) { - chatItems - .add(ChatItem.groupAction(groupActions[groupHistoryIndex])); + chatItems.add( + ChatItem.groupAction(groupActions[groupHistoryIndex]), + ); // groupHistoryIndex++; } else { break; @@ -230,6 +215,8 @@ class _ChatMessagesViewState extends State { @override Widget build(BuildContext context) { + if (_group == null) return Container(); + final group = _group!; return GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: Scaffold( @@ -237,12 +224,14 @@ class _ChatMessagesViewState extends State { title: GestureDetector( onTap: () async { if (group.isDirectChat) { - final member = - await twonlyDB.groupsDao.getAllGroupMembers(group.groupId); + final member = await twonlyDB.groupsDao.getAllGroupMembers( + group.groupId, + ); if (!context.mounted) return; if (member.isEmpty) return; - await context - .push(Routes.profileContact(member.first.contactId)); + await context.push( + Routes.profileContact(member.first.contactId), + ); } else { await context.push(Routes.profileGroup(group.groupId)); } @@ -354,7 +343,7 @@ class _ChatMessagesViewState extends State { ], ), ), - if (!group.leftGroup) + if (!group.leftGroup && !_receiverDeletedAccount) MessageInput( group: group, quotesMessage: quotesMessage, @@ -365,6 +354,8 @@ class _ChatMessagesViewState extends State { }); }, ), + if (_receiverDeletedAccount) + Text(context.lang.userDeletedAccount), ], ), ), @@ -372,3 +363,32 @@ class _ChatMessagesViewState extends State { ); } } + +Color getMessageColor(Message message) { + return (message.senderId == null) + ? const Color.fromARGB(255, 58, 136, 102) + : const Color.fromARGB(233, 68, 137, 255); +} + +class ChatItem { + const ChatItem._({ + this.message, + this.date, + this.groupAction, + }); + factory ChatItem.date(DateTime date) { + return ChatItem._(date: date); + } + factory ChatItem.message(Message message) { + return ChatItem._(message: message); + } + factory ChatItem.groupAction(GroupHistory groupAction) { + return ChatItem._(groupAction: groupAction); + } + final GroupHistory? groupAction; + final Message? message; + final DateTime? date; + bool get isMessage => message != null; + bool get isDate => date != null; + bool get isGroupAction => groupAction != null; +} diff --git a/lib/src/views/chats/media_viewer.view.dart b/lib/src/views/chats/media_viewer.view.dart index 341cb3d..fec0a32 100644 --- a/lib/src/views/chats/media_viewer.view.dart +++ b/lib/src/views/chats/media_viewer.view.dart @@ -102,8 +102,9 @@ class _MediaViewerViewState extends State { } Future asyncLoadNextMedia(bool firstRun) async { - final messages = - twonlyDB.messagesDao.watchMediaNotOpened(widget.group.groupId); + final messages = twonlyDB.messagesDao.watchMediaNotOpened( + widget.group.groupId, + ); _subscription = messages.listen((messages) async { for (final msg in messages) { @@ -121,8 +122,9 @@ class _MediaViewerViewState extends State { /// If the messages was already there just replace it and go to the next... - final index = - allMediaFiles.indexWhere((m) => m.messageId == msg.messageId); + final index = allMediaFiles.indexWhere( + (m) => m.messageId == msg.messageId, + ); if (index >= 1) { allMediaFiles[index] = msg; @@ -160,7 +162,7 @@ class _MediaViewerViewState extends State { if (group != null && group.draftMessage != null && group.draftMessage != '') { - context.replace(Routes.chatsMessages, extra: group); + context.replace(Routes.chatsMessages(group.groupId)); } else { Navigator.pop(context); } @@ -190,8 +192,9 @@ class _MediaViewerViewState extends State { unawaited(flutterLocalNotificationsPlugin.cancelAll()); - final stream = - twonlyDB.mediaFilesDao.watchMedia(allMediaFiles.first.mediaId!); + final stream = twonlyDB.mediaFilesDao.watchMedia( + allMediaFiles.first.mediaId!, + ); var downloadTriggered = false; @@ -204,8 +207,9 @@ class _MediaViewerViewState extends State { }); if (!downloadTriggered) { downloadTriggered = true; - final mediaFile = await twonlyDB.mediaFilesDao - .getMediaFileById(allMediaFiles.first.mediaId!); + final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById( + allMediaFiles.first.mediaId!, + ); if (mediaFile == null) return; await startDownloadMedia(mediaFile, true); unawaited(tryDownloadAllMediaFiles(force: true)); @@ -226,8 +230,9 @@ class _MediaViewerViewState extends State { setState(() { _showDownloadingLoader = false; }); - final currentMediaLocal = - await MediaFileService.fromMediaId(allMediaFiles.first.mediaId!); + final currentMediaLocal = await MediaFileService.fromMediaId( + allMediaFiles.first.mediaId!, + ); if (currentMediaLocal == null || !mounted) return; if (currentMediaLocal.mediaFile.requiresAuthentication) { @@ -259,8 +264,9 @@ class _MediaViewerViewState extends State { }); if (!widget.group.isDirectChat) { - final sender = - await twonlyDB.contactsDao.getContactById(currentMessage!.senderId!); + final sender = await twonlyDB.contactsDao.getContactById( + currentMessage!.senderId!, + ); if (sender != null) { _currentMediaSender = '${getContactDisplayName(sender)} (${widget.group.groupName})'; @@ -281,36 +287,49 @@ class _MediaViewerViewState extends State { var timerRequired = false; if (currentMediaLocal.mediaFile.type == MediaType.video) { - videoController = VideoPlayerController.file(currentMediaLocal.tempPath); + videoController = VideoPlayerController.file( + currentMediaLocal.tempPath, + videoPlayerOptions: VideoPlayerOptions( + // only mix in case the video can be played multiple times, + // otherwise stop the background music in case the video contains audio + mixWithOthers: + currentMediaLocal.mediaFile.displayLimitInMilliseconds == null, + ), + ); await videoController?.setLooping( currentMediaLocal.mediaFile.displayLimitInMilliseconds == null, ); - await videoController?.initialize().then((_) { - if (videoController == null) return; - videoController?.play(); - videoController?.addListener(() { - setState(() { - progress = 1 - - videoController!.value.position.inSeconds / - videoController!.value.duration.inSeconds; - }); - if (currentMediaLocal.mediaFile.displayLimitInMilliseconds != null) { - if (videoController?.value.position == - videoController?.value.duration) { - nextMediaOrExit(); - } - } - }); - // ignore: invalid_return_type_for_catch_error, argument_type_not_assignable_to_error_handler - }).catchError(Log.error); + await videoController + ?.initialize() + .then((_) { + if (videoController == null) return; + videoController?.play(); + videoController?.addListener(() { + setState(() { + progress = + 1 - + videoController!.value.position.inSeconds / + videoController!.value.duration.inSeconds; + }); + if (currentMediaLocal.mediaFile.displayLimitInMilliseconds != + null) { + if (videoController?.value.position == + videoController?.value.duration) { + nextMediaOrExit(); + } + } + }); + }) + // ignore: argument_type_not_assignable_to_error_handler, invalid_return_type_for_catch_error + .catchError(Log.error); } else { if (currentMediaLocal.mediaFile.displayLimitInMilliseconds != null) { canBeSeenUntil = clock.now().add( - Duration( - milliseconds: - currentMediaLocal.mediaFile.displayLimitInMilliseconds!, - ), - ); + Duration( + milliseconds: + currentMediaLocal.mediaFile.displayLimitInMilliseconds!, + ), + ); timerRequired = true; } } @@ -434,8 +453,8 @@ class _MediaViewerViewState extends State { height: 8, child: Center( child: EmojiAnimation( - emoji: - EmojiAnimation.animatedIcons.keys.toList()[index], + emoji: EmojiAnimation.animatedIcons.keys + .toList()[index], ), ), ); diff --git a/lib/src/views/chats/start_new_chat.view.dart b/lib/src/views/chats/start_new_chat.view.dart index 65d36b7..14a9b83 100644 --- a/lib/src/views/chats/start_new_chat.view.dart +++ b/lib/src/views/chats/start_new_chat.view.dart @@ -35,8 +35,9 @@ class _StartNewChatView extends State { void initState() { super.initState(); - contactSub = - twonlyDB.contactsDao.watchAllAcceptedContacts().listen((update) async { + contactSub = twonlyDB.contactsDao.watchAllAcceptedContacts().listen(( + update, + ) async { update.sort( (a, b) => getContactDisplayName(a).compareTo(getContactDisplayName(b)), ); @@ -46,13 +47,14 @@ class _StartNewChatView extends State { await filterUsers(); }); - allNonDirectGroupsSub = - twonlyDB.groupsDao.watchGroupsForStartNewChat().listen((update) async { - setState(() { - allNonDirectGroups = update; - }); - await filterUsers(); - }); + allNonDirectGroupsSub = twonlyDB.groupsDao + .watchGroupsForStartNewChat() + .listen((update) async { + setState(() { + allNonDirectGroups = update; + }); + await filterUsers(); + }); } @override @@ -72,16 +74,16 @@ class _StartNewChatView extends State { } final usersFiltered = allContacts .where( - (user) => getContactDisplayName(user) - .toLowerCase() - .contains(searchUserName.value.text.toLowerCase()), + (user) => getContactDisplayName( + user, + ).toLowerCase().contains(searchUserName.value.text.toLowerCase()), ) .toList(); final groupsFiltered = allNonDirectGroups .where( - (g) => g.groupName - .toLowerCase() - .contains(searchUserName.value.text.toLowerCase()), + (g) => g.groupName.toLowerCase().contains( + searchUserName.value.text.toLowerCase(), + ), ) .toList(); setState(() { @@ -108,7 +110,7 @@ class _StartNewChatView extends State { context, MaterialPageRoute( builder: (context) { - return ChatMessagesView(directChat!); + return ChatMessagesView(directChat!.groupId); }, ), ); @@ -119,7 +121,7 @@ class _StartNewChatView extends State { context, MaterialPageRoute( builder: (context) { - return ChatMessagesView(group); + return ChatMessagesView(group.groupId); }, ), ); @@ -133,8 +135,12 @@ class _StartNewChatView extends State { ), body: SafeArea( child: Padding( - padding: - const EdgeInsets.only(bottom: 40, left: 10, top: 20, right: 10), + padding: const EdgeInsets.only( + bottom: 40, + left: 10, + top: 20, + right: 10, + ), child: Column( children: [ Padding( @@ -167,8 +173,9 @@ class _StartNewChatView extends State { size: 13, ), ), - onTap: () => context - .push(Routes.groupCreateSelectMember(null)), + onTap: () => context.push( + Routes.groupCreateSelectMember(null), + ), ); } if (i == 1) { diff --git a/lib/src/views/components/group_context_menu.component.dart b/lib/src/views/components/group_context_menu.component.dart index ce856f4..08b6679 100644 --- a/lib/src/views/components/group_context_menu.component.dart +++ b/lib/src/views/components/group_context_menu.component.dart @@ -46,10 +46,7 @@ class GroupContextMenu extends StatelessWidget { ), ContextMenuItem( title: context.lang.contextMenuOpenChat, - onTap: () => context.push( - Routes.chatsMessages, - extra: group, - ), + onTap: () => context.push(Routes.chatsMessages(group.groupId)), icon: FontAwesomeIcons.comments, ), if (!group.archived) diff --git a/lib/src/views/components/verified_shield.dart b/lib/src/views/components/verified_shield.dart index f2042d7..6a6cca7 100644 --- a/lib/src/views/components/verified_shield.dart +++ b/lib/src/views/components/verified_shield.dart @@ -12,11 +12,14 @@ class VerifiedShield extends StatefulWidget { this.group, super.key, this.size = 15, + this.showOnlyIfVerified = false, }); final Group? group; final Contact? contact; final double size; + final bool showOnlyIfVerified; + @override State createState() => _VerifiedShieldState(); } @@ -33,13 +36,13 @@ class _VerifiedShieldState extends State { stream = twonlyDB.groupsDao .watchGroupContact(widget.group!.groupId) .listen((contacts) { - if (contacts.length == 1) { - contact = contacts.first; - } - setState(() { - isVerified = contacts.every((t) => t.verified); - }); - }); + if (contacts.length == 1) { + contact = contacts.first; + } + setState(() { + isVerified = contacts.every((t) => t.verified); + }); + }); } else if (widget.contact != null) { isVerified = widget.contact!.verified; contact = widget.contact; @@ -56,19 +59,24 @@ class _VerifiedShieldState extends State { @override Widget build(BuildContext context) { + if (!isVerified && widget.showOnlyIfVerified) return Container(); return GestureDetector( onTap: (contact == null) ? null - : () => context.push(Routes.settingsPublicProfile), - child: Tooltip( - message: isVerified - ? 'You verified this contact' - : 'You have not verifies this contact.', + : () => context.push(Routes.settingsHelpFaqVerifyBadge), + child: ColoredBox( + color: Colors.transparent, child: Padding( - padding: const EdgeInsetsGeometry.only(top: 2), + padding: const EdgeInsetsGeometry.only( + top: 4, + left: 3, + right: 3, + bottom: 3, + ), child: SvgIcon( - assetPath: - isVerified ? SvgIcons.verifiedGreen : SvgIcons.verifiedRed, + assetPath: isVerified + ? SvgIcons.verifiedGreen + : SvgIcons.verifiedRed, size: widget.size, ), ), diff --git a/lib/src/views/components/video_player_wrapper.dart b/lib/src/views/components/video_player_wrapper.dart index 80833d0..be470fc 100644 --- a/lib/src/views/components/video_player_wrapper.dart +++ b/lib/src/views/components/video_player_wrapper.dart @@ -21,7 +21,12 @@ class _VideoPlayerWrapperState extends State { @override void initState() { super.initState(); - _controller = VideoPlayerController.file(widget.videoPath); + _controller = VideoPlayerController.file( + widget.videoPath, + videoPlayerOptions: VideoPlayerOptions( + mixWithOthers: true, + ), + ); unawaited( _controller.initialize().then((_) async { diff --git a/lib/src/views/contact/contact.view.dart b/lib/src/views/contact/contact.view.dart index 6b583f4..2adf105 100644 --- a/lib/src/views/contact/contact.view.dart +++ b/lib/src/views/contact/contact.view.dart @@ -14,7 +14,6 @@ import 'package:twonly/src/views/components/better_list_title.dart'; import 'package:twonly/src/views/components/flame.dart'; import 'package:twonly/src/views/components/max_flame_list_title.dart'; import 'package:twonly/src/views/components/select_chat_deletion_time.comp.dart'; -import 'package:twonly/src/views/components/svg_icon.dart'; import 'package:twonly/src/views/components/verified_shield.dart'; import 'package:twonly/src/views/groups/group.view.dart'; @@ -36,8 +35,9 @@ class _ContactViewState extends State { @override void initState() { - _contactSub = - twonlyDB.contactsDao.watchContact(widget.userId).listen((update) { + _contactSub = twonlyDB.contactsDao.watchContact(widget.userId).listen(( + update, + ) { setState(() { _contact = update; }); @@ -45,8 +45,8 @@ class _ContactViewState extends State { _groupMemberSub = twonlyDB.groupsDao .watchContactGroupMember(widget.userId) .listen((groups) async { - _memberOfGroups = groups; - }); + _memberOfGroups = groups; + }); super.initState(); } @@ -81,8 +81,9 @@ class _ContactViewState extends State { final remove = await showAlertDialog( context, - context.lang - .contactRemoveTitle(getContactDisplayName(contact, maxLength: 20)), + context.lang.contactRemoveTitle( + getContactDisplayName(contact, maxLength: 20), + ), context.lang.contactRemoveBody, ); if (remove) { @@ -177,13 +178,11 @@ class _ContactViewState extends State { icon: FontAwesomeIcons.solidComments, text: context.lang.contactViewMessage, onTap: () async { - final group = - await twonlyDB.groupsDao.getDirectChat(contact.userId); + final group = await twonlyDB.groupsDao.getDirectChat( + contact.userId, + ); if (group != null && context.mounted) { - await context.push( - Routes.chatsMessages, - extra: group, - ); + await context.push(Routes.chatsMessages(group.groupId)); } }, ), @@ -196,8 +195,10 @@ class _ContactViewState extends State { if (context.mounted && nickName != null && nickName != '') { final update = ContactsCompanion(nickName: Value(nickName)); - await twonlyDB.contactsDao - .updateContact(contact.userId, update); + await twonlyDB.contactsDao.updateContact( + contact.userId, + update, + ); } }, ), @@ -208,18 +209,18 @@ class _ContactViewState extends State { MaxFlameListTitle( contactId: widget.userId, ), - BetterListTile( - leading: SvgIcon( - assetPath: SvgIcons.verifiedGreen, - size: 20, - color: IconTheme.of(context).color, + if (!contact.verified) + BetterListTile( + leading: VerifiedShield( + contact: contact, + size: 20, + ), + text: context.lang.contactVerifyNumberTitle, + onTap: () async { + await context.push(Routes.settingsHelpFaqVerifyBadge); + setState(() {}); + }, ), - text: context.lang.contactVerifyNumberTitle, - onTap: () async { - await context.push(Routes.settingsPublicProfile); - setState(() {}); - }, - ), BetterListTile( icon: FontAwesomeIcons.flag, text: context.lang.reportUser, @@ -247,8 +248,9 @@ Future showNicknameChangeDialog( BuildContext context, Contact contact, ) { - final controller = - TextEditingController(text: getContactDisplayName(contact)); + final controller = TextEditingController( + text: getContactDisplayName(contact), + ); return showDialog( context: context, @@ -258,8 +260,9 @@ Future showNicknameChangeDialog( content: TextField( controller: controller, autofocus: true, - decoration: - InputDecoration(hintText: context.lang.contactNicknameNew), + decoration: InputDecoration( + hintText: context.lang.contactNicknameNew, + ), ), actions: [ TextButton( @@ -271,8 +274,9 @@ Future showNicknameChangeDialog( TextButton( child: Text(context.lang.ok), onPressed: () { - Navigator.of(context) - .pop(controller.text); // Return the input text + Navigator.of( + context, + ).pop(controller.text); // Return the input text }, ), ], @@ -291,8 +295,9 @@ Future showReportDialog( context: context, builder: (context) { return AlertDialog( - title: - Text(context.lang.reportUserTitle(getContactDisplayName(contact))), + title: Text( + context.lang.reportUserTitle(getContactDisplayName(contact)), + ), content: TextField( controller: controller, autofocus: true, diff --git a/lib/src/views/groups/group_member.context.dart b/lib/src/views/groups/group_member.context.dart index 974a584..8bbcd08 100644 --- a/lib/src/views/groups/group_member.context.dart +++ b/lib/src/views/groups/group_member.context.dart @@ -124,14 +124,15 @@ class GroupMemberContextMenu extends StatelessWidget { ContextMenuItem( title: context.lang.contextMenuOpenChat, onTap: () async { - final directChat = - await twonlyDB.groupsDao.getDirectChat(contact.userId); + final directChat = await twonlyDB.groupsDao.getDirectChat( + contact.userId, + ); if (directChat == null) { // create return; } if (!context.mounted) return; - await context.push(Routes.chatsMessages, extra: directChat); + await context.push(Routes.chatsMessages(directChat.groupId)); }, icon: FontAwesomeIcons.message, ), diff --git a/lib/src/views/home.view.dart b/lib/src/views/home.view.dart index 1e24283..a4234a0 100644 --- a/lib/src/views/home.view.dart +++ b/lib/src/views/home.view.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_sharing_intent/model/sharing_file.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/globals.dart'; +import 'package:twonly/src/constants/routes.keys.dart'; +import 'package:twonly/src/providers/routing.provider.dart'; import 'package:twonly/src/services/intent/links.intent.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/notifications/setup.notifications.dart'; @@ -106,7 +108,12 @@ class HomeViewState extends State { }); }; selectNotificationStream.stream.listen((response) async { - globalUpdateOfHomeViewPageIndex(0); + if (response.payload != null && + response.payload!.startsWith(Routes.chats)) { + await routerProvider.push(response.payload!); + } else { + globalUpdateOfHomeViewPageIndex(0); + } }); unawaited(_mainCameraController.selectCamera(0, true)); unawaited(initAsync()); @@ -140,13 +147,26 @@ class HomeViewState extends State { } Future initAsync() async { - final notificationAppLaunchDetails = - await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails(); + final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin + .getNotificationAppLaunchDetails(); if (widget.initialPage == 0 || (notificationAppLaunchDetails != null && notificationAppLaunchDetails.didNotificationLaunchApp)) { - globalUpdateOfHomeViewPageIndex(0); + var pushed = false; + + if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) { + final payload = + notificationAppLaunchDetails?.notificationResponse?.payload; + if (payload != null && payload.startsWith(Routes.chats)) { + await routerProvider.push(payload); + pushed = true; + } + } + + if (!pushed) { + globalUpdateOfHomeViewPageIndex(0); + } } final draftMedia = await twonlyDB.mediaFilesDao.getDraftMediaFile(); @@ -168,8 +188,9 @@ class HomeViewState extends State { Widget build(BuildContext context) { return Scaffold( body: GestureDetector( - onDoubleTap: - offsetRatio == 0 ? _mainCameraController.onDoubleTap : null, + onDoubleTap: offsetRatio == 0 + ? _mainCameraController.onDoubleTap + : null, onTapDown: offsetRatio == 0 ? _mainCameraController.onTapDown : null, child: Stack( children: [ diff --git a/lib/src/views/settings/developer/developer.view.dart b/lib/src/views/settings/developer/developer.view.dart index 581bc85..a9d3ca5 100644 --- a/lib/src/views/settings/developer/developer.view.dart +++ b/lib/src/views/settings/developer/developer.view.dart @@ -3,6 +3,7 @@ import 'package:clock/clock.dart'; import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:restart_app/restart_app.dart'; import 'package:twonly/globals.dart'; @@ -32,6 +33,14 @@ class _DeveloperSettingsViewState extends State { setState(() {}); } + Future toggleVideoStabilization() async { + await updateUserdata((u) { + u.videoStabilizationEnabled = !u.videoStabilizationEnabled; + return u; + }); + setState(() {}); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -53,6 +62,14 @@ class _DeveloperSettingsViewState extends State { onTap: () => context.push(Routes.settingsDeveloperRetransmissionDatabase), ), + ListTile( + title: const Text('Toggle Video Stabilization'), + onTap: toggleVideoStabilization, + trailing: Switch( + value: gUser.videoStabilizationEnabled, + onChanged: (a) => toggleVideoStabilization(), + ), + ), ListTile( title: const Text('Delete all (!) app data'), onTap: () async { @@ -71,6 +88,10 @@ class _DeveloperSettingsViewState extends State { } }, ), + ListTile( + title: const Text('Reduce flames'), + onTap: () => context.push(Routes.settingsDeveloperReduceFlames), + ), if (!kReleaseMode) ListTile( title: const Text('Make it possible to reset flames'), @@ -84,9 +105,13 @@ class _DeveloperSettingsViewState extends State { flameCounter: const Value(0), maxFlameCounter: const Value(365), lastFlameCounterChange: Value(clock.now()), + maxFlameCounterFrom: Value( + clock.now().subtract(const Duration(days: 1)), + ), ), ); } + await HapticFeedback.heavyImpact(); }, ), if (!kReleaseMode) diff --git a/lib/src/views/settings/developer/reduce_flames.view.dart b/lib/src/views/settings/developer/reduce_flames.view.dart new file mode 100644 index 0000000..a762dff --- /dev/null +++ b/lib/src/views/settings/developer/reduce_flames.view.dart @@ -0,0 +1,130 @@ +import 'dart:async'; +import 'dart:collection'; +import 'package:drift/drift.dart' show Value; +import 'package:flutter/material.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/views/components/avatar_icon.component.dart'; +import 'package:twonly/src/views/components/flame.dart'; + +class ReduceFlamesView extends StatefulWidget { + const ReduceFlamesView({super.key}); + + @override + State createState() => _ReduceFlamesViewState(); +} + +class _ReduceFlamesViewState extends State { + List allGroups = []; + List backupFlames = []; + HashSet changedGroupIds = HashSet(); + late StreamSubscription> groupSub; + + @override + void initState() { + super.initState(); + + final stream = twonlyDB.groupsDao.watchGroupsForChatList(); + + groupSub = stream.listen((update) async { + if (backupFlames.isEmpty) { + backupFlames = update; + } + update.sort( + (a, b) => a.flameCounter.compareTo(b.flameCounter), + ); + setState(() { + allGroups = update.where((g) => g.flameCounter > 1).toList(); + }); + }); + } + + @override + void dispose() { + unawaited(groupSub.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + appBar: AppBar( + title: const Text('Reduce Flames'), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only( + bottom: 40, + left: 10, + top: 20, + right: 10, + ), + child: Column( + children: [ + const Text( + 'There was a bug that caused the flames in direct messages to be replaced by group flames. Here, you can reduce the flames again. If you reduce the flames, the other person MUST do the same; otherwise, they will be resynchronized to the higher value.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + OutlinedButton( + onPressed: () async { + for (final backupGroup in backupFlames) { + if (changedGroupIds.contains(backupGroup.groupId)) { + await twonlyDB.groupsDao.updateGroup( + backupGroup.groupId, + GroupsCompanion( + flameCounter: Value(backupGroup.flameCounter), + ), + ); + } + } + }, + child: const Text('Undo changes'), + ), + const SizedBox(height: 10), + Expanded( + child: ListView.builder( + restorationId: 'new_message_users_list', + itemCount: allGroups.length, + itemBuilder: (context, i) { + final group = allGroups[i]; + return ListTile( + title: Row( + children: [ + Text(group.groupName), + FlameCounterWidget( + groupId: group.groupId, + prefix: true, + ), + ], + ), + leading: AvatarIcon( + group: group, + fontSize: 13, + ), + trailing: OutlinedButton( + onPressed: () { + changedGroupIds.add(group.groupId); + twonlyDB.groupsDao.updateGroup( + group.groupId, + GroupsCompanion( + flameCounter: Value(group.flameCounter - 1), + ), + ); + }, + child: const Text('-1'), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/views/settings/help/faq/verifybadge.dart b/lib/src/views/settings/help/faq/verifybadge.dart index b0568a7..4bb9e17 100644 --- a/lib/src/views/settings/help/faq/verifybadge.dart +++ b/lib/src/views/settings/help/faq/verifybadge.dart @@ -1,7 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:go_router/go_router.dart'; +import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/views/components/better_list_title.dart'; import 'package:twonly/src/views/components/svg_icon.dart'; +const colorVerificationBadgeYellow = Color(0xffffa500); + class VerificationBadeFaqView extends StatefulWidget { const VerificationBadeFaqView({super.key}); @@ -33,7 +39,7 @@ class _VerificationBadeFaqViewState extends State { icon: const SvgIcon( assetPath: SvgIcons.verifiedGreen, size: 40, - color: Color.fromARGB(255, 227, 227, 3), + color: colorVerificationBadgeYellow, ), description: context.lang.verificationBadgeYellowDesc, ), @@ -41,6 +47,18 @@ class _VerificationBadeFaqViewState extends State { icon: const SvgIcon(assetPath: SvgIcons.verifiedRed, size: 40), description: context.lang.verificationBadgeRedDesc, ), + const SizedBox(height: 20), + const SizedBox(height: 20), + BetterListTile( + leading: const FaIcon(FontAwesomeIcons.camera), + text: context.lang.scanOtherProfile, + onTap: () => context.push(Routes.cameraQRScanner), + ), + BetterListTile( + leading: const FaIcon(FontAwesomeIcons.qrcode), + text: context.lang.openYourOwnQRcode, + onTap: () => context.push(Routes.settingsPublicProfile), + ), ], ), ); diff --git a/lib/src/views/settings/notification.view.dart b/lib/src/views/settings/notification.view.dart index e39c2d7..4319afa 100644 --- a/lib/src/views/settings/notification.view.dart +++ b/lib/src/views/settings/notification.view.dart @@ -13,9 +13,87 @@ import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/components/alert_dialog.dart'; -class NotificationView extends StatelessWidget { +class NotificationView extends StatefulWidget { const NotificationView({super.key}); + @override + State createState() => _NotificationViewState(); +} + +class _NotificationViewState extends State { + bool _isLoadingTroubleshooting = false; + bool _isLoadingReset = false; + bool _troubleshootingDidRun = false; + + Future _troubleshooting() async { + setState(() { + _isLoadingTroubleshooting = true; + }); + + await initFCMAfterAuthenticated(force: true); + + final storedToken = await (const FlutterSecureStorage().read( + key: SecureStorageKeys.googleFcm, + )); + + await setupNotificationWithUsers(force: true); + + if (!mounted) return; + + if (storedToken == null) { + final platform = Platform.isAndroid ? "Google's" : "Apple's"; + await showAlertDialog( + context, + 'Problem detected', + 'twonly is not able to register your app to $platform push server infrastructure. For Android that can happen when you do not have the Google Play Services installed. If you theses installed and want to help us to fix the issue please send us your debug log in Settings > Help > Debug log.', + ); + } else { + final run = await showAlertDialog( + context, + context.lang.settingsNotifyTroubleshootingNoProblem, + context.lang.settingsNotifyTroubleshootingNoProblemDesc, + ); + + if (run) { + final user = await getUser(); + if (user != null) { + final pushData = await encryptPushNotification( + user.userId, + PushNotification( + messageId: uuid.v4(), + kind: PushKind.testNotification, + ), + ); + await apiService.sendTextMessage( + user.userId, + Uint8List(0), + pushData, + ); + } + _troubleshootingDidRun = true; + } + } + setState(() { + _isLoadingTroubleshooting = false; + }); + } + + Future resetTokens() async { + setState(() { + _isLoadingReset = true; + }); + await resetFCMTokens(); + if (!mounted) return; + await showAlertDialog( + context, + context.lang.settingsNotifyResetTitleReset, + context.lang.settingsNotifyResetTitleResetDesc, + ); + setState(() { + _isLoadingReset = false; + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -27,47 +105,32 @@ class NotificationView extends StatelessWidget { ListTile( title: Text(context.lang.settingsNotifyTroubleshooting), subtitle: Text(context.lang.settingsNotifyTroubleshootingDesc), - onTap: () async { - await initFCMAfterAuthenticated(); - final storedToken = await (const FlutterSecureStorage() - .read(key: SecureStorageKeys.googleFcm)); - await setupNotificationWithUsers(force: true); - if (!context.mounted) return; - - if (storedToken == null) { - final platform = Platform.isAndroid ? "Google's" : "Apple's"; - await showAlertDialog( - context, - 'Problem detected', - 'twonly is not able to register your app to $platform push server infrastructure. For Android that can happen when you do not have the Google Play Services installed. If you theses installed and want to help us to fix the issue please send us your debug log in Settings > Help > Debug log.', - ); - } else { - final run = await showAlertDialog( - context, - context.lang.settingsNotifyTroubleshootingNoProblem, - context.lang.settingsNotifyTroubleshootingNoProblemDesc, - ); - - if (run) { - final user = await getUser(); - if (user != null) { - final pushData = await encryptPushNotification( - user.userId, - PushNotification( - messageId: uuid.v4(), - kind: PushKind.testNotification, - ), - ); - await apiService.sendTextMessage( - user.userId, - Uint8List(0), - pushData, - ); - } - } - } - }, + trailing: _isLoadingTroubleshooting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : null, + onTap: _isLoadingTroubleshooting ? null : _troubleshooting, ), + if (_troubleshootingDidRun) + ListTile( + title: Text(context.lang.settingsNotifyResetTitle), + subtitle: Text(context.lang.settingsNotifyResetTitleSubtitle), + trailing: _isLoadingReset + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : null, + onTap: _isLoadingReset ? null : resetTokens, + ), ], ), ); diff --git a/lib/src/views/settings/settings_main.view.dart b/lib/src/views/settings/settings_main.view.dart index a8b353f..47a11a4 100644 --- a/lib/src/views/settings/settings_main.view.dart +++ b/lib/src/views/settings/settings_main.view.dart @@ -121,7 +121,12 @@ class _SettingsMainViewState extends State { BetterListTile( icon: FontAwesomeIcons.circleQuestion, text: context.lang.settingsHelp, - onTap: () => context.push(Routes.settingsHelp), + onTap: () async { + await context.push(Routes.settingsHelp); + setState(() { + // gUser could have been changed + }); + }, ), if (gUser.isDeveloper) BetterListTile( diff --git a/pubspec.lock b/pubspec.lock index aa68ad4..522e1cc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: afe15ce18a287d2f89da95566e62892df339b1936bbe9b83587df45b944ee72a + sha256: f698de6eb8a0dd7a9a931bbfe13568e8b77e702eb2deb13dd83480c5373e7746 url: "https://pub.dev" source: hosted - version: "1.3.67" + version: "1.3.68" adaptive_number: dependency: "direct overridden" description: @@ -84,10 +84,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" audio_waveforms: dependency: "direct main" description: @@ -124,18 +124,18 @@ packages: dependency: transitive description: name: build - sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c url: "https://pub.dev" source: hosted - version: "4.0.4" + version: "4.0.5" build_config: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" build_daemon: dependency: transitive description: @@ -148,10 +148,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "39ad4ca8a2876779737c60e4228b4bcd35d4352ef7e14e47514093edc012c734" + sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e" url: "https://pub.dev" source: hosted - version: "2.11.1" + version: "2.13.1" built_collection: dependency: transitive description: @@ -164,10 +164,10 @@ packages: dependency: transitive description: name: built_value - sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" + sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" url: "https://pub.dev" source: hosted - version: "8.12.4" + version: "8.12.5" cached_network_image: dependency: "direct main" description: @@ -196,27 +196,27 @@ packages: dependency: "direct main" description: name: camera - sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437" + sha256: "034c38cb8014d29698dcae6d20276688a1bf74e6487dfeb274d70ea05d5f7777" url: "https://pub.dev" source: hosted - version: "0.11.4" + version: "0.12.0+1" camera_android_camerax: dependency: "direct overridden" description: path: "packages/camera/camera_android_camerax" - ref: "43b87faec960306f98d767253b9bf2cee61be630" - resolved-ref: "43b87faec960306f98d767253b9bf2cee61be630" + ref: e83fb3a27d4da2c37a3c8acbf2486283965b4f69 + resolved-ref: e83fb3a27d4da2c37a3c8acbf2486283965b4f69 url: "https://github.com/otsmr/flutter-packages.git" source: git - version: "0.6.25+1" + version: "0.7.1+2" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" + sha256: "90e4cc3fde331581a3b2d35d83be41dbb7393af0ab857eb27b732174289cb96d" url: "https://pub.dev" source: hosted - version: "0.9.23+2" + version: "0.10.1" camera_platform_interface: dependency: transitive description: @@ -301,18 +301,18 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c" + sha256: b8fe52979ff12432ecf8f0abf6ff70410b1bb734be1c9e4f2f86807ad7166c79 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.1.0" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.0" convert: dependency: "direct main" description: @@ -365,10 +365,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "6f6b30cba0301e7b38f32bdc9a6bdae6f5921a55f0a1eb9450e1e6515645dbb2" + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" url: "https://pub.dev" source: hosted - version: "3.1.6" + version: "3.1.7" dbus: dependency: transitive description: @@ -381,10 +381,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd url: "https://pub.dev" source: hosted - version: "12.3.0" + version: "12.4.0" device_info_plus_platform_interface: dependency: transitive description: @@ -504,54 +504,78 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+5" + firebase_app_installations: + dependency: "direct main" + description: + name: firebase_app_installations + sha256: "9103cac19ec40561b49a023e8e583f007f77a499c2058f5a1d82ba480c2367d7" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + firebase_app_installations_platform_interface: + dependency: transitive + description: + name: firebase_app_installations_platform_interface + sha256: "0811d37b91c992cc0c98200cca79652d0375a16c58b364a4db5601571c198ee5" + url: "https://pub.dev" + source: hosted + version: "0.1.4+67" + firebase_app_installations_web: + dependency: transitive + description: + name: firebase_app_installations_web + sha256: "02f2a96e85581bd1f78319b503dc92c80afa1527cbc299c7c921995b75595bbd" + url: "https://pub.dev" + source: hosted + version: "0.1.7+4" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: f0997fee80fbb6d2c658c5b88ae87ba1f9506b5b37126db64fc2e75d8e977fbb + sha256: "2f988dab915efde3b3105268dbd69efce0e8570d767a218ccd914afd0c10c8cc" url: "https://pub.dev" source: hosted - version: "4.5.0" + version: "4.6.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 + sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.0.3" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "856ca92bf2d75a63761286ab8e791bda3a85184c2b641764433b619647acfca6" + sha256: "1399ab1f0ac3b503d8a9be64a4c997fc066bbf33f701f42866e5569f26205ebe" url: "https://pub.dev" source: hosted - version: "3.5.0" + version: "3.5.1" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: bd17823b70e629877904d384841cda72ed2cc197517404c0c90da5c0ba786a8c + sha256: "8dc372085b1647f05e3ec1b8bc1dada87c0062f93b2a6976f620eb85edc44f97" url: "https://pub.dev" source: hosted - version: "16.1.2" + version: "16.1.3" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "550435235cc7d53683f32bf0762c28ef8cfc20a8d36318a033676ae09526d7fb" + sha256: "6ea10f7df747542b17679d5939213c09163aab9c301b2f9b858cb55f38efdb54" url: "https://pub.dev" source: hosted - version: "4.7.7" + version: "4.7.8" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "6b1b93ed90309fbce91c219e3cd32aa831e8eccaf4a61f3afaea1625479275d2" + sha256: "1f9798c8021ccf22b7e43e7fba81becd42252cb168228379fcabb7c2ef7dd638" url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "4.1.4" fixnum: dependency: "direct main" description: @@ -727,10 +751,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" url: "https://pub.dev" source: hosted - version: "2.0.33" + version: "2.0.34" flutter_secure_storage: dependency: "direct main" description: @@ -790,10 +814,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.4" flutter_test: dependency: "direct dev" description: flutter @@ -848,10 +872,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896" + sha256: "48fb2f42ad057476fa4b733cb95e9f9ea7b0b010bb349ea491dca7dbdb18ffc4" url: "https://pub.dev" source: hosted - version: "17.1.0" + version: "17.2.0" google_mlkit_barcode_scanning: dependency: "direct main" description: @@ -917,10 +941,10 @@ packages: dependency: transitive description: name: hooks - sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" html: dependency: "direct main" description: @@ -973,10 +997,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622" url: "https://pub.dev" source: hosted - version: "0.8.13+14" + version: "0.8.13+16" image_picker_for_web: dependency: transitive description: @@ -1037,10 +1061,10 @@ packages: dependency: transitive description: name: in_app_purchase_android - sha256: abb254ae159a5a9d4f867795ecb076864faeba59ce015ab81d4cca380f23df45 + sha256: "634bee4734b17fe55f370f0ac07a22431a9666e0f3a870c6d20350856e8bbf71" url: "https://pub.dev" source: hosted - version: "0.4.0+8" + version: "0.4.0+10" in_app_purchase_platform_interface: dependency: "direct dev" description: @@ -1053,10 +1077,10 @@ packages: dependency: transitive description: name: in_app_purchase_storekit - sha256: "2f1a1db44798158076ced07d401b349880dd24a29c7c50a1b1a0de230b7f2f62" + sha256: "1d512809edd9f12ff88fce4596a13a18134e2499013f4d6a8894b04699363c93" url: "https://pub.dev" source: hosted - version: "0.4.8" + version: "0.4.8+1" intl: dependency: "direct main" description: @@ -1100,10 +1124,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" + sha256: fbcf404b03520e6e795f6b9b39badb2b788407dfc0a50cf39158a6ae1ca78925 url: "https://pub.dev" source: hosted - version: "6.13.0" + version: "6.13.1" leak_tracker: dependency: transitive description: @@ -1155,10 +1179,10 @@ packages: dependency: transitive description: name: local_auth_android - sha256: dc9663a7bc8ac33d7d988e63901974f63d527ebef260eabd19c479447cc9c911 + sha256: b41970749c2d43791790724b76917eeee1e90de76e6b0eec3edca03a329bf44c url: "https://pub.dev" source: hosted - version: "2.0.5" + version: "2.0.7" local_auth_darwin: dependency: transitive description: @@ -1241,10 +1265,10 @@ packages: dependency: transitive description: name: native_toolchain_c - sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" url: "https://pub.dev" source: hosted - version: "0.17.4" + version: "0.17.6" nested: dependency: transitive description: @@ -1303,10 +1327,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "9.0.1" package_info_plus_platform_interface: dependency: transitive description: @@ -1343,10 +1367,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" url: "https://pub.dev" source: hosted - version: "2.2.22" + version: "2.2.23" path_provider_foundation: dependency: transitive description: @@ -1485,10 +1509,10 @@ packages: dependency: "direct main" description: name: pro_video_editor - sha256: "5aa37aed1399333a3ac4b78ce00c7dcba77c5e407b6420960bba43751895fa22" + sha256: cfed1424b3ca3d5981cc81efdd20b844c995c0ad2818e185eb5bc06a8674f728 url: "https://pub.dev" source: hosted - version: "1.6.2" + version: "1.14.2" protobuf: dependency: "direct main" description: @@ -1570,26 +1594,26 @@ packages: dependency: transitive description: name: sentry - sha256: "605ad1f6f1ae5b72018cbe8fc20f490fa3bd53e58882e5579566776030d8c8c1" + sha256: "288aee3d35f252ac0dc3a4b0accbbe7212fa2867604027f2cc5bc65334afd743" url: "https://pub.dev" source: hosted - version: "9.14.0" + version: "9.16.0" sentry_flutter: dependency: "direct main" description: name: sentry_flutter - sha256: "7fd0fb80050c1f6a77ae185bda997a76d384326d6777cf5137a6c38952c4ac7d" + sha256: f9e87d5895cc437902aa2b081727ee7e46524fe7cc2e1910f535480a3eeb8bed url: "https://pub.dev" source: hosted - version: "9.14.0" + version: "9.16.0" share_plus: dependency: "direct main" description: name: share_plus - sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840" + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" url: "https://pub.dev" source: hosted - version: "12.0.1" + version: "12.0.2" share_plus_platform_interface: dependency: transitive description: @@ -1602,18 +1626,18 @@ packages: dependency: transitive description: name: shared_preferences - sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 url: "https://pub.dev" source: hosted - version: "2.4.21" + version: "2.4.23" shared_preferences_foundation: dependency: transitive description: @@ -1634,10 +1658,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: @@ -1679,18 +1703,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" + sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd" url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.2.2" source_helper: dependency: transitive description: name: source_helper - sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae" + sha256: "1d3b229b2934034fb2e691fbb3d53e0f75a4af7b1407f88425ed8f209bcb1b8f" url: "https://pub.dev" source: hosted - version: "1.3.10" + version: "1.3.11" source_span: dependency: transitive description: @@ -1711,10 +1735,10 @@ packages: dependency: transitive description: name: sqflite_android - sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" url: "https://pub.dev" source: hosted - version: "2.4.2+2" + version: "2.4.2+3" sqflite_common: dependency: transitive description: @@ -1751,10 +1775,10 @@ packages: dependency: transitive description: name: sqlite3_flutter_libs - sha256: "1e800ebe7f85a80a66adacaa6febe4d5f4d8b75f244e9838a27cb2ffc7aec08d" + sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad url: "https://pub.dev" source: hosted - version: "0.5.41" + version: "0.5.42" sqlparser: dependency: transitive description: @@ -1847,10 +1871,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" url: "https://pub.dev" source: hosted - version: "6.3.28" + version: "6.3.29" url_launcher_ios: dependency: transitive description: @@ -1911,10 +1935,10 @@ packages: dependency: "direct main" description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.1.21" vector_graphics_codec: dependency: transitive description: @@ -1951,26 +1975,26 @@ packages: dependency: "direct main" description: name: video_player - sha256: "08bfba72e311d48219acad4e191b1f9c27ff8cf928f2c7234874592d9c9d7341" + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.11.1" video_player_android: dependency: transitive description: name: video_player_android - sha256: "9862c67c4661c98f30fe707bc1a4f97d6a0faa76784f485d282668e4651a7ac3" + sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" url: "https://pub.dev" source: hosted - version: "2.9.4" + version: "2.9.5" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: f93b93a3baa12ca0ff7d00ca8bc60c1ecd96865568a01ff0c18a99853ee201a5 + sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e url: "https://pub.dev" source: hosted - version: "2.9.3" + version: "2.9.4" video_player_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index db27196..c8d6bb5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.1.1+101 +version: 0.1.3+103 environment: sdk: ^3.11.0 @@ -30,7 +30,7 @@ dependencies: # Trusted publisher flutter.dev - camera: ^0.11.2 + camera: ^0.12.0+1 flutter_svg: ^2.0.17 image_picker: ^1.1.2 local_auth: ^3.0.0 @@ -54,6 +54,7 @@ dependencies: # Trustworthy publishers firebase_core: ^4.3.0 # firebase.google.com firebase_messaging: ^16.1.0 # firebase.google.com + firebase_app_installations: ^0.4.1 # firebase.google.com json_annotation: ^4.9.0 # google.dev protobuf: ^4.0.0 # google.dev scrollable_positioned_list: ^0.3.8 # google.dev @@ -152,7 +153,7 @@ dependency_overrides: git: url: https://github.com/otsmr/flutter-packages.git path: packages/camera/camera_android_camerax - ref: 43b87faec960306f98d767253b9bf2cee61be630 + ref: e83fb3a27d4da2c37a3c8acbf2486283965b4f69 emoji_picker_flutter: # Fixes the issue with recent emojis (solved by https://github.com/Fintasys/emoji_picker_flutter/pull/238) # Using override until this gets merged. diff --git a/test/features/flame_counter_test.dart b/test/features/flame_counter_test.dart index d5c43d8..574f863 100644 --- a/test/features/flame_counter_test.dart +++ b/test/features/flame_counter_test.dart @@ -126,6 +126,20 @@ void main() { ); await withClock( Clock.fixed(DateTime(2026, 3, 25, 19)), + () async { + final group2 = (await twonlyDB.groupsDao.getGroup(group.groupId))!; + expect(isItPossibleToRestoreFlames(group2), true); + }, + ); + await withClock( + Clock.fixed(DateTime(2026, 3, 26, 19)), + () async { + final group2 = (await twonlyDB.groupsDao.getGroup(group.groupId))!; + expect(isItPossibleToRestoreFlames(group2), true); + }, + ); + await withClock( + Clock.fixed(DateTime(2026, 3, 27, 19)), () async { final group2 = (await twonlyDB.groupsDao.getGroup(group.groupId))!; expect(isItPossibleToRestoreFlames(group2), false);