mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Carplay tests
This commit is contained in:
parent
36e8f59a02
commit
cb279a1c31
5 changed files with 247 additions and 10 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
175
MeshtasticTests/CarPlayTests.swift
Normal file
175
MeshtasticTests/CarPlayTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue