mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Carplay communications app templates
This commit is contained in:
parent
1d6c20925d
commit
36e8f59a02
6 changed files with 389 additions and 3 deletions
|
|
@ -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 = "<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>"; };
|
||||
0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerConfig.swift; sourceTree = "<group>"; };
|
||||
09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKCertificateManager.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -759,6 +763,15 @@
|
|||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
AA000201CPLAY000000000005 /* CarPlay */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA000201CPLAY000000000001 /* CarPlaySceneDelegate.swift */,
|
||||
AA000201CPLAY000000000003 /* CarPlayIntentDonation.swift */,
|
||||
);
|
||||
path = CarPlay;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
140
Meshtastic/CarPlay/CarPlayIntentDonation.swift
Normal file
140
Meshtastic/CarPlay/CarPlayIntentDonation.swift
Normal file
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
212
Meshtastic/CarPlay/CarPlaySceneDelegate.swift
Normal file
212
Meshtastic/CarPlay/CarPlaySceneDelegate.swift
Normal file
|
|
@ -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<AnyCancellable>()
|
||||
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> = 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,20 @@
|
|||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>CPTemplateApplicationScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>CarPlay</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
|
|
@ -243,7 +257,5 @@
|
|||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>com.apple.developer.carplay-communication</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue