From 85dbac37fbb1f505fe16902f2d3ba6f5406f6dc8 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 6 Apr 2025 15:38:15 +0200 Subject: [PATCH] new push system does work on android #53 --- lib/main.dart | 1 + lib/src/database/tables/messages_table.dart | 3 +- lib/src/json_models/message.dart | 23 + lib/src/localization/app_de.arb | 4 + lib/src/localization/app_en.arb | 8 + lib/src/providers/api/api.dart | 30 +- lib/src/providers/api/media.dart | 2 + lib/src/providers/api/server_messages.dart | 20 +- lib/src/providers/api_provider.dart | 7 +- lib/src/services/fcm_service.dart | 41 +- lib/src/services/notification_service.dart | 498 +++++++++++++++--- .../views/chats/chat_item_details_view.dart | 1 + lib/src/views/chats/media_viewer_view.dart | 2 + lib/src/views/chats/search_username_view.dart | 3 + lib/src/views/settings/notification_view.dart | 64 +++ .../views/settings/settings_main_view.dart | 16 +- 16 files changed, 601 insertions(+), 122 deletions(-) create mode 100644 lib/src/views/settings/notification_view.dart diff --git a/lib/main.dart b/lib/main.dart index fedb5eb..cb50f2f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -41,6 +41,7 @@ void main() async { apiProvider = ApiProvider(); twonlyDatabase = TwonlyDatabase(); + setupNotificationWithUsers(); runApp( MultiProvider( diff --git a/lib/src/database/tables/messages_table.dart b/lib/src/database/tables/messages_table.dart index 4751132..0595746 100644 --- a/lib/src/database/tables/messages_table.dart +++ b/lib/src/database/tables/messages_table.dart @@ -11,8 +11,7 @@ enum MessageKind { acceptRequest, opened, ack, - pushKey, - pushKeyAck, + pushKey } enum DownloadState { diff --git a/lib/src/json_models/message.dart b/lib/src/json_models/message.dart index 77eb1a3..6a741ba 100644 --- a/lib/src/json_models/message.dart +++ b/lib/src/json_models/message.dart @@ -88,6 +88,8 @@ class MessageContent { return ProfileContent.fromJson(json); case MessageKind.storedMediaFile: return StoredMediaFileContent.fromJson(json); + case MessageKind.pushKey: + return PushKeyContent.fromJson(json); default: return null; } @@ -203,3 +205,24 @@ class ProfileContent extends MessageContent { return {'avatarSvg': avatarSvg, 'displayName': displayName}; } } + +class PushKeyContent extends MessageContent { + int keyId; + List key; + PushKeyContent({required this.keyId, required this.key}); + + static PushKeyContent fromJson(Map json) { + return PushKeyContent( + keyId: json['keyId'], + key: List.from(json['key']), + ); + } + + @override + Map toJson() { + return { + 'keyId': keyId, + 'key': key, + }; + } +} diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb index 66ea768..3bcb182 100644 --- a/lib/src/localization/app_de.arb +++ b/lib/src/localization/app_de.arb @@ -65,6 +65,10 @@ "settingsPrivacyBlockUsersDesc": "Blockierte Benutzer können nicht mit dir kommunizieren. Du kannst einen blockierten Benutzer jederzeit wieder entsperren.", "settingsPrivacyBlockUsersCount": "{len} Kontakt(e)", "settingsNotification": "Benachrichtigung", + "settingsNotifyTroubleshooting": "Fehlersuche", + "settingsNotifyTroubleshootingDesc": "Hier klicken, wenn Probleme beim Empfang von Push-Benachrichtigungen auftreten.", + "settingsNotifyTroubleshootingNoProblem": "Kein Problem festgestellt", + "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.", "settingsHelp": "Hilfe", "settingsHelpSupport": "Support-Center", "settingsHelpDiagnostics": "Diagnoseprotokoll", diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index 113ef39..6d1f65f 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -138,6 +138,14 @@ }, "settingsNotification": "Notification", "@settingsNotification": {}, + "settingsNotifyTroubleshooting": "Troubleshooting", + "@settingsNotifyTroubleshooting": {}, + "settingsNotifyTroubleshootingDesc": "Click here if you have problems receiving push notifications.", + "@settingsNotifyTroubleshootingDesc": {}, + "settingsNotifyTroubleshootingNoProblem": "No problem detected", + "@settingsNotifyTroubleshootingNoProblem": {}, + "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.", + "@settingsNotifyTroubleshootingNoProblemDesc": {}, "settingsHelp": "Help", "@settingsHelp": {}, "settingsHelpDiagnostics": "Diagnostic protocol", diff --git a/lib/src/providers/api/api.dart b/lib/src/providers/api/api.dart index 8bfb752..d312d40 100644 --- a/lib/src/providers/api/api.dart +++ b/lib/src/providers/api/api.dart @@ -11,6 +11,7 @@ import 'package:twonly/src/json_models/userdata.dart'; import 'package:twonly/src/proto/api/error.pb.dart'; import 'package:twonly/src/providers/api/api_utils.dart'; import 'package:twonly/src/providers/hive.dart'; +import 'package:twonly/src/services/notification_service.dart'; // ignore: library_prefixes import 'package:twonly/src/utils/signal.dart' as SignalHelper; import 'package:twonly/src/utils/storage.dart'; @@ -31,7 +32,9 @@ Future tryTransmitMessages() async { Result resp = await apiProvider.sendTextMessage( msg.userId, msg.bytes, + msg.pushData, ); + if (resp.isSuccess) { if (msg.messageId != null) { await twonlyDatabase.messagesDao.updateMessageByMessageId( @@ -53,8 +56,13 @@ class RetransmitMessage { int? messageId; int userId; Uint8List bytes; - RetransmitMessage( - {this.messageId, required this.userId, required this.bytes}); + List? pushData; + RetransmitMessage({ + this.messageId, + required this.userId, + required this.bytes, + this.pushData, + }); // From JSON constructor factory RetransmitMessage.fromJson(Map json) { @@ -62,6 +70,7 @@ class RetransmitMessage { messageId: json['messageId'], userId: json['userId'], bytes: base64Decode(json['bytes']), + pushData: json['pushData'], ); } @@ -71,6 +80,7 @@ class RetransmitMessage { 'messageId': messageId, 'userId': userId, 'bytes': base64Encode(bytes), + 'pushData': pushData, }; } } @@ -88,7 +98,8 @@ Future> getAllMessagesForRetransmitting() async { // this functions ensures that the message is received by the server and in case of errors will try again later Future encryptAndSendMessage( - int? messageId, int userId, MessageJson msg) async { + int? messageId, int userId, MessageJson msg, + {PushKind? pushKind}) async { Uint8List? bytes = await SignalHelper.encryptMessage(msg, userId); if (bytes == null) { @@ -99,6 +110,11 @@ Future encryptAndSendMessage( String stateId = (messageId ?? (60001 + Random().nextInt(100000))).toString(); Box box = await getMediaStorage(); + List? pushData; + if (pushKind != null) { + pushData = await getPushData(userId, pushKind); + } + { var retransmit = await getAllMessagesForRetransmitting(); @@ -106,12 +122,13 @@ Future encryptAndSendMessage( messageId: messageId, userId: userId, bytes: bytes, + pushData: pushData, ).toJson()); box.put("messages-to-retransmit", jsonEncode(retransmit)); } - Result resp = await apiProvider.sendTextMessage(userId, bytes); + Result resp = await apiProvider.sendTextMessage(userId, bytes, pushData); if (resp.isSuccess) { if (messageId != null) { @@ -133,7 +150,8 @@ Future encryptAndSendMessage( return resp; } -Future sendTextMessage(int target, TextMessageContent content) async { +Future sendTextMessage( + int target, TextMessageContent content, PushKind? pushKind) async { DateTime messageSendAt = DateTime.now(); int? messageId = await twonlyDatabase.messagesDao.insertMessage( @@ -158,7 +176,7 @@ Future sendTextMessage(int target, TextMessageContent content) async { timestamp: messageSendAt, ); - encryptAndSendMessage(messageId, target, msg); + encryptAndSendMessage(messageId, target, msg, pushKind: pushKind); } Future notifyContactAboutOpeningMessage( diff --git a/lib/src/providers/api/media.dart b/lib/src/providers/api/media.dart index 3ecf1a9..c827e86 100644 --- a/lib/src/providers/api/media.dart +++ b/lib/src/providers/api/media.dart @@ -12,6 +12,7 @@ import 'package:twonly/src/proto/api/server_to_client.pb.dart'; import 'package:twonly/src/providers/api/api.dart'; import 'package:twonly/src/providers/api/api_utils.dart'; import 'package:twonly/src/providers/hive.dart'; +import 'package:twonly/src/services/notification_service.dart'; import 'package:twonly/src/utils/misc.dart'; Future tryDownloadAllMediaFiles() async { @@ -280,6 +281,7 @@ class ImageUploader { ), timestamp: metadata.messageSendAt, ), + pushKind: PushKind.image, ); } } diff --git a/lib/src/providers/api/server_messages.dart b/lib/src/providers/api/server_messages.dart index abfe0fd..234a88a 100644 --- a/lib/src/providers/api/server_messages.dart +++ b/lib/src/providers/api/server_messages.dart @@ -190,7 +190,6 @@ Future handleNewMessage(int fromUserId, Uint8List body) async { case MessageKind.acceptRequest: final update = ContactsCompanion(accepted: Value(true)); await twonlyDatabase.contactsDao.updateContact(fromUserId, update); - localPushNotificationNewMessage(fromUserId.toInt(), message, 8888888); notifyContactsAboutProfileChange(); break; @@ -214,6 +213,14 @@ Future handleNewMessage(int fromUserId, Uint8List body) async { ); break; + case MessageKind.pushKey: + if (message.content != null) { + final pushKey = message.content!; + if (pushKey is PushKeyContent) { + await handleNewPushKey(fromUserId, pushKey); + } + } + default: if (message.kind != MessageKind.textMessage && message.kind != MessageKind.media && @@ -299,7 +306,6 @@ Future handleNewMessage(int fromUserId, Uint8List body) async { } } } - localPushNotificationNewMessage(fromUserId, message, messageId); } } var ok = client.Response_Ok()..none = true; @@ -328,21 +334,13 @@ Future handleContactRequest( if (username.isSuccess) { Uint8List name = username.value.userdata.username; - int added = await twonlyDatabase.contactsDao.insertContact( + await twonlyDatabase.contactsDao.insertContact( ContactsCompanion( username: Value(utf8.decode(name)), userId: Value(fromUserId), requested: Value(true), ), ); - - if (added > 0) { - localPushNotificationNewMessage( - fromUserId, - message, - 999999, - ); - } } var ok = client.Response_Ok()..none = true; return client.Response()..ok = ok; diff --git a/lib/src/providers/api_provider.dart b/lib/src/providers/api_provider.dart index c36d1ad..f85700e 100644 --- a/lib/src/providers/api_provider.dart +++ b/lib/src/providers/api_provider.dart @@ -385,11 +385,16 @@ class ApiProvider { return await sendRequestSync(req); } - Future sendTextMessage(int target, Uint8List msg) async { + Future sendTextMessage( + int target, Uint8List msg, List? pushData) async { var testMessage = ApplicationData_TextMessage() ..userId = Int64(target) ..body = msg; + if (pushData != null) { + testMessage.pushData = pushData; + } + var appData = ApplicationData()..textmessage = testMessage; var req = createClientToServerFromApplicationData(appData); diff --git a/lib/src/services/fcm_service.dart b/lib/src/services/fcm_service.dart index 1665620..5f700c3 100644 --- a/lib/src/services/fcm_service.dart +++ b/lib/src/services/fcm_service.dart @@ -3,8 +3,7 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:logging/logging.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/app.dart'; -import 'package:twonly/src/database/twonly_database.dart'; -import 'package:twonly/src/providers/api_provider.dart'; +import 'package:twonly/src/services/notification_service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'dart:io' show Platform; import '../../firebase_options.dart'; @@ -70,38 +69,38 @@ Future initFCMService() async { } } - // APNS token is available, make FCM plugin API requests... - FirebaseMessaging.onMessage.listen((RemoteMessage message) { - print('Got a message whilst in the foreground!'); + if (!Platform.isAndroid) { + Logger("firebase-notification").shout("Got message in Dart while on iOS"); + } + + Logger("firebase-notification") + .finer('Got a message while in the foreground!'); + print('Message data: ${message.data}'); if (message.notification != null) { print('Message also contained a notification: ${message.notification}'); + String title = message.notification!.title ?? ""; + String body = message.notification!.body ?? ""; + customLocalPushNotification(title, body); + } + if (message.data["push_data"] != null) { + handlePushData(message.data["push_data"]); } }); } @pragma('vm:entry-point') Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { - // Wenn Tasks länger als 30 Sekunden ausgeführt werden, wird der Prozess möglicherweise automatisch vom Gerät beendet. - // -> offer backend via http? - Logger("firebase-background") .shout('Handling a background message: ${message.messageId}'); - twonlyDatabase = TwonlyDatabase(); - - apiProvider = ApiProvider(); - await apiProvider.connect(); - - final stopwatch = Stopwatch()..start(); - while (true) { - if (stopwatch.elapsed >= Duration(seconds: 20)) { - Logger("firebase-background").shout('Exiting background handler'); - break; - } - await Future.delayed(Duration(milliseconds: 10)); + if (!Platform.isAndroid) { + Logger("firebase-notification").shout("Got message in Dart while on iOS"); } - await apiProvider.close(() {}); + + Logger("firebase-notification") + .finer('Got a message while in the background!'); + print('Message data: ${message.data}'); } diff --git a/lib/src/services/notification_service.dart b/lib/src/services/notification_service.dart index 97d22f9..a05a86f 100644 --- a/lib/src/services/notification_service.dart +++ b/lib/src/services/notification_service.dart @@ -1,13 +1,349 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'dart:math'; +import 'dart:ui' as ui; +import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/daos/contacts_dao.dart'; import 'package:twonly/src/database/tables/messages_table.dart'; import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/json_models/message.dart' as my; +import 'package:twonly/src/providers/api/api.dart'; +import 'package:twonly/src/utils/misc.dart'; + +class PushUser { + String displayName; + List keys; + + PushUser({ + required this.displayName, + required this.keys, + }); + + // Factory method to create a User from JSON + factory PushUser.fromJson(Map json) { + return PushUser( + displayName: json['displayName'], + keys: (json['keys'] as List) + .map((keyJson) => PushKeyMeta.fromJson(keyJson)) + .toList(), + ); + } + + // Method to convert User to JSON + Map toJson() { + return { + 'displayName': displayName, + 'keys': keys.map((key) => key.toJson()).toList(), + }; + } +} + +class PushKeyMeta { + int id; + List key; + DateTime createdAt; + + PushKeyMeta({ + required this.id, + required this.key, + required this.createdAt, + }); + + // Factory method to create Keys from JSON + factory PushKeyMeta.fromJson(Map json) { + return PushKeyMeta( + id: json['id'], + key: List.from(json['key']), + createdAt: DateTime.fromMillisecondsSinceEpoch(json['createdAt']), + ); + } + + // Method to convert Keys to JSON + Map toJson() { + return { + 'id': id, + 'key': key, + 'createdAt': createdAt.millisecondsSinceEpoch, // Store as timestamp + }; + } +} + +/// This function must be called after the database is setup +Future setupNotificationWithUsers({bool force = false}) async { + var pushKeys = await getPushKeys("receivingPushKeys"); + + var wasChanged = false; + + final random = Random.secure(); + + final contacts = await twonlyDatabase.contactsDao.getAllNotBlockedContacts(); + for (final contact in contacts) { + if (pushKeys.containsKey(contact.userId)) { + // make it harder to predict the change of the key + final timeBefore = + DateTime.now().subtract(Duration(days: 5 + random.nextInt(5))); + final lastKey = pushKeys[contact.userId]!.keys.last; + if (force || lastKey.createdAt.isBefore(timeBefore)) { + final pushKey = PushKeyMeta( + id: lastKey.id + 1, + key: List.generate(32, (index) => random.nextInt(256)), + createdAt: DateTime.now(), + ); + sendNewPushKey(contact.userId, pushKey); + pushKeys[contact.userId]!.keys.add(pushKey); + pushKeys[contact.userId]!.displayName = getContactDisplayName(contact); + wasChanged = true; + } + } else { + /// Insert a new pushuser + final pushKey = PushKeyMeta( + id: 1, + key: List.generate(32, (index) => random.nextInt(256)), + createdAt: DateTime.now(), + ); + sendNewPushKey(contact.userId, pushKey); + final pushUser = PushUser( + displayName: getContactDisplayName(contact), + keys: [pushKey], + ); + pushKeys[contact.userId] = pushUser; + wasChanged = true; + } + } + + if (wasChanged) { + await setPushKeys("receivingPushKeys", pushKeys); + } +} + +Future sendNewPushKey(int userId, PushKeyMeta pushKey) async { + await encryptAndSendMessage( + null, + userId, + my.MessageJson( + kind: MessageKind.pushKey, + content: my.PushKeyContent(keyId: pushKey.id, key: pushKey.key), + timestamp: pushKey.createdAt, + ), + ); +} + +Future handleNewPushKey(int fromUserId, my.PushKeyContent pushKey) async { + var pushKeys = await getPushKeys("sendingPushKeys"); + + if (pushKeys[fromUserId] == null) { + pushKeys[fromUserId] = PushUser(displayName: "-", keys: []); + } + + // only store the newest key... + pushKeys[fromUserId]!.keys = [ + PushKeyMeta( + id: pushKey.keyId, + key: pushKey.key, + createdAt: DateTime.now(), + ), + ]; + + await setPushKeys("sendingPushKeys", pushKeys); +} + +enum PushKind { + reaction, + text, + video, + twonly, + image, + contactRequest, + acceptRequest, + storedMediaFile, + testNotification +} + +extension PushKindExtension on PushKind { + String get name => toString().split('.').last; + + static PushKind fromString(String name) { + return PushKind.values.firstWhere((e) => e.name == name); + } +} + +class PushNotification { + final int keyId; + final List nonce; + final List cipherText; + final List mac; + + PushNotification({ + required this.keyId, + required this.nonce, + required this.cipherText, + required this.mac, + }); + + // Convert a PushNotification instance to a Map + Map toJson() { + return { + 'keyId': keyId, + 'nonce': base64Encode(nonce), + 'cipherText': base64Encode(cipherText), + 'mac': base64Encode(mac), + }; + } + + // Create a PushNotification instance from a Map + factory PushNotification.fromJson(Map json) { + return PushNotification( + keyId: json['keyId'], + nonce: base64Decode(json['nonce']), + cipherText: base64Decode(json['cipherText']), + mac: base64Decode(json['mac']), + ); + } +} + +/// this will trigger a push notification +/// push notification only containing the message kind and username +Future?> getPushData(int toUserId, PushKind kind) async { + final Map pushKeys = await getPushKeys("sendingPushKeys"); + + List key = "InsecureOnlyUsedForAddingContact".codeUnits; + int keyId = 0; + + if (pushKeys[toUserId] == null) { + // user does not have send any push keys + // only allow accept request and contactrequest to be send in an insecure way :/ + // In future find a better way, e.g. use the signal protocol in a native way.. + if (kind != PushKind.acceptRequest && + kind != PushKind.contactRequest && + kind != PushKind.testNotification) { + // this will be enforced after every app uses this system... :/ + // return null; + Logger("notification_service").shout( + "Using insecure key as the receiver does not send a push key!"); + } + } else { + try { + key = pushKeys[toUserId]!.keys.last.key; + keyId = pushKeys[toUserId]!.keys.last.id; + } catch (e) { + Logger("notification_service") + .shout("No push notification key found for user $toUserId"); + return null; + } + } + + final chacha20 = Chacha20.poly1305Aead(); + final nonce = chacha20.newNonce(); + final secretBox = await chacha20.encrypt( + kind.name.codeUnits, + secretKey: SecretKeyData(key), + nonce: nonce, + ); + final res = PushNotification( + keyId: keyId, + nonce: nonce, + cipherText: secretBox.cipherText, + mac: secretBox.mac.bytes, + ); + + return jsonEncode(res.toJson()).codeUnits; +} + +Future tryDecryptMessage( + List key, PushNotification noti) async { + try { + final chacha20 = Chacha20.poly1305Aead(); + SecretKeyData secretKeyData = SecretKeyData(key); + + SecretBox secretBox = SecretBox( + noti.cipherText, + nonce: noti.nonce, + mac: Mac(noti.mac), + ); + + final plaintext = + await chacha20.decrypt(secretBox, secretKey: secretKeyData); + final plaintextString = utf8.decode(plaintext); + return PushKindExtension.fromString(plaintextString); + } catch (e) { + Logger("notification-service").shout(e); + return null; + } +} + +Future handlePushData(String pushDataJson) async { + try { + String jsonString = utf8.decode(base64.decode(pushDataJson)); + final pushData = PushNotification.fromJson(jsonDecode(jsonString)); + + PushKind? pushKind; + int? fromUserId; + + if (pushData.keyId == 0) { + List key = "InsecureOnlyUsedForAddingContact".codeUnits; + pushKind = await tryDecryptMessage(key, pushData); + } else { + var pushKeys = await getPushKeys("receivingPushKeys"); + for (final userId in pushKeys.keys) { + for (final key in pushKeys[userId]!.keys) { + if (key.id == pushData.keyId) { + pushKind = await tryDecryptMessage(key.key, pushData); + if (pushKind != null) { + fromUserId = userId; + break; + } + } + } + // found correct key and user + if (fromUserId != null) break; + } + } + + if (pushKind != null) { + if (pushKind == PushKind.testNotification) { + customLocalPushNotification( + "Test notification", "This is a test notification."); + } else if (fromUserId != null) { + showLocalPushNotification(fromUserId, pushKind); + } + } + } catch (e) { + Logger("notification-service").shout(e); + } +} + +Future> getPushKeys(String storageKey) async { + var storage = getSecureStorage(); + String? pushKeysJson = await storage.read(key: storageKey); + Map pushKeys = {}; + if (pushKeysJson != null) { + Map jsonMap = jsonDecode(pushKeysJson); + jsonMap.forEach((key, value) { + pushKeys[int.parse(key)] = PushUser.fromJson(value); + }); + } + print("read: $storageKey: $pushKeys"); + return pushKeys; +} + +Future setPushKeys(String storageKey, Map pushKeys) async { + var storage = getSecureStorage(); + Map jsonToSend = {}; + pushKeys.forEach((key, value) { + jsonToSend[key.toString()] = value.toJson(); + }); + + String jsonString = jsonEncode(jsonToSend); + print("write: $storageKey: $pushKeys"); + await storage.write(key: storageKey, value: jsonString); +} /// Streams are created so that app can respond to notification-related events /// since the plugin is initialized in the `main` function @@ -96,102 +432,81 @@ Future setupPushNotification() async { ); } -String getPushNotificationText(String key, String userName) { - String systemLanguage = Platform.localeName; +Future getAvatarIcon(Contact user) async { + if (user.avatarSvg == null) return null; - Map pushNotificationText; + final PictureInfo pictureInfo = + await vg.loadPicture(SvgStringLoader(user.avatarSvg!), null); - if (systemLanguage.contains("de")) { - pushNotificationText = { - "newTextMessage": "%userName% hat dir eine Nachricht gesendet.", - "newTwonly": "%userName% hat dir ein twonly gesendet.", - "newVideo": "%userName% hat dir ein Video gesendet.", - "newImage": "%userName% hat dir ein Bild gesendet.", - "contactRequest": "%userName% möchte sich mir dir vernetzen.", - "acceptRequest": "%userName% ist jetzt mit dir vernetzt.", - "storedMediaFile": "%userName% hat dein Bild gespeichert." - }; - } else { - pushNotificationText = { - "newTextMessage": "%userName% has sent you a message.", - "newTwonly": "%userName% has sent you a twonly.", - "newVideo": "%userName% has sent you a video.", - "newImage": "%userName% has sent you an image.", - "contactRequest": "%userName% wants to connect with you.", - "acceptRequest": "%userName% is now connected with you.", - "storedMediaFile": "%userName% has stored your image." - }; + final ui.Image image = await pictureInfo.picture.toImage(300, 300); + + final ByteData? byteData = + await image.toByteData(format: ui.ImageByteFormat.png); + final Uint8List pngBytes = byteData!.buffer.asUint8List(); + + // Get the directory to save the image + final directory = await getApplicationDocumentsDirectory(); + final avatarsDirectory = Directory('${directory.path}/avatars'); + + // Create the avatars directory if it does not exist + if (!await avatarsDirectory.exists()) { + await avatarsDirectory.create(recursive: true); } - // Replace %userName% with the actual user name - return pushNotificationText[key]?.replaceAll("%userName%", userName) ?? ""; + final filePath = '${avatarsDirectory.path}/${user.userId}.png'; + final file = File(filePath); + await file.writeAsBytes(pngBytes); + + pictureInfo.picture.dispose(); + + return filePath; } -Future localPushNotificationNewMessage( - int fromUserId, my.MessageJson message, int messageId) async { +Future showLocalPushNotification( + int fromUserId, + PushKind pushKind, +) async { + String? title; + String? body; + Contact? user = await twonlyDatabase.contactsDao .getContactByUserId(fromUserId) .getSingleOrNull(); if (user == null) return; - String msg = ""; - - final content = message.content; - - if (content is my.TextMessageContent) { - msg = - getPushNotificationText("newTextMessage", getContactDisplayName(user)); - } else if (content is my.MediaMessageContent) { - if (content.isRealTwonly) { - msg = getPushNotificationText("newTwonly", getContactDisplayName(user)); - } else if (content.isVideo) { - msg = getPushNotificationText("newVideo", getContactDisplayName(user)); - } else { - msg = getPushNotificationText("newImage", getContactDisplayName(user)); - } - } - - if (message.kind == MessageKind.contactRequest) { - msg = - getPushNotificationText("contactRequest", getContactDisplayName(user)); - } - - if (message.kind == MessageKind.acceptRequest) { - msg = getPushNotificationText("acceptRequest", getContactDisplayName(user)); - } - - if (message.kind == MessageKind.storedMediaFile) { - msg = - getPushNotificationText("storedMediaFile", getContactDisplayName(user)); - } - - if (msg == "") { + title = getContactDisplayName(user); + body = getPushNotificationText(pushKind); + if (body == "") { Logger("localPushNotificationNewMessage") .shout("No push notification type defined!"); } - const AndroidNotificationDetails androidNotificationDetails = - AndroidNotificationDetails( - '0', - 'Messages', - channelDescription: 'Messages from other users.', - importance: Importance.max, - priority: Priority.max, - ticker: 'You got a new message.', - ); + FilePathAndroidBitmap? styleInformation; + String? avatarPath = await getAvatarIcon(user); + if (avatarPath != null) { + styleInformation = FilePathAndroidBitmap(avatarPath); + } + + AndroidNotificationDetails androidNotificationDetails = + AndroidNotificationDetails('0', 'Messages', + channelDescription: 'Messages from other users.', + importance: Importance.max, + priority: Priority.max, + ticker: 'You got a new message.', + largeIcon: styleInformation); const DarwinNotificationDetails darwinNotificationDetails = DarwinNotificationDetails(); - const NotificationDetails notificationDetails = NotificationDetails( + NotificationDetails notificationDetails = NotificationDetails( android: androidNotificationDetails, iOS: darwinNotificationDetails); await flutterLocalNotificationsPlugin.show( - messageId, - getContactDisplayName(user), - msg, + fromUserId, + title, + body, notificationDetails, - payload: message.kind.index.toString(), + payload: pushKind.name, ); } @@ -199,8 +514,8 @@ Future customLocalPushNotification(String title, String msg) async { const AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( '1', - 'Error', - channelDescription: 'Error messages.', + 'System', + channelDescription: 'System messages.', importance: Importance.max, priority: Priority.max, ); @@ -211,9 +526,40 @@ Future customLocalPushNotification(String title, String msg) async { android: androidNotificationDetails, iOS: darwinNotificationDetails); await flutterLocalNotificationsPlugin.show( - 897898, + 999999 + Random.secure().nextInt(9999), title, msg, notificationDetails, ); } + +String getPushNotificationText(PushKind pushKind) { + String systemLanguage = Platform.localeName; + + Map pushNotificationText; + + if (systemLanguage.contains("de")) { + pushNotificationText = { + PushKind.text.name: "hat dir eine Nachricht gesendet.", + PushKind.twonly.name: "hat dir ein twonly gesendet.", + PushKind.video.name: "hat dir ein Video gesendet.", + PushKind.image.name: "hat dir ein Bild gesendet.", + PushKind.contactRequest.name: "möchte sich mir dir vernetzen.", + PushKind.acceptRequest.name: "ist jetzt mit dir vernetzt.", + PushKind.storedMediaFile.name: "hat dein Bild gespeichert.", + PushKind.reaction.name: "hat auf dein Bild reagiert." + }; + } else { + pushNotificationText = { + PushKind.text.name: "has sent you a message.", + PushKind.twonly.name: "has sent you a twonly.", + PushKind.video.name: "has sent you a video.", + PushKind.image.name: "has sent you an image.", + PushKind.contactRequest.name: "wants to connect with you.", + PushKind.acceptRequest.name: "is now connected with you.", + PushKind.storedMediaFile.name: "has stored your image.", + PushKind.reaction.name: "has reacted to your image." + }; + } + return pushNotificationText[pushKind.name] ?? ""; +} diff --git a/lib/src/views/chats/chat_item_details_view.dart b/lib/src/views/chats/chat_item_details_view.dart index b9ac131..47c3179 100644 --- a/lib/src/views/chats/chat_item_details_view.dart +++ b/lib/src/views/chats/chat_item_details_view.dart @@ -280,6 +280,7 @@ class _ChatItemDetailsViewState extends State { TextMessageContent( text: newMessageController.text, ), + PushKind.text, ); newMessageController.clear(); currentInputText = ""; diff --git a/lib/src/views/chats/media_viewer_view.dart b/lib/src/views/chats/media_viewer_view.dart index e63e91a..734e79a 100644 --- a/lib/src/views/chats/media_viewer_view.dart +++ b/lib/src/views/chats/media_viewer_view.dart @@ -323,6 +323,7 @@ class _MediaViewerViewState extends State { responseToMessageId: allMediaFiles.first.messageOtherId, ), + PushKind.reaction, ); setState(() { selectedShortReaction = index; @@ -393,6 +394,7 @@ class _MediaViewerViewState extends State { ), timestamp: DateTime.now(), ), + pushKind: PushKind.storedMediaFile, ); final res = await saveImageToGallery(imageBytes!); if (res == null) { diff --git a/lib/src/views/chats/search_username_view.dart b/lib/src/views/chats/search_username_view.dart index 90551b6..bc5cc82 100644 --- a/lib/src/views/chats/search_username_view.dart +++ b/lib/src/views/chats/search_username_view.dart @@ -5,6 +5,7 @@ import 'package:twonly/src/components/alert_dialog.dart'; import 'package:twonly/src/database/daos/contacts_dao.dart'; import 'package:twonly/src/database/tables/messages_table.dart'; import 'package:twonly/src/database/twonly_database.dart'; +import 'package:twonly/src/services/notification_service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/components/headline.dart'; @@ -67,6 +68,7 @@ class _SearchUsernameView extends State { timestamp: DateTime.now(), content: MessageContent(), ), + pushKind: PushKind.contactRequest, ); } } @@ -238,6 +240,7 @@ class _ContactsListViewState extends State { timestamp: DateTime.now(), content: MessageContent(), ), + pushKind: PushKind.acceptRequest, ); notifyContactsAboutProfileChange(); }, diff --git a/lib/src/views/settings/notification_view.dart b/lib/src/views/settings/notification_view.dart new file mode 100644 index 0000000..6aedcbd --- /dev/null +++ b/lib/src/views/settings/notification_view.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/components/alert_dialog.dart'; +import 'package:twonly/src/services/fcm_service.dart'; +import 'package:twonly/src/services/notification_service.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/utils/storage.dart'; + +class NotificationView extends StatelessWidget { + const NotificationView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.lang.settingsNotification), + ), + body: ListView( + children: [ + ListTile( + title: Text(context.lang.settingsNotifyTroubleshooting), + subtitle: Text(context.lang.settingsNotifyTroubleshootingDesc), + onTap: () async { + await initFCMAfterAuthenticated(); + final storage = getSecureStorage(); + String? storedToken = await storage.read(key: "google_fcm"); + //await setupNotificationWithUsers(force: true); + if (!context.mounted) return; + + if (storedToken == null) { + final platform = Platform.isAndroid ? "Google's" : "Apple's"; + showAlertDialog(context, "Problem detected", + "twonly is not able to register your app to $platform push server infrastrukture. 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 getPushData( + user.userId, + PushKind.testNotification, + ); + await apiProvider.sendTextMessage( + user.userId, + Uint8List(0), + pushData, + ); + } + } + } + }, + ), + ], + ), + ); + } +} diff --git a/lib/src/views/settings/settings_main_view.dart b/lib/src/views/settings/settings_main_view.dart index 17b8680..987e889 100644 --- a/lib/src/views/settings/settings_main_view.dart +++ b/lib/src/views/settings/settings_main_view.dart @@ -7,6 +7,7 @@ import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/settings/account_view.dart'; import 'package:twonly/src/views/settings/appearance_view.dart'; +import 'package:twonly/src/views/settings/notification_view.dart'; import 'package:twonly/src/views/settings/profile_view.dart'; import 'package:twonly/src/views/settings/help_view.dart'; import 'package:twonly/src/views/settings/privacy_view.dart'; @@ -129,11 +130,16 @@ class _SettingsMainViewState extends State { })); }, ), - // BetterListTile( - // icon: FontAwesomeIcons.bell, - // text: context.lang.settingsNotification, - // onTap: () async {}, - // ), + BetterListTile( + icon: FontAwesomeIcons.bell, + text: context.lang.settingsNotification, + onTap: () async { + Navigator.push(context, + MaterialPageRoute(builder: (context) { + return NotificationView(); + })); + }, + ), const Divider(), BetterListTile( icon: FontAwesomeIcons.circleQuestion,