From cb279a1c315126775d865e847c822933346cf993 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Thu, 16 Apr 2026 18:41:13 -0700 Subject: [PATCH] Carplay tests --- Meshtastic.xcodeproj/project.pbxproj | 4 + .../CarPlay/CarPlayIntentDonation.swift | 7 +- Meshtastic/CarPlay/CarPlaySceneDelegate.swift | 64 ++++++- Meshtastic/Info.plist | 7 + MeshtasticTests/CarPlayTests.swift | 175 ++++++++++++++++++ 5 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 MeshtasticTests/CarPlayTests.swift diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 1c3e9fa1..71a18451 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + AA000301CPTST000000000002 /* CarPlayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000301CPTST000000000001 /* CarPlayTests.swift */; }; 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 */; }; @@ -356,6 +357,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + AA000301CPTST000000000001 /* CarPlayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -935,6 +937,7 @@ children = ( AA00010022E2730EC0060000 /* ConnectViewTests.swift */, 25F5D5D02C4375DF008036E3 /* RouterTests.swift */, + AA000301CPTST000000000001 /* CarPlayTests.swift */, ); path = MeshtasticTests; sourceTree = ""; @@ -1710,6 +1713,7 @@ files = ( AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */, 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */, + AA000301CPTST000000000002 /* CarPlayTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Meshtastic/CarPlay/CarPlayIntentDonation.swift b/Meshtastic/CarPlay/CarPlayIntentDonation.swift index f6e66196..c7e84d19 100644 --- a/Meshtastic/CarPlay/CarPlayIntentDonation.swift +++ b/Meshtastic/CarPlay/CarPlayIntentDonation.swift @@ -1,8 +1,9 @@ -// MARK: CarPlayIntentDonation // // CarPlayIntentDonation.swift // Meshtastic // +// Copyright(c) Garth Vander Houwen 4/16/26. +// // Donates SiriKit interactions when messages are received so that // conversations appear in CarPlay's messaging interface and Siri // can read them aloud. @@ -118,7 +119,7 @@ enum CarPlayIntentDonation { // MARK: - Helpers - private static func mePerson() -> INPerson { + static func mePerson() -> INPerson { let meHandle = INPersonHandle(value: "me", type: .unknown) return INPerson( personHandle: meHandle, @@ -131,7 +132,7 @@ enum CarPlayIntentDonation { ) } - private static func channelDisplayName(for index: Int32) -> String { + static func channelDisplayName(for index: Int32) -> String { if index == 0 { return "Primary Channel" } diff --git a/Meshtastic/CarPlay/CarPlaySceneDelegate.swift b/Meshtastic/CarPlay/CarPlaySceneDelegate.swift index e7ab2f3e..e7c376e3 100644 --- a/Meshtastic/CarPlay/CarPlaySceneDelegate.swift +++ b/Meshtastic/CarPlay/CarPlaySceneDelegate.swift @@ -1,12 +1,15 @@ -// MARK: CarPlaySceneDelegate // // CarPlaySceneDelegate.swift // Meshtastic // +// Copyright(c) Garth Vander Houwen 4/16/26. +// // 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. +// Tapping a favorite pushes a CPContactTemplate detail view +// with a native message button that launches Siri compose. // import CarPlay @@ -15,7 +18,7 @@ import CoreData import Intents import OSLog -class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { +class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPInterfaceControllerDelegate { var interfaceController: CPInterfaceController? private var cancellables = Set() @@ -31,6 +34,7 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { ) { Logger.services.info("🚗 [CarPlay] Connected") self.interfaceController = interfaceController + interfaceController.delegate = self let rootTemplate = buildRootTemplate() interfaceController.setRootTemplate(rootTemplate, animated: false, completion: nil) @@ -53,6 +57,13 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { self.interfaceController = nil } + // MARK: - CPInterfaceControllerDelegate + + func templateWillAppear(_ aTemplate: CPTemplate, animated: Bool) {} + func templateDidAppear(_ aTemplate: CPTemplate, animated: Bool) {} + func templateWillDisappear(_ aTemplate: CPTemplate, animated: Bool) {} + func templateDidDisappear(_ aTemplate: CPTemplate, animated: Bool) {} + // MARK: - Root Template private func refreshRootTemplate() { @@ -114,13 +125,16 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { guard let user = node.user else { return nil } let name = user.longName ?? user.shortName ?? "Unknown" let shortName = user.shortName ?? "?" + let unreadCount = user.unreadMessages(context: context) + + let detailText = unreadCount > 0 ? "\(shortName) · \(unreadCount) unread" : shortName let item = CPListItem( text: name, - detailText: shortName, + detailText: detailText, image: UIImage(systemName: "person.circle.fill") ) item.handler = { [weak self] _, completion in - self?.startMessageIntent(toNodeNum: node.num, name: name) + self?.pushContactTemplate(node: node) completion() } item.isEnabled = true @@ -145,9 +159,17 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { let name = (channel.name?.isEmpty ?? true) ? (channel.index == 0 ? "Primary Channel" : "Channel \(channel.index)") : channel.name! + let unreadCount = channel.unreadMessages(context: context) + + let detailText: String + if unreadCount > 0 { + detailText = (channel.index == 0 ? "Primary" : "Ch \(channel.index)") + " · \(unreadCount) unread" + } else { + detailText = channel.index == 0 ? "Primary" : "Ch \(channel.index)" + } let item = CPListItem( text: name, - detailText: channel.index == 0 ? "Primary" : "Ch \(channel.index)", + detailText: detailText, image: UIImage(systemName: channel.index == 0 ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right") ) item.handler = { [weak self] _, completion in @@ -159,9 +181,37 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { } } - // MARK: - Siri Message Intents + // MARK: - Contact Detail Template - private func startMessageIntent(toNodeNum: Int64, name: String) { + private func pushContactTemplate(node: NodeInfoEntity) { + guard let interfaceController, + let user = node.user else { return } + + let name = user.longName ?? user.shortName ?? "Unknown" + let shortName = user.shortName ?? "?" + + let placeholderImage = UIImage(systemName: "person.circle.fill")! + .withTintColor(.systemBlue, renderingMode: .alwaysOriginal) + let contact = CPContact(name: name, image: placeholderImage) + contact.subtitle = shortName + if node.hopsAway >= 0 { + contact.informativeText = node.hopsAway == 0 ? "Direct" : "\(node.hopsAway) hop\(node.hopsAway == 1 ? "" : "s") away" + } + + // Native message button that launches Siri compose flow + let messageButton = CPContactMessageButton(phoneOrEmail: name) + contact.actions = [messageButton] + + // Also donate the intent so Siri has context for this contact + donateMessageIntent(toNodeNum: node.num, name: name) + + let contactTemplate = CPContactTemplate(contact: contact) + interfaceController.pushTemplate(contactTemplate, animated: true, completion: nil) + } + + // MARK: - Intent Donation + + private func donateMessageIntent(toNodeNum: Int64, name: String) { let person = INPerson( personHandle: INPersonHandle(value: "\(toNodeNum)", type: .unknown), nameComponents: nil, diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index 5aa47c8e..9a5f03bc 100644 --- a/Meshtastic/Info.plist +++ b/Meshtastic/Info.plist @@ -133,6 +133,13 @@ UISceneConfigurations + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + + CPTemplateApplicationSceneSessionRoleApplication diff --git a/MeshtasticTests/CarPlayTests.swift b/MeshtasticTests/CarPlayTests.swift new file mode 100644 index 00000000..61b33929 --- /dev/null +++ b/MeshtasticTests/CarPlayTests.swift @@ -0,0 +1,175 @@ +// +// CarPlayTests.swift +// MeshtasticTests +// +// Copyright(c) Garth Vander Houwen 4/16/26. +// + +import CarPlay +import CoreData +import Foundation +import Intents +import Testing + +@testable import Meshtastic + +// MARK: - CarPlaySceneDelegate Tests + +@Suite("CarPlaySceneDelegate") +struct CarPlaySceneDelegateTests { + + @Test func initialState() { + let delegate = CarPlaySceneDelegate() + #expect(delegate.interfaceController == nil) + } + + @Test func disconnectClearsInterfaceController() { + let delegate = CarPlaySceneDelegate() + // Simulate that interface controller was set during connect + delegate.interfaceController = nil + #expect(delegate.interfaceController == nil) + } +} + +// MARK: - CarPlayIntentDonation Tests + +@Suite("CarPlayIntentDonation") +struct CarPlayIntentDonationTests { + + // MARK: - channelDisplayName + + @Test func channelDisplayNamePrimary() { + let name = CarPlayIntentDonation.testChannelDisplayName(for: 0) + #expect(name == "Primary Channel") + } + + @Test func channelDisplayNameSecondary() { + let name = CarPlayIntentDonation.testChannelDisplayName(for: 1) + #expect(name == "Channel 1") + } + + @Test func channelDisplayNameHighIndex() { + let name = CarPlayIntentDonation.testChannelDisplayName(for: 7) + #expect(name == "Channel 7") + } + + // MARK: - mePerson + + @Test func mePersonIsMe() { + let me = CarPlayIntentDonation.testMePerson() + #expect(me.isMe) + #expect(me.displayName == "Me") + #expect(me.personHandle?.value == "me") + } + + // MARK: - Outgoing DM Intent Structure + + @Test func outgoingDMIntentHasCorrectConversationId() { + let intent = CarPlayIntentDonation.testBuildOutgoingIntent( + content: "Hello mesh", + toUserNum: 1234567890, + channel: 0 + ) + #expect(intent.conversationIdentifier == "dm-1234567890") + #expect(intent.serviceName == "Meshtastic") + #expect(intent.content == "Hello mesh") + #expect(intent.recipients?.count == 1) + #expect(intent.speakableGroupName == nil) + } + + @Test func outgoingChannelIntentHasCorrectConversationId() { + let intent = CarPlayIntentDonation.testBuildOutgoingIntent( + content: "Channel message", + toUserNum: 0, + channel: 2 + ) + #expect(intent.conversationIdentifier == "channel-2") + #expect(intent.serviceName == "Meshtastic") + #expect(intent.content == "Channel message") + #expect(intent.recipients == nil) + #expect(intent.speakableGroupName?.spokenPhrase == "Channel 2") + } + + @Test func outgoingPrimaryChannelIntentName() { + let intent = CarPlayIntentDonation.testBuildOutgoingIntent( + content: "Test", + toUserNum: 0, + channel: 0 + ) + #expect(intent.speakableGroupName?.spokenPhrase == "Primary Channel") + } + + // MARK: - Interaction Direction + + @Test func outgoingInteractionDirection() { + let interaction = CarPlayIntentDonation.testBuildOutgoingInteraction( + content: "Test", + toUserNum: 999, + channel: 0 + ) + #expect(interaction.direction == .outgoing) + } +} + +// MARK: - Test Helpers Extension + +extension CarPlayIntentDonation { + + /// Exposes channelDisplayName for testing + static func testChannelDisplayName(for index: Int32) -> String { + channelDisplayName(for: index) + } + + /// Exposes mePerson for testing + static func testMePerson() -> INPerson { + mePerson() + } + + /// Builds an outgoing INSendMessageIntent without donating + static func testBuildOutgoingIntent(content: String, toUserNum: Int64, channel: Int32) -> INSendMessageIntent { + let me = mePerson() + + 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) + ) + return 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) + return INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: content, + speakableGroupName: groupName, + conversationIdentifier: "channel-\(channel)", + serviceName: "Meshtastic", + sender: me, + attachments: nil + ) + } + } + + /// Builds an outgoing INInteraction without donating + static func testBuildOutgoingInteraction(content: String, toUserNum: Int64, channel: Int32) -> INInteraction { + let intent = testBuildOutgoingIntent(content: content, toUserNum: toUserNum, channel: channel) + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .outgoing + return interaction + } +}