diff --git a/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved index c47a90583..3465793d5 100644 --- a/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "d57a5aecf24a25b32ec4a74be2f5d0a995a47c4b", - "version" : "1.27.0" + "revision" : "edb6ed4919f7756157fe02f2552b7e3850a538e5", + "version" : "1.28.1" } } ], diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 87551c4fb..aae1a8ab6 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -3302,7 +3302,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate // MARK: - CB Central Manager implmentation extension BLEManager: CBCentralManagerDelegate { - + // MARK: Bluetooth enabled/disabled func centralManagerDidUpdateState(_ central: CBCentralManager) { if central.state == CBManagerState.poweredOn { @@ -3312,9 +3312,9 @@ extension BLEManager: CBCentralManagerDelegate { } else { isSwitchedOn = false } - + var status = "" - + switch central.state { case .poweredOff: status = "BLE is powered off" @@ -3333,10 +3333,10 @@ extension BLEManager: CBCentralManagerDelegate { } Logger.services.info("📜 [BLE] Bluetooth status: \(status)") } - + // Called each time a peripheral is discovered func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { - + if self.automaticallyReconnect && peripheral.identifier.uuidString == UserDefaults.standard.object(forKey: "preferredPeripheralId") as? String ?? "" { self.connectTo(peripheral: peripheral) Logger.services.info("✅ [BLE] Reconnecting to prefered peripheral: \(peripheral.name ?? "Unknown", privacy: .public)") @@ -3344,7 +3344,7 @@ extension BLEManager: CBCentralManagerDelegate { let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String let device = Peripheral(id: peripheral.identifier.uuidString, num: 0, name: name ?? "Unknown", shortName: "?", longName: name ?? "Unknown", firmwareVersion: "Unknown", rssi: RSSI.intValue, lastUpdate: Date(), peripheral: peripheral) let index = peripherals.map { $0.peripheral }.firstIndex(of: peripheral) - + if let peripheralIndex = index { peripherals[peripheralIndex] = device } else { diff --git a/Meshtastic/Helpers/LocalNotificationManager.swift b/Meshtastic/Helpers/LocalNotificationManager.swift index 3e41c8f39..1260f20c1 100644 --- a/Meshtastic/Helpers/LocalNotificationManager.swift +++ b/Meshtastic/Helpers/LocalNotificationManager.swift @@ -5,6 +5,16 @@ import OSLog class LocalNotificationManager { var notifications = [Notification]() + + let thumbsUpAction = UNNotificationAction(identifier: "messageNotification.thumbsUpAction", title: + "👍 \(Tapbacks.thumbsUp.description)", options: []) + let thumbsDownAction = UNNotificationAction(identifier: "messageNotification.thumbsDownAction", title: + "👎 \(Tapbacks.thumbsDown.description)", options: []) + let replyInputAction = UNTextInputNotificationAction( + identifier: "messageNotification.replyInputAction", + title: "reply".localized, + options: []) + // Step 1 Request Permissions for notifications private func requestAuthorization() { @@ -31,6 +41,15 @@ class LocalNotificationManager { // This function iterates over the Notification objects in the notifications array and schedules them for delivery in the future private func scheduleNotifications() { + let messageNotificationCategory = UNNotificationCategory( + identifier: "messageNotificationCategory", + actions: [thumbsUpAction, thumbsDownAction,replyInputAction], + intentIdentifiers: [], + options: .customDismissAction + ) + + UNUserNotificationCenter.current().setNotificationCategories([messageNotificationCategory]) + for notification in notifications { let content = UNMutableNotificationContent() content.subtitle = notification.subtitle @@ -45,6 +64,17 @@ class LocalNotificationManager { if notification.path != nil { content.userInfo["path"] = notification.path } + if notification.messageId != nil { + content.categoryIdentifier = "messageNotificationCategory" + content.userInfo["messageId"] = notification.messageId + } + if notification.channel != nil { + content.userInfo["channel"] = notification.channel + } + if notification.userNum != nil { + content.userInfo["userNum"] = notification.userNum + } + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: trigger) @@ -76,4 +106,7 @@ struct Notification { var content: String var target: String? var path: String? + var messageId: Int64? + var channel: Int32? + var userNum: Int64? } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index d98016994..0c270ac98 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -816,12 +816,10 @@ func textMessageAppPacket( context: NSManagedObjectContext, appState: AppState ) { - var messageText = String(bytes: packet.decoded.payload, encoding: .utf8) let rangeRef = Reference(Int.self) let rangeTestRegex = Regex { "seq " - TryCapture(as: rangeRef) { OneOrMore(.digit) } transform: { match in @@ -829,7 +827,7 @@ func textMessageAppPacket( } } let rangeTest = messageText?.contains(rangeTestRegex) ?? false && messageText?.starts(with: "seq ") ?? false - + if !wantRangeTestPackets && rangeTest { return } @@ -842,15 +840,16 @@ func textMessageAppPacket( } } } - + if messageText?.count ?? 0 > 0 { - MeshLogger.log("💬 \("mesh.log.textmessage.received".localized)") - + let messageUsers = UserEntity.fetchRequest() messageUsers.predicate = NSPredicate(format: "num IN %@", [packet.to, packet.from]) + do { let fetchedUsers = try context.fetch(messageUsers) + let newMessage = MessageEntity(context: context) newMessage.messageId = Int64(packet.id) if packet.rxTime > 0 { @@ -873,19 +872,22 @@ func textMessageAppPacket( newMessage.read = true } } + if packet.decoded.replyID > 0 { newMessage.replyID = Int64(packet.decoded.replyID) } - + if fetchedUsers.first(where: { $0.num == packet.to }) != nil && packet.to != Constants.maximumNodeNum { if !storeForwardBroadcast { newMessage.toUser = fetchedUsers.first(where: { $0.num == packet.to }) } } + if fetchedUsers.first(where: { $0.num == packet.from }) != nil { newMessage.fromUser = fetchedUsers.first(where: { $0.num == packet.from }) + if !(newMessage.fromUser?.publicKey?.isEmpty ?? true) { - /// We have a key, check if it matches + // We have a key, check if it matches if newMessage.fromUser?.publicKey != newMessage.publicKey { newMessage.fromUser?.keyMatch = false newMessage.fromUser?.newPublicKey = newMessage.publicKey @@ -897,27 +899,29 @@ func textMessageAppPacket( newMessage.fromUser?.publicKey = packet.publicKey } } + if packet.rxTime > 0 { newMessage.fromUser?.userNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) } else { newMessage.fromUser?.userNode?.lastHeard = Date() } } + newMessage.messagePayload = messageText newMessage.messagePayloadMarkdown = generateMessageMarkdown(message: messageText!) + if packet.to != Constants.maximumNodeNum && newMessage.fromUser != nil { newMessage.fromUser?.lastMessage = Date() } + var messageSaved = false - + do { - try context.save() Logger.data.info("💾 Saved a new message for \(newMessage.messageId)") messageSaved = true if messageSaved { - if packet.decoded.portnum == PortNum.detectionSensorApp && !UserDefaults.enableDetectionNotifications { return } @@ -936,14 +940,16 @@ func textMessageAppPacket( subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", content: messageText!, target: "messages", - path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)" + path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)", + messageId: newMessage.messageId, + channel: newMessage.channel, + userNum: Int64(packet.from) ) ] manager.schedule() Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "unknown".localized)") } } else if newMessage.fromUser != nil && newMessage.toUser == nil { - let fetchMyInfoRequest = MyInfoEntity.fetchRequest() fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(connectedNode)) @@ -966,7 +972,11 @@ func textMessageAppPacket( subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", content: messageText!, target: "messages", - path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.messageId)") + path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.messageId)", + messageId: newMessage.messageId, + channel: newMessage.channel, + userNum: Int64(newMessage.fromUser?.userId ?? "0") + ) ] manager.schedule() Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "unknown".localized)") @@ -974,7 +984,7 @@ func textMessageAppPacket( } } } catch { - + // Handle error } } } @@ -989,6 +999,7 @@ func textMessageAppPacket( } } + func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) { let logString = String.localizedStringWithFormat("mesh.log.waypoint.received %@".localized, String(packet.from)) diff --git a/Meshtastic/MeshtasticAppDelegate.swift b/Meshtastic/MeshtasticAppDelegate.swift index 06d290e99..d38557bc6 100644 --- a/Meshtastic/MeshtasticAppDelegate.swift +++ b/Meshtastic/MeshtasticAppDelegate.swift @@ -9,9 +9,9 @@ import SwiftUI import OSLog class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, ObservableObject { - + var router: Router? - + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { Logger.services.info("🚀 [App] Meshtstic Apple App launched!") // Default User Default Values @@ -22,7 +22,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat if #available(iOS 17.0, macOS 14.0, *) { let locationsHandler = LocationsHandler.shared locationsHandler.startLocationUpdates() - + // If a background activity session was previously active, reinstantiate it after the background launch. if locationsHandler.backgroundActivity { locationsHandler.backgroundActivity = true @@ -38,7 +38,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat ) { completionHandler([.list, .banner, .sound]) } - + // This method is called when a user clicks on the notification func userNotificationCenter( _ center: UNUserNotificationCenter, @@ -46,6 +46,64 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat withCompletionHandler completionHandler: @escaping () -> Void ) { let userInfo = response.notification.request.content.userInfo + + switch response.actionIdentifier { + case UNNotificationDefaultActionIdentifier: + break + + case "messageNotification.thumbsUpAction": + if let channel = userInfo["channel"] as? Int32, + let replyID = userInfo["messageId"] as? Int64 { + let tapbackResponse = !BLEManager.shared.sendMessage( + message: Tapbacks.thumbsUp.emojiString, + toUserNum: userInfo["userNum"] as? Int64 ?? 0, + channel: channel, + isEmoji: true, + replyID: replyID + ) + Logger.services.info("Tapback response sent") + } else { + Logger.services.error("Failed to retrieve channel or messageId from userInfo") + } + break + + case "messageNotification.thumbsDownAction": + if let channel = userInfo["channel"] as? Int32, + let replyID = userInfo["messageId"] as? Int64 { + let tapbackResponse = !BLEManager.shared.sendMessage( + message: Tapbacks.thumbsDown.emojiString, + toUserNum: userInfo["userNum"] as? Int64 ?? 0, + channel: channel, + isEmoji: true, + replyID: replyID + ) + Logger.services.info("Tapback response sent") + } else { + Logger.services.error("Failed to retrieve channel or messageId from userInfo") + } + break + + case "messageNotification.replyInputAction": + if let userInput = (response as? UNTextInputNotificationResponse)?.userText, + let channel = userInfo["channel"] as? Int32, + let replyID = userInfo["messageId"] as? Int64 { + let tapbackResponse = !BLEManager.shared.sendMessage( + message: userInput, + toUserNum: userInfo["userNum"] as? Int64 ?? 0, + channel: channel, + isEmoji: false, + replyID: replyID + ) + Logger.services.info("Actionable notification reply sent") + } else { + Logger.services.error("Failed to retrieve user input, channel, or messageId from userInfo") + } + break + + default: + break + } + if let targetValue = userInfo["target"] as? String, let deepLink = userInfo["path"] as? String, let url = URL(string: deepLink) { @@ -54,7 +112,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat } else { Logger.services.error("Failed to handle notification response: \(userInfo)") } - + completionHandler() } }