mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-15 11:22:12 +00:00
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
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
12dce4f52d
commit
3d130cb760
9 changed files with 304 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<void> _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);
|
||||
|
|
|
|||
|
|
@ -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<void> initFCMService() async {
|
|||
);
|
||||
|
||||
unawaited(checkForTokenUpdates());
|
||||
unawaited(checkFcmHealthAndResetIfNeeded());
|
||||
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||
|
||||
|
|
@ -133,6 +136,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
|||
}
|
||||
|
||||
Future<void> handleRemoteMessage(RemoteMessage message) async {
|
||||
await updateLastFcmMessageTimestamp();
|
||||
if (!Platform.isAndroid) {
|
||||
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);
|
||||
// }
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,8 +297,8 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
|||
),
|
||||
),
|
||||
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<DeveloperSettingsView> {
|
|||
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<DeveloperSettingsView> {
|
|||
? 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<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,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
|||
107
lib/src/visual/views/settings/developer/informations.view.dart
Normal file
107
lib/src/visual/views/settings/developer/informations.view.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue