From c48ce31bc652979e7215033f88c68c7b8b6a75fe Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 5 Apr 2026 16:21:58 +0200 Subject: [PATCH 01/15] add view for reduce flames --- .../settings/developer/developer.view.dart | 25 ++++ .../developer/reduce_flames.view.dart | 130 ++++++++++++++++++ .../views/settings/settings_main.view.dart | 7 +- 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 lib/src/views/settings/developer/reduce_flames.view.dart 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/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( From 955992d5d2bb2922416fd0a518a7df27d6808144 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 5 Apr 2026 16:22:48 +0200 Subject: [PATCH 02/15] add troubleshooting for notifications & video stab --- CHANGELOG.md | 6 + lib/src/constants/routes.keys.dart | 5 +- .../generated/app_localizations.dart | 26 +- .../generated/app_localizations_de.dart | 17 +- .../generated/app_localizations_en.dart | 17 +- .../generated/app_localizations_sv.dart | 17 +- lib/src/model/json/userdata.dart | 3 + lib/src/model/json/userdata.g.dart | 3 + lib/src/providers/routing.provider.dart | 11 +- .../api/mediafiles/upload.service.dart | 11 +- lib/src/services/flame.service.dart | 20 +- .../notifications/fcm.notifications.dart | 52 +++- lib/src/utils/log.dart | 4 +- .../camera_preview_controller_view.dart | 241 ++++++++++-------- .../main_camera_controller.dart | 5 + .../chat_list_components/group_list_item.dart | 119 +++++---- lib/src/views/chats/chat_messages.view.dart | 123 ++++----- lib/src/views/chats/media_viewer.view.dart | 87 ++++--- lib/src/views/chats/start_new_chat.view.dart | 49 ++-- .../group_context_menu.component.dart | 5 +- lib/src/views/contact/contact.view.dart | 50 ++-- .../views/groups/group_member.context.dart | 7 +- lib/src/views/settings/notification.view.dart | 145 ++++++++--- pubspec.lock | 214 +++++++++------- pubspec.yaml | 5 +- 25 files changed, 763 insertions(+), 479 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa4742a..a5243e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.2 + +- New: Developer settings to reduce flames +- Improve: Improved troubleshooting for issues with push notifications +- Fix: Flash not activated when starting a video recording + ## 0.1.1 - New: Groups can now collect flames as well 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/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 471dfb4..23758af 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: diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index ee28bb2..32faea1 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'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 9ba5811..3d8ab05 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'; diff --git a/lib/src/localization/generated/app_localizations_sv.dart b/lib/src/localization/generated/app_localizations_sv.dart index 3b2e90e..e4de68c 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'; diff --git a/lib/src/model/json/userdata.dart b/lib/src/model/json/userdata.dart index 39bd078..8f30a81 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: false) + bool videoStabilizationEnabled = false; + @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/mediafiles/upload.service.dart b/lib/src/services/api/mediafiles/upload.service.dart index c15ae6c..dadccdb 100644 --- a/lib/src/services/api/mediafiles/upload.service.dart +++ b/lib/src/services/api/mediafiles/upload.service.dart @@ -280,6 +280,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 +337,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/flame.service.dart b/lib/src/services/flame.service.dart index 9ef17b8..715fe25 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: 5)), + ); } diff --git a/lib/src/services/notifications/fcm.notifications.dart b/lib/src/services/notifications/fcm.notifications.dart index f202747..e7884f7 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,46 +42,69 @@ 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, diff --git a/lib/src/utils/log.dart b/lib/src/utils/log.dart index 84b5009..09646b1 100644 --- a/lib/src/utils/log.dart +++ b/lib/src/utils/log.dart @@ -129,9 +129,9 @@ Future cleanLogFile() async { if (logFile.existsSync()) { final lines = await logFile.readAsLines(); - if (lines.length <= 10000) return; + if (lines.length <= 100000) return; - final removeCount = lines.length - 10000; + final removeCount = lines.length - 100000; final remaining = lines.sublist(removeCount, lines.length); final sink = logFile.openWrite()..writeAll(remaining, '\n'); 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..4fb9b16 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 @@ -133,6 +133,11 @@ class MainCameraController { await cameraController?.initialize(); await cameraController?.startImageStream(_processCameraImage); await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor); + if (gUser.videoStabilizationEnabled) { + await cameraController?.setVideoStabilizationMode( + VideoStabilizationMode.level1, + ); + } } else { try { if (!isVideoRecording) { 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..c280222 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 @@ -64,41 +64,43 @@ class _UserListItem extends State { _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; + }); + // protectUpdateState.protect(() async { + // await updateState(lastMessage, update, messagesNotOpened); + // }); + }); _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(() {}); + }); } Mutex protectUpdateState = Mutex(); @@ -113,8 +115,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; @@ -145,8 +148,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 +175,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 +195,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 @@ -206,18 +208,18 @@ class _UserListItem extends State { ), subtitle: (_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 +241,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; @@ -253,12 +256,16 @@ class _UserListItem extends State { trailing: (widget.group.leftGroup) ? 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..858e213 100644 --- a/lib/src/views/chats/chat_messages.view.dart +++ b/lib/src/views/chats/chat_messages.view.dart @@ -23,40 +23,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 +35,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 = []; @@ -85,7 +57,6 @@ class _ChatMessagesViewState extends State { @override void initState() { super.initState(); - group = widget.group; textFieldFocus = FocusNode(); initStreams(); } @@ -102,30 +73,34 @@ 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 { @@ -153,8 +128,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 +206,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 +215,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)); } @@ -372,3 +352,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..525836d 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})'; @@ -285,32 +291,37 @@ class _MediaViewerViewState extends State { 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 +445,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/contact/contact.view.dart b/lib/src/views/contact/contact.view.dart index 6b583f4..079c246 100644 --- a/lib/src/views/contact/contact.view.dart +++ b/lib/src/views/contact/contact.view.dart @@ -36,8 +36,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 +46,8 @@ class _ContactViewState extends State { _groupMemberSub = twonlyDB.groupsDao .watchContactGroupMember(widget.userId) .listen((groups) async { - _memberOfGroups = groups; - }); + _memberOfGroups = groups; + }); super.initState(); } @@ -81,8 +82,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 +179,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 +196,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, + ); } }, ), @@ -247,8 +249,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 +261,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 +275,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 +296,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/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/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..d5087c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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. From 337b9567c61c38ddfb852fef5e9c0df6a6600516 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 5 Apr 2026 16:31:25 +0200 Subject: [PATCH 03/15] fix issue with deleted accounts --- CHANGELOG.md | 1 + lib/src/localization/translations | 2 +- lib/src/services/api/mediafiles/upload.service.dart | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5243e7..63d5cdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - New: Developer settings to reduce flames - Improve: Improved troubleshooting for issues with push notifications - Fix: Flash not activated when starting a video recording +- Fix: Problem sending media when a recipient has deleted their account. ## 0.1.1 diff --git a/lib/src/localization/translations b/lib/src/localization/translations index 284c602..425bdd5 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit 284c602b507e77addc8f21c4fc8a321f237cac1b +Subproject commit 425bdd58d02f718fbe5e6ed05e1a1e03aa181725 diff --git a/lib/src/services/api/mediafiles/upload.service.dart b/lib/src/services/api/mediafiles/upload.service.dart index dadccdb..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), From 6cc2ad3a6549132b3fcc2c54978a00c44ec46252 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 5 Apr 2026 21:50:31 +0200 Subject: [PATCH 04/15] fix logging issue for background execution --- .../background/callback_dispatcher.background.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/src/services/background/callback_dispatcher.background.dart b/lib/src/services/background/callback_dispatcher.background.dart index 94980df..0286c11 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,6 +76,7 @@ Future initBackgroundExecution() async { apiService = ApiService(); globalIsInBackgroundTask = true; + _isInitialized = true; return true; } From 2d5f0042223e23724adae96369fc63de98e273e1 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 5 Apr 2026 22:06:08 +0200 Subject: [PATCH 05/15] improve log cleaning --- lib/src/utils/log.dart | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/lib/src/utils/log.dart b/lib/src/utils/log.dart index 09646b1..f40302e 100644 --- a/lib/src/utils/log.dart +++ b/lib/src/utils/log.dart @@ -126,17 +126,34 @@ Future cleanLogFile() async { return _protectFileAccess(() async { final logFile = File('$globalApplicationSupportDirectory/app.log'); - if (logFile.existsSync()) { - final lines = await logFile.readAsLines(); - - if (lines.length <= 100000) return; - - final removeCount = lines.length - 100000; - 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(); }); } From aa26766bdff545e60bd0c8a71059dd17638c6f44 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 5 Apr 2026 22:17:45 +0200 Subject: [PATCH 06/15] fixes duplicated messages from the server --- CHANGELOG.md | 1 + lib/src/services/api.service.dart | 5 +++- lib/src/services/api/server_messages.dart | 30 ++++++++++++++--------- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d5cdb..d84b081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Improve: Improved troubleshooting for issues with push notifications - Fix: Flash not activated when starting a video recording - Fix: Problem sending media when a recipient has deleted their account. +- Fix: Incorrect processing of messages that have already been fetched from the server causes the UI to freeze ## 0.1.1 diff --git a/lib/src/services/api.service.dart b/lib/src/services/api.service.dart index cac8dd2..f54ace1 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -137,7 +137,10 @@ class ApiService { 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/server_messages.dart b/lib/src/services/api/server_messages.dart index 69b767a..75c7f7a 100644 --- a/lib/src/services/api/server_messages.dart +++ b/lib/src/services/api/server_messages.dart @@ -79,14 +79,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 +136,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 +161,10 @@ Future handleClient2ClientMessage(NewMessage newMessage) async { } if (response == null) { - final (encryptedContent, plainTextContent) = - await handleEncryptedMessage( + final ( + encryptedContent, + plainTextContent, + ) = await handleEncryptedMessage( fromUserId, encryptedContentRaw, message.type, @@ -215,7 +223,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage( null, PlaintextContent() ..decryptionErrorMessage = (PlaintextContent_DecryptionErrorMessage() - ..type = decryptionErrorType!) + ..type = decryptionErrorType!), ); } @@ -235,7 +243,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage( return ( null, PlaintextContent() - ..retryControlError = PlaintextContent_RetryErrorMessage() + ..retryControlError = PlaintextContent_RetryErrorMessage(), ); } return (null, null); @@ -312,7 +320,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage( relatedReceiptId: receiptId, ), ), - null + null, ); } Log.info( @@ -333,7 +341,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage( return ( null, PlaintextContent() - ..retryControlError = PlaintextContent_RetryErrorMessage() + ..retryControlError = PlaintextContent_RetryErrorMessage(), ); } @@ -365,7 +373,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage( return ( null, PlaintextContent() - ..retryControlError = PlaintextContent_RetryErrorMessage() + ..retryControlError = PlaintextContent_RetryErrorMessage(), ); } return (null, null); From 267e2bd3764666b5e9213d316d25b7b1f6ce9e04 Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 6 Apr 2026 00:02:55 +0200 Subject: [PATCH 07/15] opening chat if clicked on the notification --- CHANGELOG.md | 1 + ios/Podfile.lock | 67 ++++++++------- lib/src/services/api.service.dart | 2 - lib/src/services/api/messages.dart | 4 +- lib/src/services/api/server_messages.dart | 34 +++++++- lib/src/services/flame.service.dart | 2 +- .../mediafiles/compression.service.dart | 18 ++-- .../background.notifications.dart | 84 ++++++++++++++----- .../notifications/fcm.notifications.dart | 28 +++---- .../notifications/pushkeys.notifications.dart | 16 ++-- lib/src/utils/log.dart | 5 +- lib/src/views/home.view.dart | 33 ++++++-- 12 files changed, 200 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d84b081..0655968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.1.2 - New: Developer settings to reduce flames +- New: Clicking on “Text Notifications” will now open the chat directly (Android only) - Improve: Improved troubleshooting for issues with push notifications - Fix: Flash not activated when starting a video recording - Fix: Problem sending media when a recipient has deleted their account. 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/services/api.service.dart b/lib/src/services/api.service.dart index f54ace1..4b65f32 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -133,9 +133,7 @@ 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; // only try to reconnect in case the app is in the foreground if (!globalIsAppInBackground) { 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 75c7f7a..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'; @@ -164,7 +166,7 @@ Future handleClient2ClientMessage(NewMessage newMessage) async { final ( encryptedContent, plainTextContent, - ) = await handleEncryptedMessage( + ) = await handleEncryptedMessageRaw( fromUserId, encryptedContentRaw, message.type, @@ -182,6 +184,9 @@ Future handleClient2ClientMessage(NewMessage newMessage) async { encryptedContent: encryptedContent.writeToBuffer(), ); receiptIdDB = const Value.absent(); + } else { + // Message was successful processed + // } } @@ -206,19 +211,19 @@ 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() @@ -227,6 +232,27 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage( ); } + 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). diff --git a/lib/src/services/flame.service.dart b/lib/src/services/flame.service.dart index 715fe25..f25a5df 100644 --- a/lib/src/services/flame.service.dart +++ b/lib/src/services/flame.service.dart @@ -176,6 +176,6 @@ bool isItPossibleToRestoreFlames(Group group) { return group.maxFlameCounter > 2 && flameCounter < group.maxFlameCounter && group.maxFlameCounterFrom!.isAfter( - clock.now().subtract(const Duration(days: 5)), + 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..c8989da 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,25 @@ Future showLocalPushNotification( iOS: darwinNotificationDetails, ); + String? payload; + + if (groupId != null && + (pushNotification.kind == PushKind.text || + pushNotification.kind == PushKind.response || + pushNotification.kind == PushKind.reactionToAudio || + 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 +300,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 e7884f7..ce257a7 100644 --- a/lib/src/services/notifications/fcm.notifications.dart +++ b/lib/src/services/notifications/fcm.notifications.dart @@ -110,35 +110,23 @@ Future initFCMService() async { 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()) { + if (isInitialized) { await handlePeriodicTask(); } } else { @@ -164,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 f40302e..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)); 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: [ From 02ae72f6795317b3ecbb3bf93bb17b2ba338abc5 Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 6 Apr 2026 00:24:17 +0200 Subject: [PATCH 08/15] improve chat list view --- .../notifications/background.notifications.dart | 1 + .../chats/chat_list_components/group_list_item.dart | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/src/services/notifications/background.notifications.dart b/lib/src/services/notifications/background.notifications.dart index c8989da..50d7820 100644 --- a/lib/src/services/notifications/background.notifications.dart +++ b/lib/src/services/notifications/background.notifications.dart @@ -209,6 +209,7 @@ Future showLocalPushNotification( (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)) { 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 c280222..e969e85 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 @@ -128,8 +128,14 @@ 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(minutes: 10)), + )) { + _currentMessage = newLastMessage; + _previewMessages = [newLastMessage]; + } } final msgs = _previewMessages From fdb11d1a9b3091bc6985d56b1bb2c98d8ed612ff Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 6 Apr 2026 00:32:25 +0200 Subject: [PATCH 09/15] bump version --- CHANGELOG.md | 2 +- lib/src/views/chats/chat_list_components/group_list_item.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0655968..5cc3423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.1.2 +## 0.1.3 - New: Developer settings to reduce flames - New: Clicking on “Text Notifications” will now open the chat directly (Android only) 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 e969e85..43696bd 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 @@ -131,7 +131,7 @@ class _UserListItem extends State { // 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(minutes: 10)), + widget.group.lastMessageExchange.subtract(const Duration(days: 2)), )) { _currentMessage = newLastMessage; _previewMessages = [newLastMessage]; diff --git a/pubspec.yaml b/pubspec.yaml index d5087c1..bbf74e6 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.2+102 environment: sdk: ^3.11.0 From 8810ecf360fbd121ca1b6fbad5ae24e0755e8e06 Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 6 Apr 2026 01:53:42 +0200 Subject: [PATCH 10/15] lower lastExecutionInSecondsLimit --- CHANGELOG.md | 1 + .../services/background/callback_dispatcher.background.dart | 5 +++-- lib/src/services/notifications/fcm.notifications.dart | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc3423..6cefb55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Improve: Improved troubleshooting for issues with push notifications - 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: Incorrect processing of messages that have already been fetched from the server causes the UI to freeze ## 0.1.1 diff --git a/lib/src/services/background/callback_dispatcher.background.dart b/lib/src/services/background/callback_dispatcher.background.dart index 0286c11..a146026 100644 --- a/lib/src/services/background/callback_dispatcher.background.dart +++ b/lib/src/services/background/callback_dispatcher.background.dart @@ -82,7 +82,7 @@ Future initBackgroundExecution() async { final Mutex _keyValueMutex = Mutex(); -Future handlePeriodicTask() async { +Future handlePeriodicTask({int lastExecutionInSecondsLimit = 120}) async { final shouldBeExecuted = await exclusiveAccess( lockName: 'periodic_task', mutex: _keyValueMutex, @@ -96,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/notifications/fcm.notifications.dart b/lib/src/services/notifications/fcm.notifications.dart index ce257a7..89b70e4 100644 --- a/lib/src/services/notifications/fcm.notifications.dart +++ b/lib/src/services/notifications/fcm.notifications.dart @@ -127,7 +127,7 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { if (Platform.isAndroid) { if (isInitialized) { - await handlePeriodicTask(); + await handlePeriodicTask(lastExecutionInSecondsLimit: 10); } } else { // make sure every thing run... From b7e6cbfc2f41c42248cb0d8edd1571935398ebf9 Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 6 Apr 2026 13:35:36 +0200 Subject: [PATCH 11/15] crop images and small improvements --- CHANGELOG.md | 4 +- lib/src/database/daos/reactions.dao.dart | 63 +++++---- lib/src/model/json/userdata.dart | 4 +- .../main_camera_controller.dart | 3 +- .../views/camera/share_image_editor.view.dart | 125 ++++++++++++------ .../layers/background.layer.dart | 53 ++++++-- .../share_image_editor/layers_viewer.dart | 79 ++++++----- .../chat_list_components/group_list_item.dart | 3 - lib/src/views/chats/media_viewer.view.dart | 10 +- .../components/video_player_wrapper.dart | 7 +- 10 files changed, 227 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cefb55..27eaefd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,10 @@ ## 0.1.3 -- New: Developer settings to reduce flames +- New: Video stabilization +- New: Crop or rotate images before share 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 - Fix: Flash not activated when starting a video recording - Fix: Problem sending media when a recipient has deleted their account. 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/model/json/userdata.dart b/lib/src/model/json/userdata.dart index 8f30a81..40dccb3 100644 --- a/lib/src/model/json/userdata.dart +++ b/lib/src/model/json/userdata.dart @@ -53,8 +53,8 @@ class UserData { @JsonKey(defaultValue: false) bool requestedAudioPermission = false; - @JsonKey(defaultValue: false) - bool videoStabilizationEnabled = false; + @JsonKey(defaultValue: true) + bool videoStabilizationEnabled = true; @JsonKey(defaultValue: true) bool showFeedbackShortcut = true; 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 4fb9b16..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,7 +134,7 @@ class MainCameraController { await cameraController?.initialize(); await cameraController?.startImageStream(_processCameraImage); await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor); - if (gUser.videoStabilizationEnabled) { + if (gUser.videoStabilizationEnabled && !kDebugMode) { await cameraController?.setVideoStabilizationMode( VideoStabilizationMode.level1, ); diff --git a/lib/src/views/camera/share_image_editor.view.dart b/lib/src/views/camera/share_image_editor.view.dart index 4e31b88..162901f 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); @@ -552,8 +588,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 +656,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 +705,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 +720,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 43696bd..863b058 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 @@ -75,9 +75,6 @@ class _UserListItem extends State { setState(() { _lastReaction = update; }); - // protectUpdateState.protect(() async { - // await updateState(lastMessage, update, messagesNotOpened); - // }); }); _messagesNotOpenedStream = twonlyDB.messagesDao diff --git a/lib/src/views/chats/media_viewer.view.dart b/lib/src/views/chats/media_viewer.view.dart index 525836d..fec0a32 100644 --- a/lib/src/views/chats/media_viewer.view.dart +++ b/lib/src/views/chats/media_viewer.view.dart @@ -287,7 +287,15 @@ 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, ); 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 { From 3dcefcbed1d3c98e9f0dacf7ec6ecc4dda9ffd1f Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 6 Apr 2026 14:24:19 +0200 Subject: [PATCH 12/15] make verification badge more visible --- assets/icons/verified_badge_green.svg | 7 ++-- assets/icons/verified_badge_red.svg | 7 ++-- .../generated/app_localizations.dart | 6 +++ .../generated/app_localizations_de.dart | 3 ++ .../generated/app_localizations_en.dart | 3 ++ .../generated/app_localizations_sv.dart | 3 ++ .../chat_list_components/group_list_item.dart | 15 +++++++- lib/src/views/components/verified_shield.dart | 38 +++++++++++-------- lib/src/views/contact/contact.view.dart | 22 +++++------ .../views/settings/help/faq/verifybadge.dart | 20 +++++++++- 10 files changed, 87 insertions(+), 37 deletions(-) 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/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 23758af..5f28518 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -2842,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 32faea1..f6bafbd 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1568,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 3d8ab05..535dcaa 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -1558,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 e4de68c..a41fb8b 100644 --- a/lib/src/localization/generated/app_localizations_sv.dart +++ b/lib/src/localization/generated/app_localizations_sv.dart @@ -1558,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/views/chats/chat_list_components/group_list_item.dart b/lib/src/views/chats/chat_list_components/group_list_item.dart index 863b058..78efe08 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({ @@ -206,8 +207,18 @@ 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) ? (widget.group.totalMediaCounter == 0) 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/contact/contact.view.dart b/lib/src/views/contact/contact.view.dart index 079c246..f0f29b3 100644 --- a/lib/src/views/contact/contact.view.dart +++ b/lib/src/views/contact/contact.view.dart @@ -210,18 +210,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, 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), + ), ], ), ); From 48bfc774c2d67dee55f8b056d1615c96c629f036 Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 6 Apr 2026 14:24:31 +0200 Subject: [PATCH 13/15] show string --- lib/src/localization/translations | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/localization/translations b/lib/src/localization/translations index 425bdd5..662b8dd 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit 425bdd58d02f718fbe5e6ed05e1a1e03aa181725 +Subproject commit 662b8ddafcbf1c789f54c93da51ebb0514ba1f81 From 083faaa876f29d032b6830102a3b26ac78d9bb77 Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 6 Apr 2026 15:36:01 +0200 Subject: [PATCH 14/15] fixes multiple issues --- CHANGELOG.md | 5 ++++- .../views/camera/share_image_editor.view.dart | 4 +--- .../chat_list_components/group_list_item.dart | 19 ++++++++++++++++--- lib/src/views/chats/chat_messages.view.dart | 13 ++++++++++++- lib/src/views/contact/contact.view.dart | 1 - pubspec.yaml | 2 +- 6 files changed, 34 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27eaefd..81e7ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,16 @@ ## 0.1.3 - New: Video stabilization -- New: Crop or rotate images before share them +- 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 diff --git a/lib/src/views/camera/share_image_editor.view.dart b/lib/src/views/camera/share_image_editor.view.dart index 162901f..6f9e96e 100644 --- a/lib/src/views/camera/share_image_editor.view.dart +++ b/lib/src/views/camera/share_image_editor.view.dart @@ -507,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(); } } @@ -516,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(); 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 78efe08..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 @@ -45,6 +45,7 @@ class _UserListItem extends State { List _previewMessages = []; final List _previewMediaFiles = []; bool _hasNonOpenedMediaFile = false; + bool _receiverDeletedAccount = false; @override void initState() { @@ -61,7 +62,7 @@ class _UserListItem extends State { super.dispose(); } - void initStreams() { + Future initStreams() async { _lastMessageStream = twonlyDB.messagesDao .watchLastMessage(widget.group.groupId) .listen((update) { @@ -99,6 +100,13 @@ class _UserListItem extends State { } setState(() {}); }); + + final groupContacts = await twonlyDB.groupsDao.getGroupContact( + widget.group.groupId, + ); + if (groupContacts.length == 1) { + _receiverDeletedAccount = groupContacts.first.accountDeleted; + } } Mutex protectUpdateState = Mutex(); @@ -133,6 +141,9 @@ class _UserListItem extends State { )) { _currentMessage = newLastMessage; _previewMessages = [newLastMessage]; + } else { + _currentMessage = null; + _previewMessages = []; } } @@ -220,7 +231,9 @@ class _UserListItem extends State { ), ], ), - subtitle: (_currentMessage == null) + subtitle: _receiverDeletedAccount + ? Text(context.lang.userDeletedAccount) + : (_currentMessage == null) ? (widget.group.totalMediaCounter == 0) ? Text(context.lang.chatsTapToSend) : Row( @@ -267,7 +280,7 @@ class _UserListItem extends State { }, child: AvatarIcon(group: widget.group), ), - trailing: (widget.group.leftGroup) + trailing: (widget.group.leftGroup || _receiverDeletedAccount) ? null : IconButton( onPressed: () { diff --git a/lib/src/views/chats/chat_messages.view.dart b/lib/src/views/chats/chat_messages.view.dart index 858e213..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'; @@ -53,6 +54,7 @@ class _ChatMessagesViewState extends State { late FocusNode textFieldFocus; final ItemScrollController itemScrollController = ItemScrollController(); int? focusedScrollItem; + bool _receiverDeletedAccount = false; @override void initState() { @@ -107,6 +109,13 @@ class _ChatMessagesViewState extends State { await setMessages(update, groupActions); }); }); + + final groupContacts = await twonlyDB.groupsDao.getGroupContact( + widget.groupId, + ); + if (groupContacts.length == 1) { + _receiverDeletedAccount = groupContacts.first.accountDeleted; + } } Future setMessages( @@ -334,7 +343,7 @@ class _ChatMessagesViewState extends State { ], ), ), - if (!group.leftGroup) + if (!group.leftGroup && !_receiverDeletedAccount) MessageInput( group: group, quotesMessage: quotesMessage, @@ -345,6 +354,8 @@ class _ChatMessagesViewState extends State { }); }, ), + if (_receiverDeletedAccount) + Text(context.lang.userDeletedAccount), ], ), ), diff --git a/lib/src/views/contact/contact.view.dart b/lib/src/views/contact/contact.view.dart index f0f29b3..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'; diff --git a/pubspec.yaml b/pubspec.yaml index bbf74e6..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.2+102 +version: 0.1.3+103 environment: sdk: ^3.11.0 From aa7a065572cdf017fe844f3a9c83292adacd5524 Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 6 Apr 2026 15:42:41 +0200 Subject: [PATCH 15/15] fix test --- test/features/flame_counter_test.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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);