mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-15 12:54:08 +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
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
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