From 3d130cb7603b183320feee7ced9978f2841a4de1 Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 5 Jun 2026 10:36:36 +0200 Subject: [PATCH] Auto-detect if FCM token does not work and trigger a reset --- CHANGELOG.md | 6 + .../NotificationService.swift | 40 +++++++ lib/src/constants/routes.keys.dart | 2 + lib/src/constants/secure_storage.keys.dart | 3 + lib/src/providers/routing.provider.dart | 5 + lib/src/services/api/server_messages.api.dart | 18 ++- .../notifications/fcm.notifications.dart | 101 +++++++++++++++++ .../settings/developer/developer.view.dart | 47 ++++---- .../settings/developer/informations.view.dart | 107 ++++++++++++++++++ 9 files changed, 304 insertions(+), 25 deletions(-) create mode 100644 lib/src/visual/views/settings/developer/informations.view.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index bc2ef533..fd9e1fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.28 + +- Improved: Design of some UI components +- Improved: Memories viewer shows state for batch operations and has improved performance +- Fix: Auto-detect if FCM token does not work and trigger a reset + ## 0.2.26 - New: Import images from the gallery diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index 5ec11854..124932a9 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -15,6 +15,11 @@ class NotificationService: UNNotificationServiceExtension { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + // Store the current timestamp in Keychain for iOS FCM messaging tracking + let nowMs = String(format: "%.0f", Date().timeIntervalSince1970 * 1000) + writeToKeychain(key: "last_fcm_message_timestamp", value: nowMs) + NSLog("Received APNs push notification, updated last_fcm_message_timestamp to \(nowMs)") + if let bestAttemptContent = bestAttemptContent { guard bestAttemptContent.userInfo as? [String: Any] != nil, @@ -205,6 +210,41 @@ func readFromKeychain(key: String) -> String? { return nil } +// Helper function to write to Keychain +func writeToKeychain(key: String, value: String) { + guard let data = value.data(using: .utf8) else { + NSLog("Failed to convert value to data for keychain key: \(key)") + return + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: "CN332ZUGRP.eu.twonly.shared", + ] + + let attributesToUpdate: [String: Any] = [ + kSecValueData as String: data + ] + + let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) + + if status == errSecItemNotFound { + var addQuery = query + addQuery[kSecValueData as String] = data + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + if addStatus != errSecSuccess { + NSLog("Failed to add keychain item: \(addStatus)") + } else { + NSLog("Successfully added keychain item for key: \(key)") + } + } else if status != errSecSuccess { + NSLog("Failed to update keychain item: \(status)") + } else { + NSLog("Successfully updated keychain item for key: \(key)") + } +} + func getPushNotificationText(pushNotification: PushNotification, userKnown: Bool) -> (String, String) { let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" // Get the current system language diff --git a/lib/src/constants/routes.keys.dart b/lib/src/constants/routes.keys.dart index 86804303..017745f0 100644 --- a/lib/src/constants/routes.keys.dart +++ b/lib/src/constants/routes.keys.dart @@ -62,5 +62,7 @@ class Routes { '/settings/developer/automated_testing'; static const String settingsDeveloperReduceFlames = '/settings/developer/reduce_flames'; + static const String settingsDeveloperInformations = + '/settings/developer/informations'; static const String settingsInvite = '/settings/invite'; } diff --git a/lib/src/constants/secure_storage.keys.dart b/lib/src/constants/secure_storage.keys.dart index 37aebba0..d8020454 100644 --- a/lib/src/constants/secure_storage.keys.dart +++ b/lib/src/constants/secure_storage.keys.dart @@ -12,4 +12,7 @@ class SecureStorageKeys { // Not required for backup... static const String receivingPushKeys = 'push_keys_receiving'; static const String sendingPushKeys = 'push_keys_sending'; + static const String lastFcmMessageTimestamp = 'last_fcm_message_timestamp'; + static const String lastServerMessageTimestamp = + 'last_server_message_timestamp'; } diff --git a/lib/src/providers/routing.provider.dart b/lib/src/providers/routing.provider.dart index a807edd2..55b3ef1e 100644 --- a/lib/src/providers/routing.provider.dart +++ b/lib/src/providers/routing.provider.dart @@ -26,6 +26,7 @@ import 'package:twonly/src/visual/views/settings/data_and_storage/import_from_ga import 'package:twonly/src/visual/views/settings/data_and_storage/manage_storage.view.dart'; import 'package:twonly/src/visual/views/settings/developer/automated_testing.view.dart'; import 'package:twonly/src/visual/views/settings/developer/developer.view.dart'; +import 'package:twonly/src/visual/views/settings/developer/informations.view.dart'; import 'package:twonly/src/visual/views/settings/developer/reduce_flames.view.dart'; import 'package:twonly/src/visual/views/settings/developer/retransmission_data.view.dart'; import 'package:twonly/src/visual/views/settings/help/changelog.view.dart'; @@ -288,6 +289,10 @@ final routerProvider = GoRouter( path: 'automated_testing', builder: (context, state) => const AutomatedTestingView(), ), + GoRoute( + path: 'informations', + builder: (context, state) => const DeveloperInformationsView(), + ), GoRoute( path: 'reduce_flames', builder: (context, state) => const ReduceFlamesView(), diff --git a/lib/src/services/api/server_messages.api.dart b/lib/src/services/api/server_messages.api.dart index a16b2028..b1833390 100644 --- a/lib/src/services/api/server_messages.api.dart +++ b/lib/src/services/api/server_messages.api.dart @@ -31,6 +31,7 @@ import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/group.service.dart'; import 'package:twonly/src/services/key_verification.service.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; +import 'package:twonly/src/services/notifications/fcm.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'; @@ -153,7 +154,10 @@ Future _handleClient2ClientMessage( Log.info( '[$receiptId] Sending error message to the original sender with receiptId $newReceiptId.', ); - await tryToSendCompleteMessage(receiptId: newReceiptId, blocking: false); + await tryToSendCompleteMessage( + receiptId: newReceiptId, + blocking: false, + ); } case Message_Type.CIPHERTEXT: @@ -276,9 +280,15 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessageRaw( Log.info('[$receiptId] Finished handleEncryptedMessage'); - if (Platform.isAndroid && a == null && b == null) { - // Message was handled without any error -> Show push notification to the user. - await showPushNotificationFromServerMessages(fromUserId, encryptedContent); + if (a == null && b == null) { + unawaited(updateLastServerMessageTimestamp()); + if (Platform.isAndroid) { + // Message was handled without any error. Show push notification to the user for Android. + await showPushNotificationFromServerMessages( + fromUserId, + encryptedContent, + ); + } } return (a, b); diff --git a/lib/src/services/notifications/fcm.notifications.dart b/lib/src/services/notifications/fcm.notifications.dart index f7059ea1..fce869b5 100644 --- a/lib/src/services/notifications/fcm.notifications.dart +++ b/lib/src/services/notifications/fcm.notifications.dart @@ -6,9 +6,11 @@ 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'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; +import 'package:twonly/src/constants/secure_storage.keys.dart'; import 'package:twonly/src/services/background/callback_dispatcher.background.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/services/notifications/setup.notifications.dart'; @@ -107,6 +109,7 @@ Future initFCMService() async { ); unawaited(checkForTokenUpdates()); + unawaited(checkFcmHealthAndResetIfNeeded()); FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); @@ -133,6 +136,7 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { } Future handleRemoteMessage(RemoteMessage message) async { + await updateLastFcmMessageTimestamp(); if (!Platform.isAndroid) { Log.error('Got message in Dart while on iOS'); } @@ -157,3 +161,100 @@ Future handleRemoteMessage(RemoteMessage message) async { // await handlePushData(message.data['push_data'] as String); // } } + +Future updateLastFcmMessageTimestamp() async { + const storage = FlutterSecureStorage(); + final nowMs = DateTime.now().millisecondsSinceEpoch.toString(); + try { + await storage.write( + key: SecureStorageKeys.lastFcmMessageTimestamp, + value: nowMs, + iOptions: const IOSOptions( + groupId: 'CN332ZUGRP.eu.twonly.shared', + accessibility: KeychainAccessibility.first_unlock, + ), + ); + Log.info('Updated last FCM message timestamp to $nowMs'); + } catch (e) { + Log.error('Could not write last FCM message timestamp: $e'); + } +} + +Future updateLastServerMessageTimestamp() async { + const storage = FlutterSecureStorage(); + final nowMs = DateTime.now().millisecondsSinceEpoch.toString(); + try { + await storage.write( + key: SecureStorageKeys.lastServerMessageTimestamp, + value: nowMs, + iOptions: const IOSOptions( + groupId: 'CN332ZUGRP.eu.twonly.shared', + accessibility: KeychainAccessibility.first_unlock, + ), + ); + Log.info('Updated last server message timestamp to $nowMs'); + } catch (e) { + Log.error('Could not write last server message timestamp: $e'); + } +} + +Future checkFcmHealthAndResetIfNeeded() async { + if (!userService.isUserCreated) return; + const storage = FlutterSecureStorage(); + try { + final lastFcmStr = await storage.read( + key: SecureStorageKeys.lastFcmMessageTimestamp, + iOptions: const IOSOptions( + groupId: 'CN332ZUGRP.eu.twonly.shared', + accessibility: KeychainAccessibility.first_unlock, + ), + ); + final lastServerStr = await storage.read( + key: SecureStorageKeys.lastServerMessageTimestamp, + iOptions: const IOSOptions( + groupId: 'CN332ZUGRP.eu.twonly.shared', + accessibility: KeychainAccessibility.first_unlock, + ), + ); + + final now = DateTime.now(); + final threeDaysAgo = now.subtract(const Duration(days: 3)); + + DateTime? lastFcmTime; + if (lastFcmStr != null) { + final ms = int.tryParse(lastFcmStr); + if (ms != null) { + lastFcmTime = DateTime.fromMillisecondsSinceEpoch(ms); + } + } + + if (lastFcmTime != null) { + Log.info('Last message received via FCM messaging system: $lastFcmTime'); + } else { + Log.info('No record of a message received via FCM messaging system.'); + } + + DateTime? lastServerTime; + if (lastServerStr != null) { + final ms = int.tryParse(lastServerStr); + if (ms != null) { + lastServerTime = DateTime.fromMillisecondsSinceEpoch(ms); + } + } + + // Check conditions: + // 1. No messages received via FCM in the last 3 days (either null or older than 3 days) + final fcmInactive = lastFcmTime == null || lastFcmTime.isBefore(threeDaysAgo); + // 2. Server message received within the last 3 days + final serverActive = lastServerTime != null && lastServerTime.isAfter(threeDaysAgo); + + if (fcmInactive && serverActive) { + Log.warn('FCM has been inactive for >3 days, but server messages have been active. Resetting FCM tokens...'); + await resetFCMTokens(); + } else { + Log.info('FCM check passed. No reset needed.'); + } + } catch (e) { + Log.error('Error during FCM health check: $e'); + } +} diff --git a/lib/src/visual/views/settings/developer/developer.view.dart b/lib/src/visual/views/settings/developer/developer.view.dart index 7926d295..bc6d203b 100644 --- a/lib/src/visual/views/settings/developer/developer.view.dart +++ b/lib/src/visual/views/settings/developer/developer.view.dart @@ -297,8 +297,8 @@ class _DeveloperSettingsViewState extends State { ), ), ListTile( - title: const Text('User ID'), - subtitle: Text(userService.currentUser.userId.toString()), + title: const Text('Informations'), + onTap: () => context.push(Routes.settingsDeveloperInformations), ), ListTile( title: const Text('Show Retransmission Database'), @@ -343,24 +343,6 @@ class _DeveloperSettingsViewState extends State { onChanged: (a) => toggleVideoStabilization(), ), ), - ListTile( - title: const Text('Delete all (!) app data'), - onTap: () async { - final ok = await showAlertDialog( - context, - 'Sure?', - 'If you do not have a backup, you have to register with a new account.', - ); - if (ok) { - await deleteLocalUserData(); - await Restart.restartApp( - notificationTitle: 'Account successfully deleted', - notificationBody: 'Click here to open the app again', - forceKill: true, - ); - } - }, - ), ListTile( title: const Text('Reduce flames'), onTap: () => context.push(Routes.settingsDeveloperReduceFlames), @@ -400,7 +382,9 @@ class _DeveloperSettingsViewState extends State { ? const SizedBox( width: 24, height: 24, - child: CircularProgressIndicator.adaptive(strokeWidth: 2), + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), ) : null, onTap: _isGeneratingMockImages @@ -415,6 +399,27 @@ class _DeveloperSettingsViewState extends State { }); }, ), + ListTile( + title: const Text( + 'Delete all app data', + style: TextStyle(color: Colors.red), + ), + onTap: () async { + final ok = await showAlertDialog( + context, + 'Sure?', + 'If you do not have a backup, you have to register with a new account.', + ); + if (ok) { + await deleteLocalUserData(); + await Restart.restartApp( + notificationTitle: 'Account successfully deleted', + notificationBody: 'Click here to open the app again', + forceKill: true, + ); + } + }, + ), ], ); }, diff --git a/lib/src/visual/views/settings/developer/informations.view.dart b/lib/src/visual/views/settings/developer/informations.view.dart new file mode 100644 index 00000000..6faaa7ec --- /dev/null +++ b/lib/src/visual/views/settings/developer/informations.view.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/constants/secure_storage.keys.dart'; +import 'package:twonly/src/visual/components/snackbar.dart'; + +class DeveloperInformationsView extends StatefulWidget { + const DeveloperInformationsView({super.key}); + + @override + State createState() => + _DeveloperInformationsViewState(); +} + +class _DeveloperInformationsViewState extends State { + String? _lastFcmTimestamp; + String? _lastServerTimestamp; + + @override + void initState() { + super.initState(); + _loadInformations(); + } + + Future _loadInformations({bool showFeedback = false}) async { + const storage = FlutterSecureStorage(); + try { + final lastFcm = await storage.read( + key: SecureStorageKeys.lastFcmMessageTimestamp, + iOptions: const IOSOptions( + groupId: 'CN332ZUGRP.eu.twonly.shared', + accessibility: KeychainAccessibility.first_unlock, + ), + ); + final lastServer = await storage.read( + key: SecureStorageKeys.lastServerMessageTimestamp, + iOptions: const IOSOptions( + groupId: 'CN332ZUGRP.eu.twonly.shared', + accessibility: KeychainAccessibility.first_unlock, + ), + ); + if (mounted) { + setState(() { + _lastFcmTimestamp = lastFcm; + _lastServerTimestamp = lastServer; + }); + if (showFeedback) { + showSnackbar( + context, + 'Developer information loaded', + level: SnackbarLevel.success, + ); + } + } + } catch (_) {} + } + + String _formatTimestamp(String? timestampStr) { + if (timestampStr == null) return 'Never'; + final ms = int.tryParse(timestampStr); + if (ms == null) return 'Invalid: $timestampStr'; + final dt = DateTime.fromMillisecondsSinceEpoch(ms); + return dt.toLocal().toString(); + } + + @override + Widget build(BuildContext context) { + final userId = userService.currentUser.userId.toString(); + + return Scaffold( + appBar: AppBar( + title: const Text('Informations'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => _loadInformations(showFeedback: true), + ), + ], + ), + body: ListView( + children: [ + ListTile( + title: const Text('User ID'), + subtitle: Text(userId), + trailing: IconButton( + icon: const Icon(Icons.copy), + onPressed: () { + Clipboard.setData(ClipboardData(text: userId)); + showSnackbar(context, 'User ID copied to clipboard'); + }, + ), + ), + const Divider(), + ListTile( + title: const Text('Last FCM Message'), + subtitle: Text(_formatTimestamp(_lastFcmTimestamp)), + ), + ListTile( + title: const Text('Last Server Message'), + subtitle: Text(_formatTimestamp(_lastServerTimestamp)), + ), + ], + ), + ); + } +}