Carplay tests

This commit is contained in:
Garth Vander Houwen 2026-04-16 18:41:13 -07:00
parent 36e8f59a02
commit cb279a1c31
5 changed files with 247 additions and 10 deletions

View file

@ -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 = "<group>"; };
AA000201CPLAY000000000001 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = "<group>"; };
AA000201CPLAY000000000003 /* CarPlayIntentDonation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayIntentDonation.swift; sourceTree = "<group>"; };
01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.swift; sourceTree = "<group>"; };
@ -935,6 +937,7 @@
children = (
AA00010022E2730EC0060000 /* ConnectViewTests.swift */,
25F5D5D02C4375DF008036E3 /* RouterTests.swift */,
AA000301CPTST000000000001 /* CarPlayTests.swift */,
);
path = MeshtasticTests;
sourceTree = "<group>";
@ -1710,6 +1713,7 @@
files = (
AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */,
25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */,
AA000301CPTST000000000002 /* CarPlayTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View file

@ -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"
}

View file

@ -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<AnyCancellable>()
@ -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,

View file

@ -133,6 +133,13 @@
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
</dict>
</array>
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
<array>
<dict>

View file

@ -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
}
}