ios push notification should work now #53

This commit is contained in:
otsmr 2025-04-06 19:19:39 +02:00
parent 4af2ef8260
commit 1962c70431
10 changed files with 376 additions and 176 deletions

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)eu.twonly.shared</string>
</array>
</dict>
</plist>

View file

@ -18,64 +18,20 @@ class NotificationService: UNNotificationServiceExtension {
self.contentHandler = contentHandler self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
if let bestAttemptContent = bestAttemptContent { if let bestAttemptContent = bestAttemptContent {
// Extract the ciphertext and nonce from the notification's userInfo
guard let _userInfo = bestAttemptContent.userInfo as? [String: Any], guard let _userInfo = bestAttemptContent.userInfo as? [String: Any],
let ciphertextString = bestAttemptContent.userInfo["ciphertext"] as? String, let push_data = bestAttemptContent.userInfo["push_data"] as? String else {
let nonceString = bestAttemptContent.userInfo["nonce"] as? String else { return contentHandler(bestAttemptContent);
return
} }
// Convert the base64 encoded strings to Data let data = getPushNotificationData(pushDataJson: push_data)
guard let ciphertextData = Data(base64Encoded: ciphertextString),
let nonceData = Data(base64Encoded: nonceString) else {
return
}
// Create the key (32 bytes of "A") if data != nil {
let keyString = String(repeating: "A", count: 32) bestAttemptContent.title = data!.title;
guard let keyData = keyString.data(using: .utf8) else { bestAttemptContent.body = data!.body;
return } else {
} bestAttemptContent.title = "\(bestAttemptContent.title) [failed to decrypt]"
// 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)")
} }
contentHandler(bestAttemptContent) 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] ?? ""
}

View file

@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 54; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@ -878,6 +878,7 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CN332ZUGRP; DEVELOPMENT_TEAM = CN332ZUGRP;
@ -918,6 +919,7 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CN332ZUGRP; DEVELOPMENT_TEAM = CN332ZUGRP;
@ -955,6 +957,7 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CN332ZUGRP; DEVELOPMENT_TEAM = CN332ZUGRP;

View file

@ -33,86 +33,24 @@ import Foundation
override func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { override func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
NSLog("userNotificationCenter:willPresent") 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]) 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")
}
} }

View file

@ -4,5 +4,9 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)eu.twonly.shared</string>
</array>
</dict> </dict>
</plist> </plist>

View file

@ -169,9 +169,13 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
children: <Widget>[ children: <Widget>[
// First icon (bottom icon) // First icon (bottom icon)
icons[0], icons[0],
Positioned(
top: 5.0, Transform(
left: 5.0, 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], child: icons[1],
), ),
// Second icon (top icon, slightly offset) // Second icon (top icon, slightly offset)

View file

@ -6,6 +6,7 @@ import 'dart:ui' as ui;
import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.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:flutter_svg/svg.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -321,7 +322,13 @@ Future handlePushData(String pushDataJson) async {
Future<Map<int, PushUser>> getPushKeys(String storageKey) async { Future<Map<int, PushUser>> getPushKeys(String storageKey) async {
var storage = getSecureStorage(); 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<int, PushUser> pushKeys = <int, PushUser>{}; Map<int, PushUser> pushKeys = <int, PushUser>{};
if (pushKeysJson != null) { if (pushKeysJson != null) {
Map<String, dynamic> jsonMap = jsonDecode(pushKeysJson); Map<String, dynamic> jsonMap = jsonDecode(pushKeysJson);
@ -342,7 +349,12 @@ Future setPushKeys(String storageKey, Map<int, PushUser> pushKeys) async {
String jsonString = jsonEncode(jsonToSend); String jsonString = jsonEncode(jsonToSend);
print("write: $storageKey: $pushKeys"); 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 /// Streams are created so that app can respond to notification-related events

View file

@ -200,7 +200,7 @@ class _UserListItem extends State<UserListItem> {
previewMessages = []; previewMessages = [];
} else if (newMessagesNotOpened.isEmpty) { } else if (newMessagesNotOpened.isEmpty) {
// there are no not opened messages show just the last message in the table // there are no not opened messages show just the last message in the table
currentMessage = newLastMessages.first; currentMessage = newLastMessages.last;
previewMessages = newLastMessages; previewMessages = newLastMessages;
} else { } else {
// filter first for received messages // filter first for received messages
@ -208,28 +208,12 @@ class _UserListItem extends State<UserListItem> {
newMessagesNotOpened.where((x) => x.messageOtherId != null).toList(); newMessagesNotOpened.where((x) => x.messageOtherId != null).toList();
if (receivedMessages.isNotEmpty) { 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; previewMessages = receivedMessages;
currentMessage = receivedMessages.first;
} else { } else {
// The not opened message was send previewMessages = newMessagesNotOpened;
final mediaMessages =
newMessagesNotOpened.where((x) => x.kind == MessageKind.media);
if (mediaMessages.isNotEmpty) {
currentMessage = mediaMessages.first;
} else {
currentMessage = newMessagesNotOpened.first; currentMessage = newMessagesNotOpened.first;
} }
previewMessages = [currentMessage!];
}
} }
lastMessages = newLastMessages; lastMessages = newLastMessages;
@ -299,18 +283,20 @@ class _UserListItem extends State<UserListItem> {
globalUpdateOfHomeViewPageIndex(0); globalUpdateOfHomeViewPageIndex(0);
return; return;
} }
Message msg = currentMessage!; List<Message> msgs = previewMessages
if (msg.kind == MessageKind.media && .where((x) => x.kind == MessageKind.media)
msg.messageOtherId != null && .toList();
msg.openedAt == null) { if (msgs.isNotEmpty &&
switch (msg.downloadState) { msgs.first.kind == MessageKind.media &&
msgs.first.messageOtherId != null &&
msgs.first.openedAt == null) {
switch (msgs.first.downloadState) {
case DownloadState.pending: case DownloadState.pending:
MediaMessageContent content = MediaMessageContent content = MediaMessageContent.fromJson(
MediaMessageContent.fromJson(jsonDecode(msg.contentJson!)); jsonDecode(msgs.first.contentJson!));
tryDownloadMedia(msg.messageId, msg.contactId, content, tryDownloadMedia(
msgs.first.messageId, msgs.first.contactId, content,
force: true); force: true);
return;
case DownloadState.downloaded: case DownloadState.downloaded:
Navigator.push( Navigator.push(
context, context,
@ -318,11 +304,9 @@ class _UserListItem extends State<UserListItem> {
return MediaViewerView(widget.user.userId); return MediaViewerView(widget.user.userId);
}), }),
); );
return;
default: default:
return;
} }
return;
} }
Navigator.push( Navigator.push(
context, context,

View file

@ -590,10 +590,9 @@ packages:
flutter_secure_storage: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_secure_storage path: "../flutter_secure_storage/flutter_secure_storage"
sha256: f7eceb0bc6f4fd0441e29d43cab9ac2a1c5ffd7ea7b64075136b718c46954874 relative: true
url: "https://pub.dev" source: path
source: hosted
version: "10.0.0-beta.4" version: "10.0.0-beta.4"
flutter_secure_storage_darwin: flutter_secure_storage_darwin:
dependency: transitive dependency: transitive

View file

@ -23,7 +23,12 @@ dependencies:
flutter_local_notifications: ^18.0.1 flutter_local_notifications: ^18.0.1
flutter_localizations: flutter_localizations:
sdk: flutter 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 font_awesome_flutter: ^10.8.0
gal: ^2.3.1 gal: ^2.3.1
hand_signature: ^3.0.3 hand_signature: ^3.0.3