diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index 62c20f2..922c986 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -1,421 +1,286 @@ -// -// NotificationService.swift -// NotificationService -// -// Created by Tobi on 03.04.25. -// +import CryptoKit +import Foundation +import Security +import UserNotifications -// import UserNotifications -// import CryptoKit -// import Foundation +class NotificationService: UNNotificationServiceExtension { -// class NotificationService: UNNotificationServiceExtension { + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? -// var contentHandler: ((UNNotificationContent) -> Void)? -// var bestAttemptContent: UNMutableNotificationContent? + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) -// override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { -// self.contentHandler = contentHandler -// bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + if let bestAttemptContent = bestAttemptContent { -// if let bestAttemptContent = bestAttemptContent { + guard bestAttemptContent.userInfo as? [String: Any] != nil, + let push_data = bestAttemptContent.userInfo["push_data"] as? String + else { + return contentHandler(bestAttemptContent) + } -// guard let _ = bestAttemptContent.userInfo as? [String: Any], -// let push_data = bestAttemptContent.userInfo["push_data"] as? String else { -// return contentHandler(bestAttemptContent); -// } + let data = getPushNotificationData(pushData: push_data) -// let data = getPushNotificationData(pushDataJson: push_data) + if data != nil { + if data!.title == "blocked" { + NSLog("Block message because user is blocked!") + // https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.usernotifications.filtering + return contentHandler(UNNotificationContent()) + } + bestAttemptContent.title = data!.title + bestAttemptContent.body = data!.body + bestAttemptContent.threadIdentifier = String(format: "%d", data!.notificationId) + } else { + bestAttemptContent.title = "\(bestAttemptContent.title)" + } -// if data != nil { -// if data!.title == "blocked" { -// NSLog("Block message because user is blocked!") -// // https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.usernotifications.filtering -// return contentHandler(UNNotificationContent()) -// } -// bestAttemptContent.title = data!.title; -// bestAttemptContent.body = data!.body; -// bestAttemptContent.threadIdentifier = String(format: "%d", data!.notificationId) -// } else { -// bestAttemptContent.title = "\(bestAttemptContent.title)" -// } + contentHandler(bestAttemptContent) + } + } -// contentHandler(bestAttemptContent) -// } -// } + override func serviceExtensionTimeWillExpire() { + // Called just before the extension will be terminated by the system. + // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } + } -// override func serviceExtensionTimeWillExpire() { -// // Called just before the extension will be terminated by the system. -// // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. -// if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { -// contentHandler(bestAttemptContent) -// } -// } +} -// } +func getPushNotificationData(pushData: String) -> ( + title: String, body: String, notificationId: Int64 +)? { + + guard let data = Data(base64Encoded: pushData) else { + NSLog("Failed to decode base64 string") + return nil + } -// enum PushKind: String, Codable { -// case text -// case twonly -// case video -// case image -// case contactRequest -// case acceptRequest -// case storedMediaFile -// case reaction -// case testNotification -// case reopenedMedia -// case reactionToVideo -// case reactionToText -// case reactionToImage -// case response -// } + do { + let pushData = try EncryptedPushNotification(serializedBytes: data) -// import CryptoKit -// import Foundation -// import Security -// func getPushNotificationData(pushDataJson: String) -> (title: String, body: String, notificationId: Int)? { -// // Decode the pushDataJson -// guard let pushData = decodePushData(pushDataJson) else { -// NSLog("Failed to decode push data") -// return nil -// } + var pushNotification: PushNotification? + var pushUser: PushUser? -// var pushKind: PushKind? -// var displayName: String? -// var fromUserId: Int? -// var blocked: Bool? + // Check the keyId + if pushData.keyID == 0 { + let key = "InsecureOnlyUsedForAddingContact".data(using: .utf8)! + pushNotification = tryDecryptMessage(key: key, pushData: pushData) + } else { + let pushUsers = getPushUsers() + if pushUsers != nil { + for tryPushUser in pushUsers! { + for pushKey in tryPushUser.pushKeys { + if pushKey.id == pushData.keyID { + pushNotification = tryDecryptMessage(key: pushKey.key, pushData: pushData) + if pushNotification != nil { + if (pushNotification!.messageID <= pushUser!.lastMessageID) { + return ("blocked", "blocked", 0) + } + pushUser = tryPushUser + break + } + } + } + if pushUser != nil { break } + } + } else { + NSLog("pushKeys are empty") + } + } + + if pushUser?.blocked == true { + return ("blocked", "blocked", 0) + } -// // 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 -// fromUserId = userId -// blocked = userKeys.blocked -// 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 pushNotification = pushNotification { -// if blocked == true { -// return ("blocked", "blocked", 0) -// } + if pushNotification.kind == .testNotification { + return ("Test Notification", "This is a test notification.", 0) + } else if pushUser != nil { + return (pushUser!.displayName, getPushNotificationText(pushNotification: pushNotification), pushUser!.userID) + } else { + return ("", getPushNotificationTextWithoutUserId(pushKind: pushNotification.kind), 1) + } -// // Handle the push notification based on the pushKind -// if let pushKind = pushKind { + } else { + NSLog("Failed to decrypt message or pushKind is nil") + } + return nil + } catch { + NSLog("Error decoding JSON: \(error)") + return nil + } +} -// if pushKind == .testNotification { -// return ("Test Notification", "This is a test notification.", 0) -// } else if displayName != nil && fromUserId != nil { -// return (displayName!, getPushNotificationText(pushKind: pushKind), fromUserId!) -// } else { -// return ("", getPushNotificationTextWithoutUserId(pushKind: pushKind), 1) -// } +func tryDecryptMessage(key: Data, pushData: EncryptedPushNotification) -> PushNotification? { -// } else { -// NSLog("Failed to decrypt message or pushKind is nil") -// } -// return nil -// } + do { + // Create a nonce for ChaChaPoly + let nonce = try ChaChaPoly.Nonce(data: pushData.nonce) -// 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 + // Create a sealed box for ChaChaPoly + let sealedBox = try ChaChaPoly.SealedBox( + nonce: nonce, + ciphertext: pushData.ciphertext, + tag: pushData.mac + ) -// 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 -// } + // Decrypt the data using the key + let decryptedData = try ChaChaPoly.open(sealedBox, using: SymmetricKey(data: key)) -// do { -// // Create a nonce for ChaChaPoly -// let nonce = try ChaChaPoly.Nonce(data: nonceData) + // Here you can determine the PushKind based on the decrypted message + return try PushNotification(serializedBytes: decryptedData) + } catch { + NSLog("Decryption failed: \(error)") + } -// // Create a sealed box for ChaChaPoly -// let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: cipherTextData, tag: macData) + return nil +} -// // Decrypt the data using the key -// let decryptedData = try ChaChaPoly.open(sealedBox, using: SymmetricKey(data: keyData)) +func getPushUsers() -> [PushUser]? { + // Retrieve the data from secure storage (Keychain) + guard let pushUsersB64 = readFromKeychain(key: "receiving_push_keys") else { + NSLog("No data found for key: receiving_push_keys") + return nil + } + guard let pushUsersBytes = Data(base64Encoded: pushUsersB64) else { + NSLog("Failed to decode base64 push users") + return nil + } -// // Convert decrypted data to a string -// if let decryptedMessage = String(data: decryptedData, encoding: .utf8) { -// NSLog("Decrypted message: \(decryptedMessage)") + do { + let pushUsers = try PushUsers(serializedBytes: pushUsersBytes) + return pushUsers.users + } catch { + NSLog("Error decoding JSON: \(error)") + return nil + } +} -// // Here you can determine the PushKind based on the decrypted message -// return determinePushKind(from: decryptedMessage) -// } -// } catch { -// NSLog("Decryption failed: \(error)") -// } +// 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 + ] -// return nil -// } + var dataTypeRef: AnyObject? = nil + let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) -// // 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 if message.contains("reopenedMedia") { -// return .reopenedMedia -// } else if message.contains("reactionToVideo") { -// return .reactionToVideo -// } else if message.contains("reactionToText") { -// return .reactionToText -// } else if message.contains("reactionToImage") { -// return .reactionToImage -// } else if message.contains("response") { -// return .response -// } else { -// return nil // Unknown PushKind -// } -// } + if status == errSecSuccess { + if let data = dataTypeRef as? Data { + return String(data: data, encoding: .utf8) + } + } -// 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 -// } + 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 -// } +func getPushNotificationText(pushNotification: PushNotification) -> String { + let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" // Get the current system language -// // Convert the JSON string to Data -// guard let jsonData = jsonString.data(using: .utf8) else { -// NSLog("Failed to convert JSON string to Data") -// return nil -// } + var pushNotificationText: [PushKind: String] = [:] -// 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 -// } -// } + // 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.", + .testNotification: "Das ist eine Testbenachrichtigung.", + .reopenedMedia: "hat dein Bild erneut geöffnet.", + .reactionToVideo: "hat mit {{reaction}} auf dein Video reagiert.", + .reactionToText: "hat mit {{reaction}} auf deinen Text reagiert.", + .reactionToImage: "hat mit {{reaction}} auf dein Bild reagiert.", + .response: "hat dir geantwortet.", + ] + } 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.", + .testNotification: "This is a test notification.", + .reopenedMedia: "has reopened your image.", + .reactionToVideo: "has reacted with {{reaction}} to your video.", + .reactionToText: "has reacted with {{reaction}} to your text.", + .reactionToImage: "has reacted with {{reaction}} to your image.", + .response: "has responded.", + ] + } + + var content = pushNotificationText[pushNotification.kind] ?? ""; + + if (pushNotification.hasReactionContent) { + content.replace("{{reaction}}", with: pushNotification.reactionContent) + } -// struct PushNotification: Codable { -// let keyId: Int -// let nonce: String -// let cipherText: String -// let mac: String + // Return the corresponding message or an empty string if not found + return content +} -// // 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 -// } -// } +func getPushNotificationTextWithoutUserId(pushKind: PushKind) -> String { + let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" // Get the current system language -// struct PushKeyMeta: Codable { -// let id: Int -// let key: [Int] -// let createdAt: Date + var pushNotificationText: [PushKind: String] = [:] -// enum CodingKeys: String, CodingKey { -// case id -// case key -// case createdAt -// } -// } + // Define the messages based on the system language + if systemLanguage.contains("de") { // German + pushNotificationText = [ + .text: "Du hast eine Nachricht erhalten.", + .twonly: "Du hast ein twonly erhalten.", + .video: "Du hast ein Video erhalten.", + .image: "Du hast ein Bild erhalten.", + .contactRequest: "Du hast eine Kontaktanfrage erhalten.", + .acceptRequest: "Deine Kontaktanfrage wurde angenommen.", + .storedMediaFile: "Dein Bild wurde gespeichert.", + .reaction: "Du hast eine Reaktion auf dein Bild erhalten.", + .testNotification: "Das ist eine Testbenachrichtigung.", + .reopenedMedia: "hat dein Bild erneut geöffnet.", + .reactionToVideo: "Du hast eine Reaktion auf dein Video erhalten.", + .reactionToText: "Du hast eine Reaktion auf deinen Text erhalten.", + .reactionToImage: "Du hast eine Reaktion auf dein Bild erhalten.", + .response: "Du hast eine Antwort erhalten.", + ] + } else { // Default to English + pushNotificationText = [ + .text: "You got a message.", + .twonly: "You got a twonly.", + .video: "You got a video.", + .image: "You got an image.", + .contactRequest: "You got a contact request.", + .acceptRequest: "Your contact request has been accepted.", + .storedMediaFile: "Your image has been saved.", + .reaction: "You got a reaction to your image.", + .testNotification: "This is a test notification.", + .reopenedMedia: "has reopened your image.", + .reactionToVideo: "You got a reaction to your video.", + .reactionToText: "You got a reaction to your text.", + .reactionToImage: "You got a reaction to your image.", + .response: "You got a response.", + ] + } -// struct PushUser: Codable { -// let displayName: String -// let keys: [PushKeyMeta] -// let blocked: Bool? - -// enum CodingKeys: String, CodingKey { -// case displayName -// case keys -// case blocked -// } -// } - -// func getPushKey() -> [Int: PushUser]? { -// // Retrieve the data from secure storage (Keychain) -// guard let data = readFromKeychain(key: "receivingPushKeys") else { -// NSLog("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 { -// NSLog("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.", -// .testNotification: "Das ist eine Testbenachrichtigung.", -// .reopenedMedia: "hat dein Bild erneut geöffnet.", -// .reactionToVideo: "hat auf dein Video reagiert.", -// .reactionToText: "hat auf deinen Text reagiert.", -// .reactionToImage: "hat auf dein Bild reagiert.", -// .response: "hat dir geantwortet." -// ] -// } 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.", -// .testNotification: "This is a test notification.", -// .reopenedMedia: "has reopened your image.", -// .reactionToVideo: "has reacted to your video.", -// .reactionToText: "has reacted to your text.", -// .reactionToImage: "has reacted to your image.", -// .response: "has responded." -// ] -// } - -// // Return the corresponding message or an empty string if not found -// return pushNotificationText[pushKind] ?? "" -// } - -// func getPushNotificationTextWithoutUserId(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: "Du hast eine Nachricht erhalten.", -// .twonly: "Du hast ein twonly erhalten.", -// .video: "Du hast ein Video erhalten.", -// .image: "Du hast ein Bild erhalten.", -// .contactRequest: "Du hast eine Kontaktanfrage erhalten.", -// .acceptRequest: "Deine Kontaktanfrage wurde angenommen.", -// .storedMediaFile: "Dein Bild wurde gespeichert.", -// .reaction: "Du hast eine Reaktion auf dein Bild erhalten.", -// .testNotification: "Das ist eine Testbenachrichtigung.", -// .reopenedMedia: "hat dein Bild erneut geöffnet.", -// .reactionToVideo: "Du hast eine Reaktion auf dein Video erhalten.", -// .reactionToText: "Du hast eine Reaktion auf deinen Text erhalten.", -// .reactionToImage: "Du hast eine Reaktion auf dein Bild erhalten.", -// .response: "Du hast eine Antwort erhalten." -// ] -// } else { // Default to English -// pushNotificationText = [ -// .text: "You got a message.", -// .twonly: "You got a twonly.", -// .video: "You got a video.", -// .image: "You got an image.", -// .contactRequest: "You got a contact request.", -// .acceptRequest: "Your contact request has been accepted.", -// .storedMediaFile: "Your image has been saved.", -// .reaction: "You got a reaction to your image.", -// .testNotification: "This is a test notification.", -// .reopenedMedia: "has reopened your image.", -// .reactionToVideo: "You got a reaction to your video.", -// .reactionToText: "You got a reaction to your text.", -// .reactionToImage: "You got a reaction to your image.", -// .response: "You got a response." -// ] -// } - -// // Return the corresponding message or an empty string if not found -// return pushNotificationText[pushKind] ?? "" -// } + // Return the corresponding message or an empty string if not found + return pushNotificationText[pushKind] ?? "" +} diff --git a/lib/src/constants/secure_storage_keys.dart b/lib/src/constants/secure_storage_keys.dart index 73b59a4..fb5fd69 100644 --- a/lib/src/constants/secure_storage_keys.dart +++ b/lib/src/constants/secure_storage_keys.dart @@ -6,6 +6,6 @@ class SecureStorageKeys { static const String userData = "userData"; static const String twonlySafeLastBackupHash = "twonly_safe_last_backup_hash"; - static const String receivingPushKeys = "receiving_pus_keys"; - static const String sendingPushKeys = "sending_pus_keys"; + static const String receivingPushKeys = "receiving_push_keys"; + static const String sendingPushKeys = "sending_push_keys"; } diff --git a/lib/src/services/api/messages.dart b/lib/src/services/api/messages.dart index ba152e5..3fefdd9 100644 --- a/lib/src/services/api/messages.dart +++ b/lib/src/services/api/messages.dart @@ -164,6 +164,11 @@ Future sendTextMessage( PushNotification? pushNotification, ) async { DateTime messageSendAt = DateTime.now(); + DateTime? openedAt; + + if (pushNotification != null && pushNotification.hasReactionContent()) { + openedAt = DateTime.now(); + } int? messageId = await twonlyDB.messagesDao.insertMessage( MessagesCompanion( @@ -173,6 +178,7 @@ Future sendTextMessage( responseToOtherMessageId: Value(content.responseToMessageId), responseToMessageId: Value(content.responseToOtherMessageId), downloadState: Value(DownloadState.downloaded), + openedAt: Value(openedAt), contentJson: Value( jsonEncode(content.toJson()), ),