From 36e8f59a02eb22469997e7c54b7adfe5b611e68a Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Thu, 16 Apr 2026 17:33:18 -0700 Subject: [PATCH] Carplay communications app templates --- Meshtastic.xcodeproj/project.pbxproj | 16 ++ .../AccessoryManager+ToRadio.swift | 5 +- .../CarPlay/CarPlayIntentDonation.swift | 140 ++++++++++++ Meshtastic/CarPlay/CarPlaySceneDelegate.swift | 212 ++++++++++++++++++ Meshtastic/Helpers/MeshPackets.swift | 3 + Meshtastic/Info.plist | 16 +- 6 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 Meshtastic/CarPlay/CarPlayIntentDonation.swift create mode 100644 Meshtastic/CarPlay/CarPlaySceneDelegate.swift diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 6180637a..1c3e9fa1 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + AA000201CPLAY000000000002 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000201CPLAY000000000001 /* CarPlaySceneDelegate.swift */; }; + AA000201CPLAY000000000004 /* CarPlayIntentDonation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000201CPLAY000000000003 /* CarPlayIntentDonation.swift */; }; 102B5EAB2E172F41003D191E /* DatadogCore in Frameworks */ = {isa = PBXBuildFile; productRef = 102B5EAA2E172F41003D191E /* DatadogCore */; }; 102B5EAD2E172F41003D191E /* DatadogCrashReporting in Frameworks */ = {isa = PBXBuildFile; productRef = 102B5EAC2E172F41003D191E /* DatadogCrashReporting */; }; 102B5EAF2E172F41003D191E /* DatadogLogs in Frameworks */ = {isa = PBXBuildFile; productRef = 102B5EAE2E172F41003D191E /* DatadogLogs */; }; @@ -354,6 +356,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + AA000201CPLAY000000000001 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; + AA000201CPLAY000000000003 /* CarPlayIntentDonation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayIntentDonation.swift; sourceTree = ""; }; 01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.swift; sourceTree = ""; }; 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerConfig.swift; sourceTree = ""; }; 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKCertificateManager.swift; sourceTree = ""; }; @@ -759,6 +763,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + AA000201CPLAY000000000005 /* CarPlay */ = { + isa = PBXGroup; + children = ( + AA000201CPLAY000000000001 /* CarPlaySceneDelegate.swift */, + AA000201CPLAY000000000003 /* CarPlayIntentDonation.swift */, + ); + path = CarPlay; + sourceTree = ""; + }; 231B3F1E2D0879BC0069A07D /* Metrics Visualization */ = { isa = PBXGroup; children = ( @@ -1251,6 +1264,7 @@ isa = PBXGroup; children = ( 237AEB8D2E1FE120003B7CE3 /* Accessory */, + AA000201CPLAY000000000005 /* CarPlay */, BCB6137F2C6728E700485544 /* AppIntents */, D05BD108B673E15AD4B01BC8 /* Intents */, DD1BD0EC2C603C5B008C0C70 /* Measurement */, @@ -1704,6 +1718,8 @@ buildActionMask = 2147483647; files = ( 43C0CB306098FD005C2D489F /* IntentHandler.swift in Sources */, + AA000201CPLAY000000000002 /* CarPlaySceneDelegate.swift in Sources */, + AA000201CPLAY000000000004 /* CarPlayIntentDonation.swift in Sources */, 7717E5954788B23527BACF65 /* IntentMessageConverters.swift in Sources */, AB4622DCF4B1D4115ED00312 /* SendMessageIntentHandler.swift in Sources */, B0E4EEF2D2C41A884A5E949C /* SearchForMessagesIntentHandler.swift in Sources */, diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index 29870d16..a2704821 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -376,7 +376,10 @@ extension AccessoryManager { do { try context.save() Logger.data.info("💾 Saved a new sent message from \(self.activeDeviceNum?.toHex() ?? "0", privacy: .public) to \(toUserNum.toHex(), privacy: .public)") - + // Donate outgoing message to SiriKit for CarPlay + if !isEmoji { + CarPlayIntentDonation.donateOutgoingMessage(content: message, toUserNum: toUserNum, channel: channel) + } } catch { context.rollback() let nsError = error as NSError diff --git a/Meshtastic/CarPlay/CarPlayIntentDonation.swift b/Meshtastic/CarPlay/CarPlayIntentDonation.swift new file mode 100644 index 00000000..f6e66196 --- /dev/null +++ b/Meshtastic/CarPlay/CarPlayIntentDonation.swift @@ -0,0 +1,140 @@ +// MARK: CarPlayIntentDonation +// +// CarPlayIntentDonation.swift +// Meshtastic +// +// Donates SiriKit interactions when messages are received so that +// conversations appear in CarPlay's messaging interface and Siri +// can read them aloud. +// + +import CoreData +import Intents +import OSLog + +enum CarPlayIntentDonation { + + /// Donates an incoming message interaction so it appears in CarPlay Messages. + /// Call this after saving a new `MessageEntity` to Core Data. + static func donateReceivedMessage(_ message: MessageEntity) { + guard let fromUser = message.fromUser else { return } + guard !message.isEmoji, !message.admin else { return } + + let sender = IntentMessageConverters.inPerson(from: fromUser) + let me = mePerson() + + let intent: INSendMessageIntent + if message.toUser != nil { + // Direct message + intent = INSendMessageIntent( + recipients: [me], + outgoingMessageType: .outgoingMessageText, + content: message.messagePayload, + speakableGroupName: nil, + conversationIdentifier: "dm-\(fromUser.num)", + serviceName: "Meshtastic", + sender: sender, + attachments: nil + ) + } else { + // Channel message + let channelName = channelDisplayName(for: message.channel) + let groupName = INSpeakableString(spokenPhrase: channelName) + intent = INSendMessageIntent( + recipients: [me], + outgoingMessageType: .outgoingMessageText, + content: message.messagePayload, + speakableGroupName: groupName, + conversationIdentifier: "channel-\(message.channel)", + serviceName: "Meshtastic", + sender: sender, + attachments: nil + ) + intent.setImage( + INImage(named: "antenna.radiowaves.left.and.right"), + forParameterNamed: \.speakableGroupName + ) + } + + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate { error in + if let error { + Logger.services.error("🚗 [CarPlay] Failed to donate interaction: \(error.localizedDescription, privacy: .public)") + } else { + Logger.services.debug("🚗 [CarPlay] Donated incoming message from \(fromUser.longName ?? "unknown", privacy: .public)") + } + } + } + + /// Donates an outgoing message interaction after the user sends a message. + static func donateOutgoingMessage(content: String, toUserNum: Int64, channel: Int32) { + let me = mePerson() + + let intent: INSendMessageIntent + if toUserNum != 0 { + let recipientHandle = INPersonHandle(value: String(toUserNum), type: .unknown) + let recipient = INPerson( + personHandle: recipientHandle, + nameComponents: nil, + displayName: "Node \(toUserNum.toHex())", + image: nil, + contactIdentifier: String(toUserNum), + customIdentifier: String(toUserNum) + ) + intent = INSendMessageIntent( + recipients: [recipient], + outgoingMessageType: .outgoingMessageText, + content: content, + speakableGroupName: nil, + conversationIdentifier: "dm-\(toUserNum)", + serviceName: "Meshtastic", + sender: me, + attachments: nil + ) + } else { + let channelName = channelDisplayName(for: channel) + let groupName = INSpeakableString(spokenPhrase: channelName) + intent = INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: content, + speakableGroupName: groupName, + conversationIdentifier: "channel-\(channel)", + serviceName: "Meshtastic", + sender: me, + attachments: nil + ) + } + + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .outgoing + interaction.donate { error in + if let error { + Logger.services.error("🚗 [CarPlay] Failed to donate outgoing interaction: \(error.localizedDescription, privacy: .public)") + } + } + } + + // MARK: - Helpers + + private static func mePerson() -> INPerson { + let meHandle = INPersonHandle(value: "me", type: .unknown) + return INPerson( + personHandle: meHandle, + nameComponents: nil, + displayName: "Me", + image: nil, + contactIdentifier: "me", + customIdentifier: "me", + isMe: true + ) + } + + private static func channelDisplayName(for index: Int32) -> String { + if index == 0 { + return "Primary Channel" + } + return "Channel \(index)" + } +} diff --git a/Meshtastic/CarPlay/CarPlaySceneDelegate.swift b/Meshtastic/CarPlay/CarPlaySceneDelegate.swift new file mode 100644 index 00000000..e7ab2f3e --- /dev/null +++ b/Meshtastic/CarPlay/CarPlaySceneDelegate.swift @@ -0,0 +1,212 @@ +// MARK: CarPlaySceneDelegate +// +// CarPlaySceneDelegate.swift +// Meshtastic +// +// CarPlay Communication app scene delegate. +// For communication apps, the system provides the messaging UI. +// This delegate manages the CarPlay scene lifecycle and shows +// favorite contacts and channels for quick messaging via Siri. +// + +import CarPlay +import Combine +import CoreData +import Intents +import OSLog + +class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { + + var interfaceController: CPInterfaceController? + private var cancellables = Set() + private var context: NSManagedObjectContext { + PersistenceController.shared.container.viewContext + } + + // MARK: - CPTemplateApplicationSceneDelegate + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didConnect interfaceController: CPInterfaceController + ) { + Logger.services.info("🚗 [CarPlay] Connected") + self.interfaceController = interfaceController + + let rootTemplate = buildRootTemplate() + interfaceController.setRootTemplate(rootTemplate, animated: false, completion: nil) + + // Observe connection state changes and refresh the template + AccessoryManager.shared.$isConnected + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshRootTemplate() + } + .store(in: &cancellables) + } + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didDisconnectInterfaceController interfaceController: CPInterfaceController + ) { + Logger.services.info("🚗 [CarPlay] Disconnected") + cancellables.removeAll() + self.interfaceController = nil + } + + // MARK: - Root Template + + private func refreshRootTemplate() { + guard let interfaceController else { return } + let rootTemplate = buildRootTemplate() + interfaceController.setRootTemplate(rootTemplate, animated: true, completion: nil) + } + + private func buildRootTemplate() -> CPTemplate { + let connected = AccessoryManager.shared.isConnected + + var sections = [CPListSection]() + + // Status section + let statusItem = CPListItem( + text: connected ? "Connected" : "Not Connected", + detailText: connected + ? (AccessoryManager.shared.activeConnection?.device.name ?? "Unknown Device") + : "Open Meshtastic on your phone to connect", + image: UIImage(systemName: connected + ? "antenna.radiowaves.left.and.right" + : "antenna.radiowaves.left.and.right.slash") + ) + statusItem.isEnabled = false + sections.append(CPListSection(items: [statusItem], header: "Status", sectionIndexTitle: nil)) + + if connected { + // Favorite contacts section + let favoriteItems = fetchFavoriteContactItems() + if !favoriteItems.isEmpty { + sections.append(CPListSection(items: favoriteItems, header: "Favorites", sectionIndexTitle: nil)) + } + + // Channels section + let channelItems = fetchChannelItems() + if !channelItems.isEmpty { + sections.append(CPListSection(items: channelItems, header: "Channels", sectionIndexTitle: nil)) + } + } + + let listTemplate = CPListTemplate(title: "Meshtastic", sections: sections) + listTemplate.tabImage = UIImage(systemName: "antenna.radiowaves.left.and.right") + return listTemplate + } + + // MARK: - Data Fetching + + private func fetchFavoriteContactItems() -> [CPListItem] { + let request: NSFetchRequest = NodeInfoEntity.fetchRequest() + request.predicate = NSPredicate(format: "favorite == YES AND num != %lld", AccessoryManager.shared.activeDeviceNum ?? 0) + request.sortDescriptors = [ + NSSortDescriptor(key: "user.longName", ascending: true) + ] + request.relationshipKeyPathsForPrefetching = ["user"] + + do { + let nodes = try context.fetch(request) + return nodes.compactMap { node -> CPListItem? in + guard let user = node.user else { return nil } + let name = user.longName ?? user.shortName ?? "Unknown" + let shortName = user.shortName ?? "?" + let item = CPListItem( + text: name, + detailText: shortName, + image: UIImage(systemName: "person.circle.fill") + ) + item.handler = { [weak self] _, completion in + self?.startMessageIntent(toNodeNum: node.num, name: name) + completion() + } + item.isEnabled = true + return item + } + } catch { + Logger.services.error("🚗 [CarPlay] Failed to fetch favorites: \(error.localizedDescription, privacy: .public)") + return [] + } + } + + private func fetchChannelItems() -> [CPListItem] { + guard let connectedNum = AccessoryManager.shared.activeDeviceNum, + let connectedNode = getNodeInfo(id: connectedNum, context: context), + let myInfo = connectedNode.myInfo, + let channels = myInfo.channels?.array as? [ChannelEntity] else { + return [] + } + + return channels.compactMap { channel -> CPListItem? in + guard channel.role > 0 else { return nil } + let name = (channel.name?.isEmpty ?? true) + ? (channel.index == 0 ? "Primary Channel" : "Channel \(channel.index)") + : channel.name! + let item = CPListItem( + text: name, + detailText: channel.index == 0 ? "Primary" : "Ch \(channel.index)", + image: UIImage(systemName: channel.index == 0 ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right") + ) + item.handler = { [weak self] _, completion in + self?.startChannelMessageIntent(channelIndex: Int(channel.index), channelName: name) + completion() + } + item.isEnabled = true + return item + } + } + + // MARK: - Siri Message Intents + + private func startMessageIntent(toNodeNum: Int64, name: String) { + let person = INPerson( + personHandle: INPersonHandle(value: "\(toNodeNum)", type: .unknown), + nameComponents: nil, + displayName: name, + image: nil, + contactIdentifier: nil, + customIdentifier: "meshtastic-node-\(toNodeNum)" + ) + let intent = INSendMessageIntent( + recipients: [person], + outgoingMessageType: .outgoingMessageText, + content: nil, + speakableGroupName: nil, + conversationIdentifier: "dm-\(toNodeNum)", + serviceName: "Meshtastic", + sender: nil, + attachments: nil + ) + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .outgoing + interaction.donate { error in + if let error { + Logger.services.error("🚗 [CarPlay] DM intent donation error: \(error.localizedDescription, privacy: .public)") + } + } + } + + private func startChannelMessageIntent(channelIndex: Int, channelName: String) { + let groupName = INSpeakableString(spokenPhrase: channelName) + let intent = INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: nil, + speakableGroupName: groupName, + conversationIdentifier: "channel-\(channelIndex)", + serviceName: "Meshtastic", + sender: nil, + attachments: nil + ) + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .outgoing + interaction.donate { error in + if let error { + Logger.services.error("🚗 [CarPlay] Channel intent donation error: \(error.localizedDescription, privacy: .public)") + } + } + } +} diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index fdde3515..c3301c0e 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -1095,6 +1095,9 @@ actor MeshPackets { } // Send notifications if the message saved properly to core data if messageSaved { + // Donate to SiriKit so the message appears in CarPlay Messages + CarPlayIntentDonation.donateReceivedMessage(newMessage) + if packet.decoded.portnum == PortNum.detectionSensorApp && !UserDefaults.enableDetectionNotifications { return } diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index 02e9ce32..5aa47c8e 100644 --- a/Meshtastic/Info.plist +++ b/Meshtastic/Info.plist @@ -131,6 +131,20 @@ UIApplicationSupportsMultipleScenes + UISceneConfigurations + + CPTemplateApplicationSceneSessionRoleApplication + + + UISceneClassName + CPTemplateApplicationScene + UISceneConfigurationName + CarPlay + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate + + + UIApplicationSupportsIndirectInputEvents @@ -243,7 +257,5 @@ - com.apple.developer.carplay-communication -