diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 815a8db1..6180637a 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -315,6 +315,11 @@ DDFFA7472B3A7F3C004730DB /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFFA7462B3A7F3C004730DB /* Bundle.swift */; }; E3ED80145D0E873011982556 /* TAKServerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */; }; FE508F9AF5AD5DA20AA64DBF /* AccessoryManager+TAK.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */; }; + 43C0CB306098FD005C2D489F /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA63632A10C2DD076FA222BE /* IntentHandler.swift */; }; + 7717E5954788B23527BACF65 /* IntentMessageConverters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39D80697D5BB72C4566988E0 /* IntentMessageConverters.swift */; }; + AB4622DCF4B1D4115ED00312 /* SendMessageIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCB3877F157D9011FA5C6CF /* SendMessageIntentHandler.swift */; }; + B0E4EEF2D2C41A884A5E949C /* SearchForMessagesIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E644AE784C52500A9241481 /* SearchForMessagesIntentHandler.swift */; }; + 9BC51D7EF97090D149658843 /* SetMessageAttributeIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA6A18109101FA06A9FBBFB /* SetMessageAttributeIntentHandler.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -707,6 +712,11 @@ DDF924C926FBB953009FE055 /* ConnectedDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectedDevice.swift; sourceTree = ""; }; DDFEB3BA29900C1200EE7472 /* CurrentConditionsCompact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentConditionsCompact.swift; sourceTree = ""; }; DDFFA7462B3A7F3C004730DB /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; + CA63632A10C2DD076FA222BE /* IntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentHandler.swift; sourceTree = ""; }; + 39D80697D5BB72C4566988E0 /* IntentMessageConverters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentMessageConverters.swift; sourceTree = ""; }; + 5FCB3877F157D9011FA5C6CF /* SendMessageIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageIntentHandler.swift; sourceTree = ""; }; + 0E644AE784C52500A9241481 /* SearchForMessagesIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchForMessagesIntentHandler.swift; sourceTree = ""; }; + CDA6A18109101FA06A9FBBFB /* SetMessageAttributeIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetMessageAttributeIntentHandler.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -935,6 +945,18 @@ path = AppIntents; sourceTree = ""; }; + D05BD108B673E15AD4B01BC8 /* Intents */ = { + isa = PBXGroup; + children = ( + CA63632A10C2DD076FA222BE /* IntentHandler.swift */, + 39D80697D5BB72C4566988E0 /* IntentMessageConverters.swift */, + 5FCB3877F157D9011FA5C6CF /* SendMessageIntentHandler.swift */, + 0E644AE784C52500A9241481 /* SearchForMessagesIntentHandler.swift */, + CDA6A18109101FA06A9FBBFB /* SetMessageAttributeIntentHandler.swift */, + ); + path = Intents; + sourceTree = ""; + }; C37572859BC745C4284A9B42 /* TAK */ = { isa = PBXGroup; children = ( @@ -1230,6 +1252,7 @@ children = ( 237AEB8D2E1FE120003B7CE3 /* Accessory */, BCB6137F2C6728E700485544 /* AppIntents */, + D05BD108B673E15AD4B01BC8 /* Intents */, DD1BD0EC2C603C5B008C0C70 /* Measurement */, 25F5D5BC2C3F6D7B008036E3 /* Router */, DD7709392AA1ABA1007A8BF0 /* Tips */, @@ -1680,6 +1703,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 43C0CB306098FD005C2D489F /* IntentHandler.swift in Sources */, + 7717E5954788B23527BACF65 /* IntentMessageConverters.swift in Sources */, + AB4622DCF4B1D4115ED00312 /* SendMessageIntentHandler.swift in Sources */, + B0E4EEF2D2C41A884A5E949C /* SearchForMessagesIntentHandler.swift in Sources */, + 9BC51D7EF97090D149658843 /* SetMessageAttributeIntentHandler.swift in Sources */, 230BC3972E31071E0046BF2A /* AccessoryManager+Discovery.swift in Sources */, 25F26B1F2C2F611300C9CD9D /* AppData.swift in Sources */, 25F26B1E2C2F610D00C9CD9D /* Logger.swift in Sources */, diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index c2cbcdd8..02e9ce32 100644 --- a/Meshtastic/Info.plist +++ b/Meshtastic/Info.plist @@ -83,7 +83,9 @@ $(CURRENT_PROJECT_VERSION) INIntentsSupported - Intent + INSendMessageIntent + INSearchForMessagesIntent + INSetMessageAttributeIntent ITSAppUsesNonExemptEncryption @@ -119,6 +121,8 @@ We use your location to display it on the mesh map as well as to have GPS coordinates to send to the connected device. NSLocationWhenInUseUsageDescription We use your location to display it on the mesh map, show and filter by distance as well as to have GPS coordinates to send to the connected device. Route Recording uses location in the background. + NSSiriUsageDescription + Siri is used for messaging to send and receive Meshtastic messages by voice and through CarPlay. NSSupportsLiveActivities Privacy – Bluetooth Always Usage Description diff --git a/Meshtastic/Intents/IntentHandler.swift b/Meshtastic/Intents/IntentHandler.swift new file mode 100644 index 00000000..e8ad2639 --- /dev/null +++ b/Meshtastic/Intents/IntentHandler.swift @@ -0,0 +1,26 @@ +// +// IntentHandler.swift +// Meshtastic +// +// Routes incoming SiriKit intents to the appropriate handler. +// Used by the app delegate for in-app intent handling to support +// CarPlay messaging and Siri voice commands. +// + +import Intents + +final class IntentHandler: INExtension { + + override func handler(for intent: INIntent) -> Any? { + switch intent { + case is INSendMessageIntent: + return SendMessageIntentHandler() + case is INSearchForMessagesIntent: + return SearchForMessagesIntentHandler() + case is INSetMessageAttributeIntent: + return SetMessageAttributeIntentHandler() + default: + return nil + } + } +} diff --git a/Meshtastic/Intents/IntentMessageConverters.swift b/Meshtastic/Intents/IntentMessageConverters.swift new file mode 100644 index 00000000..cfd1a724 --- /dev/null +++ b/Meshtastic/Intents/IntentMessageConverters.swift @@ -0,0 +1,81 @@ +// +// IntentMessageConverters.swift +// Meshtastic +// +// Helpers for converting Core Data entities to SiriKit intent objects (INPerson, INMessage) +// used by the CarPlay messaging intent handlers. +// + +import CoreData +import Intents + +enum IntentMessageConverters { + + /// Converts a `UserEntity` to an `INPerson` for use with SiriKit intents. + static func inPerson(from user: UserEntity) -> INPerson { + let handle = INPersonHandle(value: String(user.num), type: .unknown) + return INPerson( + personHandle: handle, + nameComponents: nil, + displayName: user.longName ?? user.shortName ?? "Node \(user.num)", + image: nil, + contactIdentifier: String(user.num), + customIdentifier: String(user.num) + ) + } + + /// Converts a `MessageEntity` to an `INMessage` for use with SiriKit search results. + static func inMessage(from message: MessageEntity) -> INMessage { + let sender: INPerson? = message.fromUser.map { inPerson(from: $0) } + let recipients: [INPerson]? = message.toUser.map { [inPerson(from: $0)] } + let dateSent = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp)) + let groupName: INSpeakableString? = message.channel > 0 + ? INSpeakableString(spokenPhrase: "Channel \(message.channel)") + : nil + + return INMessage( + identifier: String(message.messageId), + conversationIdentifier: conversationIdentifier(for: message), + content: message.messagePayload, + dateSent: dateSent, + sender: sender, + recipients: recipients, + groupName: groupName, + messageType: .text + ) + } + + /// Builds a stable conversation identifier from a message. + /// Channel messages use "channel-", direct messages use "dm-". + static func conversationIdentifier(for message: MessageEntity) -> String { + if let toUser = message.toUser { + return "dm-\(toUser.num)" + } + return "channel-\(message.channel)" + } + + /// Searches for `UserEntity` objects whose name matches the given search term. + static func findUsers(matching searchTerm: String, in context: NSManagedObjectContext) -> [UserEntity] { + let fetchRequest: NSFetchRequest = UserEntity.fetchRequest() + fetchRequest.predicate = NSPredicate( + format: "longName CONTAINS[cd] %@ OR shortName CONTAINS[cd] %@", + searchTerm, searchTerm + ) + return (try? context.fetch(fetchRequest)) ?? [] + } + + /// Looks up a `ChannelEntity` by matching name. + static func findChannels(matching name: String, in context: NSManagedObjectContext) -> [ChannelEntity] { + let fetchRequest: NSFetchRequest = ChannelEntity.fetchRequest() + fetchRequest.predicate = NSPredicate( + format: "name != nil AND name != '' AND name CONTAINS[cd] %@", name + ) + return (try? context.fetch(fetchRequest)) ?? [] + } + + /// Resolves a channel index from a spoken group name, defaulting to the primary channel. + static func channelIndex(for name: String, in context: NSManagedObjectContext) -> Int { + let channels = findChannels(matching: name, in: context) + return channels.first.map { Int($0.index) } ?? 0 + } +} diff --git a/Meshtastic/Intents/SearchForMessagesIntentHandler.swift b/Meshtastic/Intents/SearchForMessagesIntentHandler.swift new file mode 100644 index 00000000..42d6208e --- /dev/null +++ b/Meshtastic/Intents/SearchForMessagesIntentHandler.swift @@ -0,0 +1,106 @@ +// +// SearchForMessagesIntentHandler.swift +// Meshtastic +// +// Handles INSearchForMessagesIntent for CarPlay and Siri. +// Queries Core Data for messages matching the intent criteria +// and returns them as INMessage objects. +// + +import CoreData +import Intents +import OSLog + +final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentHandling { + + /// Maximum number of messages to return in a single search. + private static let maxResults = 20 + + // MARK: - Handling + + func handle(intent: INSearchForMessagesIntent) async -> INSearchForMessagesIntentResponse { + let context = PersistenceController.shared.container.viewContext + + let messages: [INMessage] = await MainActor.run { + let fetchRequest: NSFetchRequest = MessageEntity.fetchRequest() + var predicates: [NSPredicate] = [] + + // Exclude admin and emoji messages + predicates.append(NSPredicate(format: "admin == NO")) + predicates.append(NSPredicate(format: "isEmoji == NO")) + + // Filter by identifiers (specific message IDs) + if let identifiers = intent.identifiers, !identifiers.isEmpty { + let messageIds = identifiers.compactMap { Int64($0) } + if !messageIds.isEmpty { + predicates.append(NSPredicate(format: "messageId IN %@", messageIds)) + } + } + + // Filter by sender + if let senders = intent.senders, !senders.isEmpty { + let senderNums = senders.compactMap { $0.personHandle?.value }.compactMap { Int64($0) } + if !senderNums.isEmpty { + predicates.append(NSPredicate(format: "fromUser.num IN %@", senderNums)) + } + } + + // Filter by date range. + // INDateComponentsRange exposes DateComponents on all platforms; + // .startDate/.endDate are iOS-only and unavailable on Mac Catalyst. + if let dateRange = intent.dateTimeRange { + let calendar = Calendar.current + if let startComponents = dateRange.startDateComponents, + let startDate = calendar.date(from: startComponents) { + let startTimestamp = Int32(startDate.timeIntervalSince1970) + predicates.append(NSPredicate(format: "messageTimestamp >= %d", startTimestamp)) + } + if let endComponents = dateRange.endDateComponents, + let endDate = calendar.date(from: endComponents) { + let endTimestamp = Int32(endDate.timeIntervalSince1970) + predicates.append(NSPredicate(format: "messageTimestamp <= %d", endTimestamp)) + } + } + + // Filter by group/channel name + if let groupNames = intent.speakableGroupNames, !groupNames.isEmpty { + let channelIndices: [Int32] = groupNames.compactMap { groupName in + let channels = IntentMessageConverters.findChannels( + matching: groupName.spokenPhrase, in: context + ) + return channels.first.map { Int32($0.index) } + } + if !channelIndices.isEmpty { + predicates.append(NSPredicate(format: "channel IN %@", channelIndices)) + } + } + + // Filter by read/unread attribute + let attributes = intent.attributes + if attributes.contains(.read) { + predicates.append(NSPredicate(format: "read == YES")) + } else if attributes.contains(.unread) { + predicates.append(NSPredicate(format: "read == NO")) + } + + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + fetchRequest.sortDescriptors = [ + NSSortDescriptor(key: "messageTimestamp", ascending: false) + ] + fetchRequest.fetchLimit = Self.maxResults + fetchRequest.relationshipKeyPathsForPrefetching = ["fromUser", "toUser"] + + do { + let results = try context.fetch(fetchRequest) + return results.map { IntentMessageConverters.inMessage(from: $0) } + } catch { + Logger.services.error("CarPlay/Siri: Failed to search messages: \(error.localizedDescription)") + return [] + } + } + + let response = INSearchForMessagesIntentResponse(code: .success, userActivity: nil) + response.messages = messages + return response + } +} diff --git a/Meshtastic/Intents/SendMessageIntentHandler.swift b/Meshtastic/Intents/SendMessageIntentHandler.swift new file mode 100644 index 00000000..06acb240 --- /dev/null +++ b/Meshtastic/Intents/SendMessageIntentHandler.swift @@ -0,0 +1,148 @@ +// +// SendMessageIntentHandler.swift +// Meshtastic +// +// Handles INSendMessageIntent for CarPlay and Siri messaging. +// Meshtastic supports exactly one destination per message: either a single +// direct-message recipient (a mesh node) or a channel (speakableGroupName). +// Multiple recipients are not supported. +// + +import CoreData +import Intents +import OSLog + +final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling { + + // MARK: - Resolution + + func resolveRecipients(for intent: INSendMessageIntent) async -> [INSendMessageRecipientResolutionResult] { + guard let recipients = intent.recipients, !recipients.isEmpty else { + if intent.speakableGroupName != nil { + return [] + } + return [.needsValue()] + } + + // Meshtastic only supports a single direct-message recipient. + if recipients.count > 1 { + return [.unsupported(forReason: .noAccount)] + } + + let context = PersistenceController.shared.container.viewContext + let searchTerm = recipients[0].displayName + let matchingUsers = await MainActor.run { + IntentMessageConverters.findUsers(matching: searchTerm, in: context) + } + + if matchingUsers.isEmpty { + return [.unsupported(forReason: .noAccount)] + } else if matchingUsers.count == 1, let user = matchingUsers.first { + return [.success(with: IntentMessageConverters.inPerson(from: user))] + } else { + let persons = matchingUsers.map { IntentMessageConverters.inPerson(from: $0) } + return [.disambiguation(with: persons)] + } + } + + func resolveContent(for intent: INSendMessageIntent) async -> INStringResolutionResult { + guard let content = intent.content, !content.isEmpty else { + return .needsValue() + } + + guard let data = content.data(using: .utf8), data.count <= 200 else { + return .unsupported() + } + + return .success(with: content) + } + + func resolveSpeakableGroupName(for intent: INSendMessageIntent) async -> INSpeakableStringResolutionResult { + guard let groupName = intent.speakableGroupName else { + if let recipients = intent.recipients, !recipients.isEmpty { + return .notRequired() + } + return .needsValue() + } + + let context = PersistenceController.shared.container.viewContext + let matchingChannels = await MainActor.run { + IntentMessageConverters.findChannels(matching: groupName.spokenPhrase, in: context) + } + + if matchingChannels.count == 1, let channel = matchingChannels.first { + let speakable = INSpeakableString(spokenPhrase: channel.name ?? "Channel \(channel.index)") + return .success(with: speakable) + } else if matchingChannels.count > 1 { + let speakables = matchingChannels.map { + INSpeakableString(spokenPhrase: $0.name ?? "Channel \($0.index)") + } + return .disambiguation(with: speakables) + } + + #if targetEnvironment(macCatalyst) + return .unsupported() + #else + return .unsupported(forReason: .noHandlesForValue) + #endif + } + + // MARK: - Confirmation + + func confirm(intent: INSendMessageIntent) async -> INSendMessageIntentResponse { + let connected = await AccessoryManager.shared.isConnected + guard connected else { + return INSendMessageIntentResponse(code: .failureRequiringAppLaunch, userActivity: nil) + } + return INSendMessageIntentResponse(code: .ready, userActivity: nil) + } + + // MARK: - Handling + + func handle(intent: INSendMessageIntent) async -> INSendMessageIntentResponse { + let connected = await AccessoryManager.shared.isConnected + guard connected else { + return INSendMessageIntentResponse(code: .failureRequiringAppLaunch, userActivity: nil) + } + + guard let content = intent.content, !content.isEmpty else { + return INSendMessageIntentResponse(code: .failure, userActivity: nil) + } + + do { + if let groupName = intent.speakableGroupName { + // Channel message + let context = PersistenceController.shared.container.viewContext + let channelIndex = await MainActor.run { + IntentMessageConverters.channelIndex(for: groupName.spokenPhrase, in: context) + } + try await AccessoryManager.shared.sendMessage( + message: content, + toUserNum: 0, + channel: Int32(channelIndex), + isEmoji: false, + replyID: 0 + ) + } else if let recipient = intent.recipients?.first, + let handleValue = recipient.personHandle?.value, + let nodeNum = Int64(handleValue) { + // Direct message to a single node + try await AccessoryManager.shared.sendMessage( + message: content, + toUserNum: nodeNum, + channel: 0, + isEmoji: false, + replyID: 0 + ) + } else { + return INSendMessageIntentResponse(code: .failure, userActivity: nil) + } + + Logger.services.info("CarPlay/Siri: Message sent successfully") + return INSendMessageIntentResponse(code: .success, userActivity: nil) + } catch { + Logger.services.error("CarPlay/Siri: Failed to send message: \(error.localizedDescription)") + return INSendMessageIntentResponse(code: .failure, userActivity: nil) + } + } +} diff --git a/Meshtastic/Intents/SetMessageAttributeIntentHandler.swift b/Meshtastic/Intents/SetMessageAttributeIntentHandler.swift new file mode 100644 index 00000000..c95529b9 --- /dev/null +++ b/Meshtastic/Intents/SetMessageAttributeIntentHandler.swift @@ -0,0 +1,85 @@ +// +// SetMessageAttributeIntentHandler.swift +// Meshtastic +// +// Handles INSetMessageAttributeIntent for CarPlay and Siri. +// Marks messages as read or unread in Core Data. +// + +import CoreData +import Intents +import OSLog + +final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeIntentHandling { + + // MARK: - Resolution + + func resolveAttribute(for intent: INSetMessageAttributeIntent) async -> INMessageAttributeResolutionResult { + let attribute = intent.attribute + guard attribute != .unknown else { + return .needsValue() + } + return .success(with: attribute) + } + + // MARK: - Confirmation + + func confirm(intent: INSetMessageAttributeIntent) async -> INSetMessageAttributeIntentResponse { + guard let identifiers = intent.identifiers, !identifiers.isEmpty else { + return INSetMessageAttributeIntentResponse(code: .failure, userActivity: nil) + } + return INSetMessageAttributeIntentResponse(code: .ready, userActivity: nil) + } + + // MARK: - Handling + + func handle(intent: INSetMessageAttributeIntent) async -> INSetMessageAttributeIntentResponse { + guard let identifiers = intent.identifiers, !identifiers.isEmpty else { + return INSetMessageAttributeIntentResponse(code: .failure, userActivity: nil) + } + + let attribute = intent.attribute + let context = PersistenceController.shared.container.viewContext + + let success: Bool = await MainActor.run { + let messageIds = identifiers.compactMap { Int64($0) } + guard !messageIds.isEmpty else { return false } + + let fetchRequest: NSFetchRequest = MessageEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "messageId IN %@", messageIds) + + do { + let messages = try context.fetch(fetchRequest) + guard !messages.isEmpty else { return false } + + for message in messages { + switch attribute { + case .read: + message.read = true + case .unread: + message.read = false + case .flagged, .unflagged: + // Meshtastic does not support message flagging + break + default: + break + } + } + + if context.hasChanges { + try context.save() + } + Logger.services.info("CarPlay/Siri: Updated \(messages.count) message(s) to \(String(describing: attribute))") + return true + } catch { + Logger.services.error("CarPlay/Siri: Failed to update message attributes: \(error.localizedDescription)") + return false + } + } + + return INSetMessageAttributeIntentResponse( + code: success ? .success : .failure, + userActivity: nil + ) + } +} diff --git a/Meshtastic/MeshtasticAppDelegate.swift b/Meshtastic/MeshtasticAppDelegate.swift index 2658a4bf..e26714d6 100644 --- a/Meshtastic/MeshtasticAppDelegate.swift +++ b/Meshtastic/MeshtasticAppDelegate.swift @@ -5,6 +5,7 @@ // Created by Ben on 8/20/23. // +import Intents import SwiftUI import OSLog @@ -29,8 +30,22 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat Task { @MainActor in TAKServerManager.shared.initializeOnStartup() } + // Request Siri authorization so intent donations work and CarPlay messaging is available. + #if !targetEnvironment(macCatalyst) + INPreferences.requestSiriAuthorization { status in + Logger.services.info("Siri authorization status: \(String(describing: status))") + } + #endif return true } + + // MARK: - SiriKit Intent Handling + + /// Routes incoming SiriKit intents to the appropriate handler for CarPlay and Siri messaging support. + func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any? { + IntentHandler().handler(for: intent) + } + // Lets us show the notification in the app in the foreground func userNotificationCenter( _ center: UNUserNotificationCenter,