Auto-detect if FCM token does not work and trigger a reset
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-06-05 10:36:36 +02:00
parent 12dce4f52d
commit 3d130cb760
9 changed files with 304 additions and 25 deletions

View file

@ -1,5 +1,11 @@
# Changelog # 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 ## 0.2.26
- New: Import images from the gallery - New: Import images from the gallery

View file

@ -15,6 +15,11 @@ class NotificationService: UNNotificationServiceExtension {
self.contentHandler = contentHandler self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) 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 { if let bestAttemptContent = bestAttemptContent {
guard bestAttemptContent.userInfo as? [String: Any] != nil, guard bestAttemptContent.userInfo as? [String: Any] != nil,
@ -205,6 +210,41 @@ func readFromKeychain(key: String) -> String? {
return nil 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) { func getPushNotificationText(pushNotification: PushNotification, userKnown: Bool) -> (String, String) {
let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" // Get the current system language let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" // Get the current system language

View file

@ -62,5 +62,7 @@ class Routes {
'/settings/developer/automated_testing'; '/settings/developer/automated_testing';
static const String settingsDeveloperReduceFlames = static const String settingsDeveloperReduceFlames =
'/settings/developer/reduce_flames'; '/settings/developer/reduce_flames';
static const String settingsDeveloperInformations =
'/settings/developer/informations';
static const String settingsInvite = '/settings/invite'; static const String settingsInvite = '/settings/invite';
} }

View file

@ -12,4 +12,7 @@ class SecureStorageKeys {
// Not required for backup... // Not required for backup...
static const String receivingPushKeys = 'push_keys_receiving'; static const String receivingPushKeys = 'push_keys_receiving';
static const String sendingPushKeys = 'push_keys_sending'; static const String sendingPushKeys = 'push_keys_sending';
static const String lastFcmMessageTimestamp = 'last_fcm_message_timestamp';
static const String lastServerMessageTimestamp =
'last_server_message_timestamp';
} }

View file

@ -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/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/automated_testing.view.dart';
import 'package:twonly/src/visual/views/settings/developer/developer.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/reduce_flames.view.dart';
import 'package:twonly/src/visual/views/settings/developer/retransmission_data.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'; import 'package:twonly/src/visual/views/settings/help/changelog.view.dart';
@ -288,6 +289,10 @@ final routerProvider = GoRouter(
path: 'automated_testing', path: 'automated_testing',
builder: (context, state) => const AutomatedTestingView(), builder: (context, state) => const AutomatedTestingView(),
), ),
GoRoute(
path: 'informations',
builder: (context, state) => const DeveloperInformationsView(),
),
GoRoute( GoRoute(
path: 'reduce_flames', path: 'reduce_flames',
builder: (context, state) => const ReduceFlamesView(), builder: (context, state) => const ReduceFlamesView(),

View file

@ -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/group.service.dart';
import 'package:twonly/src/services/key_verification.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/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/encryption.signal.dart';
import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/services/signal/session.signal.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
@ -153,7 +154,10 @@ Future<void> _handleClient2ClientMessage(
Log.info( Log.info(
'[$receiptId] Sending error message to the original sender with receiptId $newReceiptId.', '[$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: case Message_Type.CIPHERTEXT:
@ -276,9 +280,15 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessageRaw(
Log.info('[$receiptId] Finished handleEncryptedMessage'); Log.info('[$receiptId] Finished handleEncryptedMessage');
if (Platform.isAndroid && a == null && b == null) { if (a == null && b == null) {
// Message was handled without any error -> Show push notification to the user. unawaited(updateLastServerMessageTimestamp());
await showPushNotificationFromServerMessages(fromUserId, encryptedContent); if (Platform.isAndroid) {
// Message was handled without any error. Show push notification to the user for Android.
await showPushNotificationFromServerMessages(
fromUserId,
encryptedContent,
);
}
} }
return (a, b); return (a, b);

View file

@ -6,9 +6,11 @@ import 'dart:io' show Platform;
import 'package:firebase_app_installations/firebase_app_installations.dart'; import 'package:firebase_app_installations/firebase_app_installations.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.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:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.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/background/callback_dispatcher.background.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart'; import 'package:twonly/src/services/notifications/setup.notifications.dart';
@ -107,6 +109,7 @@ Future<void> initFCMService() async {
); );
unawaited(checkForTokenUpdates()); unawaited(checkForTokenUpdates());
unawaited(checkFcmHealthAndResetIfNeeded());
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
@ -133,6 +136,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
} }
Future<void> handleRemoteMessage(RemoteMessage message) async { Future<void> handleRemoteMessage(RemoteMessage message) async {
await updateLastFcmMessageTimestamp();
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
Log.error('Got message in Dart while on iOS'); Log.error('Got message in Dart while on iOS');
} }
@ -157,3 +161,100 @@ Future<void> handleRemoteMessage(RemoteMessage message) async {
// await handlePushData(message.data['push_data'] as String); // await handlePushData(message.data['push_data'] as String);
// } // }
} }
Future<void> 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<void> 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<void> 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');
}
}

View file

@ -297,8 +297,8 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
), ),
), ),
ListTile( ListTile(
title: const Text('User ID'), title: const Text('Informations'),
subtitle: Text(userService.currentUser.userId.toString()), onTap: () => context.push(Routes.settingsDeveloperInformations),
), ),
ListTile( ListTile(
title: const Text('Show Retransmission Database'), title: const Text('Show Retransmission Database'),
@ -343,24 +343,6 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
onChanged: (a) => toggleVideoStabilization(), 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( ListTile(
title: const Text('Reduce flames'), title: const Text('Reduce flames'),
onTap: () => context.push(Routes.settingsDeveloperReduceFlames), onTap: () => context.push(Routes.settingsDeveloperReduceFlames),
@ -400,7 +382,9 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
? const SizedBox( ? const SizedBox(
width: 24, width: 24,
height: 24, height: 24,
child: CircularProgressIndicator.adaptive(strokeWidth: 2), child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
) )
: null, : null,
onTap: _isGeneratingMockImages onTap: _isGeneratingMockImages
@ -415,6 +399,27 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
}); });
}, },
), ),
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,
);
}
},
),
], ],
); );
}, },

View file

@ -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<DeveloperInformationsView> createState() =>
_DeveloperInformationsViewState();
}
class _DeveloperInformationsViewState extends State<DeveloperInformationsView> {
String? _lastFcmTimestamp;
String? _lastServerTimestamp;
@override
void initState() {
super.initState();
_loadInformations();
}
Future<void> _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)),
),
],
),
);
}
}