From 1962c70431cb20431a3d5df30cde319fd8c222a9 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 6 Apr 2025 19:19:39 +0200 Subject: [PATCH] ios push notification should work now #53 --- .../NotificationService.entitlements | 10 + .../NotificationService.swift | 347 +++++++++++++++--- ios/Runner.xcodeproj/project.pbxproj | 5 +- ios/Runner/AppDelegate.swift | 96 +---- ios/Runner/Runner.entitlements | 4 + .../components/message_send_state_icon.dart | 10 +- lib/src/services/notification_service.dart | 16 +- lib/src/views/chats/chat_list_view.dart | 50 +-- pubspec.lock | 7 +- pubspec.yaml | 7 +- 10 files changed, 376 insertions(+), 176 deletions(-) create mode 100644 ios/NotificationService/NotificationService.entitlements diff --git a/ios/NotificationService/NotificationService.entitlements b/ios/NotificationService/NotificationService.entitlements new file mode 100644 index 0000000..78e4d59 --- /dev/null +++ b/ios/NotificationService/NotificationService.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)eu.twonly.shared + + + diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index e515fb4..83cfd55 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -18,64 +18,20 @@ class NotificationService: UNNotificationServiceExtension { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - - if let bestAttemptContent = bestAttemptContent { - - - // Extract the ciphertext and nonce from the notification's userInfo + guard let _userInfo = bestAttemptContent.userInfo as? [String: Any], - let ciphertextString = bestAttemptContent.userInfo["ciphertext"] as? String, - let nonceString = bestAttemptContent.userInfo["nonce"] as? String else { - return + let push_data = bestAttemptContent.userInfo["push_data"] as? String else { + return contentHandler(bestAttemptContent); } - // Convert the base64 encoded strings to Data - guard let ciphertextData = Data(base64Encoded: ciphertextString), - let nonceData = Data(base64Encoded: nonceString) else { - return - } + let data = getPushNotificationData(pushDataJson: push_data) - // Create the key (32 bytes of "A") - let keyString = String(repeating: "A", count: 32) - guard let keyData = keyString.data(using: .utf8) else { - return - } - - // Ensure the key is 32 bytes - guard keyData.count == 32 else { - return - } - - // Ensure the ciphertext is more than 16 Bytes - guard ciphertextData.count >= 16 else { - return - } - - // Split the ciphertextData into the actual ciphertext and the tag - let tagLength = 16 - let ciphertext = ciphertextData.prefix(ciphertextData.count - tagLength) - let tag = ciphertextData.suffix(tagLength) - - // Create a SymmetricKey from the key data - let key = SymmetricKey(data: keyData) - - // Decrypt the ciphertext using ChaCha20 - do { - let nonce = try ChaChaPoly.Nonce(data: nonceData) - let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: Data(tag)) - let decryptedData = try ChaChaPoly.open(sealedBox, using: key) - - // Convert decrypted data to a string - if let decryptedMessage = String(data: decryptedData, encoding: .utf8) { - NSLog("Decrypted message: \(decryptedMessage)") - - bestAttemptContent.title = "\(bestAttemptContent.title)" - bestAttemptContent.body = decryptedMessage - - } - } catch { - NSLog("Decryption failed: \(error)") + if data != nil { + bestAttemptContent.title = data!.title; + bestAttemptContent.body = data!.body; + } else { + bestAttemptContent.title = "\(bestAttemptContent.title) [failed to decrypt]" } contentHandler(bestAttemptContent) @@ -91,3 +47,288 @@ class NotificationService: UNNotificationServiceExtension { } } + + +enum PushKind: String, Codable { + case text + case twonly + case video + case image + case contactRequest + case acceptRequest + case storedMediaFile + case reaction + case testNotification +} + +import CryptoKit +import Foundation +import Security + +func getPushNotificationData(pushDataJson: String) -> (title: String, body: String)? { + // Decode the pushDataJson + guard let pushData = decodePushData(pushDataJson) else { + NSLog("Failed to decode push data") + return nil + } + + var pushKind: PushKind? + var displayName: String? + + // Check the keyId + if pushData.keyId == 0 { + let key = "InsecureOnlyUsedForAddingContact".data(using: .utf8)!.map { Int($0) } + pushKind = tryDecryptMessage(key: key, pushData: pushData) + } else { + let pushKeys = getPushKey() + if pushKeys != nil { + for (userId, userKeys) in pushKeys! { + for key in userKeys.keys { + if key.id == pushData.keyId { + pushKind = tryDecryptMessage(key: key.key, pushData: pushData) + if pushKind != nil { + displayName = userKeys.displayName + break + } + } + } + // Found correct key and user + if displayName != nil { break } + } + } else { + NSLog("pushKeys are empty") + } + } + + // Handle the push notification based on the pushKind + if let pushKind = pushKind { + + let bestAttemptContent = UNMutableNotificationContent() + + if pushKind == .testNotification { + return ("Test Notification", "This is a test notification.") + } else if displayName != nil { + return (displayName!, getPushNotificationText(pushKind: pushKind)) + } + + } else { + NSLog("Failed to decrypt message or pushKind is nil") + } + return nil +} + +func tryDecryptMessage(key: [Int], pushData: PushNotification) -> PushKind? { + // Convert the key from [Int] to Data + let keyData = Data(key.map { UInt8($0) }) // Convert Int to UInt8 + + guard let nonceData = Data(base64Encoded: pushData.nonce), + let cipherTextData = Data(base64Encoded: pushData.cipherText), + let macData = Data(base64Encoded: pushData.mac) else { + NSLog("Failed to decode base64 strings") + return nil + } + + do { + // Create a nonce for ChaChaPoly + let nonce = try ChaChaPoly.Nonce(data: nonceData) + + // Create a sealed box for ChaChaPoly + let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: cipherTextData, tag: macData) + + // Decrypt the data using the key + let decryptedData = try ChaChaPoly.open(sealedBox, using: SymmetricKey(data: keyData)) + + // Convert decrypted data to a string + if let decryptedMessage = String(data: decryptedData, encoding: .utf8) { + NSLog("Decrypted message: \(decryptedMessage)") + + // Here you can determine the PushKind based on the decrypted message + return determinePushKind(from: decryptedMessage) + } + } catch { + NSLog("Decryption failed: \(error)") + } + + return nil +} + +// Placeholder function to determine PushKind from the decrypted message +func determinePushKind(from message: String) -> PushKind? { + // Implement your logic to determine the PushKind based on the message content + // For example, you might check for specific keywords or formats in the message + // This is just a placeholder implementation + if message.contains("text") { + return .text + } else if message.contains("video") { + return .video + } else if message.contains("image") { + return .image + } else if message.contains("twonly") { + return .twonly + } else if message.contains("contactRequest") { + return .contactRequest + } else if message.contains("acceptRequest") { + return .acceptRequest + } else if message.contains("storedMediaFile") { + return .storedMediaFile + } else if message.contains("reaction") { + return .reaction + } else if message.contains("testNotification") { + return .testNotification + } else { + return nil // Unknown PushKind + } +} + + +func decodePushData(_ json: String) -> PushNotification? { + // First, decode the base64 string + guard let base64Data = Data(base64Encoded: json) else { + NSLog("Failed to decode base64 string") + return nil + } + + // Convert the base64 decoded data to a JSON string + guard let jsonString = String(data: base64Data, encoding: .utf8) else { + NSLog("Failed to convert base64 data to JSON string") + return nil + } + + // Convert the JSON string to Data + guard let jsonData = jsonString.data(using: .utf8) else { + NSLog("Failed to convert JSON string to Data") + return nil + } + + do { + // Use JSONDecoder to decode the JSON data into a PushNotification instance + let decoder = JSONDecoder() + let pushNotification = try decoder.decode(PushNotification.self, from: jsonData) + return pushNotification + } catch { + NSLog("Error decoding JSON: \(error)") + return nil + } +} + +struct PushNotification: Codable { + let keyId: Int + let nonce: String + let cipherText: String + let mac: String + + // You can add custom coding keys if the JSON keys differ from the property names + enum CodingKeys: String, CodingKey { + case keyId + case nonce + case cipherText + case mac + } +} + +struct PushKeyMeta: Codable { + let id: Int + let key: [Int] + let createdAt: Date + + enum CodingKeys: String, CodingKey { + case id + case key + case createdAt + } +} + +struct PushUser: Codable { + let displayName: String + let keys: [PushKeyMeta] + + enum CodingKeys: String, CodingKey { + case displayName + case keys + } +} + +func getPushKey() -> [Int: PushUser]? { + // Retrieve the data from secure storage (Keychain) + guard let data = readFromKeychain(key: "receivingPushKeys") else { + print("No data found for key: receivingPushKeys") + return nil + } + + do { + // Decode the JSON data into a dictionary + let jsonData = data.data(using: .utf8)! + let jsonMap = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] + + var pushKeys: [Int: PushUser] = [:] + + // Iterate through the JSON map and decode each PushUser + for (key, value) in jsonMap ?? [:] { + if let userData = try? JSONSerialization.data(withJSONObject: value, options: []), + let pushUser = try? JSONDecoder().decode(PushUser.self, from: userData) { + pushKeys[Int(key)!] = pushUser + } + } + + return pushKeys + } catch { + print("Error decoding JSON: \(error)") + return nil + } +} + +// Helper function to read from Keychain +func readFromKeychain(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecAttrAccessGroup as String: "CN332ZUGRP.eu.twonly.shared" // Use your access group + ] + + var dataTypeRef: AnyObject? = nil + let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + if status == errSecSuccess { + if let data = dataTypeRef as? Data { + return String(data: data, encoding: .utf8) + } + } + + return nil +} + +func getPushNotificationText(pushKind: PushKind) -> String { + let systemLanguage = Locale.current.languageCode ?? "en" // Get the current system language + + var pushNotificationText: [PushKind: String] = [:] + + // Define the messages based on the system language + if systemLanguage.contains("de") { // German + pushNotificationText = [ + .text: "hat dir eine Nachricht gesendet.", + .twonly: "hat dir ein twonly gesendet.", + .video: "hat dir ein Video gesendet.", + .image: "hat dir ein Bild gesendet.", + .contactRequest: "möchte sich mit dir vernetzen.", + .acceptRequest: "ist jetzt mit dir vernetzt.", + .storedMediaFile: "hat dein Bild gespeichert.", + .reaction: "hat auf dein Bild reagiert." + ] + } else { // Default to English + pushNotificationText = [ + .text: "has sent you a message.", + .twonly: "has sent you a twonly.", + .video: "has sent you a video.", + .image: "has sent you an image.", + .contactRequest: "wants to connect with you.", + .acceptRequest: "is now connected with you.", + .storedMediaFile: "has stored your image.", + .reaction: "has reacted to your image." + ] + } + + // Return the corresponding message or an empty string if not found + return pushNotificationText[pushKind] ?? "" +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d336b8e..933446c 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -878,6 +878,7 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = CN332ZUGRP; @@ -918,6 +919,7 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = CN332ZUGRP; @@ -955,6 +957,7 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = CN332ZUGRP; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 4aa4d70..1b12b3f 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -33,86 +33,24 @@ import Foundation override func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { NSLog("userNotificationCenter:willPresent") + + /* + debugging NotificationService + let pushKeys = getPushKey(); + print(pushKeys) + + let bestAttemptContent = notification.request.content + + guard let _userInfo = bestAttemptContent.userInfo as? [String: Any], + let push_data = bestAttemptContent.userInfo["push_data"] as? String else { + return completionHandler([.alert, .sound]) + } + + let data = getPushNotificationData(pushDataJson: push_data) + print(data) + */ + completionHandler([.alert, .sound]) } - -} - - -import Security -import CommonCrypto -import Foundation -import CryptoKit - -class KeychainHelper { - static func save(key: String, data: Data) -> OSStatus { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecValueData as String: data - ] - SecItemDelete(query as CFDictionary) // Delete any existing item - return SecItemAdd(query as CFDictionary, nil) - } - - static func load(key: String) -> Data? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecReturnData as String: kCFBooleanTrue!, - kSecMatchLimit as String: kSecMatchLimitOne - ] - - var dataTypeRef: AnyObject? = nil - let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) - - if status == errSecSuccess { - return dataTypeRef as? Data - } else { - return nil - } - } -} - -// Function to decrypt data -func decrypt(data: Data, key: SymmetricKey) -> Data? { - do { - // Extract nonce, ciphertext, and tag - let nonceData = data.prefix(12) // ChaCha20-Poly1305 nonce is 12 bytes - let ciphertext = data.dropFirst(12).dropLast(16) // Exclude nonce and tag - let tag = data.suffix(16) // Last 16 bytes are the tag - - // Create a nonce from the extracted nonce data - let nonce = try ChaChaPoly.Nonce(data: nonceData) - - // Create a sealed box with the ciphertext and tag - let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag) - - // Decrypt the data - let decryptedData = try ChaChaPoly.open(sealedBox, using: key) - return decryptedData - } catch { - print("Decryption error: \(error)") - return nil - } -} - - -func handleReceivedMessage(encryptedMessage: Data, keyID: String) { - // Load the AES key from Keychain - guard let keyData = KeychainHelper.load(key: keyID) else { - print("Key not found") - return - } - - let key = SymmetricKey(data: keyData); - - // Decrypt the message - if let decryptedData = decrypt(data: encryptedMessage, key: key) { - let decryptedMessage = String(data: decryptedData, encoding: .utf8) - print("Decrypted message: \(decryptedMessage ?? "nil")") - } else { - print("Decryption failed") - } } diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 903def2..d2149cc 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -4,5 +4,9 @@ aps-environment development + keychain-access-groups + + $(AppIdentifierPrefix)eu.twonly.shared + diff --git a/lib/src/components/message_send_state_icon.dart b/lib/src/components/message_send_state_icon.dart index 98a0c76..f1a1e62 100644 --- a/lib/src/components/message_send_state_icon.dart +++ b/lib/src/components/message_send_state_icon.dart @@ -169,9 +169,13 @@ class _MessageSendStateIconState extends State { children: [ // First icon (bottom icon) icons[0], - Positioned( - top: 5.0, - left: 5.0, + + Transform( + transform: Matrix4.identity() + ..scale(0.7) // Scale to half + ..translate(3.0, 5.0), + // Move down by 10 pixels (adjust as needed) + alignment: Alignment.center, child: icons[1], ), // Second icon (top icon, slightly offset) diff --git a/lib/src/services/notification_service.dart b/lib/src/services/notification_service.dart index a05a86f..1631961 100644 --- a/lib/src/services/notification_service.dart +++ b/lib/src/services/notification_service.dart @@ -6,6 +6,7 @@ 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_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_svg/svg.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; @@ -321,7 +322,13 @@ Future handlePushData(String pushDataJson) async { Future> getPushKeys(String storageKey) async { var storage = getSecureStorage(); - String? pushKeysJson = await storage.read(key: storageKey); + String? pushKeysJson = await storage.read( + key: storageKey, + iOptions: IOSOptions( + groupId: "CN332ZUGRP.eu.twonly.shared", + synchronizable: false, + ), + ); Map pushKeys = {}; if (pushKeysJson != null) { Map jsonMap = jsonDecode(pushKeysJson); @@ -342,7 +349,12 @@ Future setPushKeys(String storageKey, Map pushKeys) async { String jsonString = jsonEncode(jsonToSend); print("write: $storageKey: $pushKeys"); - await storage.write(key: storageKey, value: jsonString); + await storage.write( + key: storageKey, + value: jsonString, + iOptions: IOSOptions( + groupId: "CN332ZUGRP.eu.twonly.shared", synchronizable: false), + ); } /// Streams are created so that app can respond to notification-related events diff --git a/lib/src/views/chats/chat_list_view.dart b/lib/src/views/chats/chat_list_view.dart index 9e41087..15d7e36 100644 --- a/lib/src/views/chats/chat_list_view.dart +++ b/lib/src/views/chats/chat_list_view.dart @@ -200,7 +200,7 @@ class _UserListItem extends State { previewMessages = []; } else if (newMessagesNotOpened.isEmpty) { // there are no not opened messages show just the last message in the table - currentMessage = newLastMessages.first; + currentMessage = newLastMessages.last; previewMessages = newLastMessages; } else { // filter first for received messages @@ -208,27 +208,11 @@ class _UserListItem extends State { newMessagesNotOpened.where((x) => x.messageOtherId != null).toList(); if (receivedMessages.isNotEmpty) { - // There are received messages - final mediaMessages = - receivedMessages.where((x) => x.kind == MessageKind.media); - - if (mediaMessages.isNotEmpty) { - currentMessage = mediaMessages.first; - } else { - currentMessage = receivedMessages.first; - } previewMessages = receivedMessages; + currentMessage = receivedMessages.first; } else { - // The not opened message was send - final mediaMessages = - newMessagesNotOpened.where((x) => x.kind == MessageKind.media); - - if (mediaMessages.isNotEmpty) { - currentMessage = mediaMessages.first; - } else { - currentMessage = newMessagesNotOpened.first; - } - previewMessages = [currentMessage!]; + previewMessages = newMessagesNotOpened; + currentMessage = newMessagesNotOpened.first; } } @@ -299,18 +283,20 @@ class _UserListItem extends State { globalUpdateOfHomeViewPageIndex(0); return; } - Message msg = currentMessage!; - if (msg.kind == MessageKind.media && - msg.messageOtherId != null && - msg.openedAt == null) { - switch (msg.downloadState) { + List msgs = previewMessages + .where((x) => x.kind == MessageKind.media) + .toList(); + if (msgs.isNotEmpty && + msgs.first.kind == MessageKind.media && + msgs.first.messageOtherId != null && + msgs.first.openedAt == null) { + switch (msgs.first.downloadState) { case DownloadState.pending: - MediaMessageContent content = - MediaMessageContent.fromJson(jsonDecode(msg.contentJson!)); - tryDownloadMedia(msg.messageId, msg.contactId, content, + MediaMessageContent content = MediaMessageContent.fromJson( + jsonDecode(msgs.first.contentJson!)); + tryDownloadMedia( + msgs.first.messageId, msgs.first.contactId, content, force: true); - return; - case DownloadState.downloaded: Navigator.push( context, @@ -318,11 +304,9 @@ class _UserListItem extends State { return MediaViewerView(widget.user.userId); }), ); - return; - default: - return; } + return; } Navigator.push( context, diff --git a/pubspec.lock b/pubspec.lock index c6d511a..e21fa18 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -590,10 +590,9 @@ packages: flutter_secure_storage: dependency: "direct main" description: - name: flutter_secure_storage - sha256: f7eceb0bc6f4fd0441e29d43cab9ac2a1c5ffd7ea7b64075136b718c46954874 - url: "https://pub.dev" - source: hosted + path: "../flutter_secure_storage/flutter_secure_storage" + relative: true + source: path version: "10.0.0-beta.4" flutter_secure_storage_darwin: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 0c66b63..805d00a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,12 @@ dependencies: flutter_local_notifications: ^18.0.1 flutter_localizations: sdk: flutter - flutter_secure_storage: ^10.0.0-beta.4 + # flutter_secure_storage: ^10.0.0-beta.4 + flutter_secure_storage: + path: ../flutter_secure_storage/flutter_secure_storage + # git: + # url: https://github.com/juliansteenbakker/flutter_secure_storage/tree/develop/flutter_secure_storage_darwin + # ref: develop # 10.0.0-beta.4 does not work because of https://github.com/juliansteenbakker/flutter_secure_storage/issues/8660 font_awesome_flutter: ^10.8.0 gal: ^2.3.1 hand_signature: ^3.0.3