From 5e29f6ca3c6ba95c4c550d55ea75f45a500ee5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 5 May 2026 14:09:07 +0200 Subject: [PATCH 01/23] CarPlay tweaks for assist + debug settings --- Sources/App/Assist/Audio/AudioRecorder.swift | 14 +- Sources/App/Settings/DebugView.swift | 54 ++++++ .../QuickAccess/CarPlayAssistSession.swift | 182 +++++++++++++++++- Sources/Shared/Settings/SettingsStore.swift | 133 +++++++++++++ 4 files changed, 368 insertions(+), 15 deletions(-) diff --git a/Sources/App/Assist/Audio/AudioRecorder.swift b/Sources/App/Assist/Audio/AudioRecorder.swift index 274c66d3cf..53e08a4008 100644 --- a/Sources/App/Assist/Audio/AudioRecorder.swift +++ b/Sources/App/Assist/Audio/AudioRecorder.swift @@ -5,6 +5,7 @@ import Shared protocol AudioRecorderProtocol { var delegate: AudioRecorderDelegate? { get set } var audioSampleRate: Double? { get } + var managesAudioSession: Bool { get set } func startRecording() func stopRecording() } @@ -23,6 +24,7 @@ enum AudioRecorderError: Error { final class AudioRecorder: NSObject, AudioRecorderProtocol { weak var delegate: AudioRecorderDelegate? + var managesAudioSession = true private(set) var audioSampleRate: Double? private var captureSession: AVCaptureSession? @@ -63,11 +65,13 @@ final class AudioRecorder: NSObject, AudioRecorderProtocol { } do { - try audioSession.setActive(false) - try audioSession.setCategory(.record, mode: .default) - try audioSession.setPreferredOutputNumberOfChannels(1) - try audioSession.setPreferredSampleRate(16000.0) - try audioSession.setActive(true) + if managesAudioSession { + try audioSession.setActive(false) + try audioSession.setCategory(.record, mode: .default) + try audioSession.setPreferredOutputNumberOfChannels(1) + try audioSession.setPreferredSampleRate(16000.0) + try audioSession.setActive(true) + } let audioInput = try AVCaptureDeviceInput(device: captureDevice) captureSession = AVCaptureSession() diff --git a/Sources/App/Settings/DebugView.swift b/Sources/App/Settings/DebugView.swift index b0f8df1cd9..38875fe12f 100644 --- a/Sources/App/Settings/DebugView.swift +++ b/Sources/App/Settings/DebugView.swift @@ -160,6 +160,8 @@ struct DebugView: View { } #endif + carPlayDebugSection + criticalSection if tapsOnCasitaLogo < 10 { @@ -323,6 +325,58 @@ struct DebugView: View { } } + private var carPlayDebugSection: some View { + Section { + Picker("Audio category", selection: Binding( + get: { Current.settingsStore.carPlayAssistAudioCategory }, + set: { Current.settingsStore.carPlayAssistAudioCategory = $0 } + )) { + ForEach(SettingsStore.CarPlayAssistAudioCategory.allCases, id: \.self) { category in + Text(category.title).tag(category) + } + } + + Picker("Audio mode", selection: Binding( + get: { Current.settingsStore.carPlayAssistAudioMode }, + set: { Current.settingsStore.carPlayAssistAudioMode = $0 } + )) { + ForEach(SettingsStore.CarPlayAssistAudioMode.allCases, id: \.self) { mode in + Text(mode.title).tag(mode) + } + } + + Toggle("Allow Bluetooth HFP", isOn: Binding( + get: { Current.settingsStore.carPlayAssistAllowBluetoothHFP }, + set: { Current.settingsStore.carPlayAssistAllowBluetoothHFP = $0 } + )) + + Toggle("Allow Bluetooth A2DP", isOn: Binding( + get: { Current.settingsStore.carPlayAssistAllowBluetoothA2DP }, + set: { Current.settingsStore.carPlayAssistAllowBluetoothA2DP = $0 } + )) + + Toggle("Play recording indicator tone", isOn: Binding( + get: { Current.settingsStore.carPlayAssistPlayRecordingIndicatorTone }, + set: { Current.settingsStore.carPlayAssistPlayRecordingIndicatorTone = $0 } + )) + + Toggle("AudioRecorder manages audio session", isOn: Binding( + get: { Current.settingsStore.carPlayAssistRecorderManagesAudioSession }, + set: { Current.settingsStore.carPlayAssistRecorderManagesAudioSession = $0 } + )) + + Button("Reset CarPlay audio defaults") { + Current.settingsStore.resetCarPlayAssistDebugSettings() + } + } header: { + Text("CarPlay") + } footer: { + Text( + "These values apply to the next CarPlay Assist session. Unsupported category and mode combinations fall back to system behavior and are logged." + ) + } + } + private var developerSection: some View { Section { Toggle("Toasts handled by the app", isOn: Binding( diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift index 2594629cf4..7e3d6d3ae2 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift @@ -1,3 +1,4 @@ +import AudioToolbox import AVFoundation import CarPlay import Foundation @@ -29,8 +30,10 @@ final class CarPlayAssistSession: NSObject { weak var interfaceController: CPInterfaceController? var onStop: OnStop? + private let audioSession = AVAudioSession.sharedInstance() private var assistService: AssistServiceProtocol private var audioRecorder: AudioRecorderProtocol + private var recordingIndicatorPlayer: AVAudioPlayer? private let ttsPlayer = AVPlayer() /// Serial queue protecting all mutable session state (`canSendAudioData`, `state`, `isStopped`). @@ -81,6 +84,8 @@ final class CarPlayAssistSession: NSObject { self.audioRecorder = audioRecorder self.assistService = assistService ?? AssistService(server: server) super.init() + self.audioRecorder.managesAudioSession = Current.settingsStore.carPlayAssistRecorderManagesAudioSession + registerForAudioSessionNotifications() } deinit { @@ -95,6 +100,7 @@ final class CarPlayAssistSession: NSObject { isStopped = false canSendAudioData = false } + configureAudioSessionForAssist() activateVoiceControlState(for: .recording) interfaceController?.presentTemplate(template, animated: true, completion: nil) audioRecorder.startRecording() @@ -131,28 +137,130 @@ final class CarPlayAssistSession: NSObject { // MARK: - Audio Session + private func configureAudioSessionForAssist() { + do { + var options: AVAudioSession.CategoryOptions = [] + if Current.settingsStore.carPlayAssistAllowBluetoothHFP { + options.insert(.allowBluetoothHFP) + } + if Current.settingsStore.carPlayAssistAllowBluetoothA2DP { + options.insert(.allowBluetoothA2DP) + } + + try audioSession.setCategory( + Current.settingsStore.carPlayAssistAudioCategory.avCategory, + mode: Current.settingsStore.carPlayAssistAudioMode.avMode, + options: options + ) + try audioSession.setPreferredSampleRate(16000.0) + try audioSession.setActive(true) + logCurrentAudioRoute(context: "activated") + } catch { + Current.Log.error("CarPlay Assist failed to configure audio session: \(error.localizedDescription)") + } + } + private func deactivateAudioSession() { do { - try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + try audioSession.setActive(false, options: .notifyOthersOnDeactivation) } catch { Current.Log.error("CarPlay Assist failed to deactivate audio session: \(error.localizedDescription)") } } - // MARK: - TTS Playback + private func registerForAudioSessionNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioSessionInterruption(_:)), + name: AVAudioSession.interruptionNotification, + object: audioSession + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioSessionRouteChange(_:)), + name: AVAudioSession.routeChangeNotification, + object: audioSession + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleMediaServicesWereReset), + name: AVAudioSession.mediaServicesWereResetNotification, + object: audioSession + ) + } + + @objc private func handleAudioSessionInterruption(_ notification: Notification) { + guard let rawType = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: rawType) else { + Current.Log.error("CarPlay Assist received audio interruption without a valid type") + return + } + + switch type { + case .began: + Current.Log.info("CarPlay Assist audio session interruption began") + let stopped = stateQueue.sync { isStopped } + guard !stopped else { return } + stop() + case .ended: + let rawOptions = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0 + let options = AVAudioSession.InterruptionOptions(rawValue: rawOptions) + Current.Log + .info( + "CarPlay Assist audio session interruption ended, shouldResume: \(options.contains(.shouldResume))" + ) + @unknown default: + Current.Log.info("CarPlay Assist audio session interruption ended with unknown type") + } + } + + @objc private func handleAudioSessionRouteChange(_ notification: Notification) { + let reasonDescription: String + if let rawReason = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: rawReason) { + reasonDescription = String(describing: reason) + } else { + reasonDescription = "unknown" + } + + Current.Log.info("CarPlay Assist audio route changed: \(reasonDescription)") + logCurrentAudioRoute(context: "route change") + } + + @objc private func handleMediaServicesWereReset() { + let stopped = stateQueue.sync { isStopped } + guard !stopped else { return } + + Current.Log.error("CarPlay Assist audio media services were reset") + configureAudioSessionForAssist() + } + + private func logCurrentAudioRoute(context: String) { + let inputs = audioSession.currentRoute.inputs.map(\.portType.rawValue).joined(separator: ",") + let outputs = audioSession.currentRoute.outputs.map(\.portType.rawValue).joined(separator: ",") + Current.Log.info("CarPlay Assist audio route \(context). inputs: [\(inputs)] outputs: [\(outputs)]") + } + + private func playRecordingIndicatorToneIfNeeded() { + guard Current.settingsStore.carPlayAssistPlayRecordingIndicatorTone else { return } - /// Plays TTS audio directly via AVPlayer, bypassing the phone volume check - /// that would incorrectly skip playback in the CarPlay context. - private func playTTS(url: URL) { do { - let audioSession = AVAudioSession.sharedInstance() - try audioSession.setActive(false) - try audioSession.setCategory(.playback, mode: .default, options: [.duckOthers]) - try audioSession.setActive(true) + recordingIndicatorPlayer = try AVAudioPlayer(data: Self.recordingIndicatorToneData) + recordingIndicatorPlayer?.volume = 0.7 + recordingIndicatorPlayer?.prepareToPlay() + recordingIndicatorPlayer?.play() } catch { - Current.Log.error("CarPlay Assist failed to setup audio session for TTS: \(error.localizedDescription)") + Current.Log.error("CarPlay Assist failed to play recording indicator tone: \(error.localizedDescription)") + AudioServicesPlaySystemSound(1113) } + } + // MARK: - TTS Playback + + /// Plays TTS audio using the already active conversational audio session to preserve the car route. + private func playTTS(url: URL) { NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) let playerItem = AVPlayerItem(url: url) @@ -216,6 +324,7 @@ final class CarPlayAssistSession: NSObject { @available(iOS 16.0, *) extension CarPlayAssistSession: AudioRecorderDelegate { func didStartRecording(with sampleRate: Double) { + playRecordingIndicatorToneIfNeeded() assistService.assist(source: .audio( pipelineId: pipelineId, audioSampleRate: sampleRate, @@ -245,6 +354,59 @@ extension CarPlayAssistSession: AudioRecorderDelegate { } } +@available(iOS 16.0, *) +private extension CarPlayAssistSession { + static let recordingIndicatorToneData: Data = { + let sampleRate = 24000 + let duration = 0.12 + let frequency = 880.0 + let frameCount = Int(Double(sampleRate) * duration) + let amplitude = 0.25 + + var pcmData = Data(capacity: frameCount * MemoryLayout.size) + + for frame in 0 ..< frameCount { + let progress = Double(frame) / Double(frameCount) + let envelope = min(progress / 0.1, (1.0 - progress) / 0.15, 1.0) + let sample = sin(2.0 * .pi * frequency * progress * duration) * amplitude * envelope + let intSample = Int16(max(-1.0, min(1.0, sample)) * Double(Int16.max)) + var littleEndianSample = intSample.littleEndian + pcmData.append(Data(bytes: &littleEndianSample, count: MemoryLayout.size)) + } + + let bytesPerSample = MemoryLayout.size + let subchunk2Size = frameCount * bytesPerSample + let chunkSize = 36 + subchunk2Size + let byteRate = sampleRate * bytesPerSample + let blockAlign = UInt16(bytesPerSample) + let bitsPerSample: UInt16 = 16 + let channels: UInt16 = 1 + let audioFormat: UInt16 = 1 + + func littleEndianData(_ value: T) -> Data { + var littleEndian = value.littleEndian + return Data(bytes: &littleEndian, count: MemoryLayout.size) + } + + var wavData = Data() + wavData.append("RIFF".data(using: .ascii)!) + wavData.append(littleEndianData(UInt32(chunkSize))) + wavData.append("WAVE".data(using: .ascii)!) + wavData.append("fmt ".data(using: .ascii)!) + wavData.append(littleEndianData(UInt32(16))) + wavData.append(littleEndianData(audioFormat)) + wavData.append(littleEndianData(channels)) + wavData.append(littleEndianData(UInt32(sampleRate))) + wavData.append(littleEndianData(UInt32(byteRate))) + wavData.append(littleEndianData(blockAlign)) + wavData.append(littleEndianData(bitsPerSample)) + wavData.append("data".data(using: .ascii)!) + wavData.append(littleEndianData(UInt32(subchunk2Size))) + wavData.append(pcmData) + return wavData + }() +} + // MARK: - AssistServiceDelegate @available(iOS 16.0, *) diff --git a/Sources/Shared/Settings/SettingsStore.swift b/Sources/Shared/Settings/SettingsStore.swift index 1cc470b247..a05bfde422 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -1,3 +1,4 @@ +import AVFoundation import CoreLocation import CoreMotion import Foundation @@ -6,6 +7,61 @@ import UIKit import Version public class SettingsStore { + public enum CarPlayAssistAudioCategory: String, CaseIterable { + case playAndRecord + case playback + case record + + public var avCategory: AVAudioSession.Category { + switch self { + case .playAndRecord: + .playAndRecord + case .playback: + .playback + case .record: + .record + } + } + + public var title: String { + switch self { + case .playAndRecord: + "playAndRecord" + case .playback: + "playback" + case .record: + "record" + } + } + } + + public enum CarPlayAssistAudioMode: String, CaseIterable { + case `default` + case voiceChat + case voicePrompt + case spokenAudio + case measurement + + public var avMode: AVAudioSession.Mode { + switch self { + case .default: + .default + case .voiceChat: + .voiceChat + case .voicePrompt: + .voicePrompt + case .spokenAudio: + .spokenAudio + case .measurement: + .measurement + } + } + + public var title: String { + rawValue + } + } + let keychain = AppConstants.Keychain let prefs = UserDefaults(suiteName: AppConstants.AppGroupID)! @@ -509,6 +565,83 @@ public class SettingsStore { } } + public var carPlayAssistAudioCategory: CarPlayAssistAudioCategory { + get { + guard let rawValue = prefs.string(forKey: "carPlayAssistAudioCategory"), + let value = CarPlayAssistAudioCategory(rawValue: rawValue) else { + return .playAndRecord + } + return value + } + set { + prefs.set(newValue.rawValue, forKey: "carPlayAssistAudioCategory") + } + } + + public var carPlayAssistAudioMode: CarPlayAssistAudioMode { + get { + guard let rawValue = prefs.string(forKey: "carPlayAssistAudioMode"), + let value = CarPlayAssistAudioMode(rawValue: rawValue) else { + return .voiceChat + } + return value + } + set { + prefs.set(newValue.rawValue, forKey: "carPlayAssistAudioMode") + } + } + + public var carPlayAssistAllowBluetoothHFP: Bool { + get { + if prefs.object(forKey: "carPlayAssistAllowBluetoothHFP") == nil { + return true + } + return prefs.bool(forKey: "carPlayAssistAllowBluetoothHFP") + } + set { + prefs.set(newValue, forKey: "carPlayAssistAllowBluetoothHFP") + } + } + + public var carPlayAssistAllowBluetoothA2DP: Bool { + get { + if prefs.object(forKey: "carPlayAssistAllowBluetoothA2DP") == nil { + return true + } + return prefs.bool(forKey: "carPlayAssistAllowBluetoothA2DP") + } + set { + prefs.set(newValue, forKey: "carPlayAssistAllowBluetoothA2DP") + } + } + + public var carPlayAssistPlayRecordingIndicatorTone: Bool { + get { + prefs.bool(forKey: "carPlayAssistPlayRecordingIndicatorTone") + } + set { + prefs.set(newValue, forKey: "carPlayAssistPlayRecordingIndicatorTone") + } + } + + public var carPlayAssistRecorderManagesAudioSession: Bool { + get { + prefs.bool(forKey: "carPlayAssistRecorderManagesAudioSession") + } + set { + prefs.set(newValue, forKey: "carPlayAssistRecorderManagesAudioSession") + } + } + + public func resetCarPlayAssistDebugSettings() { + carPlayAssistAudioCategory = .playAndRecord + carPlayAssistAudioMode = .voiceChat + carPlayAssistAllowBluetoothHFP = true + carPlayAssistAllowBluetoothA2DP = true + carPlayAssistPlayRecordingIndicatorTone = false + carPlayAssistRecorderManagesAudioSession = false + } + // MARK: - Private helpers private var defaultDeviceID: String { From 7daaf1bc075bff2c34cbbfd1d57a2e2566f0854e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 5 May 2026 15:05:09 +0200 Subject: [PATCH 02/23] More tweaks --- Sources/App/Settings/DebugView.swift | 223 +++++++++++++---- .../QuickAccess/CarPlayAssistSession.swift | 235 +++++++++++++++++- Sources/Shared/Settings/SettingsStore.swift | 209 ++++++++++++++++ 3 files changed, 614 insertions(+), 53 deletions(-) diff --git a/Sources/App/Settings/DebugView.swift b/Sources/App/Settings/DebugView.swift index 38875fe12f..049cbeed16 100644 --- a/Sources/App/Settings/DebugView.swift +++ b/Sources/App/Settings/DebugView.swift @@ -327,53 +327,18 @@ struct DebugView: View { private var carPlayDebugSection: some View { Section { - Picker("Audio category", selection: Binding( - get: { Current.settingsStore.carPlayAssistAudioCategory }, - set: { Current.settingsStore.carPlayAssistAudioCategory = $0 } - )) { - ForEach(SettingsStore.CarPlayAssistAudioCategory.allCases, id: \.self) { category in - Text(category.title).tag(category) - } - } - - Picker("Audio mode", selection: Binding( - get: { Current.settingsStore.carPlayAssistAudioMode }, - set: { Current.settingsStore.carPlayAssistAudioMode = $0 } - )) { - ForEach(SettingsStore.CarPlayAssistAudioMode.allCases, id: \.self) { mode in - Text(mode.title).tag(mode) - } - } - - Toggle("Allow Bluetooth HFP", isOn: Binding( - get: { Current.settingsStore.carPlayAssistAllowBluetoothHFP }, - set: { Current.settingsStore.carPlayAssistAllowBluetoothHFP = $0 } - )) - - Toggle("Allow Bluetooth A2DP", isOn: Binding( - get: { Current.settingsStore.carPlayAssistAllowBluetoothA2DP }, - set: { Current.settingsStore.carPlayAssistAllowBluetoothA2DP = $0 } - )) - - Toggle("Play recording indicator tone", isOn: Binding( - get: { Current.settingsStore.carPlayAssistPlayRecordingIndicatorTone }, - set: { Current.settingsStore.carPlayAssistPlayRecordingIndicatorTone = $0 } - )) - - Toggle("AudioRecorder manages audio session", isOn: Binding( - get: { Current.settingsStore.carPlayAssistRecorderManagesAudioSession }, - set: { Current.settingsStore.carPlayAssistRecorderManagesAudioSession = $0 } - )) - - Button("Reset CarPlay audio defaults") { - Current.settingsStore.resetCarPlayAssistDebugSettings() + NavigationLink { + CarPlayDebugSettingsView() + } label: { + linkContent( + image: .init(systemSymbol: .carFill), + title: "CarPlay Debug Settings" + ) } } header: { Text("CarPlay") } footer: { - Text( - "These values apply to the next CarPlay Assist session. Unsupported category and mode combinations fall back to system behavior and are logged." - ) + Text("CarPlay audio and TTS debugging controls live here to avoid cluttering the main debug screen.") } } @@ -638,6 +603,178 @@ struct DebugView: View { } } +private struct CarPlayDebugSettingsView: View { + var body: some View { + List { + assistSessionSection + ttsPlaybackSection + ttsSessionSection + resetSection + } + .navigationTitle("CarPlay Debug") + .navigationBarTitleDisplayMode(.inline) + } + + private var assistSessionSection: some View { + Section { + Picker("Audio category", selection: Binding( + get: { Current.settingsStore.carPlayAssistAudioCategory }, + set: { Current.settingsStore.carPlayAssistAudioCategory = $0 } + )) { + ForEach(SettingsStore.CarPlayAssistAudioCategory.allCases, id: \.self) { category in + Text(category.title).tag(category) + } + } + + Picker("Audio mode", selection: Binding( + get: { Current.settingsStore.carPlayAssistAudioMode }, + set: { Current.settingsStore.carPlayAssistAudioMode = $0 } + )) { + ForEach(SettingsStore.CarPlayAssistAudioMode.allCases, id: \.self) { mode in + Text(mode.title).tag(mode) + } + } + + Picker("Preferred sample rate", selection: Binding( + get: { Current.settingsStore.carPlayAssistPreferredSampleRate }, + set: { Current.settingsStore.carPlayAssistPreferredSampleRate = $0 } + )) { + ForEach(SettingsStore.CarPlayAssistPreferredSampleRate.allCases, id: \.self) { sampleRate in + Text(sampleRate.title).tag(sampleRate) + } + } + + Toggle("Allow Bluetooth HFP", isOn: Binding( + get: { Current.settingsStore.carPlayAssistAllowBluetoothHFP }, + set: { Current.settingsStore.carPlayAssistAllowBluetoothHFP = $0 } + )) + + Toggle("Allow Bluetooth A2DP", isOn: Binding( + get: { Current.settingsStore.carPlayAssistAllowBluetoothA2DP }, + set: { Current.settingsStore.carPlayAssistAllowBluetoothA2DP = $0 } + )) + + Toggle("AudioRecorder manages audio session", isOn: Binding( + get: { Current.settingsStore.carPlayAssistRecorderManagesAudioSession }, + set: { Current.settingsStore.carPlayAssistRecorderManagesAudioSession = $0 } + )) + + Toggle("Play recording indicator tone", isOn: Binding( + get: { Current.settingsStore.carPlayAssistPlayRecordingIndicatorTone }, + set: { Current.settingsStore.carPlayAssistPlayRecordingIndicatorTone = $0 } + )) + } header: { + Text("Assist Session") + } footer: { + Text("These values apply when a new CarPlay Assist session starts.") + } + } + + private var ttsPlaybackSection: some View { + Section { + Picker("Playback strategy", selection: Binding( + get: { Current.settingsStore.carPlayAssistTTSPlaybackStrategy }, + set: { Current.settingsStore.carPlayAssistTTSPlaybackStrategy = $0 } + )) { + ForEach(SettingsStore.CarPlayAssistTTSPlaybackStrategy.allCases, id: \.self) { strategy in + Text(strategy.title).tag(strategy) + } + } + + Picker("Playback delay", selection: Binding( + get: { Current.settingsStore.carPlayAssistTTSPlaybackDelay }, + set: { Current.settingsStore.carPlayAssistTTSPlaybackDelay = $0 } + )) { + ForEach(SettingsStore.CarPlayAssistPlaybackDelay.allCases, id: \.self) { delay in + Text(delay.title).tag(delay) + } + } + + Toggle("AVPlayer waits to minimize stalling", isOn: Binding( + get: { Current.settingsStore.carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling }, + set: { Current.settingsStore.carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling = $0 } + )) + } header: { + Text("TTS Playback") + } footer: { + Text( + "Use the downloaded AVAudioPlayer strategy to determine whether the failure is tied to AVPlayer or remote URL playback." + ) + } + } + + private var ttsSessionSection: some View { + Section { + Toggle("Reconfigure before TTS", isOn: Binding( + get: { Current.settingsStore.carPlayAssistTTSReconfigureAudioSession }, + set: { Current.settingsStore.carPlayAssistTTSReconfigureAudioSession = $0 } + )) + + Toggle("Deactivate before reconfigure", isOn: Binding( + get: { Current.settingsStore.carPlayAssistTTSDeactivateBeforeReconfigure }, + set: { Current.settingsStore.carPlayAssistTTSDeactivateBeforeReconfigure = $0 } + )) + + Toggle("Activate audio session before play", isOn: Binding( + get: { Current.settingsStore.carPlayAssistTTSActivateAudioSession }, + set: { Current.settingsStore.carPlayAssistTTSActivateAudioSession = $0 } + )) + + Picker("TTS category", selection: Binding( + get: { Current.settingsStore.carPlayAssistTTSCategory }, + set: { Current.settingsStore.carPlayAssistTTSCategory = $0 } + )) { + ForEach(SettingsStore.CarPlayAssistAudioCategory.allCases, id: \.self) { category in + Text(category.title).tag(category) + } + } + + Picker("TTS mode", selection: Binding( + get: { Current.settingsStore.carPlayAssistTTSMode }, + set: { Current.settingsStore.carPlayAssistTTSMode = $0 } + )) { + ForEach(SettingsStore.CarPlayAssistAudioMode.allCases, id: \.self) { mode in + Text(mode.title).tag(mode) + } + } + + Toggle("TTS allow Bluetooth HFP", isOn: Binding( + get: { Current.settingsStore.carPlayAssistTTSAllowBluetoothHFP }, + set: { Current.settingsStore.carPlayAssistTTSAllowBluetoothHFP = $0 } + )) + + Toggle("TTS allow Bluetooth A2DP", isOn: Binding( + get: { Current.settingsStore.carPlayAssistTTSAllowBluetoothA2DP }, + set: { Current.settingsStore.carPlayAssistTTSAllowBluetoothA2DP = $0 } + )) + + Toggle("TTS duck others", isOn: Binding( + get: { Current.settingsStore.carPlayAssistTTSDuckOthers }, + set: { Current.settingsStore.carPlayAssistTTSDuckOthers = $0 } + )) + + Toggle("TTS interrupt spoken audio", isOn: Binding( + get: { Current.settingsStore.carPlayAssistTTSInterruptSpokenAudio }, + set: { Current.settingsStore.carPlayAssistTTSInterruptSpokenAudio = $0 } + )) + } header: { + Text("TTS Audio Session") + } footer: { + Text( + "This section lets you force a dedicated TTS session reconfiguration, which is the most likely area if another app starting playback makes the response suddenly audible." + ) + } + } + + private var resetSection: some View { + Section { + Button("Reset CarPlay debug defaults", role: .destructive) { + Current.settingsStore.resetCarPlayAssistDebugSettings() + } + } + } +} + private struct DeleteKeychainAlertModifier: ViewModifier { @Binding var isPresented: Bool @Binding var confirmationText: String diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift index 7e3d6d3ae2..41e3835194 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift @@ -34,7 +34,10 @@ final class CarPlayAssistSession: NSObject { private var assistService: AssistServiceProtocol private var audioRecorder: AudioRecorderProtocol private var recordingIndicatorPlayer: AVAudioPlayer? + private var ttsAudioPlayer: AVAudioPlayer? private let ttsPlayer = AVPlayer() + private var ttsPlayerItemStatusObservation: NSKeyValueObservation? + private var ttsPlayerTimeControlObservation: NSKeyValueObservation? /// Serial queue protecting all mutable session state (`canSendAudioData`, `state`, `isStopped`). /// Callbacks from AVCaptureSession, HAKit, and NotificationCenter may arrive on arbitrary threads. @@ -125,8 +128,11 @@ final class CarPlayAssistSession: NSObject { guard !alreadyStopped else { return } audioRecorder.stopRecording() assistService.finishSendingAudio() + ttsAudioPlayer?.stop() + ttsAudioPlayer = nil ttsPlayer.pause() ttsPlayer.replaceCurrentItem(with: nil) + clearTTSPlayerObservers() deactivateAudioSession() NotificationCenter.default.removeObserver(self) if dismissTemplate { @@ -139,20 +145,17 @@ final class CarPlayAssistSession: NSObject { private func configureAudioSessionForAssist() { do { - var options: AVAudioSession.CategoryOptions = [] - if Current.settingsStore.carPlayAssistAllowBluetoothHFP { - options.insert(.allowBluetoothHFP) - } - if Current.settingsStore.carPlayAssistAllowBluetoothA2DP { - options.insert(.allowBluetoothA2DP) - } - try audioSession.setCategory( Current.settingsStore.carPlayAssistAudioCategory.avCategory, mode: Current.settingsStore.carPlayAssistAudioMode.avMode, - options: options + options: makeAudioSessionOptions( + allowBluetoothHFP: Current.settingsStore.carPlayAssistAllowBluetoothHFP, + allowBluetoothA2DP: Current.settingsStore.carPlayAssistAllowBluetoothA2DP, + duckOthers: false, + interruptSpokenAudio: false + ) ) - try audioSession.setPreferredSampleRate(16000.0) + try audioSession.setPreferredSampleRate(Current.settingsStore.carPlayAssistPreferredSampleRate.value) try audioSession.setActive(true) logCurrentAudioRoute(context: "activated") } catch { @@ -160,6 +163,57 @@ final class CarPlayAssistSession: NSObject { } } + private func configureAudioSessionForTTSIfNeeded() { + guard Current.settingsStore.carPlayAssistTTSReconfigureAudioSession else { return } + + do { + if Current.settingsStore.carPlayAssistTTSDeactivateBeforeReconfigure { + try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + } + + try audioSession.setCategory( + Current.settingsStore.carPlayAssistTTSCategory.avCategory, + mode: Current.settingsStore.carPlayAssistTTSMode.avMode, + options: makeAudioSessionOptions( + allowBluetoothHFP: Current.settingsStore.carPlayAssistTTSAllowBluetoothHFP, + allowBluetoothA2DP: Current.settingsStore.carPlayAssistTTSAllowBluetoothA2DP, + duckOthers: Current.settingsStore.carPlayAssistTTSDuckOthers, + interruptSpokenAudio: Current.settingsStore.carPlayAssistTTSInterruptSpokenAudio + ) + ) + + if Current.settingsStore.carPlayAssistTTSActivateAudioSession { + try audioSession.setActive(true) + } + + logCurrentAudioRoute(context: "tts configured") + } catch { + Current.Log.error("CarPlay Assist failed to configure TTS audio session: \(error.localizedDescription)") + } + } + + private func makeAudioSessionOptions( + allowBluetoothHFP: Bool, + allowBluetoothA2DP: Bool, + duckOthers: Bool, + interruptSpokenAudio: Bool + ) -> AVAudioSession.CategoryOptions { + var options: AVAudioSession.CategoryOptions = [] + if allowBluetoothHFP { + options.insert(.allowBluetoothHFP) + } + if allowBluetoothA2DP { + options.insert(.allowBluetoothA2DP) + } + if duckOthers { + options.insert(.duckOthers) + } + if interruptSpokenAudio { + options.insert(.interruptSpokenAudioAndMixWithOthers) + } + return options + } + private func deactivateAudioSession() { do { try audioSession.setActive(false, options: .notifyOthersOnDeactivation) @@ -261,10 +315,44 @@ final class CarPlayAssistSession: NSObject { /// Plays TTS audio using the already active conversational audio session to preserve the car route. private func playTTS(url: URL) { + let playbackDelay = Current.settingsStore.carPlayAssistTTSPlaybackDelay.seconds + if playbackDelay > 0 { + Current.Log.info("CarPlay Assist delaying TTS playback by \(playbackDelay)s") + DispatchQueue.main.asyncAfter(deadline: .now() + playbackDelay) { [weak self] in + self?.startTTSPlayback(url: url) + } + } else { + startTTSPlayback(url: url) + } + } + + private func startTTSPlayback(url: URL) { + let stopped = stateQueue.sync { isStopped } + guard !stopped else { return } + + configureAudioSessionForTTSIfNeeded() + logCurrentAudioRoute(context: "before tts playback") + + switch Current.settingsStore.carPlayAssistTTSPlaybackStrategy { + case .avPlayer: + playTTSWithAVPlayer(url: url) + case .downloadedAVAudioPlayer: + playTTSWithDownloadedAudioPlayer(url: url) + } + } + + private func playTTSWithAVPlayer(url: URL) { NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemPlaybackStalled, object: nil) + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemFailedToPlayToEndTime, object: nil) + clearTTSPlayerObservers() let playerItem = AVPlayerItem(url: url) + ttsPlayer.automaticallyWaitsToMinimizeStalling = + Current.settingsStore.carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling ttsPlayer.replaceCurrentItem(with: playerItem) + observeTTSPlayer(item: playerItem) + Current.Log.info("CarPlay Assist starting AVPlayer TTS for URL: \(url.absoluteString)") ttsPlayer.play() NotificationCenter.default.addObserver( @@ -273,6 +361,111 @@ final class CarPlayAssistSession: NSObject { name: .AVPlayerItemDidPlayToEndTime, object: playerItem ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(ttsPlaybackStalled(_:)), + name: .AVPlayerItemPlaybackStalled, + object: playerItem + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(ttsFailedToPlayToEnd(_:)), + name: .AVPlayerItemFailedToPlayToEndTime, + object: playerItem + ) + } + + private func playTTSWithDownloadedAudioPlayer(url: URL) { + Current.Log.info("CarPlay Assist downloading TTS audio for AVAudioPlayer: \(url.absoluteString)") + + URLSession.shared.dataTask(with: url) { [weak self] data, _, error in + guard let self else { return } + + if let error { + Current.Log.error("CarPlay Assist failed to download TTS audio: \(error.localizedDescription)") + stop() + return + } + + guard let data else { + Current.Log.error("CarPlay Assist downloaded empty TTS audio data") + stop() + return + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let stopped = stateQueue.sync { self.isStopped } + guard !stopped else { return } + + do { + ttsAudioPlayer = try AVAudioPlayer(data: data) + ttsAudioPlayer?.delegate = self + ttsAudioPlayer?.prepareToPlay() + if ttsAudioPlayer?.play() == true { + Current.Log.info("CarPlay Assist started downloaded AVAudioPlayer TTS playback") + } else { + Current.Log.error("CarPlay Assist AVAudioPlayer failed to start TTS playback") + stop() + } + } catch { + Current.Log + .error("CarPlay Assist failed to create AVAudioPlayer for TTS: \(error.localizedDescription)") + stop() + } + } + }.resume() + } + + private func observeTTSPlayer(item: AVPlayerItem) { + ttsPlayerItemStatusObservation = item.observe(\.status, options: [.initial, .new]) { item, _ in + switch item.status { + case .unknown: + Current.Log.info("CarPlay Assist TTS player item status: unknown") + case .readyToPlay: + Current.Log.info("CarPlay Assist TTS player item status: readyToPlay") + case .failed: + Current.Log.error( + "CarPlay Assist TTS player item failed: \(item.error?.localizedDescription ?? "unknown error")" + ) + @unknown default: + Current.Log.info("CarPlay Assist TTS player item status: unknown future case") + } + } + + ttsPlayerTimeControlObservation = ttsPlayer.observe(\.timeControlStatus, options: [ + .initial, + .new, + ]) { player, _ in + let description: String + switch player.timeControlStatus { + case .paused: + description = "paused" + case .waitingToPlayAtSpecifiedRate: + description = "waiting" + case .playing: + description = "playing" + @unknown default: + description = "unknown" + } + Current.Log.info("CarPlay Assist TTS player timeControlStatus: \(description)") + } + } + + private func clearTTSPlayerObservers() { + ttsPlayerItemStatusObservation = nil + ttsPlayerTimeControlObservation = nil + } + + @objc private func ttsPlaybackStalled(_ notification: Notification) { + Current.Log.error("CarPlay Assist TTS playback stalled") + } + + @objc private func ttsFailedToPlayToEnd(_ notification: Notification) { + let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error + Current.Log.error("CarPlay Assist TTS failed to play to end: \(error?.localizedDescription ?? "unknown error")") } @objc private func ttsDidFinishPlaying(_ notification: Notification) { @@ -354,6 +547,28 @@ extension CarPlayAssistSession: AudioRecorderDelegate { } } +@available(iOS 16.0, *) +extension CarPlayAssistSession: AVAudioPlayerDelegate { + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + Current.Log.info("CarPlay Assist AVAudioPlayer TTS finished, success: \(flag)") + let stopped = stateQueue.sync { isStopped } + guard !stopped else { return } + + if assistService.shouldStartListeningAgainAfterPlaybackEnd { + assistService.resetShouldStartListeningAgainAfterPlaybackEnd() + restartRecording() + } else { + stop() + } + } + + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + Current.Log + .error("CarPlay Assist AVAudioPlayer decode error: \(error?.localizedDescription ?? "unknown error")") + stop() + } +} + @available(iOS 16.0, *) private extension CarPlayAssistSession { static let recordingIndicatorToneData: Data = { diff --git a/Sources/Shared/Settings/SettingsStore.swift b/Sources/Shared/Settings/SettingsStore.swift index a05bfde422..a13cc285ce 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -62,6 +62,56 @@ public class SettingsStore { } } + public enum CarPlayAssistPreferredSampleRate: Int, CaseIterable { + case rate16000 = 16000 + case rate24000 = 24000 + case rate44100 = 44100 + case rate48000 = 48000 + + public var title: String { + "\(rawValue) Hz" + } + + public var value: Double { + Double(rawValue) + } + } + + public enum CarPlayAssistTTSPlaybackStrategy: String, CaseIterable { + case avPlayer + case downloadedAVAudioPlayer + + public var title: String { + switch self { + case .avPlayer: + "AVPlayer" + case .downloadedAVAudioPlayer: + "Download then AVAudioPlayer" + } + } + } + + public enum CarPlayAssistPlaybackDelay: Int, CaseIterable { + case none = 0 + case ms100 = 100 + case ms250 = 250 + case ms500 = 500 + case ms1000 = 1000 + + public var title: String { + switch self { + case .none: + "None" + default: + "\(rawValue) ms" + } + } + + public var seconds: Double { + Double(rawValue) / 1000.0 + } + } + let keychain = AppConstants.Keychain let prefs = UserDefaults(suiteName: AppConstants.AppGroupID)! @@ -591,6 +641,16 @@ public class SettingsStore { } } + public var carPlayAssistPreferredSampleRate: CarPlayAssistPreferredSampleRate { + get { + let rawValue = prefs.integer(forKey: "carPlayAssistPreferredSampleRate") + return CarPlayAssistPreferredSampleRate(rawValue: rawValue) ?? .rate16000 + } + set { + prefs.set(newValue.rawValue, forKey: "carPlayAssistPreferredSampleRate") + } + } + public var carPlayAssistAllowBluetoothHFP: Bool { get { if prefs.object(forKey: "carPlayAssistAllowBluetoothHFP") == nil { @@ -633,13 +693,162 @@ public class SettingsStore { } } + public var carPlayAssistTTSPlaybackStrategy: CarPlayAssistTTSPlaybackStrategy { + get { + guard let rawValue = prefs.string(forKey: "carPlayAssistTTSPlaybackStrategy"), + let value = CarPlayAssistTTSPlaybackStrategy(rawValue: rawValue) else { + return .avPlayer + } + return value + } + set { + prefs.set(newValue.rawValue, forKey: "carPlayAssistTTSPlaybackStrategy") + } + } + + public var carPlayAssistTTSReconfigureAudioSession: Bool { + get { + prefs.bool(forKey: "carPlayAssistTTSReconfigureAudioSession") + } + set { + prefs.set(newValue, forKey: "carPlayAssistTTSReconfigureAudioSession") + } + } + + public var carPlayAssistTTSDeactivateBeforeReconfigure: Bool { + get { + prefs.bool(forKey: "carPlayAssistTTSDeactivateBeforeReconfigure") + } + set { + prefs.set(newValue, forKey: "carPlayAssistTTSDeactivateBeforeReconfigure") + } + } + + public var carPlayAssistTTSActivateAudioSession: Bool { + get { + if prefs.object(forKey: "carPlayAssistTTSActivateAudioSession") == nil { + return true + } + return prefs.bool(forKey: "carPlayAssistTTSActivateAudioSession") + } + set { + prefs.set(newValue, forKey: "carPlayAssistTTSActivateAudioSession") + } + } + + public var carPlayAssistTTSCategory: CarPlayAssistAudioCategory { + get { + guard let rawValue = prefs.string(forKey: "carPlayAssistTTSCategory"), + let value = CarPlayAssistAudioCategory(rawValue: rawValue) else { + return .playAndRecord + } + return value + } + set { + prefs.set(newValue.rawValue, forKey: "carPlayAssistTTSCategory") + } + } + + public var carPlayAssistTTSMode: CarPlayAssistAudioMode { + get { + guard let rawValue = prefs.string(forKey: "carPlayAssistTTSMode"), + let value = CarPlayAssistAudioMode(rawValue: rawValue) else { + return .voicePrompt + } + return value + } + set { + prefs.set(newValue.rawValue, forKey: "carPlayAssistTTSMode") + } + } + + public var carPlayAssistTTSAllowBluetoothHFP: Bool { + get { + if prefs.object(forKey: "carPlayAssistTTSAllowBluetoothHFP") == nil { + return true + } + return prefs.bool(forKey: "carPlayAssistTTSAllowBluetoothHFP") + } + set { + prefs.set(newValue, forKey: "carPlayAssistTTSAllowBluetoothHFP") + } + } + + public var carPlayAssistTTSAllowBluetoothA2DP: Bool { + get { + if prefs.object(forKey: "carPlayAssistTTSAllowBluetoothA2DP") == nil { + return true + } + return prefs.bool(forKey: "carPlayAssistTTSAllowBluetoothA2DP") + } + set { + prefs.set(newValue, forKey: "carPlayAssistTTSAllowBluetoothA2DP") + } + } + + public var carPlayAssistTTSDuckOthers: Bool { + get { + prefs.bool(forKey: "carPlayAssistTTSDuckOthers") + } + set { + prefs.set(newValue, forKey: "carPlayAssistTTSDuckOthers") + } + } + + public var carPlayAssistTTSInterruptSpokenAudio: Bool { + get { + if prefs.object(forKey: "carPlayAssistTTSInterruptSpokenAudio") == nil { + return true + } + return prefs.bool(forKey: "carPlayAssistTTSInterruptSpokenAudio") + } + set { + prefs.set(newValue, forKey: "carPlayAssistTTSInterruptSpokenAudio") + } + } + + public var carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling: Bool { + get { + if prefs.object(forKey: "carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling") == nil { + return true + } + return prefs.bool(forKey: "carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling") + } + set { + prefs.set(newValue, forKey: "carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling") + } + } + + public var carPlayAssistTTSPlaybackDelay: CarPlayAssistPlaybackDelay { + get { + let rawValue = prefs.integer(forKey: "carPlayAssistTTSPlaybackDelay") + return CarPlayAssistPlaybackDelay(rawValue: rawValue) ?? .none + } + set { + prefs.set(newValue.rawValue, forKey: "carPlayAssistTTSPlaybackDelay") + } + } + public func resetCarPlayAssistDebugSettings() { carPlayAssistAudioCategory = .playAndRecord carPlayAssistAudioMode = .voiceChat + carPlayAssistPreferredSampleRate = .rate16000 carPlayAssistAllowBluetoothHFP = true carPlayAssistAllowBluetoothA2DP = true carPlayAssistPlayRecordingIndicatorTone = false carPlayAssistRecorderManagesAudioSession = false + carPlayAssistTTSPlaybackStrategy = .avPlayer + carPlayAssistTTSReconfigureAudioSession = false + carPlayAssistTTSDeactivateBeforeReconfigure = false + carPlayAssistTTSActivateAudioSession = true + carPlayAssistTTSCategory = .playAndRecord + carPlayAssistTTSMode = .voicePrompt + carPlayAssistTTSAllowBluetoothHFP = true + carPlayAssistTTSAllowBluetoothA2DP = true + carPlayAssistTTSDuckOthers = false + carPlayAssistTTSInterruptSpokenAudio = true + carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling = true + carPlayAssistTTSPlaybackDelay = .none } // MARK: - Private helpers From ac77debaee4271b4ecff3503dbaded8bc2a94c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 5 May 2026 15:52:53 +0200 Subject: [PATCH 03/23] Add custom sound --- HomeAssistant.xcodeproj/project.pbxproj | 68 ++++++++++++------ .../Sounds/Assist/center_button_press.flac | Bin 0 -> 21676 bytes .../QuickAccess/CarPlayAssistSession.swift | 12 +++- Sources/Shared/Settings/SettingsStore.swift | 7 +- 4 files changed, 63 insertions(+), 24 deletions(-) create mode 100644 Sources/App/Resources/Sounds/Assist/center_button_press.flac diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 7b552ae103..93178be350 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1005,6 +1005,7 @@ 42BB4C382CD26490003E47FD /* HATypedRequest+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BB4C362CD26490003E47FD /* HATypedRequest+App.swift */; }; 42BB53302CAA09F300680ED8 /* WatchConfig.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BB532F2CAA09F300680ED8 /* WatchConfig.test.swift */; }; 42BB53322CAA0B3C00680ED8 /* WatchConfigV1.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 42BB53312CAA0B3C00680ED8 /* WatchConfigV1.sqlite */; }; + 42BC581A2FAA2C8F0080EE09 /* center_button_press.flac in Resources */ = {isa = PBXBuildFile; fileRef = 42BC58192FAA2C8F0080EE09 /* center_button_press.flac */; }; 42BF7F302DF867E600875A0F /* HAAppEntityAppIntentEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BF7F2F2DF867E600875A0F /* HAAppEntityAppIntentEntity.swift */; }; 42BF7F312DF867E600875A0F /* HAAppEntityAppIntentEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BF7F2F2DF867E600875A0F /* HAAppEntityAppIntentEntity.swift */; }; 42BF8DB12EC4E16900DCB7E7 /* AssistSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BF8DB02EC4E16900DCB7E7 /* AssistSceneDelegate.swift */; }; @@ -1236,7 +1237,7 @@ 651755E378F6F79AB401F05C /* AssistPipelineAddList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07701F2786F6D45E945CC1AA /* AssistPipelineAddList.swift */; }; 65286F3B745551AD4090EE6B /* Pods-iOS-SharedTesting-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4053903E4C54A6803204286E /* Pods-iOS-SharedTesting-metadata.plist */; }; 6596FA74E1A501276EA62D86 /* Pods_watchOS_Shared_watchOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD370D44DFFB906B05C3EB3A /* Pods_watchOS_Shared_watchOS.framework */; }; - 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */; }; + 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */; }; 6FCEBAA2C8E9C5403055E73D /* IntentFanEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E5E2F9F8F008EEA30C533FD /* IntentFanEntity.swift */; }; 70BD8A8EA1ABC5DC1F0A0D6E /* Pods_iOS_Shared_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C663B0750E0318469E7008C3 /* Pods_iOS_Shared_iOS.framework */; }; 71E0BF803A854C3B9F0CB726 /* HandlerLiveActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58D524991C142DBB38A1968 /* HandlerLiveActivityTests.swift */; }; @@ -1251,7 +1252,7 @@ 999549244371450BC98C700E /* Pods_iOS_Extensions_PushProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */; }; 9D57ECBD5431BC00BDC16F1E /* NotificationActionEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F913E441276235B7A2D7B29 /* NotificationActionEditorView.swift */; }; A1619F1ED93FB8B0E7E53C38 /* KioskLifecycleBrightness.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373AE72CB925F044BAE18B62 /* KioskLifecycleBrightness.test.swift */; }; - A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */; }; + A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */; }; A596C4D1E125E6863C7D2034 /* ComplicationEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512A72C5F1BCC979E74F7629 /* ComplicationEditViewModel.swift */; }; A5A3C1932BE1F4A40EA78754 /* Pods-iOS-Extensions-Matter-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */; }; A60E917B401A6D456F1DB630 /* ComplicationFamilySelectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1861EB0361816DC9260D1F5E /* ComplicationFamilySelectView.swift */; }; @@ -1517,7 +1518,7 @@ B6E42613215C4333007FEB7E /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; }; BB77559927344584B2C0E987 /* OnboardingAuthError.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.swift */; }; BD1044995DE13A04C0FA039A /* Pods_iOS_Extensions_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9C81015FD7A8FA8716E4F2 /* Pods_iOS_Extensions_Widgets.framework */; }; - BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */; }; + BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */; }; C10D762EFE08D347D0538339 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */; }; C35621B95F7E4548BC8F6D75 /* FolderEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BECEB2525564358A124F818 /* FolderEditView.swift */; }; C3EB3740FA097F36D51F525E /* BarometerSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1396822195C7FF562AB891F2 /* BarometerSensor.swift */; }; @@ -1569,7 +1570,7 @@ D87EC7A89E0515C4CAB93220 /* BarometerSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1396822195C7FF562AB891F2 /* BarometerSensor.swift */; }; D8B4F2A61E9C73058AF2D49E /* KioskSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A3E91F5B8D42A6E0F13B74 /* KioskSettingsViewModel.swift */; }; D9A6697AF4D05BB8DE822A54 /* Pods_iOS_Extensions_Share.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33CA7FF55788E7084DA5E4B3 /* Pods_iOS_Extensions_Share.framework */; }; - DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */; }; + DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */; }; DB54626ADCE0C32094C8C0B9 /* LoadingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFD0D30836C69840AB63A8A /* LoadingButton.swift */; }; DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */; }; E3A02409794174F002C8BB4F /* IconSearchPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC23DE131CA8813C2DBD657 /* IconSearchPicker.swift */; }; @@ -2870,6 +2871,7 @@ 42BB4C362CD26490003E47FD /* HATypedRequest+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HATypedRequest+App.swift"; sourceTree = ""; }; 42BB532F2CAA09F300680ED8 /* WatchConfig.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfig.test.swift; sourceTree = ""; }; 42BB53312CAA0B3C00680ED8 /* WatchConfigV1.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = WatchConfigV1.sqlite; sourceTree = ""; }; + 42BC58192FAA2C8F0080EE09 /* center_button_press.flac */ = {isa = PBXFileReference; lastKnownFileType = file; path = center_button_press.flac; sourceTree = ""; }; 42BE698E2C46D37800745ECA /* UIScreen+PerfectCornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScreen+PerfectCornerRadius.swift"; sourceTree = ""; }; 42BF7F2F2DF867E600875A0F /* HAAppEntityAppIntentEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAAppEntityAppIntentEntity.swift; sourceTree = ""; }; 42BF8DAF2EC4D69600DCB7E7 /* copilot-instructions.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "copilot-instructions.md"; sourceTree = ""; }; @@ -3061,7 +3063,7 @@ 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-App-metadata.plist"; path = "Pods/Pods-iOS-App-metadata.plist"; sourceTree = ""; }; 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-NotificationService-metadata.plist"; path = "Pods/Pods-iOS-Extensions-NotificationService-metadata.plist"; sourceTree = ""; }; 5BFD0D30836C69840AB63A8A /* LoadingButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LoadingButton.swift; sourceTree = ""; }; - 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; + 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; 5D4737412F241342009A70EA /* FolderDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderDetailView.swift; sourceTree = ""; }; 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLiveActivityRegistry.swift; sourceTree = ""; }; 5FF9C3A30E10A8E214623EBB /* ComplicationListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationListView.swift; sourceTree = ""; }; @@ -3085,7 +3087,7 @@ 825E1E44BA9ABF1BF53733D3 /* KioskConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskConstants.swift; sourceTree = ""; }; 862436CFE6E3F4B31500EFB2 /* ComplicationListViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationListViewModel.swift; sourceTree = ""; }; 86BFD63671D2D0A012DFE169 /* Pods-iOS-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.debug.xcconfig"; sourceTree = ""; }; - 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; }; + 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; }; 8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlFanValueProvider.swift; sourceTree = ""; }; 8D6888525DCF492642BA7EA3 /* FanIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanIntent.swift; sourceTree = ""; }; 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-watchOS-WatchExtension-Watch-metadata.plist"; path = "Pods/Pods-watchOS-WatchExtension-Watch-metadata.plist"; sourceTree = ""; }; @@ -3098,7 +3100,7 @@ 9C4E5E27229D992A0044C8EC /* HomeAssistant.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.xcconfig; sourceTree = ""; }; 9D84964A844E6CD21F16D3AB /* Pods-watchOS-WatchExtension-Watch.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; sourceTree = ""; }; 9DA2D62699FC44A99AB37480 /* WatchFolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchFolderRow.swift; sourceTree = ""; }; - 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; }; + 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; }; 9F913E441276235B7A2D7B29 /* NotificationActionEditorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationActionEditorView.swift; sourceTree = ""; }; 9F9398CFD66E4C66DC39E1D3 /* Pods-iOS-Extensions-PushProvider.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.beta.xcconfig"; sourceTree = ""; }; A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthError.test.swift; sourceTree = ""; }; @@ -3428,7 +3430,7 @@ B6FD0574228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; B7D8DAEFAD435091FDDD61E7 /* Pods_iOS_Extensions_NotificationContent.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_NotificationContent.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B833A17275EC47FA65A3235A /* YamlPreviewSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = YamlPreviewSection.swift; sourceTree = ""; }; - BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; }; + BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; }; BC9B77AAC44845DC9EE48759 /* Pods_iOS_Extensions_Intents.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Intents.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BDC6ACBDCC2C47510C37E4C8 /* NotificationCategoryListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationCategoryListView.swift; sourceTree = ""; }; BEF9A7008EFA4A6FC9E02B5E /* Pods-iOS-Extensions-Intents.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.release.xcconfig"; sourceTree = ""; }; @@ -6024,6 +6026,14 @@ path = Settings; sourceTree = ""; }; + 42BC58182FAA2C740080EE09 /* Assist */ = { + isa = PBXGroup; + children = ( + 42BC58192FAA2C8F0080EE09 /* center_button_press.flac */, + ); + path = Assist; + sourceTree = ""; + }; 42BE698B2C4691E000745ECA /* Views */ = { isa = PBXGroup; children = ( @@ -6654,6 +6664,7 @@ B60614B31D1F116D00249C11 /* Sounds */ = { isa = PBXGroup; children = ( + 42BC58182FAA2C740080EE09 /* Assist */, B60615531D1F117700249C11 /* Alexa */, B606159A1D1F117700249C11 /* Generic */, B60614B41D1F117700249C11 /* MorganFreeman */, @@ -7274,10 +7285,10 @@ 11CB98CC249E637300B05222 /* Version+HA.test.swift */, 11883CC424C12C8A0036A6C6 /* CLLocation+Extensions.test.swift */, 11883CC624C131EE0036A6C6 /* RealmZone.test.swift */, - 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */, - BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */, - 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */, - 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */, + 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */, + BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */, + 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */, + 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */, 11EE9B4B24C5181A00404AF8 /* ModelManager.test.swift */, 11BC9E5424FDB88200B9FBF7 /* ActiveStateManager.test.swift */, 1104FCCE253275CF00B8BE34 /* WatchBackgroundRefreshScheduler.test.swift */, @@ -7487,7 +7498,6 @@ 6BC23DE131CA8813C2DBD657 /* IconSearchPicker.swift */, 5BFD0D30836C69840AB63A8A /* LoadingButton.swift */, ); - name = Complications; path = Complications; sourceTree = ""; }; @@ -7507,7 +7517,6 @@ 99EC7EF1136575D0E7A17091 /* NotificationIdentifierField.swift */, 208A5362BE60377368ACB1D6 /* RealmResultsObserver.swift */, ); - name = Components; path = Components; sourceTree = ""; }; @@ -8165,7 +8174,7 @@ packageReferences = ( 420E64BB2D676B2400A31E86 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, 42B89EA62E05CC54000224A2 /* XCRemoteSwiftPackageReference "WebRTC" */, - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */, + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */, 4237E6372E5333370023B673 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 42B18FD52F38CA2300A1537A /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); @@ -8358,6 +8367,7 @@ B60616541D1F117700249C11 /* US-EN-Morgan-Freeman-Water-Detected-In-Garage.wav in Resources */, B606162D1D1F117700249C11 /* US-EN-Morgan-Freeman-Patio-Door-Opened.wav in Resources */, 42E0D82B2DCCE5900095A245 /* Colors.xcassets in Resources */, + 42BC581A2FAA2C8F0080EE09 /* center_button_press.flac in Resources */, B60616991D1F117800249C11 /* US-EN-Alexa-Water-Detected-In-Garage.wav in Resources */, B60616411D1F117700249C11 /* US-EN-Morgan-Freeman-Turning-Off-The-Family-Room-Lights.wav in Resources */, B60616851D1F117700249C11 /* US-EN-Alexa-Good-Morning.wav in Resources */, @@ -8692,10 +8702,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks.sh\"\n"; @@ -8833,10 +8847,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks.sh\"\n"; @@ -8872,10 +8890,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks.sh\"\n"; @@ -8975,10 +8997,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks.sh\"\n"; @@ -10415,10 +10441,10 @@ 11AF4D2C249D965C006C74C0 /* BatterySensor.test.swift in Sources */, 11F2F2B8258728B200F61F7C /* NotificationAttachmentParserURL.test.swift in Sources */, 11883CC724C131EE0036A6C6 /* RealmZone.test.swift in Sources */, - DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */, - A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */, - 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */, - BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */, + DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */, + A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */, + 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */, + BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */, 11267D0925BBA9FE00F28E5C /* Updater.test.swift in Sources */, 11A3F08C24ECE88C0018D84F /* WebhookUpdateLocation.test.swift in Sources */, 42FDCA272F0C7EB900C92958 /* EntityRegistry.test.swift in Sources */, @@ -12299,7 +12325,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */ = { + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */ = { isa = XCLocalSwiftPackageReference; relativePath = Sources/SharedPush; }; @@ -12363,7 +12389,7 @@ }; 4273F7DF2E258827000629F7 /* SharedPush */ = { isa = XCSwiftPackageProductDependency; - package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */; + package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */; productName = SharedPush; }; 427692E22B98B82500F24321 /* SharedPush */ = { diff --git a/Sources/App/Resources/Sounds/Assist/center_button_press.flac b/Sources/App/Resources/Sounds/Assist/center_button_press.flac new file mode 100644 index 0000000000000000000000000000000000000000..40202bbfad73eda92167e76842749ed7becdf884 GIT binary patch literal 21676 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%0DV0?%<18jldUiG(|E2&tx)&NcBI6cy8 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 literal 0 HcmV?d00001 diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift index 41e3835194..a18d6db506 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift @@ -301,7 +301,17 @@ final class CarPlayAssistSession: NSObject { guard Current.settingsStore.carPlayAssistPlayRecordingIndicatorTone else { return } do { - recordingIndicatorPlayer = try AVAudioPlayer(data: Self.recordingIndicatorToneData) + guard let toneURL = Bundle.main.url( + forResource: "center_button_press", + withExtension: "flac", + subdirectory: "Sounds/Assist" + ) else { + Current.Log.error("CarPlay Assist could not find center_button_press.flac in the app bundle") + AudioServicesPlaySystemSound(1113) + return + } + + recordingIndicatorPlayer = try AVAudioPlayer(contentsOf: toneURL) recordingIndicatorPlayer?.volume = 0.7 recordingIndicatorPlayer?.prepareToPlay() recordingIndicatorPlayer?.play() diff --git a/Sources/Shared/Settings/SettingsStore.swift b/Sources/Shared/Settings/SettingsStore.swift index a13cc285ce..aab329bda3 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -677,7 +677,10 @@ public class SettingsStore { public var carPlayAssistPlayRecordingIndicatorTone: Bool { get { - prefs.bool(forKey: "carPlayAssistPlayRecordingIndicatorTone") + if prefs.object(forKey: "carPlayAssistPlayRecordingIndicatorTone") == nil { + return true + } + return prefs.bool(forKey: "carPlayAssistPlayRecordingIndicatorTone") } set { prefs.set(newValue, forKey: "carPlayAssistPlayRecordingIndicatorTone") @@ -835,7 +838,7 @@ public class SettingsStore { carPlayAssistPreferredSampleRate = .rate16000 carPlayAssistAllowBluetoothHFP = true carPlayAssistAllowBluetoothA2DP = true - carPlayAssistPlayRecordingIndicatorTone = false + carPlayAssistPlayRecordingIndicatorTone = true carPlayAssistRecorderManagesAudioSession = false carPlayAssistTTSPlaybackStrategy = .avPlayer carPlayAssistTTSReconfigureAudioSession = false From 84867df7f253afc35e65139abb544379517169b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 5 May 2026 16:03:16 +0200 Subject: [PATCH 04/23] Gate Assist in CarPlay to iOS 26.4+ only --- .../QuickAccess/CarPlayAssistSession.swift | 53 ++++++++++++++++--- .../CarPlayQuickAccessTemplate.swift | 11 ++-- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift index a18d6db506..04f0152ff3 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift @@ -5,11 +5,12 @@ import Foundation import HAKit import Shared -@available(iOS 16.0, *) +@available(iOS 26.4, *) final class CarPlayAssistSession: NSObject { typealias OnStop = () -> Void enum State { + case idle case recording case processing case responding @@ -22,6 +23,7 @@ final class CarPlayAssistSession: NSObject { } private enum VoiceControlStateID: String { + case idle case recording case processing case responding @@ -51,6 +53,19 @@ final class CarPlayAssistSession: NSObject { private let pipelineName: String private lazy var template: CPVoiceControlTemplate = { + let idleState = CPVoiceControlState( + identifier: VoiceControlStateID.idle.rawValue, + titleVariants: [L10n.Assist.Carplay.TapToRecord.title], + image: MaterialDesignIcons.microphoneIcon.carPlayIcon(color: .haPrimary), + repeats: false + ) + let idleButton = CPButton( + image: MaterialDesignIcons.microphoneIcon.carPlayIcon(color: .haPrimary) + ) { [weak self] _ in + self?.restartRecording() + } + idleState.actionButtons = [idleButton] + let recordingState = CPVoiceControlState( identifier: VoiceControlStateID.recording.rawValue, titleVariants: [L10n.Assist.Button.Listening.title], @@ -69,7 +84,7 @@ final class CarPlayAssistSession: NSObject { image: MaterialDesignIcons.volumeHighIcon.carPlayIcon(color: .haPrimary), repeats: true ) - return CPVoiceControlTemplate(voiceControlStates: [recordingState, processingState, respondingState]) + return CPVoiceControlTemplate(voiceControlStates: [recordingState, processingState, respondingState, idleState]) }() init( @@ -485,7 +500,7 @@ final class CarPlayAssistSession: NSObject { assistService.resetShouldStartListeningAgainAfterPlaybackEnd() restartRecording() } else { - stop() + enterIdleState() } } @@ -494,6 +509,8 @@ final class CarPlayAssistSession: NSObject { private func activateVoiceControlState(for state: State) { let identifier: String switch state { + case .idle: + identifier = VoiceControlStateID.idle.rawValue case .recording: identifier = VoiceControlStateID.recording.rawValue case .processing: @@ -517,14 +534,34 @@ final class CarPlayAssistSession: NSObject { canSendAudioData = false state = .recording } + ttsAudioPlayer?.stop() + ttsAudioPlayer = nil + ttsPlayer.pause() + ttsPlayer.replaceCurrentItem(with: nil) + clearTTSPlayerObservers() + configureAudioSessionForAssist() activateVoiceControlState(for: .recording) audioRecorder.startRecording() } + + private func enterIdleState() { + stateQueue.sync { + canSendAudioData = false + state = .idle + } + ttsAudioPlayer?.stop() + ttsAudioPlayer = nil + ttsPlayer.pause() + ttsPlayer.replaceCurrentItem(with: nil) + clearTTSPlayerObservers() + deactivateAudioSession() + activateVoiceControlState(for: .idle) + } } // MARK: - AudioRecorderDelegate -@available(iOS 16.0, *) +@available(iOS 26.4, *) extension CarPlayAssistSession: AudioRecorderDelegate { func didStartRecording(with sampleRate: Double) { playRecordingIndicatorToneIfNeeded() @@ -557,7 +594,7 @@ extension CarPlayAssistSession: AudioRecorderDelegate { } } -@available(iOS 16.0, *) +@available(iOS 26.4, *) extension CarPlayAssistSession: AVAudioPlayerDelegate { func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { Current.Log.info("CarPlay Assist AVAudioPlayer TTS finished, success: \(flag)") @@ -568,7 +605,7 @@ extension CarPlayAssistSession: AVAudioPlayerDelegate { assistService.resetShouldStartListeningAgainAfterPlaybackEnd() restartRecording() } else { - stop() + enterIdleState() } } @@ -579,7 +616,7 @@ extension CarPlayAssistSession: AVAudioPlayerDelegate { } } -@available(iOS 16.0, *) +@available(iOS 26.4, *) private extension CarPlayAssistSession { static let recordingIndicatorToneData: Data = { let sampleRate = 24000 @@ -634,7 +671,7 @@ private extension CarPlayAssistSession { // MARK: - AssistServiceDelegate -@available(iOS 16.0, *) +@available(iOS 26.4, *) extension CarPlayAssistSession: AssistServiceDelegate { func didReceiveGreenLightForAudioInput() { stateQueue.sync { canSendAudioData = true } diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift index 83a02adc48..1835a894b1 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift @@ -38,7 +38,7 @@ final class CarPlayQuickAccessTemplate: CarPlayTemplateProvider { private var executingItemIds: Set = [] private var executingStartedAt: [String: Date] = [:] private var pendingExecutingClearWorkItems: [String: DispatchWorkItem] = [:] - private var activeAssistSession: CarPlayAssistSession? + private var activeAssistSession: AnyObject? private var preferredServerId: String { prefs.string(forKey: CarPlayServersListTemplate.carPlayPreferredServerKey) ?? "" @@ -74,7 +74,10 @@ final class CarPlayQuickAccessTemplate: CarPlayTemplateProvider { } func templateWillDisappear(template: CPTemplate) { - activeAssistSession?.templateWillDisappear(template: template) + if #available(iOS 26.4, *), + let activeAssistSession = activeAssistSession as? CarPlayAssistSession { + activeAssistSession.templateWillDisappear(template: template) + } if template == self.template { /* no-op */ } @@ -536,8 +539,10 @@ final class CarPlayQuickAccessTemplate: CarPlayTemplateProvider { } private func presentAssistSession(magicItem: MagicItem, info: MagicItem.Info) { + guard #available(iOS 26.4, *) else { return } + // Stop any existing session before starting a new one - activeAssistSession?.stop() + (activeAssistSession as? CarPlayAssistSession)?.stop() activeAssistSession = nil guard let server = Current.servers.all.first(where: { $0.identifier.rawValue == magicItem.serverId }) else { From 671a67e1d2615c76b04f70bfd6d347d8f2f9dbbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 5 May 2026 16:18:42 +0200 Subject: [PATCH 05/23] Error handling --- .../QuickAccess/CarPlayAssistSession.swift | 64 ++++++++++++++----- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift index 04f0152ff3..f4b701d7e1 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift @@ -27,6 +27,7 @@ final class CarPlayAssistSession: NSObject { case recording case processing case responding + case error } weak var interfaceController: CPInterfaceController? @@ -53,18 +54,19 @@ final class CarPlayAssistSession: NSObject { private let pipelineName: String private lazy var template: CPVoiceControlTemplate = { + let retryButton = CPButton( + image: MaterialDesignIcons.microphoneIcon.carPlayIcon(color: .haPrimary) + ) { [weak self] _ in + self?.restartRecording() + } + let idleState = CPVoiceControlState( identifier: VoiceControlStateID.idle.rawValue, titleVariants: [L10n.Assist.Carplay.TapToRecord.title], image: MaterialDesignIcons.microphoneIcon.carPlayIcon(color: .haPrimary), repeats: false ) - let idleButton = CPButton( - image: MaterialDesignIcons.microphoneIcon.carPlayIcon(color: .haPrimary) - ) { [weak self] _ in - self?.restartRecording() - } - idleState.actionButtons = [idleButton] + idleState.actionButtons = [retryButton] let recordingState = CPVoiceControlState( identifier: VoiceControlStateID.recording.rawValue, @@ -84,7 +86,17 @@ final class CarPlayAssistSession: NSObject { image: MaterialDesignIcons.volumeHighIcon.carPlayIcon(color: .haPrimary), repeats: true ) - return CPVoiceControlTemplate(voiceControlStates: [recordingState, processingState, respondingState, idleState]) + let errorState = CPVoiceControlState( + identifier: VoiceControlStateID.error.rawValue, + titleVariants: [L10n.errorLabel], + image: MaterialDesignIcons.alertCircleIcon.carPlayIcon(color: .systemRed), + repeats: false + ) + errorState.actionButtons = [retryButton] + + return CPVoiceControlTemplate( + voiceControlStates: [recordingState, processingState, respondingState, idleState, errorState] + ) }() init( @@ -410,13 +422,13 @@ final class CarPlayAssistSession: NSObject { if let error { Current.Log.error("CarPlay Assist failed to download TTS audio: \(error.localizedDescription)") - stop() + enterErrorState(message: error.localizedDescription) return } guard let data else { Current.Log.error("CarPlay Assist downloaded empty TTS audio data") - stop() + enterErrorState(message: "Downloaded empty TTS audio data") return } @@ -433,12 +445,12 @@ final class CarPlayAssistSession: NSObject { Current.Log.info("CarPlay Assist started downloaded AVAudioPlayer TTS playback") } else { Current.Log.error("CarPlay Assist AVAudioPlayer failed to start TTS playback") - stop() + enterErrorState(message: "AVAudioPlayer failed to start TTS playback") } } catch { Current.Log .error("CarPlay Assist failed to create AVAudioPlayer for TTS: \(error.localizedDescription)") - stop() + enterErrorState(message: error.localizedDescription) } } }.resume() @@ -486,11 +498,13 @@ final class CarPlayAssistSession: NSObject { @objc private func ttsPlaybackStalled(_ notification: Notification) { Current.Log.error("CarPlay Assist TTS playback stalled") + enterErrorState(message: "TTS playback stalled") } @objc private func ttsFailedToPlayToEnd(_ notification: Notification) { let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error Current.Log.error("CarPlay Assist TTS failed to play to end: \(error?.localizedDescription ?? "unknown error")") + enterErrorState(message: error?.localizedDescription ?? "TTS failed to play to end") } @objc private func ttsDidFinishPlaying(_ notification: Notification) { @@ -518,7 +532,7 @@ final class CarPlayAssistSession: NSObject { case .responding: identifier = VoiceControlStateID.responding.rawValue case .error: - return + identifier = VoiceControlStateID.error.rawValue } if Thread.isMainThread { template.activateVoiceControlState(withIdentifier: identifier) @@ -557,6 +571,25 @@ final class CarPlayAssistSession: NSObject { deactivateAudioSession() activateVoiceControlState(for: .idle) } + + private func enterErrorState(message: String) { + let shouldHandle = stateQueue.sync { () -> Bool in + guard !isStopped else { return false } + canSendAudioData = false + state = .error(message) + return true + } + guard shouldHandle else { return } + + ttsAudioPlayer?.stop() + ttsAudioPlayer = nil + ttsPlayer.pause() + ttsPlayer.replaceCurrentItem(with: nil) + clearTTSPlayerObservers() + deactivateAudioSession() + Current.Log.error("CarPlay Assist entered error state: \(message)") + activateVoiceControlState(for: .error(message)) + } } // MARK: - AudioRecorderDelegate @@ -590,7 +623,7 @@ extension CarPlayAssistSession: AudioRecorderDelegate { } guard shouldHandle else { return } Current.Log.error("CarPlay Assist recording failed: \(error.localizedDescription)") - stop() + enterErrorState(message: error.localizedDescription) } } @@ -612,7 +645,7 @@ extension CarPlayAssistSession: AVAudioPlayerDelegate { func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { Current.Log .error("CarPlay Assist AVAudioPlayer decode error: \(error?.localizedDescription ?? "unknown error")") - stop() + enterErrorState(message: error?.localizedDescription ?? "AVAudioPlayer decode error") } } @@ -717,7 +750,6 @@ extension CarPlayAssistSession: AssistServiceDelegate { let stopped = stateQueue.sync { isStopped } guard !stopped else { return } Current.Log.error("CarPlay Assist error [\(code)]: \(message)") - stateQueue.sync { state = .error(message) } - stop() + enterErrorState(message: message) } } From afd7220097a2a7d939ef6b5d7460d86da2e86f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 5 May 2026 16:53:10 +0200 Subject: [PATCH 06/23] Debug settings tweaks --- HomeAssistant.xcodeproj/project.pbxproj | 6 + Sources/App/Settings/DebugView.swift | 184 +++++-- .../QuickAccess/CarPlayAssistSession.swift | 106 +--- .../CarPlayQuickAccessTemplate.swift | 3 +- .../Settings/CarPlayAssistDebugSettings.swift | 155 ++++++ Sources/Shared/Settings/SettingsStore.swift | 461 +++++------------- 6 files changed, 458 insertions(+), 457 deletions(-) create mode 100644 Sources/Shared/Settings/CarPlayAssistDebugSettings.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 93178be350..1deb4fc9ee 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1006,6 +1006,8 @@ 42BB53302CAA09F300680ED8 /* WatchConfig.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BB532F2CAA09F300680ED8 /* WatchConfig.test.swift */; }; 42BB53322CAA0B3C00680ED8 /* WatchConfigV1.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 42BB53312CAA0B3C00680ED8 /* WatchConfigV1.sqlite */; }; 42BC581A2FAA2C8F0080EE09 /* center_button_press.flac in Resources */ = {isa = PBXBuildFile; fileRef = 42BC58192FAA2C8F0080EE09 /* center_button_press.flac */; }; + 42BC581C2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BC581B2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift */; }; + 42BC581D2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BC581B2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift */; }; 42BF7F302DF867E600875A0F /* HAAppEntityAppIntentEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BF7F2F2DF867E600875A0F /* HAAppEntityAppIntentEntity.swift */; }; 42BF7F312DF867E600875A0F /* HAAppEntityAppIntentEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BF7F2F2DF867E600875A0F /* HAAppEntityAppIntentEntity.swift */; }; 42BF8DB12EC4E16900DCB7E7 /* AssistSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BF8DB02EC4E16900DCB7E7 /* AssistSceneDelegate.swift */; }; @@ -2872,6 +2874,7 @@ 42BB532F2CAA09F300680ED8 /* WatchConfig.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfig.test.swift; sourceTree = ""; }; 42BB53312CAA0B3C00680ED8 /* WatchConfigV1.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = WatchConfigV1.sqlite; sourceTree = ""; }; 42BC58192FAA2C8F0080EE09 /* center_button_press.flac */ = {isa = PBXFileReference; lastKnownFileType = file; path = center_button_press.flac; sourceTree = ""; }; + 42BC581B2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayAssistDebugSettings.swift; sourceTree = ""; }; 42BE698E2C46D37800745ECA /* UIScreen+PerfectCornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScreen+PerfectCornerRadius.swift"; sourceTree = ""; }; 42BF7F2F2DF867E600875A0F /* HAAppEntityAppIntentEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAAppEntityAppIntentEntity.swift; sourceTree = ""; }; 42BF8DAF2EC4D69600DCB7E7 /* copilot-instructions.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "copilot-instructions.md"; sourceTree = ""; }; @@ -7412,6 +7415,7 @@ children = ( D0C884792122A65800CCB501 /* SettingsStore.swift */, 4238DCA32DD1F1E300126434 /* AppSessionValues.swift */, + 42BC581B2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift */, ); path = Settings; sourceTree = ""; @@ -9824,6 +9828,7 @@ 11C9E43C2505B04E00492A88 /* HACoreAudioObjectSystem.swift in Sources */, 11F2F1ED2586ED6100F61F7C /* NotificationAttachmentManager.swift in Sources */, 3997926F2B7F907B00231B54 /* MobileAppConfigPush.swift in Sources */, + 42BC581C2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift in Sources */, 42A3B63C2BD91891007BC0F3 /* Color+Codable.swift in Sources */, 42905D872E12B01200250728 /* DesignSystem.swift in Sources */, 117675F0252D5CA80047B1D3 /* WebhookResponseUpdateComplications.swift in Sources */, @@ -10157,6 +10162,7 @@ D0DD2CEE213BCA8900C3D9F7 /* URL+Extensions.swift in Sources */, 427FEE682D9ECFD70047C00C /* PrivacyNoteView.swift in Sources */, 11BA5EC92759AC0300FC40E8 /* XCGLogger+Export.swift in Sources */, + 42BC581D2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift in Sources */, 11B38EE4275C54A200205C7B /* FireEventIntentHandler.swift in Sources */, 42EEEFE32E2791430080E973 /* Service.swift in Sources */, 1120C5842749C6350046C38B /* ServerProviding.swift in Sources */, diff --git a/Sources/App/Settings/DebugView.swift b/Sources/App/Settings/DebugView.swift index 049cbeed16..bd9eca4edf 100644 --- a/Sources/App/Settings/DebugView.swift +++ b/Sources/App/Settings/DebugView.swift @@ -618,50 +618,96 @@ private struct CarPlayDebugSettingsView: View { private var assistSessionSection: some View { Section { Picker("Audio category", selection: Binding( - get: { Current.settingsStore.carPlayAssistAudioCategory }, - set: { Current.settingsStore.carPlayAssistAudioCategory = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.audioCategory }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.audioCategory = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) { - ForEach(SettingsStore.CarPlayAssistAudioCategory.allCases, id: \.self) { category in + ForEach(CarPlayAssistAudioCategory.allCases, id: \.self) { category in Text(category.title).tag(category) } } Picker("Audio mode", selection: Binding( - get: { Current.settingsStore.carPlayAssistAudioMode }, - set: { Current.settingsStore.carPlayAssistAudioMode = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.audioMode }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.audioMode = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) { - ForEach(SettingsStore.CarPlayAssistAudioMode.allCases, id: \.self) { mode in + ForEach(CarPlayAssistAudioMode.allCases, id: \.self) { mode in Text(mode.title).tag(mode) } } Picker("Preferred sample rate", selection: Binding( - get: { Current.settingsStore.carPlayAssistPreferredSampleRate }, - set: { Current.settingsStore.carPlayAssistPreferredSampleRate = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.preferredSampleRate }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.preferredSampleRate = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) { - ForEach(SettingsStore.CarPlayAssistPreferredSampleRate.allCases, id: \.self) { sampleRate in + ForEach(CarPlayAssistPreferredSampleRate.allCases, id: \.self) { sampleRate in Text(sampleRate.title).tag(sampleRate) } } Toggle("Allow Bluetooth HFP", isOn: Binding( - get: { Current.settingsStore.carPlayAssistAllowBluetoothHFP }, - set: { Current.settingsStore.carPlayAssistAllowBluetoothHFP = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.allowBluetoothHFP }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.allowBluetoothHFP = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) Toggle("Allow Bluetooth A2DP", isOn: Binding( - get: { Current.settingsStore.carPlayAssistAllowBluetoothA2DP }, - set: { Current.settingsStore.carPlayAssistAllowBluetoothA2DP = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.allowBluetoothA2DP }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.allowBluetoothA2DP = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } + )) + + Toggle("Duck others", isOn: Binding( + get: { Current.settingsStore.carPlayAssistDebugSettings.duckOthers }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.duckOthers = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } + )) + + Toggle("Interrupt spoken audio", isOn: Binding( + get: { Current.settingsStore.carPlayAssistDebugSettings.interruptSpokenAudio }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.interruptSpokenAudio = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) Toggle("AudioRecorder manages audio session", isOn: Binding( - get: { Current.settingsStore.carPlayAssistRecorderManagesAudioSession }, - set: { Current.settingsStore.carPlayAssistRecorderManagesAudioSession = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.recorderManagesAudioSession }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.recorderManagesAudioSession = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) Toggle("Play recording indicator tone", isOn: Binding( - get: { Current.settingsStore.carPlayAssistPlayRecordingIndicatorTone }, - set: { Current.settingsStore.carPlayAssistPlayRecordingIndicatorTone = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.playRecordingIndicatorTone }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.playRecordingIndicatorTone = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) } header: { Text("Assist Session") @@ -673,26 +719,38 @@ private struct CarPlayDebugSettingsView: View { private var ttsPlaybackSection: some View { Section { Picker("Playback strategy", selection: Binding( - get: { Current.settingsStore.carPlayAssistTTSPlaybackStrategy }, - set: { Current.settingsStore.carPlayAssistTTSPlaybackStrategy = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.ttsPlaybackStrategy }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.ttsPlaybackStrategy = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) { - ForEach(SettingsStore.CarPlayAssistTTSPlaybackStrategy.allCases, id: \.self) { strategy in + ForEach(CarPlayAssistTTSPlaybackStrategy.allCases, id: \.self) { strategy in Text(strategy.title).tag(strategy) } } Picker("Playback delay", selection: Binding( - get: { Current.settingsStore.carPlayAssistTTSPlaybackDelay }, - set: { Current.settingsStore.carPlayAssistTTSPlaybackDelay = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.ttsPlaybackDelay }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.ttsPlaybackDelay = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) { - ForEach(SettingsStore.CarPlayAssistPlaybackDelay.allCases, id: \.self) { delay in + ForEach(CarPlayAssistPlaybackDelay.allCases, id: \.self) { delay in Text(delay.title).tag(delay) } } Toggle("AVPlayer waits to minimize stalling", isOn: Binding( - get: { Current.settingsStore.carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling }, - set: { Current.settingsStore.carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.avPlayerAutomaticallyWaitsToMinimizeStalling }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.avPlayerAutomaticallyWaitsToMinimizeStalling = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) } header: { Text("TTS Playback") @@ -706,56 +764,92 @@ private struct CarPlayDebugSettingsView: View { private var ttsSessionSection: some View { Section { Toggle("Reconfigure before TTS", isOn: Binding( - get: { Current.settingsStore.carPlayAssistTTSReconfigureAudioSession }, - set: { Current.settingsStore.carPlayAssistTTSReconfigureAudioSession = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.ttsReconfigureAudioSession }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.ttsReconfigureAudioSession = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) Toggle("Deactivate before reconfigure", isOn: Binding( - get: { Current.settingsStore.carPlayAssistTTSDeactivateBeforeReconfigure }, - set: { Current.settingsStore.carPlayAssistTTSDeactivateBeforeReconfigure = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.ttsDeactivateBeforeReconfigure }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.ttsDeactivateBeforeReconfigure = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) Toggle("Activate audio session before play", isOn: Binding( - get: { Current.settingsStore.carPlayAssistTTSActivateAudioSession }, - set: { Current.settingsStore.carPlayAssistTTSActivateAudioSession = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.ttsActivateAudioSession }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.ttsActivateAudioSession = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) Picker("TTS category", selection: Binding( - get: { Current.settingsStore.carPlayAssistTTSCategory }, - set: { Current.settingsStore.carPlayAssistTTSCategory = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.ttsCategory }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.ttsCategory = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) { - ForEach(SettingsStore.CarPlayAssistAudioCategory.allCases, id: \.self) { category in + ForEach(CarPlayAssistAudioCategory.allCases, id: \.self) { category in Text(category.title).tag(category) } } Picker("TTS mode", selection: Binding( - get: { Current.settingsStore.carPlayAssistTTSMode }, - set: { Current.settingsStore.carPlayAssistTTSMode = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.ttsMode }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.ttsMode = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) { - ForEach(SettingsStore.CarPlayAssistAudioMode.allCases, id: \.self) { mode in + ForEach(CarPlayAssistAudioMode.allCases, id: \.self) { mode in Text(mode.title).tag(mode) } } Toggle("TTS allow Bluetooth HFP", isOn: Binding( - get: { Current.settingsStore.carPlayAssistTTSAllowBluetoothHFP }, - set: { Current.settingsStore.carPlayAssistTTSAllowBluetoothHFP = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.ttsAllowBluetoothHFP }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.ttsAllowBluetoothHFP = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) Toggle("TTS allow Bluetooth A2DP", isOn: Binding( - get: { Current.settingsStore.carPlayAssistTTSAllowBluetoothA2DP }, - set: { Current.settingsStore.carPlayAssistTTSAllowBluetoothA2DP = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.ttsAllowBluetoothA2DP }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.ttsAllowBluetoothA2DP = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) Toggle("TTS duck others", isOn: Binding( - get: { Current.settingsStore.carPlayAssistTTSDuckOthers }, - set: { Current.settingsStore.carPlayAssistTTSDuckOthers = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.ttsDuckOthers }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.ttsDuckOthers = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) Toggle("TTS interrupt spoken audio", isOn: Binding( - get: { Current.settingsStore.carPlayAssistTTSInterruptSpokenAudio }, - set: { Current.settingsStore.carPlayAssistTTSInterruptSpokenAudio = $0 } + get: { Current.settingsStore.carPlayAssistDebugSettings.ttsInterruptSpokenAudio }, + set: { + var settings = Current.settingsStore.carPlayAssistDebugSettings + settings.ttsInterruptSpokenAudio = $0 + Current.settingsStore.carPlayAssistDebugSettings = settings + } )) } header: { Text("TTS Audio Session") diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift index f4b701d7e1..0df899c673 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift @@ -49,9 +49,7 @@ final class CarPlayAssistSession: NSObject { private var state: State = .recording private var isStopped = false - private let server: Server private let pipelineId: String - private let pipelineName: String private lazy var template: CPVoiceControlTemplate = { let retryButton = CPButton( @@ -103,18 +101,16 @@ final class CarPlayAssistSession: NSObject { interfaceController: CPInterfaceController?, server: Server, pipelineId: String, - pipelineName: String, audioRecorder: AudioRecorderProtocol = AudioRecorder(), assistService: AssistServiceProtocol? = nil ) { self.interfaceController = interfaceController - self.server = server self.pipelineId = pipelineId - self.pipelineName = pipelineName self.audioRecorder = audioRecorder self.assistService = assistService ?? AssistService(server: server) super.init() - self.audioRecorder.managesAudioSession = Current.settingsStore.carPlayAssistRecorderManagesAudioSession + self.audioRecorder.managesAudioSession = Current.settingsStore.carPlayAssistDebugSettings + .recorderManagesAudioSession registerForAudioSessionNotifications() } @@ -171,18 +167,19 @@ final class CarPlayAssistSession: NSObject { // MARK: - Audio Session private func configureAudioSessionForAssist() { + let settings = Current.settingsStore.carPlayAssistDebugSettings do { try audioSession.setCategory( - Current.settingsStore.carPlayAssistAudioCategory.avCategory, - mode: Current.settingsStore.carPlayAssistAudioMode.avMode, + settings.audioCategory.avCategory, + mode: settings.audioMode.avMode, options: makeAudioSessionOptions( - allowBluetoothHFP: Current.settingsStore.carPlayAssistAllowBluetoothHFP, - allowBluetoothA2DP: Current.settingsStore.carPlayAssistAllowBluetoothA2DP, - duckOthers: false, - interruptSpokenAudio: false + allowBluetoothHFP: settings.allowBluetoothHFP, + allowBluetoothA2DP: settings.allowBluetoothA2DP, + duckOthers: settings.duckOthers, + interruptSpokenAudio: settings.interruptSpokenAudio ) ) - try audioSession.setPreferredSampleRate(Current.settingsStore.carPlayAssistPreferredSampleRate.value) + try audioSession.setPreferredSampleRate(settings.preferredSampleRate.value) try audioSession.setActive(true) logCurrentAudioRoute(context: "activated") } catch { @@ -191,25 +188,26 @@ final class CarPlayAssistSession: NSObject { } private func configureAudioSessionForTTSIfNeeded() { - guard Current.settingsStore.carPlayAssistTTSReconfigureAudioSession else { return } + let settings = Current.settingsStore.carPlayAssistDebugSettings + guard settings.ttsReconfigureAudioSession else { return } do { - if Current.settingsStore.carPlayAssistTTSDeactivateBeforeReconfigure { + if settings.ttsDeactivateBeforeReconfigure { try audioSession.setActive(false, options: .notifyOthersOnDeactivation) } try audioSession.setCategory( - Current.settingsStore.carPlayAssistTTSCategory.avCategory, - mode: Current.settingsStore.carPlayAssistTTSMode.avMode, + settings.ttsCategory.avCategory, + mode: settings.ttsMode.avMode, options: makeAudioSessionOptions( - allowBluetoothHFP: Current.settingsStore.carPlayAssistTTSAllowBluetoothHFP, - allowBluetoothA2DP: Current.settingsStore.carPlayAssistTTSAllowBluetoothA2DP, - duckOthers: Current.settingsStore.carPlayAssistTTSDuckOthers, - interruptSpokenAudio: Current.settingsStore.carPlayAssistTTSInterruptSpokenAudio + allowBluetoothHFP: settings.ttsAllowBluetoothHFP, + allowBluetoothA2DP: settings.ttsAllowBluetoothA2DP, + duckOthers: settings.ttsDuckOthers, + interruptSpokenAudio: settings.ttsInterruptSpokenAudio ) ) - if Current.settingsStore.carPlayAssistTTSActivateAudioSession { + if settings.ttsActivateAudioSession { try audioSession.setActive(true) } @@ -325,7 +323,7 @@ final class CarPlayAssistSession: NSObject { } private func playRecordingIndicatorToneIfNeeded() { - guard Current.settingsStore.carPlayAssistPlayRecordingIndicatorTone else { return } + guard Current.settingsStore.carPlayAssistDebugSettings.playRecordingIndicatorTone else { return } do { guard let toneURL = Bundle.main.url( @@ -352,7 +350,7 @@ final class CarPlayAssistSession: NSObject { /// Plays TTS audio using the already active conversational audio session to preserve the car route. private func playTTS(url: URL) { - let playbackDelay = Current.settingsStore.carPlayAssistTTSPlaybackDelay.seconds + let playbackDelay = Current.settingsStore.carPlayAssistDebugSettings.ttsPlaybackDelay.seconds if playbackDelay > 0 { Current.Log.info("CarPlay Assist delaying TTS playback by \(playbackDelay)s") DispatchQueue.main.asyncAfter(deadline: .now() + playbackDelay) { [weak self] in @@ -370,7 +368,7 @@ final class CarPlayAssistSession: NSObject { configureAudioSessionForTTSIfNeeded() logCurrentAudioRoute(context: "before tts playback") - switch Current.settingsStore.carPlayAssistTTSPlaybackStrategy { + switch Current.settingsStore.carPlayAssistDebugSettings.ttsPlaybackStrategy { case .avPlayer: playTTSWithAVPlayer(url: url) case .downloadedAVAudioPlayer: @@ -385,8 +383,9 @@ final class CarPlayAssistSession: NSObject { clearTTSPlayerObservers() let playerItem = AVPlayerItem(url: url) - ttsPlayer.automaticallyWaitsToMinimizeStalling = - Current.settingsStore.carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling + ttsPlayer.automaticallyWaitsToMinimizeStalling = Current.settingsStore + .carPlayAssistDebugSettings + .avPlayerAutomaticallyWaitsToMinimizeStalling ttsPlayer.replaceCurrentItem(with: playerItem) observeTTSPlayer(item: playerItem) Current.Log.info("CarPlay Assist starting AVPlayer TTS for URL: \(url.absoluteString)") @@ -649,59 +648,6 @@ extension CarPlayAssistSession: AVAudioPlayerDelegate { } } -@available(iOS 26.4, *) -private extension CarPlayAssistSession { - static let recordingIndicatorToneData: Data = { - let sampleRate = 24000 - let duration = 0.12 - let frequency = 880.0 - let frameCount = Int(Double(sampleRate) * duration) - let amplitude = 0.25 - - var pcmData = Data(capacity: frameCount * MemoryLayout.size) - - for frame in 0 ..< frameCount { - let progress = Double(frame) / Double(frameCount) - let envelope = min(progress / 0.1, (1.0 - progress) / 0.15, 1.0) - let sample = sin(2.0 * .pi * frequency * progress * duration) * amplitude * envelope - let intSample = Int16(max(-1.0, min(1.0, sample)) * Double(Int16.max)) - var littleEndianSample = intSample.littleEndian - pcmData.append(Data(bytes: &littleEndianSample, count: MemoryLayout.size)) - } - - let bytesPerSample = MemoryLayout.size - let subchunk2Size = frameCount * bytesPerSample - let chunkSize = 36 + subchunk2Size - let byteRate = sampleRate * bytesPerSample - let blockAlign = UInt16(bytesPerSample) - let bitsPerSample: UInt16 = 16 - let channels: UInt16 = 1 - let audioFormat: UInt16 = 1 - - func littleEndianData(_ value: T) -> Data { - var littleEndian = value.littleEndian - return Data(bytes: &littleEndian, count: MemoryLayout.size) - } - - var wavData = Data() - wavData.append("RIFF".data(using: .ascii)!) - wavData.append(littleEndianData(UInt32(chunkSize))) - wavData.append("WAVE".data(using: .ascii)!) - wavData.append("fmt ".data(using: .ascii)!) - wavData.append(littleEndianData(UInt32(16))) - wavData.append(littleEndianData(audioFormat)) - wavData.append(littleEndianData(channels)) - wavData.append(littleEndianData(UInt32(sampleRate))) - wavData.append(littleEndianData(UInt32(byteRate))) - wavData.append(littleEndianData(blockAlign)) - wavData.append(littleEndianData(bitsPerSample)) - wavData.append("data".data(using: .ascii)!) - wavData.append(littleEndianData(UInt32(subchunk2Size))) - wavData.append(pcmData) - return wavData - }() -} - // MARK: - AssistServiceDelegate @available(iOS 26.4, *) diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift index 1835a894b1..db6d4ecfb0 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift @@ -552,8 +552,7 @@ final class CarPlayQuickAccessTemplate: CarPlayTemplateProvider { let session = CarPlayAssistSession( interfaceController: interfaceController, server: server, - pipelineId: magicItem.id, - pipelineName: magicItem.name(info: info) + pipelineId: magicItem.id ) session.onStop = { [weak self] in self?.activeAssistSession = nil diff --git a/Sources/Shared/Settings/CarPlayAssistDebugSettings.swift b/Sources/Shared/Settings/CarPlayAssistDebugSettings.swift new file mode 100644 index 0000000000..81ebd6b6c3 --- /dev/null +++ b/Sources/Shared/Settings/CarPlayAssistDebugSettings.swift @@ -0,0 +1,155 @@ +import AVFoundation +import Foundation + +public enum CarPlayAssistAudioCategory: String, CaseIterable { + case playAndRecord + case playback + case record + + public var avCategory: AVAudioSession.Category { + switch self { + case .playAndRecord: + .playAndRecord + case .playback: + .playback + case .record: + .record + } + } + + public var title: String { + switch self { + case .playAndRecord: + "playAndRecord" + case .playback: + "playback" + case .record: + "record" + } + } +} + +public enum CarPlayAssistAudioMode: String, CaseIterable { + case `default` + case voiceChat + case voicePrompt + case spokenAudio + case measurement + + public var avMode: AVAudioSession.Mode { + switch self { + case .default: + .default + case .voiceChat: + .voiceChat + case .voicePrompt: + .voicePrompt + case .spokenAudio: + .spokenAudio + case .measurement: + .measurement + } + } + + public var title: String { + rawValue + } +} + +public enum CarPlayAssistPreferredSampleRate: Int, CaseIterable { + case rate16000 = 16000 + case rate24000 = 24000 + case rate44100 = 44100 + case rate48000 = 48000 + + public var title: String { + "\(rawValue) Hz" + } + + public var value: Double { + Double(rawValue) + } +} + +public enum CarPlayAssistTTSPlaybackStrategy: String, CaseIterable { + case avPlayer + case downloadedAVAudioPlayer + + public var title: String { + switch self { + case .avPlayer: + "AVPlayer" + case .downloadedAVAudioPlayer: + "Download then AVAudioPlayer" + } + } +} + +public enum CarPlayAssistPlaybackDelay: Int, CaseIterable { + case none = 0 + case ms100 = 100 + case ms250 = 250 + case ms500 = 500 + case ms1000 = 1000 + + public var title: String { + switch self { + case .none: + "None" + default: + "\(rawValue) ms" + } + } + + public var seconds: Double { + Double(rawValue) / 1000.0 + } +} + +public struct CarPlayAssistDebugSettings { + public var audioCategory: CarPlayAssistAudioCategory + public var audioMode: CarPlayAssistAudioMode + public var preferredSampleRate: CarPlayAssistPreferredSampleRate + public var allowBluetoothHFP: Bool + public var allowBluetoothA2DP: Bool + public var duckOthers: Bool + public var interruptSpokenAudio: Bool + public var playRecordingIndicatorTone: Bool + public var recorderManagesAudioSession: Bool + public var ttsPlaybackStrategy: CarPlayAssistTTSPlaybackStrategy + public var ttsReconfigureAudioSession: Bool + public var ttsDeactivateBeforeReconfigure: Bool + public var ttsActivateAudioSession: Bool + public var ttsCategory: CarPlayAssistAudioCategory + public var ttsMode: CarPlayAssistAudioMode + public var ttsAllowBluetoothHFP: Bool + public var ttsAllowBluetoothA2DP: Bool + public var ttsDuckOthers: Bool + public var ttsInterruptSpokenAudio: Bool + public var avPlayerAutomaticallyWaitsToMinimizeStalling: Bool + public var ttsPlaybackDelay: CarPlayAssistPlaybackDelay + + public static let `default` = CarPlayAssistDebugSettings( + audioCategory: .playAndRecord, + audioMode: .voiceChat, + preferredSampleRate: .rate16000, + allowBluetoothHFP: true, + allowBluetoothA2DP: true, + duckOthers: false, + interruptSpokenAudio: false, + playRecordingIndicatorTone: true, + recorderManagesAudioSession: false, + ttsPlaybackStrategy: .avPlayer, + ttsReconfigureAudioSession: false, + ttsDeactivateBeforeReconfigure: false, + ttsActivateAudioSession: true, + ttsCategory: .playAndRecord, + ttsMode: .voicePrompt, + ttsAllowBluetoothHFP: true, + ttsAllowBluetoothA2DP: true, + ttsDuckOthers: false, + ttsInterruptSpokenAudio: true, + avPlayerAutomaticallyWaitsToMinimizeStalling: true, + ttsPlaybackDelay: .none + ) +} diff --git a/Sources/Shared/Settings/SettingsStore.swift b/Sources/Shared/Settings/SettingsStore.swift index aab329bda3..5ed3f2b5fc 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -1,4 +1,3 @@ -import AVFoundation import CoreLocation import CoreMotion import Foundation @@ -7,111 +6,6 @@ import UIKit import Version public class SettingsStore { - public enum CarPlayAssistAudioCategory: String, CaseIterable { - case playAndRecord - case playback - case record - - public var avCategory: AVAudioSession.Category { - switch self { - case .playAndRecord: - .playAndRecord - case .playback: - .playback - case .record: - .record - } - } - - public var title: String { - switch self { - case .playAndRecord: - "playAndRecord" - case .playback: - "playback" - case .record: - "record" - } - } - } - - public enum CarPlayAssistAudioMode: String, CaseIterable { - case `default` - case voiceChat - case voicePrompt - case spokenAudio - case measurement - - public var avMode: AVAudioSession.Mode { - switch self { - case .default: - .default - case .voiceChat: - .voiceChat - case .voicePrompt: - .voicePrompt - case .spokenAudio: - .spokenAudio - case .measurement: - .measurement - } - } - - public var title: String { - rawValue - } - } - - public enum CarPlayAssistPreferredSampleRate: Int, CaseIterable { - case rate16000 = 16000 - case rate24000 = 24000 - case rate44100 = 44100 - case rate48000 = 48000 - - public var title: String { - "\(rawValue) Hz" - } - - public var value: Double { - Double(rawValue) - } - } - - public enum CarPlayAssistTTSPlaybackStrategy: String, CaseIterable { - case avPlayer - case downloadedAVAudioPlayer - - public var title: String { - switch self { - case .avPlayer: - "AVPlayer" - case .downloadedAVAudioPlayer: - "Download then AVAudioPlayer" - } - } - } - - public enum CarPlayAssistPlaybackDelay: Int, CaseIterable { - case none = 0 - case ms100 = 100 - case ms250 = 250 - case ms500 = 500 - case ms1000 = 1000 - - public var title: String { - switch self { - case .none: - "None" - default: - "\(rawValue) ms" - } - } - - public var seconds: Double { - Double(rawValue) / 1000.0 - } - } - let keychain = AppConstants.Keychain let prefs = UserDefaults(suiteName: AppConstants.AppGroupID)! @@ -615,247 +509,154 @@ public class SettingsStore { } } - public var carPlayAssistAudioCategory: CarPlayAssistAudioCategory { - get { - guard let rawValue = prefs.string(forKey: "carPlayAssistAudioCategory"), - let value = CarPlayAssistAudioCategory(rawValue: rawValue) else { - return .playAndRecord - } - return value - } - set { - prefs.set(newValue.rawValue, forKey: "carPlayAssistAudioCategory") - } - } - - public var carPlayAssistAudioMode: CarPlayAssistAudioMode { - get { - guard let rawValue = prefs.string(forKey: "carPlayAssistAudioMode"), - let value = CarPlayAssistAudioMode(rawValue: rawValue) else { - return .voiceChat - } - return value - } - set { - prefs.set(newValue.rawValue, forKey: "carPlayAssistAudioMode") - } - } - - public var carPlayAssistPreferredSampleRate: CarPlayAssistPreferredSampleRate { - get { - let rawValue = prefs.integer(forKey: "carPlayAssistPreferredSampleRate") - return CarPlayAssistPreferredSampleRate(rawValue: rawValue) ?? .rate16000 - } - set { - prefs.set(newValue.rawValue, forKey: "carPlayAssistPreferredSampleRate") - } - } - - public var carPlayAssistAllowBluetoothHFP: Bool { - get { - if prefs.object(forKey: "carPlayAssistAllowBluetoothHFP") == nil { - return true - } - return prefs.bool(forKey: "carPlayAssistAllowBluetoothHFP") - } - set { - prefs.set(newValue, forKey: "carPlayAssistAllowBluetoothHFP") - } - } - - public var carPlayAssistAllowBluetoothA2DP: Bool { - get { - if prefs.object(forKey: "carPlayAssistAllowBluetoothA2DP") == nil { - return true - } - return prefs.bool(forKey: "carPlayAssistAllowBluetoothA2DP") - } - set { - prefs.set(newValue, forKey: "carPlayAssistAllowBluetoothA2DP") - } - } - - public var carPlayAssistPlayRecordingIndicatorTone: Bool { - get { - if prefs.object(forKey: "carPlayAssistPlayRecordingIndicatorTone") == nil { - return true - } - return prefs.bool(forKey: "carPlayAssistPlayRecordingIndicatorTone") - } - set { - prefs.set(newValue, forKey: "carPlayAssistPlayRecordingIndicatorTone") - } - } - - public var carPlayAssistRecorderManagesAudioSession: Bool { - get { - prefs.bool(forKey: "carPlayAssistRecorderManagesAudioSession") - } - set { - prefs.set(newValue, forKey: "carPlayAssistRecorderManagesAudioSession") - } - } - - public var carPlayAssistTTSPlaybackStrategy: CarPlayAssistTTSPlaybackStrategy { - get { - guard let rawValue = prefs.string(forKey: "carPlayAssistTTSPlaybackStrategy"), - let value = CarPlayAssistTTSPlaybackStrategy(rawValue: rawValue) else { - return .avPlayer - } - return value - } - set { - prefs.set(newValue.rawValue, forKey: "carPlayAssistTTSPlaybackStrategy") - } - } - - public var carPlayAssistTTSReconfigureAudioSession: Bool { - get { - prefs.bool(forKey: "carPlayAssistTTSReconfigureAudioSession") - } - set { - prefs.set(newValue, forKey: "carPlayAssistTTSReconfigureAudioSession") - } - } - - public var carPlayAssistTTSDeactivateBeforeReconfigure: Bool { - get { - prefs.bool(forKey: "carPlayAssistTTSDeactivateBeforeReconfigure") - } - set { - prefs.set(newValue, forKey: "carPlayAssistTTSDeactivateBeforeReconfigure") - } - } - - public var carPlayAssistTTSActivateAudioSession: Bool { - get { - if prefs.object(forKey: "carPlayAssistTTSActivateAudioSession") == nil { - return true - } - return prefs.bool(forKey: "carPlayAssistTTSActivateAudioSession") - } - set { - prefs.set(newValue, forKey: "carPlayAssistTTSActivateAudioSession") - } - } - - public var carPlayAssistTTSCategory: CarPlayAssistAudioCategory { - get { - guard let rawValue = prefs.string(forKey: "carPlayAssistTTSCategory"), - let value = CarPlayAssistAudioCategory(rawValue: rawValue) else { - return .playAndRecord - } - return value - } - set { - prefs.set(newValue.rawValue, forKey: "carPlayAssistTTSCategory") - } - } - - public var carPlayAssistTTSMode: CarPlayAssistAudioMode { - get { - guard let rawValue = prefs.string(forKey: "carPlayAssistTTSMode"), - let value = CarPlayAssistAudioMode(rawValue: rawValue) else { - return .voicePrompt - } - return value - } - set { - prefs.set(newValue.rawValue, forKey: "carPlayAssistTTSMode") - } - } - - public var carPlayAssistTTSAllowBluetoothHFP: Bool { - get { - if prefs.object(forKey: "carPlayAssistTTSAllowBluetoothHFP") == nil { - return true - } - return prefs.bool(forKey: "carPlayAssistTTSAllowBluetoothHFP") + public var carPlayAssistDebugSettings: CarPlayAssistDebugSettings { + get { + let defaults = CarPlayAssistDebugSettings.default + return CarPlayAssistDebugSettings( + audioCategory: carPlayAssistEnum( + key: "carPlayAssistAudioCategory", + default: defaults.audioCategory + ), + audioMode: carPlayAssistEnum( + key: "carPlayAssistAudioMode", + default: defaults.audioMode + ), + preferredSampleRate: carPlayAssistEnum( + key: "carPlayAssistPreferredSampleRate", + default: defaults.preferredSampleRate + ), + allowBluetoothHFP: carPlayAssistBool( + key: "carPlayAssistAllowBluetoothHFP", + default: defaults.allowBluetoothHFP + ), + allowBluetoothA2DP: carPlayAssistBool( + key: "carPlayAssistAllowBluetoothA2DP", + default: defaults.allowBluetoothA2DP + ), + duckOthers: carPlayAssistBool( + key: "carPlayAssistDuckOthers", + default: defaults.duckOthers + ), + interruptSpokenAudio: carPlayAssistBool( + key: "carPlayAssistInterruptSpokenAudio", + default: defaults.interruptSpokenAudio + ), + playRecordingIndicatorTone: carPlayAssistBool( + key: "carPlayAssistPlayRecordingIndicatorTone", + default: defaults.playRecordingIndicatorTone + ), + recorderManagesAudioSession: carPlayAssistBool( + key: "carPlayAssistRecorderManagesAudioSession", + default: defaults.recorderManagesAudioSession + ), + ttsPlaybackStrategy: carPlayAssistEnum( + key: "carPlayAssistTTSPlaybackStrategy", + default: defaults.ttsPlaybackStrategy + ), + ttsReconfigureAudioSession: carPlayAssistBool( + key: "carPlayAssistTTSReconfigureAudioSession", + default: defaults.ttsReconfigureAudioSession + ), + ttsDeactivateBeforeReconfigure: carPlayAssistBool( + key: "carPlayAssistTTSDeactivateBeforeReconfigure", + default: defaults.ttsDeactivateBeforeReconfigure + ), + ttsActivateAudioSession: carPlayAssistBool( + key: "carPlayAssistTTSActivateAudioSession", + default: defaults.ttsActivateAudioSession + ), + ttsCategory: carPlayAssistEnum( + key: "carPlayAssistTTSCategory", + default: defaults.ttsCategory + ), + ttsMode: carPlayAssistEnum( + key: "carPlayAssistTTSMode", + default: defaults.ttsMode + ), + ttsAllowBluetoothHFP: carPlayAssistBool( + key: "carPlayAssistTTSAllowBluetoothHFP", + default: defaults.ttsAllowBluetoothHFP + ), + ttsAllowBluetoothA2DP: carPlayAssistBool( + key: "carPlayAssistTTSAllowBluetoothA2DP", + default: defaults.ttsAllowBluetoothA2DP + ), + ttsDuckOthers: carPlayAssistBool( + key: "carPlayAssistTTSDuckOthers", + default: defaults.ttsDuckOthers + ), + ttsInterruptSpokenAudio: carPlayAssistBool( + key: "carPlayAssistTTSInterruptSpokenAudio", + default: defaults.ttsInterruptSpokenAudio + ), + avPlayerAutomaticallyWaitsToMinimizeStalling: carPlayAssistBool( + key: "carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling", + default: defaults.avPlayerAutomaticallyWaitsToMinimizeStalling + ), + ttsPlaybackDelay: carPlayAssistEnum( + key: "carPlayAssistTTSPlaybackDelay", + default: defaults.ttsPlaybackDelay + ) + ) } set { - prefs.set(newValue, forKey: "carPlayAssistTTSAllowBluetoothHFP") + prefs.set(newValue.audioCategory.rawValue, forKey: "carPlayAssistAudioCategory") + prefs.set(newValue.audioMode.rawValue, forKey: "carPlayAssistAudioMode") + prefs.set(newValue.preferredSampleRate.rawValue, forKey: "carPlayAssistPreferredSampleRate") + prefs.set(newValue.allowBluetoothHFP, forKey: "carPlayAssistAllowBluetoothHFP") + prefs.set(newValue.allowBluetoothA2DP, forKey: "carPlayAssistAllowBluetoothA2DP") + prefs.set(newValue.duckOthers, forKey: "carPlayAssistDuckOthers") + prefs.set(newValue.interruptSpokenAudio, forKey: "carPlayAssistInterruptSpokenAudio") + prefs.set(newValue.playRecordingIndicatorTone, forKey: "carPlayAssistPlayRecordingIndicatorTone") + prefs.set(newValue.recorderManagesAudioSession, forKey: "carPlayAssistRecorderManagesAudioSession") + prefs.set(newValue.ttsPlaybackStrategy.rawValue, forKey: "carPlayAssistTTSPlaybackStrategy") + prefs.set(newValue.ttsReconfigureAudioSession, forKey: "carPlayAssistTTSReconfigureAudioSession") + prefs.set(newValue.ttsDeactivateBeforeReconfigure, forKey: "carPlayAssistTTSDeactivateBeforeReconfigure") + prefs.set(newValue.ttsActivateAudioSession, forKey: "carPlayAssistTTSActivateAudioSession") + prefs.set(newValue.ttsCategory.rawValue, forKey: "carPlayAssistTTSCategory") + prefs.set(newValue.ttsMode.rawValue, forKey: "carPlayAssistTTSMode") + prefs.set(newValue.ttsAllowBluetoothHFP, forKey: "carPlayAssistTTSAllowBluetoothHFP") + prefs.set(newValue.ttsAllowBluetoothA2DP, forKey: "carPlayAssistTTSAllowBluetoothA2DP") + prefs.set(newValue.ttsDuckOthers, forKey: "carPlayAssistTTSDuckOthers") + prefs.set(newValue.ttsInterruptSpokenAudio, forKey: "carPlayAssistTTSInterruptSpokenAudio") + prefs.set( + newValue.avPlayerAutomaticallyWaitsToMinimizeStalling, + forKey: "carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling" + ) + prefs.set(newValue.ttsPlaybackDelay.rawValue, forKey: "carPlayAssistTTSPlaybackDelay") } } - public var carPlayAssistTTSAllowBluetoothA2DP: Bool { - get { - if prefs.object(forKey: "carPlayAssistTTSAllowBluetoothA2DP") == nil { - return true - } - return prefs.bool(forKey: "carPlayAssistTTSAllowBluetoothA2DP") - } - set { - prefs.set(newValue, forKey: "carPlayAssistTTSAllowBluetoothA2DP") - } + public func resetCarPlayAssistDebugSettings() { + carPlayAssistDebugSettings = .default } - public var carPlayAssistTTSDuckOthers: Bool { - get { - prefs.bool(forKey: "carPlayAssistTTSDuckOthers") - } - set { - prefs.set(newValue, forKey: "carPlayAssistTTSDuckOthers") - } - } + // MARK: - Private helpers - public var carPlayAssistTTSInterruptSpokenAudio: Bool { - get { - if prefs.object(forKey: "carPlayAssistTTSInterruptSpokenAudio") == nil { - return true - } - return prefs.bool(forKey: "carPlayAssistTTSInterruptSpokenAudio") - } - set { - prefs.set(newValue, forKey: "carPlayAssistTTSInterruptSpokenAudio") + private func carPlayAssistBool(key: String, default defaultValue: Bool) -> Bool { + guard prefs.object(forKey: key) != nil else { + return defaultValue } + return prefs.bool(forKey: key) } - public var carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling: Bool { - get { - if prefs.object(forKey: "carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling") == nil { - return true - } - return prefs.bool(forKey: "carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling") - } - set { - prefs.set(newValue, forKey: "carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling") + private func carPlayAssistEnum(key: String, default defaultValue: T) -> T + where T.RawValue == String { + guard let rawValue = prefs.string(forKey: key), + let value = T(rawValue: rawValue) else { + return defaultValue } + return value } - public var carPlayAssistTTSPlaybackDelay: CarPlayAssistPlaybackDelay { - get { - let rawValue = prefs.integer(forKey: "carPlayAssistTTSPlaybackDelay") - return CarPlayAssistPlaybackDelay(rawValue: rawValue) ?? .none - } - set { - prefs.set(newValue.rawValue, forKey: "carPlayAssistTTSPlaybackDelay") + private func carPlayAssistEnum(key: String, default defaultValue: T) -> T + where T.RawValue == Int { + guard prefs.object(forKey: key) != nil else { + return defaultValue } + return T(rawValue: prefs.integer(forKey: key)) ?? defaultValue } - public func resetCarPlayAssistDebugSettings() { - carPlayAssistAudioCategory = .playAndRecord - carPlayAssistAudioMode = .voiceChat - carPlayAssistPreferredSampleRate = .rate16000 - carPlayAssistAllowBluetoothHFP = true - carPlayAssistAllowBluetoothA2DP = true - carPlayAssistPlayRecordingIndicatorTone = true - carPlayAssistRecorderManagesAudioSession = false - carPlayAssistTTSPlaybackStrategy = .avPlayer - carPlayAssistTTSReconfigureAudioSession = false - carPlayAssistTTSDeactivateBeforeReconfigure = false - carPlayAssistTTSActivateAudioSession = true - carPlayAssistTTSCategory = .playAndRecord - carPlayAssistTTSMode = .voicePrompt - carPlayAssistTTSAllowBluetoothHFP = true - carPlayAssistTTSAllowBluetoothA2DP = true - carPlayAssistTTSDuckOthers = false - carPlayAssistTTSInterruptSpokenAudio = true - carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling = true - carPlayAssistTTSPlaybackDelay = .none - } - - // MARK: - Private helpers - private var defaultDeviceID: String { let baseID = removeSpecialCharsFromString(text: Current.device.deviceName()) .replacingOccurrences(of: " ", with: "_") From 501b7ffe7102b304b383d4f3e19df3445af3f900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 5 May 2026 17:00:45 +0200 Subject: [PATCH 07/23] Update CI xcode --- .github/workflows/ci.yml | 6 +++--- .github/workflows/distribute.yml | 4 ++-- fastlane/lanes/testing.rb | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4f0f62186..b7617c70bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ on: - main env: - DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.4.app/Contents/Developer FASTLANE_SKIP_UPDATE_CHECK: true FASTLANE_XCODE_LIST_TIMEOUT: 80 FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 80 @@ -134,7 +134,7 @@ jobs: test: needs: check-swiftlint-disables - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 45 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -202,7 +202,7 @@ jobs: if: | github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'home-assistant/iOS' - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 45 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/distribute.yml b/.github/workflows/distribute.yml index 213ca1e5b9..a2e4b14bc1 100644 --- a/.github/workflows/distribute.yml +++ b/.github/workflows/distribute.yml @@ -7,7 +7,7 @@ on: - main env: - DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.4.app/Contents/Developer FASTLANE_SKIP_UPDATE_CHECK: true FASTLANE_XCODE_LIST_TIMEOUT: 60 FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 60 @@ -16,7 +16,7 @@ env: jobs: build: - runs-on: macos-15 + runs-on: macos-26 strategy: fail-fast: false matrix: diff --git a/fastlane/lanes/testing.rb b/fastlane/lanes/testing.rb index 14e8a28caf..ab083bfc13 100644 --- a/fastlane/lanes/testing.rb +++ b/fastlane/lanes/testing.rb @@ -39,6 +39,6 @@ scheme: 'Tests-Unit', result_bundle: true, skip_package_dependencies_resolution: true, - destination: 'platform=iOS Simulator,name=iPhone 17,OS=26.2' + destination: 'platform=iOS Simulator,name=iPhone 17,OS=26.4' ) end From ced42819182d70af557b0eae4c1906a204899c34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 5 May 2026 17:06:36 +0200 Subject: [PATCH 08/23] Add ruby/setup-ruby and cache .ruby-version --- .github/workflows/ci.yml | 10 ++++++++-- .github/workflows/distribute.yml | 3 +++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7617c70bd..5e3bb8a911 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,9 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0 + with: + ruby-version: .ruby-version - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 name: "Cache: Pods" @@ -157,7 +160,7 @@ jobs: with: path: vendor/bundle key: >- - ${{ runner.os }}-gems-${{ env.ImageVersion }}-${{ env.DEVELOPER_DIR }}-${{ hashFiles('**/Gemfile.lock') }} + ${{ runner.os }}-gems-${{ env.ImageVersion }}-${{ env.DEVELOPER_DIR }}-${{ hashFiles('.ruby-version', '**/Gemfile.lock') }} - name: Install Brews # right now, we don't need anything from brew for tests, so save some time @@ -206,6 +209,9 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0 + with: + ruby-version: .ruby-version - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4.6.2 name: "Cache: Pods" id: cache_pods @@ -224,7 +230,7 @@ jobs: with: path: vendor/bundle key: >- - ${{ runner.os }}-gems-${{ env.ImageVersion }}-${{ env.DEVELOPER_DIR }}-${{ hashFiles('**/Gemfile.lock') }} + ${{ runner.os }}-gems-${{ env.ImageVersion }}-${{ env.DEVELOPER_DIR }}-${{ hashFiles('.ruby-version', '**/Gemfile.lock') }} - name: Install Brews # right now, we don't need anything from brew for sizing, so save some time diff --git a/.github/workflows/distribute.yml b/.github/workflows/distribute.yml index a2e4b14bc1..332d9b5575 100644 --- a/.github/workflows/distribute.yml +++ b/.github/workflows/distribute.yml @@ -23,6 +23,9 @@ jobs: kind: [mac, ios] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0 + with: + ruby-version: .ruby-version - name: Install Gems run: bundle install --jobs 4 --retry 3 From f4a8d9a554bdc3768eb374a2fa30d573852095e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 15:04:47 +0200 Subject: [PATCH 09/23] Fix icons and localization --- .swiftlint.yml | 1 + HomeAssistant.xcodeproj/project.pbxproj | 58 ++-- .../Resources/en.lproj/Localizable.strings | 54 +++- .../CarPlay/CarPlayAssistSettingsView.swift | 51 +++ .../CarPlay/CarPlayConfigurationView.swift | 14 +- Sources/App/Settings/DebugView.swift | 290 +++++------------- .../QuickAccess/CarPlayAssistSession.swift | 100 +++++- .../Shared/MaterialDesignIcons+CarPlay.swift | 21 +- .../Shared/Resources/Swiftgen/Strings.swift | 136 ++++++++ .../Settings/CarPlayAssistDebugSettings.swift | 27 +- 10 files changed, 490 insertions(+), 262 deletions(-) create mode 100644 Sources/App/Settings/CarPlay/CarPlayAssistSettingsView.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 09a1a36260..27ad866d93 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -55,6 +55,7 @@ excluded: - Pods - vendor - .claude + - build - "**/**/.build" - "./.swiftlint.yml" diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 1deb4fc9ee..e70fb76865 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -929,6 +929,7 @@ 429BA2AF2C800CAB00A50996 /* SFSymbolEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429BA2AE2C800CAB00A50996 /* SFSymbolEntity.swift */; }; 429BEA1A2D102F3A00F070F9 /* ConnectionErrorDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429BEA192D102F3A00F070F9 /* ConnectionErrorDetailsView.swift */; }; 429BEA1D2D10315F00F070F9 /* SheetCloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429BEA1B2D1030EA00F070F9 /* SheetCloseButton.swift */; }; + 429C07722FAB2D31000302D1 /* CarPlayAssistSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C07712FAB2D31000302D1 /* CarPlayAssistSettingsView.swift */; }; 429C33BF2F17989F0033EF5E /* EntityPickerViewModel.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */; }; 429C33C22F17A7010033EF5E /* HAUsagePredictionCommonControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */; }; 429C33C32F17A7010033EF5E /* HAUsagePredictionCommonControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */; }; @@ -1239,7 +1240,7 @@ 651755E378F6F79AB401F05C /* AssistPipelineAddList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07701F2786F6D45E945CC1AA /* AssistPipelineAddList.swift */; }; 65286F3B745551AD4090EE6B /* Pods-iOS-SharedTesting-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4053903E4C54A6803204286E /* Pods-iOS-SharedTesting-metadata.plist */; }; 6596FA74E1A501276EA62D86 /* Pods_watchOS_Shared_watchOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD370D44DFFB906B05C3EB3A /* Pods_watchOS_Shared_watchOS.framework */; }; - 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */; }; + 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */; }; 6FCEBAA2C8E9C5403055E73D /* IntentFanEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E5E2F9F8F008EEA30C533FD /* IntentFanEntity.swift */; }; 70BD8A8EA1ABC5DC1F0A0D6E /* Pods_iOS_Shared_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C663B0750E0318469E7008C3 /* Pods_iOS_Shared_iOS.framework */; }; 71E0BF803A854C3B9F0CB726 /* HandlerLiveActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58D524991C142DBB38A1968 /* HandlerLiveActivityTests.swift */; }; @@ -1254,7 +1255,7 @@ 999549244371450BC98C700E /* Pods_iOS_Extensions_PushProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */; }; 9D57ECBD5431BC00BDC16F1E /* NotificationActionEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F913E441276235B7A2D7B29 /* NotificationActionEditorView.swift */; }; A1619F1ED93FB8B0E7E53C38 /* KioskLifecycleBrightness.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373AE72CB925F044BAE18B62 /* KioskLifecycleBrightness.test.swift */; }; - A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */; }; + A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */; }; A596C4D1E125E6863C7D2034 /* ComplicationEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512A72C5F1BCC979E74F7629 /* ComplicationEditViewModel.swift */; }; A5A3C1932BE1F4A40EA78754 /* Pods-iOS-Extensions-Matter-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */; }; A60E917B401A6D456F1DB630 /* ComplicationFamilySelectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1861EB0361816DC9260D1F5E /* ComplicationFamilySelectView.swift */; }; @@ -1520,7 +1521,7 @@ B6E42613215C4333007FEB7E /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; }; BB77559927344584B2C0E987 /* OnboardingAuthError.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.swift */; }; BD1044995DE13A04C0FA039A /* Pods_iOS_Extensions_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9C81015FD7A8FA8716E4F2 /* Pods_iOS_Extensions_Widgets.framework */; }; - BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */; }; + BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */; }; C10D762EFE08D347D0538339 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */; }; C35621B95F7E4548BC8F6D75 /* FolderEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BECEB2525564358A124F818 /* FolderEditView.swift */; }; C3EB3740FA097F36D51F525E /* BarometerSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1396822195C7FF562AB891F2 /* BarometerSensor.swift */; }; @@ -1572,7 +1573,7 @@ D87EC7A89E0515C4CAB93220 /* BarometerSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1396822195C7FF562AB891F2 /* BarometerSensor.swift */; }; D8B4F2A61E9C73058AF2D49E /* KioskSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A3E91F5B8D42A6E0F13B74 /* KioskSettingsViewModel.swift */; }; D9A6697AF4D05BB8DE822A54 /* Pods_iOS_Extensions_Share.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33CA7FF55788E7084DA5E4B3 /* Pods_iOS_Extensions_Share.framework */; }; - DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */; }; + DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */; }; DB54626ADCE0C32094C8C0B9 /* LoadingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFD0D30836C69840AB63A8A /* LoadingButton.swift */; }; DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */; }; E3A02409794174F002C8BB4F /* IconSearchPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC23DE131CA8813C2DBD657 /* IconSearchPicker.swift */; }; @@ -2639,6 +2640,7 @@ 429BA2AE2C800CAB00A50996 /* SFSymbolEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSymbolEntity.swift; sourceTree = ""; }; 429BEA192D102F3A00F070F9 /* ConnectionErrorDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionErrorDetailsView.swift; sourceTree = ""; }; 429BEA1B2D1030EA00F070F9 /* SheetCloseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetCloseButton.swift; sourceTree = ""; }; + 429C07712FAB2D31000302D1 /* CarPlayAssistSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayAssistSettingsView.swift; sourceTree = ""; }; 429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPickerViewModel.test.swift; sourceTree = ""; }; 429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAUsagePredictionCommonControl.swift; sourceTree = ""; }; 429C721F2B28D0EC00BCD558 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; @@ -3066,7 +3068,7 @@ 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-App-metadata.plist"; path = "Pods/Pods-iOS-App-metadata.plist"; sourceTree = ""; }; 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-NotificationService-metadata.plist"; path = "Pods/Pods-iOS-Extensions-NotificationService-metadata.plist"; sourceTree = ""; }; 5BFD0D30836C69840AB63A8A /* LoadingButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LoadingButton.swift; sourceTree = ""; }; - 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; + 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; 5D4737412F241342009A70EA /* FolderDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderDetailView.swift; sourceTree = ""; }; 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLiveActivityRegistry.swift; sourceTree = ""; }; 5FF9C3A30E10A8E214623EBB /* ComplicationListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationListView.swift; sourceTree = ""; }; @@ -3090,7 +3092,7 @@ 825E1E44BA9ABF1BF53733D3 /* KioskConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskConstants.swift; sourceTree = ""; }; 862436CFE6E3F4B31500EFB2 /* ComplicationListViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationListViewModel.swift; sourceTree = ""; }; 86BFD63671D2D0A012DFE169 /* Pods-iOS-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.debug.xcconfig"; sourceTree = ""; }; - 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; }; + 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; }; 8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlFanValueProvider.swift; sourceTree = ""; }; 8D6888525DCF492642BA7EA3 /* FanIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanIntent.swift; sourceTree = ""; }; 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-watchOS-WatchExtension-Watch-metadata.plist"; path = "Pods/Pods-watchOS-WatchExtension-Watch-metadata.plist"; sourceTree = ""; }; @@ -3103,7 +3105,7 @@ 9C4E5E27229D992A0044C8EC /* HomeAssistant.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.xcconfig; sourceTree = ""; }; 9D84964A844E6CD21F16D3AB /* Pods-watchOS-WatchExtension-Watch.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; sourceTree = ""; }; 9DA2D62699FC44A99AB37480 /* WatchFolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchFolderRow.swift; sourceTree = ""; }; - 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; }; + 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; }; 9F913E441276235B7A2D7B29 /* NotificationActionEditorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationActionEditorView.swift; sourceTree = ""; }; 9F9398CFD66E4C66DC39E1D3 /* Pods-iOS-Extensions-PushProvider.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.beta.xcconfig"; sourceTree = ""; }; A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthError.test.swift; sourceTree = ""; }; @@ -3433,7 +3435,7 @@ B6FD0574228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; B7D8DAEFAD435091FDDD61E7 /* Pods_iOS_Extensions_NotificationContent.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_NotificationContent.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B833A17275EC47FA65A3235A /* YamlPreviewSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = YamlPreviewSection.swift; sourceTree = ""; }; - BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; }; + BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; }; BC9B77AAC44845DC9EE48759 /* Pods_iOS_Extensions_Intents.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Intents.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BDC6ACBDCC2C47510C37E4C8 /* NotificationCategoryListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationCategoryListView.swift; sourceTree = ""; }; BEF9A7008EFA4A6FC9E02B5E /* Pods-iOS-Extensions-Intents.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.release.xcconfig"; sourceTree = ""; }; @@ -6302,6 +6304,7 @@ 504A2C852F8133E6002A3C0E /* CarPlayTabsSelectionView.swift */, 42ABB0BA2C888BB10081461D /* CarPlayConfigurationViewModel.swift */, 42ABB0B82C888AA10081461D /* CarPlayConfig.swift */, + 429C07712FAB2D31000302D1 /* CarPlayAssistSettingsView.swift */, ); path = CarPlay; sourceTree = ""; @@ -7288,10 +7291,10 @@ 11CB98CC249E637300B05222 /* Version+HA.test.swift */, 11883CC424C12C8A0036A6C6 /* CLLocation+Extensions.test.swift */, 11883CC624C131EE0036A6C6 /* RealmZone.test.swift */, - 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */, - BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */, - 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */, - 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */, + 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */, + BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */, + 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */, + 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */, 11EE9B4B24C5181A00404AF8 /* ModelManager.test.swift */, 11BC9E5424FDB88200B9FBF7 /* ActiveStateManager.test.swift */, 1104FCCE253275CF00B8BE34 /* WatchBackgroundRefreshScheduler.test.swift */, @@ -8178,7 +8181,7 @@ packageReferences = ( 420E64BB2D676B2400A31E86 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, 42B89EA62E05CC54000224A2 /* XCRemoteSwiftPackageReference "WebRTC" */, - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */, + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */, 4237E6372E5333370023B673 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 42B18FD52F38CA2300A1537A /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); @@ -8706,14 +8709,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks.sh\"\n"; @@ -8851,14 +8850,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks.sh\"\n"; @@ -8894,14 +8889,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks.sh\"\n"; @@ -9001,14 +8992,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks.sh\"\n"; @@ -9654,6 +9641,7 @@ 421FC34D2F5EFD3C0027DB31 /* SpeechSynthesizer.swift in Sources */, 42FCCFFF2B9B1C310057783F /* ThreadCredentialsSharingView.swift in Sources */, 429106872BA9D22500D452F9 /* AudioRecorder.swift in Sources */, + 429C07722FAB2D31000302D1 /* CarPlayAssistSettingsView.swift in Sources */, 425573C92B5572DB00145217 /* CarPlayServerListViewModel.swift in Sources */, 421D89A22EAF721F00E352A7 /* BaseOnboardingViewModifiers.swift in Sources */, 11A71C6F24A4644A00D9565F /* ZoneManagerIgnoreReason.swift in Sources */, @@ -10447,10 +10435,10 @@ 11AF4D2C249D965C006C74C0 /* BatterySensor.test.swift in Sources */, 11F2F2B8258728B200F61F7C /* NotificationAttachmentParserURL.test.swift in Sources */, 11883CC724C131EE0036A6C6 /* RealmZone.test.swift in Sources */, - DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */, - A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */, - 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */, - BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */, + DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */, + A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */, + 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */, + BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */, 11267D0925BBA9FE00F28E5C /* Updater.test.swift in Sources */, 11A3F08C24ECE88C0018D84F /* WebhookUpdateLocation.test.swift in Sources */, 42FDCA272F0C7EB900C92958 /* EntityRegistry.test.swift in Sources */, @@ -12331,7 +12319,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */ = { + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */ = { isa = XCLocalSwiftPackageReference; relativePath = Sources/SharedPush; }; @@ -12395,7 +12383,7 @@ }; 4273F7DF2E258827000629F7 /* SharedPush */ = { isa = XCSwiftPackageProductDependency; - package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */; + package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */; productName = SharedPush; }; 427692E22B98B82500F24321 /* SharedPush */ = { diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 3b203c2913..3f8adaa698 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -117,6 +117,14 @@ "assist.button.finish_recording.title" = "Tap to finish recording..."; "assist.button.listening.title" = "Listening..."; "assist.carplay.processing.title" = "Processing..."; +"assist.carplay.playback_help.change_playback.detail" = "Choose Download and play if Stream does not play audio in your car."; +"assist.carplay.playback_help.change_playback.title" = "TTS Playback"; +"assist.carplay.playback_help.go_to_advanced.detail" = "Open Advanced, then Assist."; +"assist.carplay.playback_help.go_to_advanced.title" = "Advanced > Assist"; +"assist.carplay.playback_help.message" = "If you encounter audio playback issues, open CarPlay settings in the Home Assistant Companion app, tap Advanced, open Assist, and change TTS Playback to Download and play."; +"assist.carplay.playback_help.open_app.detail" = "Open CarPlay settings in the Home Assistant Companion app."; +"assist.carplay.playback_help.open_app.title" = "Companion app"; +"assist.carplay.playback_help.title" = "Audio Playback Help"; "assist.carplay.responding.title" = "Responding..."; "assist.carplay.tap_to_record.title" = "Tap to record"; "assist.error.pipelines_response" = "Failed to obtain Assist pipelines, please check your pipelines configuration."; @@ -175,6 +183,47 @@ "carPlay.debug.delete_db.alert.failed.message" = "Failed to delete configuration, error: %@"; "carPlay.debug.delete_db.alert.title" = "Are you sure you want to delete CarPlay configuration? This can't be reverted"; "carPlay.debug.delete_db.button.title" = "Delete CarPlay configuration"; +"carPlay.debug.settings.assist_session.allow_bluetooth_a2dp" = "Allow Bluetooth A2DP"; +"carPlay.debug.settings.assist_session.allow_bluetooth_hfp" = "Allow Bluetooth HFP"; +"carPlay.debug.settings.assist_session.audio_category" = "Audio category"; +"carPlay.debug.settings.assist_session.audio_mode" = "Audio mode"; +"carPlay.debug.settings.assist_session.footer" = "These values apply when a new CarPlay Assist session starts."; +"carPlay.debug.settings.assist_session.interrupt_spoken_audio" = "Interrupt spoken audio"; +"carPlay.debug.settings.assist_session.play_recording_indicator_tone" = "Play recording indicator tone"; +"carPlay.debug.settings.assist_session.preferred_sample_rate" = "Preferred sample rate"; +"carPlay.debug.settings.assist_session.recorder_manages_audio_session" = "AudioRecorder manages audio session"; +"carPlay.debug.settings.assist_session.title" = "Assist Session"; +"carPlay.debug.settings.assist_session.duck_others" = "Duck others"; +"carPlay.debug.settings.navigation_title" = "CarPlay Debug"; +"carPlay.debug.settings.option.audio_category.play_and_record" = "playAndRecord"; +"carPlay.debug.settings.option.audio_category.playback" = "playback"; +"carPlay.debug.settings.option.audio_category.record" = "record"; +"carPlay.debug.settings.option.audio_mode.default" = "default"; +"carPlay.debug.settings.option.audio_mode.measurement" = "measurement"; +"carPlay.debug.settings.option.audio_mode.spoken_audio" = "spokenAudio"; +"carPlay.debug.settings.option.audio_mode.voice_chat" = "voiceChat"; +"carPlay.debug.settings.option.audio_mode.voice_prompt" = "voicePrompt"; +"carPlay.debug.settings.option.playback_delay.none" = "None"; +"carPlay.debug.settings.option.tts_playback_strategy.download_and_play" = "Download and play"; +"carPlay.debug.settings.option.tts_playback_strategy.stream" = "Stream"; +"carPlay.debug.settings.reset" = "Reset"; +"carPlay.debug.settings.row_title" = "Carplay Debug Settings"; +"carPlay.debug.settings.tts_audio_session.activate_audio_session_before_play" = "Activate audio session before play"; +"carPlay.debug.settings.tts_audio_session.allow_bluetooth_a2dp" = "TTS allow Bluetooth A2DP"; +"carPlay.debug.settings.tts_audio_session.allow_bluetooth_hfp" = "TTS allow Bluetooth HFP"; +"carPlay.debug.settings.tts_audio_session.category" = "TTS category"; +"carPlay.debug.settings.tts_audio_session.deactivate_before_reconfigure" = "Deactivate before reconfigure"; +"carPlay.debug.settings.tts_audio_session.duck_others" = "TTS duck others"; +"carPlay.debug.settings.tts_audio_session.footer" = "This section lets you force a dedicated TTS session reconfiguration, which is the most likely area if another app starting playback makes the response suddenly audible."; +"carPlay.debug.settings.tts_audio_session.interrupt_spoken_audio" = "TTS interrupt spoken audio"; +"carPlay.debug.settings.tts_audio_session.mode" = "TTS mode"; +"carPlay.debug.settings.tts_audio_session.reconfigure_before_tts" = "Reconfigure before TTS"; +"carPlay.debug.settings.tts_audio_session.title" = "TTS Audio Session"; +"carPlay.debug.settings.tts_playback.avplayer_waits_to_minimize_stalling" = "AVPlayer waits to minimize stalling"; +"carPlay.debug.settings.tts_playback.footer" = "Use the downloaded AVAudioPlayer strategy to determine whether the failure is tied to AVPlayer or remote URL playback."; +"carPlay.debug.settings.tts_playback.playback_delay" = "Playback delay"; +"carPlay.debug.settings.tts_playback.playback_strategy" = "Playback strategy"; +"carPlay.debug.settings.tts_playback.title" = "TTS Playback"; "carPlay.debug.delete_db.reset.title" = "Reset configuration"; "carPlay.labels.already_added_server" = "Already added"; "carPlay.labels.empty_domain_list" = "No domains available"; @@ -183,6 +232,9 @@ "carPlay.labels.servers" = "Servers"; "carPlay.labels.settings.advanced.section.button.detail" = "Use this option if your server data is not loaded properly."; "carPlay.labels.settings.advanced.section.button.title" = "Restart App"; +"carPlay.labels.settings.advanced.assist.section.title" = "Assist"; +"carPlay.labels.settings.advanced.assist.tts_playback.footer" = "In some cars, spoken responses may not play when 'Stream' is selected. If that happens, 'Download and play' can potentially fix it."; +"carPlay.labels.settings.advanced.assist.tts_playback.title" = "TTS Playback"; "carPlay.labels.settings.advanced.section.title" = "Advanced"; "carPlay.labels.tab.settings" = "Settings"; "carPlay.lock.confirmation.title" = "Are you sure you want to perform lock action on %@?"; @@ -1657,4 +1709,4 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho "widgets.todo_list.refresh_title" = "Refresh To-do List"; "widgets.todo_list.select_list" = "Edit widget to select list."; "widgets.todo_list.title" = "To-do List"; -"yes_label" = "Yes"; \ No newline at end of file +"yes_label" = "Yes"; diff --git a/Sources/App/Settings/CarPlay/CarPlayAssistSettingsView.swift b/Sources/App/Settings/CarPlay/CarPlayAssistSettingsView.swift new file mode 100644 index 0000000000..fc88c0c44d --- /dev/null +++ b/Sources/App/Settings/CarPlay/CarPlayAssistSettingsView.swift @@ -0,0 +1,51 @@ +import Shared +import SwiftUI + +struct CarPlayAdvancedSettingsView: View { + @State private var settings: CarPlayAssistDebugSettings + + init() { + _settings = State(initialValue: Current.settingsStore.carPlayAssistDebugSettings) + } + + var body: some View { + List { + assistSection + } + .navigationTitle(L10n.CarPlay.Labels.Settings.Advanced.Section.title) + .navigationBarTitleDisplayMode(.inline) + .onChange(of: settings) { updatedSettings in + Current.settingsStore.carPlayAssistDebugSettings = updatedSettings + } + } + + private var assistSection: some View { + Section { + Picker( + L10n.CarPlay.Labels.Settings.Advanced.Assist.TtsPlayback.title, + selection: $settings.ttsPlaybackStrategy + ) { + ForEach(CarPlayAssistTTSPlaybackStrategy.allCases, id: \.self) { strategy in + Text(strategy.title).tag(strategy) + } + } + .lineLimit(1) + } header: { + Text(L10n.CarPlay.Labels.Settings.Advanced.Assist.Section.title) + } footer: { + Text(L10n.CarPlay.Labels.Settings.Advanced.Assist.TtsPlayback.footer) + } + } +} + +#Preview { + if #available(iOS 16.0, *) { + NavigationStack { + CarPlayAdvancedSettingsView() + } + } else { + NavigationView { + CarPlayAdvancedSettingsView() + } + } +} diff --git a/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift index 043435badc..bf9635d940 100644 --- a/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift +++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift @@ -3,6 +3,7 @@ import SFSafeSymbols import Shared import StoreKit import SwiftUI +import UIKit struct CarPlayConfigurationView: View { @Environment(\.dismiss) private var dismiss @@ -39,6 +40,7 @@ struct CarPlayConfigurationView: View { carPlayLogo tabsSection itemsSection + advancedSection resetView } .navigationTitle("CarPlay") @@ -46,7 +48,9 @@ struct CarPlayConfigurationView: View { .toolbar(content: { ToolbarItem(placement: .topBarTrailing) { Button(action: { - SKStoreReviewController.requestReview() + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { + SKStoreReviewController.requestReview(in: windowScene) + } dismiss() }, label: { Text(L10n.doneLabel) @@ -192,6 +196,14 @@ struct CarPlayConfigurationView: View { Button(L10n.noLabel, role: .cancel) {} } } + + private var advancedSection: some View { + NavigationLink { + CarPlayAdvancedSettingsView() + } label: { + Text(L10n.CarPlay.Labels.Settings.Advanced.Section.title) + } + } } #Preview { diff --git a/Sources/App/Settings/DebugView.swift b/Sources/App/Settings/DebugView.swift index bd9eca4edf..8dd329387c 100644 --- a/Sources/App/Settings/DebugView.swift +++ b/Sources/App/Settings/DebugView.swift @@ -326,19 +326,13 @@ struct DebugView: View { } private var carPlayDebugSection: some View { - Section { - NavigationLink { - CarPlayDebugSettingsView() - } label: { - linkContent( - image: .init(systemSymbol: .carFill), - title: "CarPlay Debug Settings" - ) - } - } header: { - Text("CarPlay") - } footer: { - Text("CarPlay audio and TTS debugging controls live here to avoid cluttering the main debug screen.") + NavigationLink { + CarPlayDebugSettingsView() + } label: { + linkContent( + image: .init(systemSymbol: .carFill), + title: L10n.CarPlay.Debug.Settings.rowTitle + ) } } @@ -568,7 +562,7 @@ struct DebugView: View { } private func wait(seconds: Int) async { - await Task.sleep(UInt64(seconds * 1_000_000_000)) + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) } private func revokeToken(api: HomeAssistantAPI) async { @@ -604,6 +598,13 @@ struct DebugView: View { } private struct CarPlayDebugSettingsView: View { + @State private var settings: CarPlayAssistDebugSettings + @State private var showResetConfirmation = false + + init() { + _settings = State(initialValue: Current.settingsStore.carPlayAssistDebugSettings) + } + var body: some View { List { assistSessionSection @@ -611,259 +612,138 @@ private struct CarPlayDebugSettingsView: View { ttsSessionSection resetSection } - .navigationTitle("CarPlay Debug") + .navigationTitle(L10n.CarPlay.Debug.Settings.navigationTitle) .navigationBarTitleDisplayMode(.inline) + .onChange(of: settings) { updatedSettings in + Current.settingsStore.carPlayAssistDebugSettings = updatedSettings + } } private var assistSessionSection: some View { Section { - Picker("Audio category", selection: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.audioCategory }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.audioCategory = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) { + Picker(L10n.CarPlay.Debug.Settings.AssistSession.audioCategory, selection: $settings.audioCategory) { ForEach(CarPlayAssistAudioCategory.allCases, id: \.self) { category in Text(category.title).tag(category) } } - Picker("Audio mode", selection: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.audioMode }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.audioMode = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) { + Picker(L10n.CarPlay.Debug.Settings.AssistSession.audioMode, selection: $settings.audioMode) { ForEach(CarPlayAssistAudioMode.allCases, id: \.self) { mode in Text(mode.title).tag(mode) } } - Picker("Preferred sample rate", selection: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.preferredSampleRate }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.preferredSampleRate = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) { + Picker( + L10n.CarPlay.Debug.Settings.AssistSession.preferredSampleRate, + selection: $settings.preferredSampleRate + ) { ForEach(CarPlayAssistPreferredSampleRate.allCases, id: \.self) { sampleRate in Text(sampleRate.title).tag(sampleRate) } } - Toggle("Allow Bluetooth HFP", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.allowBluetoothHFP }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.allowBluetoothHFP = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) - - Toggle("Allow Bluetooth A2DP", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.allowBluetoothA2DP }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.allowBluetoothA2DP = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) - - Toggle("Duck others", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.duckOthers }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.duckOthers = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) - - Toggle("Interrupt spoken audio", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.interruptSpokenAudio }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.interruptSpokenAudio = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) - - Toggle("AudioRecorder manages audio session", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.recorderManagesAudioSession }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.recorderManagesAudioSession = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) - - Toggle("Play recording indicator tone", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.playRecordingIndicatorTone }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.playRecordingIndicatorTone = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) + Toggle(L10n.CarPlay.Debug.Settings.AssistSession.allowBluetoothHfp, isOn: $settings.allowBluetoothHFP) + Toggle(L10n.CarPlay.Debug.Settings.AssistSession.allowBluetoothA2dp, isOn: $settings.allowBluetoothA2DP) + Toggle(L10n.CarPlay.Debug.Settings.AssistSession.duckOthers, isOn: $settings.duckOthers) + Toggle(L10n.CarPlay.Debug.Settings.AssistSession.interruptSpokenAudio, isOn: $settings.interruptSpokenAudio) + Toggle( + L10n.CarPlay.Debug.Settings.AssistSession.recorderManagesAudioSession, + isOn: $settings.recorderManagesAudioSession + ) + Toggle( + L10n.CarPlay.Debug.Settings.AssistSession.playRecordingIndicatorTone, + isOn: $settings.playRecordingIndicatorTone + ) } header: { - Text("Assist Session") + Text(L10n.CarPlay.Debug.Settings.AssistSession.title) } footer: { - Text("These values apply when a new CarPlay Assist session starts.") + Text(L10n.CarPlay.Debug.Settings.AssistSession.footer) } } private var ttsPlaybackSection: some View { Section { - Picker("Playback strategy", selection: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.ttsPlaybackStrategy }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.ttsPlaybackStrategy = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) { + Picker(L10n.CarPlay.Debug.Settings.TtsPlayback.playbackStrategy, selection: $settings.ttsPlaybackStrategy) { ForEach(CarPlayAssistTTSPlaybackStrategy.allCases, id: \.self) { strategy in Text(strategy.title).tag(strategy) } } - Picker("Playback delay", selection: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.ttsPlaybackDelay }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.ttsPlaybackDelay = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) { + Picker(L10n.CarPlay.Debug.Settings.TtsPlayback.playbackDelay, selection: $settings.ttsPlaybackDelay) { ForEach(CarPlayAssistPlaybackDelay.allCases, id: \.self) { delay in Text(delay.title).tag(delay) } } - Toggle("AVPlayer waits to minimize stalling", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.avPlayerAutomaticallyWaitsToMinimizeStalling }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.avPlayerAutomaticallyWaitsToMinimizeStalling = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) + Toggle( + L10n.CarPlay.Debug.Settings.TtsPlayback.avplayerWaitsToMinimizeStalling, + isOn: $settings.avPlayerAutomaticallyWaitsToMinimizeStalling + ) } header: { - Text("TTS Playback") + Text(L10n.CarPlay.Debug.Settings.TtsPlayback.title) } footer: { - Text( - "Use the downloaded AVAudioPlayer strategy to determine whether the failure is tied to AVPlayer or remote URL playback." - ) + Text(L10n.CarPlay.Debug.Settings.TtsPlayback.footer) } } private var ttsSessionSection: some View { Section { - Toggle("Reconfigure before TTS", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.ttsReconfigureAudioSession }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.ttsReconfigureAudioSession = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) - - Toggle("Deactivate before reconfigure", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.ttsDeactivateBeforeReconfigure }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.ttsDeactivateBeforeReconfigure = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) - - Toggle("Activate audio session before play", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.ttsActivateAudioSession }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.ttsActivateAudioSession = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) + Toggle( + L10n.CarPlay.Debug.Settings.TtsAudioSession.reconfigureBeforeTts, + isOn: $settings.ttsReconfigureAudioSession + ) + Toggle( + L10n.CarPlay.Debug.Settings.TtsAudioSession.deactivateBeforeReconfigure, + isOn: $settings.ttsDeactivateBeforeReconfigure + ) + Toggle( + L10n.CarPlay.Debug.Settings.TtsAudioSession.activateAudioSessionBeforePlay, + isOn: $settings.ttsActivateAudioSession + ) - Picker("TTS category", selection: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.ttsCategory }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.ttsCategory = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) { + Picker(L10n.CarPlay.Debug.Settings.TtsAudioSession.category, selection: $settings.ttsCategory) { ForEach(CarPlayAssistAudioCategory.allCases, id: \.self) { category in Text(category.title).tag(category) } } - Picker("TTS mode", selection: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.ttsMode }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.ttsMode = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) { + Picker(L10n.CarPlay.Debug.Settings.TtsAudioSession.mode, selection: $settings.ttsMode) { ForEach(CarPlayAssistAudioMode.allCases, id: \.self) { mode in Text(mode.title).tag(mode) } } - Toggle("TTS allow Bluetooth HFP", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.ttsAllowBluetoothHFP }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.ttsAllowBluetoothHFP = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) - - Toggle("TTS allow Bluetooth A2DP", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.ttsAllowBluetoothA2DP }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.ttsAllowBluetoothA2DP = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) - - Toggle("TTS duck others", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.ttsDuckOthers }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.ttsDuckOthers = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) - - Toggle("TTS interrupt spoken audio", isOn: Binding( - get: { Current.settingsStore.carPlayAssistDebugSettings.ttsInterruptSpokenAudio }, - set: { - var settings = Current.settingsStore.carPlayAssistDebugSettings - settings.ttsInterruptSpokenAudio = $0 - Current.settingsStore.carPlayAssistDebugSettings = settings - } - )) + Toggle(L10n.CarPlay.Debug.Settings.TtsAudioSession.allowBluetoothHfp, isOn: $settings.ttsAllowBluetoothHFP) + Toggle( + L10n.CarPlay.Debug.Settings.TtsAudioSession.allowBluetoothA2dp, + isOn: $settings.ttsAllowBluetoothA2DP + ) + Toggle(L10n.CarPlay.Debug.Settings.TtsAudioSession.duckOthers, isOn: $settings.ttsDuckOthers) + Toggle( + L10n.CarPlay.Debug.Settings.TtsAudioSession.interruptSpokenAudio, + isOn: $settings.ttsInterruptSpokenAudio + ) } header: { - Text("TTS Audio Session") + Text(L10n.CarPlay.Debug.Settings.TtsAudioSession.title) } footer: { - Text( - "This section lets you force a dedicated TTS session reconfiguration, which is the most likely area if another app starting playback makes the response suddenly audible." - ) + Text(L10n.CarPlay.Debug.Settings.TtsAudioSession.footer) } } private var resetSection: some View { Section { - Button("Reset CarPlay debug defaults", role: .destructive) { - Current.settingsStore.resetCarPlayAssistDebugSettings() + Button(L10n.CarPlay.Debug.Settings.reset, role: .destructive) { + showResetConfirmation = true + } + .confirmationDialog( + L10n.Alert.Confirmation.Generic.title, + isPresented: $showResetConfirmation, + titleVisibility: .visible + ) { + Button(L10n.CarPlay.Debug.Settings.reset, role: .destructive) { + settings = .default + } + Button(L10n.cancelLabel, role: .cancel) {} } } } diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift index 0df899c673..9aa47dcdf5 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift @@ -48,49 +48,58 @@ final class CarPlayAssistSession: NSObject { private var canSendAudioData = false private var state: State = .recording private var isStopped = false + private var postDismissAction: (() -> Void)? private let pipelineId: String private lazy var template: CPVoiceControlTemplate = { let retryButton = CPButton( - image: MaterialDesignIcons.microphoneIcon.carPlayIcon(color: .haPrimary) + image: makeActionButtonImage(icon: .microphoneIcon, color: .haPrimary) ) { [weak self] _ in self?.restartRecording() } + let helpButton = CPButton( + image: makeActionButtonImage(icon: .commentQuestionIcon, color: .white) + ) { [weak self] _ in + self?.showPlaybackHelp() + } let idleState = CPVoiceControlState( identifier: VoiceControlStateID.idle.rawValue, titleVariants: [L10n.Assist.Carplay.TapToRecord.title], - image: MaterialDesignIcons.microphoneIcon.carPlayIcon(color: .haPrimary), + image: .messageProcessingOutline.withTintColor(.haPrimary), repeats: false ) - idleState.actionButtons = [retryButton] + idleState.actionButtons = [retryButton, helpButton] let recordingState = CPVoiceControlState( identifier: VoiceControlStateID.recording.rawValue, titleVariants: [L10n.Assist.Button.Listening.title], - image: MaterialDesignIcons.microphoneIcon.carPlayIcon(color: .haPrimary), + image: MaterialDesignIcons.microphoneIcon.carPlayIcon(color: .haPrimary, context: .assistStateIndicator), repeats: true ) let processingState = CPVoiceControlState( identifier: VoiceControlStateID.processing.rawValue, titleVariants: [L10n.Assist.Carplay.Processing.title], - image: MaterialDesignIcons.dotsHorizontalIcon.carPlayIcon(color: .haPrimary), + image: MaterialDesignIcons.dotsHorizontalIcon.carPlayIcon( + color: .haPrimary, + context: .assistStateIndicator + ), repeats: true ) let respondingState = CPVoiceControlState( identifier: VoiceControlStateID.responding.rawValue, titleVariants: [L10n.Assist.Carplay.Responding.title], - image: MaterialDesignIcons.volumeHighIcon.carPlayIcon(color: .haPrimary), + image: MaterialDesignIcons.volumeHighIcon.carPlayIcon(color: .haPrimary, context: .assistStateIndicator), repeats: true ) let errorState = CPVoiceControlState( identifier: VoiceControlStateID.error.rawValue, titleVariants: [L10n.errorLabel], - image: MaterialDesignIcons.alertCircleIcon.carPlayIcon(color: .systemRed), + image: MaterialDesignIcons.alertCircleIcon.carPlayIcon(color: .systemRed, context: .assistStateIndicator), repeats: false ) - errorState.actionButtons = [retryButton] + errorState.actionButtons = [retryButton, helpButton] return CPVoiceControlTemplate( voiceControlStates: [recordingState, processingState, respondingState, idleState, errorState] @@ -159,9 +168,80 @@ final class CarPlayAssistSession: NSObject { deactivateAudioSession() NotificationCenter.default.removeObserver(self) if dismissTemplate { - interfaceController?.dismissTemplate(animated: true, completion: nil) + interfaceController?.dismissTemplate(animated: true, completion: { [weak self] _, error in + if let error { + Current.Log.error("CarPlay Assist failed to dismiss template: \(error.localizedDescription)") + } + + let postDismissAction = self?.postDismissAction + self?.postDismissAction = nil + postDismissAction?() + self?.onStop?() + }) + } else { + postDismissAction = nil + onStop?() + } + } + + private func showPlaybackHelp() { + postDismissAction = { [weak self] in + self?.presentPlaybackHelpTemplate() + } + stop() + } + + private func presentPlaybackHelpTemplate() { + let template = CPInformationTemplate( + title: L10n.Assist.Carplay.PlaybackHelp.title, + layout: .leading, + items: [ + CPInformationItem( + title: L10n.Assist.Carplay.PlaybackHelp.OpenApp.title, + detail: L10n.Assist.Carplay.PlaybackHelp.OpenApp.detail + ), + CPInformationItem( + title: L10n.Assist.Carplay.PlaybackHelp.GoToAdvanced.title, + detail: L10n.Assist.Carplay.PlaybackHelp.GoToAdvanced.detail + ), + CPInformationItem( + title: L10n.Assist.Carplay.PlaybackHelp.ChangePlayback.title, + detail: L10n.Assist.Carplay.PlaybackHelp.ChangePlayback.detail + ), + ], + actions: [] + ) + interfaceController?.pushTemplate(template, animated: true, completion: { _, error in + if let error { + Current.Log.error("CarPlay Assist failed to present playback help: \(error.localizedDescription)") + } + }) + } + + private func makeActionButtonImage( + icon: MaterialDesignIcons, + color: UIColor + ) -> UIImage { + let iconScale: CGFloat = 0.42 + let canvasSize = CPButtonMaximumImageSize + let iconSize = CGSize( + width: canvasSize.width * iconScale, + height: canvasSize.height * iconScale + ) + let iconImage = icon.image(ofSize: iconSize, color: color) + let iconOrigin = CGPoint( + x: (canvasSize.width - iconSize.width) / 2, + y: (canvasSize.height - iconSize.height) / 2 + ) + + return UIGraphicsImageRenderer( + size: canvasSize, + format: with(UIGraphicsImageRendererFormat.preferred()) { + $0.opaque = false + } + ).image { _ in + iconImage.draw(in: CGRect(origin: iconOrigin, size: iconSize)) } - onStop?() } // MARK: - Audio Session diff --git a/Sources/Shared/MaterialDesignIcons+CarPlay.swift b/Sources/Shared/MaterialDesignIcons+CarPlay.swift index b890ee0092..24c3a56c46 100644 --- a/Sources/Shared/MaterialDesignIcons+CarPlay.swift +++ b/Sources/Shared/MaterialDesignIcons+CarPlay.swift @@ -2,10 +2,27 @@ import CarPlay import Foundation import UIKit +public enum CarPlayIconContext { + case `default` + case assistStateIndicator +} + public extension MaterialDesignIcons { - func carPlayIcon(color: UIColor? = nil) -> UIImage { + func carPlayIcon(color: UIColor? = nil, context: CarPlayIconContext = .default) -> UIImage { let color = color ?? .haPrimary - return image(ofSize: CPListItem.maximumImageSize, color: color) + switch context { + case .default: + return image(ofSize: CPListItem.maximumImageSize, color: color) + case .assistStateIndicator: + let multiplier: CGFloat = 2 + return image( + ofSize: .init( + width: CPListItem.maximumImageSize.width * multiplier, + height: CPListItem.maximumImageSize.height * multiplier + ), + color: color + ) + } } @available(iOS 26.0, *) diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 0a7fb6e491..986b94b365 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -533,6 +533,30 @@ public enum L10n { } } public enum Carplay { + public enum PlaybackHelp { + /// If you encounter audio playback issues, open CarPlay settings in the Home Assistant Companion app, tap Advanced, open Assist, and change TTS Playback to Download and play. + public static var message: String { return L10n.tr("Localizable", "assist.carplay.playback_help.message") } + /// Audio Playback Help + public static var title: String { return L10n.tr("Localizable", "assist.carplay.playback_help.title") } + public enum ChangePlayback { + /// Choose Download and play if Stream does not play audio in your car. + public static var detail: String { return L10n.tr("Localizable", "assist.carplay.playback_help.change_playback.detail") } + /// TTS Playback + public static var title: String { return L10n.tr("Localizable", "assist.carplay.playback_help.change_playback.title") } + } + public enum GoToAdvanced { + /// Open Advanced, then Assist. + public static var detail: String { return L10n.tr("Localizable", "assist.carplay.playback_help.go_to_advanced.detail") } + /// Advanced > Assist + public static var title: String { return L10n.tr("Localizable", "assist.carplay.playback_help.go_to_advanced.title") } + } + public enum OpenApp { + /// Open CarPlay settings in the Home Assistant Companion app. + public static var detail: String { return L10n.tr("Localizable", "assist.carplay.playback_help.open_app.detail") } + /// Companion app + public static var title: String { return L10n.tr("Localizable", "assist.carplay.playback_help.open_app.title") } + } + } public enum Processing { /// Processing... public static var title: String { return L10n.tr("Localizable", "assist.carplay.processing.title") } @@ -761,6 +785,106 @@ public enum L10n { public static var title: String { return L10n.tr("Localizable", "carPlay.debug.delete_db.reset.title") } } } + public enum Settings { + /// CarPlay Debug + public static var navigationTitle: String { return L10n.tr("Localizable", "carPlay.debug.settings.navigation_title") } + /// Reset + public static var reset: String { return L10n.tr("Localizable", "carPlay.debug.settings.reset") } + /// Carplay Debug Settings + public static var rowTitle: String { return L10n.tr("Localizable", "carPlay.debug.settings.row_title") } + public enum AssistSession { + /// Allow Bluetooth A2DP + public static var allowBluetoothA2dp: String { return L10n.tr("Localizable", "carPlay.debug.settings.assist_session.allow_bluetooth_a2dp") } + /// Allow Bluetooth HFP + public static var allowBluetoothHfp: String { return L10n.tr("Localizable", "carPlay.debug.settings.assist_session.allow_bluetooth_hfp") } + /// Audio category + public static var audioCategory: String { return L10n.tr("Localizable", "carPlay.debug.settings.assist_session.audio_category") } + /// Audio mode + public static var audioMode: String { return L10n.tr("Localizable", "carPlay.debug.settings.assist_session.audio_mode") } + /// Duck others + public static var duckOthers: String { return L10n.tr("Localizable", "carPlay.debug.settings.assist_session.duck_others") } + /// These values apply when a new CarPlay Assist session starts. + public static var footer: String { return L10n.tr("Localizable", "carPlay.debug.settings.assist_session.footer") } + /// Interrupt spoken audio + public static var interruptSpokenAudio: String { return L10n.tr("Localizable", "carPlay.debug.settings.assist_session.interrupt_spoken_audio") } + /// Play recording indicator tone + public static var playRecordingIndicatorTone: String { return L10n.tr("Localizable", "carPlay.debug.settings.assist_session.play_recording_indicator_tone") } + /// Preferred sample rate + public static var preferredSampleRate: String { return L10n.tr("Localizable", "carPlay.debug.settings.assist_session.preferred_sample_rate") } + /// AudioRecorder manages audio session + public static var recorderManagesAudioSession: String { return L10n.tr("Localizable", "carPlay.debug.settings.assist_session.recorder_manages_audio_session") } + /// Assist Session + public static var title: String { return L10n.tr("Localizable", "carPlay.debug.settings.assist_session.title") } + } + public enum Option { + public enum AudioCategory { + /// playAndRecord + public static var playAndRecord: String { return L10n.tr("Localizable", "carPlay.debug.settings.option.audio_category.play_and_record") } + /// playback + public static var playback: String { return L10n.tr("Localizable", "carPlay.debug.settings.option.audio_category.playback") } + /// record + public static var record: String { return L10n.tr("Localizable", "carPlay.debug.settings.option.audio_category.record") } + } + public enum AudioMode { + /// default + public static var `default`: String { return L10n.tr("Localizable", "carPlay.debug.settings.option.audio_mode.default") } + /// measurement + public static var measurement: String { return L10n.tr("Localizable", "carPlay.debug.settings.option.audio_mode.measurement") } + /// spokenAudio + public static var spokenAudio: String { return L10n.tr("Localizable", "carPlay.debug.settings.option.audio_mode.spoken_audio") } + /// voiceChat + public static var voiceChat: String { return L10n.tr("Localizable", "carPlay.debug.settings.option.audio_mode.voice_chat") } + /// voicePrompt + public static var voicePrompt: String { return L10n.tr("Localizable", "carPlay.debug.settings.option.audio_mode.voice_prompt") } + } + public enum PlaybackDelay { + /// None + public static var `none`: String { return L10n.tr("Localizable", "carPlay.debug.settings.option.playback_delay.none") } + } + public enum TtsPlaybackStrategy { + /// Download and play + public static var downloadAndPlay: String { return L10n.tr("Localizable", "carPlay.debug.settings.option.tts_playback_strategy.download_and_play") } + /// Stream + public static var stream: String { return L10n.tr("Localizable", "carPlay.debug.settings.option.tts_playback_strategy.stream") } + } + } + public enum TtsAudioSession { + /// Activate audio session before play + public static var activateAudioSessionBeforePlay: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_audio_session.activate_audio_session_before_play") } + /// TTS allow Bluetooth A2DP + public static var allowBluetoothA2dp: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_audio_session.allow_bluetooth_a2dp") } + /// TTS allow Bluetooth HFP + public static var allowBluetoothHfp: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_audio_session.allow_bluetooth_hfp") } + /// TTS category + public static var category: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_audio_session.category") } + /// Deactivate before reconfigure + public static var deactivateBeforeReconfigure: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_audio_session.deactivate_before_reconfigure") } + /// TTS duck others + public static var duckOthers: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_audio_session.duck_others") } + /// This section lets you force a dedicated TTS session reconfiguration, which is the most likely area if another app starting playback makes the response suddenly audible. + public static var footer: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_audio_session.footer") } + /// TTS interrupt spoken audio + public static var interruptSpokenAudio: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_audio_session.interrupt_spoken_audio") } + /// TTS mode + public static var mode: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_audio_session.mode") } + /// Reconfigure before TTS + public static var reconfigureBeforeTts: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_audio_session.reconfigure_before_tts") } + /// TTS Audio Session + public static var title: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_audio_session.title") } + } + public enum TtsPlayback { + /// AVPlayer waits to minimize stalling + public static var avplayerWaitsToMinimizeStalling: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_playback.avplayer_waits_to_minimize_stalling") } + /// Use the downloaded AVAudioPlayer strategy to determine whether the failure is tied to AVPlayer or remote URL playback. + public static var footer: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_playback.footer") } + /// Playback delay + public static var playbackDelay: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_playback.playback_delay") } + /// Playback strategy + public static var playbackStrategy: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_playback.playback_strategy") } + /// TTS Playback + public static var title: String { return L10n.tr("Localizable", "carPlay.debug.settings.tts_playback.title") } + } + } } public enum Labels { /// Already added @@ -775,6 +899,18 @@ public enum L10n { public static var servers: String { return L10n.tr("Localizable", "carPlay.labels.servers") } public enum Settings { public enum Advanced { + public enum Assist { + public enum Section { + /// Assist + public static var title: String { return L10n.tr("Localizable", "carPlay.labels.settings.advanced.assist.section.title") } + } + public enum TtsPlayback { + /// In some cars, spoken responses may not play when 'Stream' is selected. If that happens, 'Download and play' can potentially fix it. + public static var footer: String { return L10n.tr("Localizable", "carPlay.labels.settings.advanced.assist.tts_playback.footer") } + /// TTS Playback + public static var title: String { return L10n.tr("Localizable", "carPlay.labels.settings.advanced.assist.tts_playback.title") } + } + } public enum Section { /// Advanced public static var title: String { return L10n.tr("Localizable", "carPlay.labels.settings.advanced.section.title") } diff --git a/Sources/Shared/Settings/CarPlayAssistDebugSettings.swift b/Sources/Shared/Settings/CarPlayAssistDebugSettings.swift index 81ebd6b6c3..de1d3f3a6c 100644 --- a/Sources/Shared/Settings/CarPlayAssistDebugSettings.swift +++ b/Sources/Shared/Settings/CarPlayAssistDebugSettings.swift @@ -20,11 +20,11 @@ public enum CarPlayAssistAudioCategory: String, CaseIterable { public var title: String { switch self { case .playAndRecord: - "playAndRecord" + L10n.CarPlay.Debug.Settings.Option.AudioCategory.playAndRecord case .playback: - "playback" + L10n.CarPlay.Debug.Settings.Option.AudioCategory.playback case .record: - "record" + L10n.CarPlay.Debug.Settings.Option.AudioCategory.record } } } @@ -52,7 +52,18 @@ public enum CarPlayAssistAudioMode: String, CaseIterable { } public var title: String { - rawValue + switch self { + case .default: + L10n.CarPlay.Debug.Settings.Option.AudioMode.default + case .voiceChat: + L10n.CarPlay.Debug.Settings.Option.AudioMode.voiceChat + case .voicePrompt: + L10n.CarPlay.Debug.Settings.Option.AudioMode.voicePrompt + case .spokenAudio: + L10n.CarPlay.Debug.Settings.Option.AudioMode.spokenAudio + case .measurement: + L10n.CarPlay.Debug.Settings.Option.AudioMode.measurement + } } } @@ -78,9 +89,9 @@ public enum CarPlayAssistTTSPlaybackStrategy: String, CaseIterable { public var title: String { switch self { case .avPlayer: - "AVPlayer" + L10n.CarPlay.Debug.Settings.Option.TtsPlaybackStrategy.stream case .downloadedAVAudioPlayer: - "Download then AVAudioPlayer" + L10n.CarPlay.Debug.Settings.Option.TtsPlaybackStrategy.downloadAndPlay } } } @@ -95,7 +106,7 @@ public enum CarPlayAssistPlaybackDelay: Int, CaseIterable { public var title: String { switch self { case .none: - "None" + L10n.CarPlay.Debug.Settings.Option.PlaybackDelay.none default: "\(rawValue) ms" } @@ -106,7 +117,7 @@ public enum CarPlayAssistPlaybackDelay: Int, CaseIterable { } } -public struct CarPlayAssistDebugSettings { +public struct CarPlayAssistDebugSettings: Equatable { public var audioCategory: CarPlayAssistAudioCategory public var audioMode: CarPlayAssistAudioMode public var preferredSampleRate: CarPlayAssistPreferredSampleRate From 6dc025fc5d325e2febd51e6db22b01a5683f3d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 15:19:21 +0200 Subject: [PATCH 10/23] Add more sounds for assist states --- .../QuickAccess/CarPlayAssistSession.swift | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift index 9aa47dcdf5..94ebc9ab0d 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift @@ -38,6 +38,8 @@ final class CarPlayAssistSession: NSObject { private var audioRecorder: AudioRecorderProtocol private var recordingIndicatorPlayer: AVAudioPlayer? private var ttsAudioPlayer: AVAudioPlayer? + private var processingIndicatorSoundID: SystemSoundID? + private var errorIndicatorSoundID: SystemSoundID? private let ttsPlayer = AVPlayer() private var ttsPlayerItemStatusObservation: NSKeyValueObservation? private var ttsPlayerTimeControlObservation: NSKeyValueObservation? @@ -125,6 +127,8 @@ final class CarPlayAssistSession: NSObject { deinit { NotificationCenter.default.removeObserver(self) + disposeSystemSoundIfNeeded(&processingIndicatorSoundID) + disposeSystemSoundIfNeeded(&errorIndicatorSoundID) } func start() { @@ -426,6 +430,81 @@ final class CarPlayAssistSession: NSObject { } } + private func playProcessingIndicatorToneIfNeeded() { + playSystemLibrarySoundIfNeeded( + named: "SiriStopSuccess_Haptic.caf", + candidateSubdirectories: ["", "nano"], + soundID: &processingIndicatorSoundID + ) + } + + private func playErrorIndicatorToneIfNeeded() { + playSystemLibrarySoundIfNeeded( + named: "PINUnexpected.caf", + candidateSubdirectories: [""], + soundID: &errorIndicatorSoundID + ) + } + + private func playSystemLibrarySoundIfNeeded( + named fileName: String, + candidateSubdirectories: [String], + soundID: inout SystemSoundID? + ) { + if soundID == nil { + soundID = makeSystemSoundID( + named: fileName, + candidateSubdirectories: candidateSubdirectories + ) + } + + guard let soundID else { + Current.Log.error("CarPlay Assist could not load system sound \(fileName)") + return + } + + AudioServicesPlaySystemSound(soundID) + } + + private func makeSystemSoundID( + named fileName: String, + candidateSubdirectories: [String] + ) -> SystemSoundID? { + let soundsRoot = URL(fileURLWithPath: "/System/Library/Audio/UISounds", isDirectory: true) + + for subdirectory in candidateSubdirectories { + let candidateURL: URL + if subdirectory.isEmpty { + candidateURL = soundsRoot.appendingPathComponent(fileName) + } else { + candidateURL = soundsRoot + .appendingPathComponent(subdirectory, isDirectory: true) + .appendingPathComponent(fileName) + } + + guard FileManager.default.fileExists(atPath: candidateURL.path) else { continue } + + var soundID: SystemSoundID = 0 + let status = AudioServicesCreateSystemSoundID(candidateURL as CFURL, &soundID) + if status == kAudioServicesNoError { + return soundID + } + + Current.Log + .error( + "CarPlay Assist failed to create system sound ID for \(candidateURL.lastPathComponent): \(status)" + ) + } + + return nil + } + + private func disposeSystemSoundIfNeeded(_ soundID: inout SystemSoundID?) { + guard let resolvedSoundID = soundID else { return } + AudioServicesDisposeSystemSoundID(resolvedSoundID) + soundID = nil + } + // MARK: - TTS Playback /// Plays TTS audio using the already active conversational audio session to preserve the car route. @@ -667,6 +746,7 @@ final class CarPlayAssistSession: NSObject { clearTTSPlayerObservers() deactivateAudioSession() Current.Log.error("CarPlay Assist entered error state: \(message)") + playErrorIndicatorToneIfNeeded() activateVoiceControlState(for: .error(message)) } } @@ -747,6 +827,7 @@ extension CarPlayAssistSession: AssistServiceDelegate { guard shouldHandleSttEnd else { return } audioRecorder.stopRecording() assistService.finishSendingAudio() + playProcessingIndicatorToneIfNeeded() activateVoiceControlState(for: .processing) } } From 7f999fcd1365f0f587d9a45c8041443f5338861e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 15:28:19 +0200 Subject: [PATCH 11/23] Use format() for cache key in CI workflow --- .github/workflows/ci.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e3bb8a911..b66898c6d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,7 +160,11 @@ jobs: with: path: vendor/bundle key: >- - ${{ runner.os }}-gems-${{ env.ImageVersion }}-${{ env.DEVELOPER_DIR }}-${{ hashFiles('.ruby-version', '**/Gemfile.lock') }} + ${{ format('{0}-gems-{1}-{2}-{3}', + runner.os, + env.ImageVersion, + env.DEVELOPER_DIR, + hashFiles('.ruby-version', '**/Gemfile.lock')) }} - name: Install Brews # right now, we don't need anything from brew for tests, so save some time @@ -230,7 +234,11 @@ jobs: with: path: vendor/bundle key: >- - ${{ runner.os }}-gems-${{ env.ImageVersion }}-${{ env.DEVELOPER_DIR }}-${{ hashFiles('.ruby-version', '**/Gemfile.lock') }} + ${{ format('{0}-gems-{1}-{2}-{3}', + runner.os, + env.ImageVersion, + env.DEVELOPER_DIR, + hashFiles('.ruby-version', '**/Gemfile.lock')) }} - name: Install Brews # right now, we don't need anything from brew for sizing, so save some time From 5d2962f2e3d7b00b250108cc2c6261e9c478ab2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o=20Gon=C3=A7alves?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 15:32:14 +0200 Subject: [PATCH 12/23] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/App/Resources/en.lproj/Localizable.strings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 3f8adaa698..cc13ec7c31 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -207,7 +207,7 @@ "carPlay.debug.settings.option.tts_playback_strategy.download_and_play" = "Download and play"; "carPlay.debug.settings.option.tts_playback_strategy.stream" = "Stream"; "carPlay.debug.settings.reset" = "Reset"; -"carPlay.debug.settings.row_title" = "Carplay Debug Settings"; +"carPlay.debug.settings.row_title" = "CarPlay Debug Settings"; "carPlay.debug.settings.tts_audio_session.activate_audio_session_before_play" = "Activate audio session before play"; "carPlay.debug.settings.tts_audio_session.allow_bluetooth_a2dp" = "TTS allow Bluetooth A2DP"; "carPlay.debug.settings.tts_audio_session.allow_bluetooth_hfp" = "TTS allow Bluetooth HFP"; From f2a9241da3db78109155c3b68ce8597325ab25dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 15:45:52 +0200 Subject: [PATCH 13/23] Improve Assist picker in carplay config --- .../CarPlay/CarPlayConfigurationView.swift | 73 ++++++++++++++++++- .../MagicItem/Add/MagicItemAddView.swift | 41 ++++++++++- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift index bf9635d940..5153fef1e8 100644 --- a/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift +++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift @@ -6,11 +6,37 @@ import SwiftUI import UIKit struct CarPlayConfigurationView: View { + private enum AddItemDestination: String, Identifiable { + case entity + case assist + + var id: String { rawValue } + + var magicItemType: MagicItemAddType { + switch self { + case .entity: + return .entities + case .assist: + return .assistPipelines + } + } + + var pickerOption: MagicItemAddView.PickerOption { + switch self { + case .entity: + return .entities + case .assist: + return .assistPipelines + } + } + } + @Environment(\.dismiss) private var dismiss @StateObject private var viewModel: CarPlayConfigurationViewModel @State private var isLoaded = false @State private var showResetConfirmation = false + @State private var addItemDestination: AddItemDestination? private let needsNavigationController: Bool @@ -63,8 +89,12 @@ struct CarPlayConfigurationView: View { viewModel.loadConfig() isLoaded = true } - .sheet(isPresented: $viewModel.showAddItem, content: { - MagicItemAddView(context: .carPlay) { itemToAdd in + .sheet(item: $addItemDestination, content: { destination in + MagicItemAddView( + context: .carPlay, + initialItemType: destination.magicItemType, + visiblePickerOptions: [destination.pickerOption] + ) { itemToAdd in guard let itemToAdd else { return } viewModel.addItem(itemToAdd) } @@ -95,11 +125,38 @@ struct CarPlayConfigurationView: View { .onDelete { indexSet in viewModel.deleteItem(at: indexSet) } + addItemButton + } + } + + @ViewBuilder + private var addItemButton: some View { + Menu { + Button { + addItemDestination = .entity + } label: { + Label { + Text(L10n.MagicItem.ItemType.Entity.List.title) + } icon: { + Image(systemSymbol: .lightbulb) + } + } + Button { - viewModel.showAddItem = true + addItemDestination = .assist } label: { - Label(L10n.Watch.Configuration.AddItem.title, systemSymbol: .plus) + Label { + Text(isAssistSupported ? L10n.Widgets.Action.Name.assist : "Assist (iOS 26.4+)") + } icon: { + Image(uiImage: MaterialDesignIcons.messageProcessingOutlineIcon.image( + ofSize: .init(width: 18, height: 18), + color: .label + )) + } } + .disabled(!isAssistSupported) + } label: { + Label(L10n.Watch.Configuration.AddItem.title, systemSymbol: .plus) } } @@ -204,6 +261,14 @@ struct CarPlayConfigurationView: View { Text(L10n.CarPlay.Labels.Settings.Advanced.Section.title) } } + + private var isAssistSupported: Bool { + if #available(iOS 26.4, *) { + return true + } else { + return false + } + } } #Preview { diff --git a/Sources/App/Settings/MagicItem/Add/MagicItemAddView.swift b/Sources/App/Settings/MagicItem/Add/MagicItemAddView.swift index 56ff2ed60d..b0a447cc93 100644 --- a/Sources/App/Settings/MagicItem/Add/MagicItemAddView.swift +++ b/Sources/App/Settings/MagicItem/Add/MagicItemAddView.swift @@ -22,15 +22,21 @@ struct MagicItemAddView: View { @StateObject private var viewModel = MagicItemAddViewModel() @State private var selectedEntity: HAAppEntity? private let visiblePickerOptions: [PickerOption] + private let initialItemType: MagicItemAddType let context: Context let itemToAdd: (MagicItem?) -> Void - init(context: Context, itemToAdd: @escaping (MagicItem?) -> Void) { + init( + context: Context, + initialItemType: MagicItemAddType? = nil, + visiblePickerOptions: [PickerOption]? = nil, + itemToAdd: @escaping (MagicItem?) -> Void + ) { self.context = context self.itemToAdd = itemToAdd - self.visiblePickerOptions = { + let resolvedPickerOptions = visiblePickerOptions ?? { var options: [PickerOption] = [] if [.carPlay, .widget, .appIconShortcut].contains(context) { options.append(.entities) @@ -49,6 +55,11 @@ struct MagicItemAddView: View { } return options }() + self.visiblePickerOptions = resolvedPickerOptions + self.initialItemType = initialItemType ?? Self.defaultItemType( + for: context, + visiblePickerOptions: resolvedPickerOptions + ) } var body: some View { @@ -151,11 +162,33 @@ struct MagicItemAddView: View { } private func autoSelectItemType() { + viewModel.selectedItemType = initialItemType + } + + private static func defaultItemType( + for context: Context, + visiblePickerOptions: [PickerOption] + ) -> MagicItemAddType { + if let firstOption = visiblePickerOptions.first { + switch firstOption { + case .entities: + return .entities + case .scripts: + return .scripts + case .scenes: + return .scenes + case .legacyiOSActions: + return .actions + case .assistPipelines: + return .assistPipelines + } + } + switch context { case .watch: - viewModel.selectedItemType = .scripts + return .scripts case .carPlay, .widget, .appIconShortcut: - viewModel.selectedItemType = .entities + return .entities } } From c14e30f996c9e58f5aa5e9702173988a3837ae4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 15:46:26 +0200 Subject: [PATCH 14/23] Update Strings.swift --- Sources/Shared/Resources/Swiftgen/Strings.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 986b94b365..a9053018f3 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -790,7 +790,7 @@ public enum L10n { public static var navigationTitle: String { return L10n.tr("Localizable", "carPlay.debug.settings.navigation_title") } /// Reset public static var reset: String { return L10n.tr("Localizable", "carPlay.debug.settings.reset") } - /// Carplay Debug Settings + /// CarPlay Debug Settings public static var rowTitle: String { return L10n.tr("Localizable", "carPlay.debug.settings.row_title") } public enum AssistSession { /// Allow Bluetooth A2DP From 956b415bd658785079b9fa360c1c98fd82e21c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 15:46:54 +0200 Subject: [PATCH 15/23] Remove labs label for Assist --- Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift index 5153fef1e8..027a0401c2 100644 --- a/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift +++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift @@ -190,10 +190,6 @@ struct CarPlayConfigurationView: View { Image(uiImage: image(for: item, itemInfo: info, watchPreview: false, color: .accent)) Text(item.name(info: info)) .frame(maxWidth: .infinity, alignment: .leading) - if item.type == .assistPipeline { - LabsLabel() - .fixedSize() - } Image(systemSymbol: .line3Horizontal) .foregroundStyle(.gray) } From 78b301b313fe09843ff0cf17bafe78201f84fb8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o=20Gon=C3=A7alves?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 15:47:40 +0200 Subject: [PATCH 16/23] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../QuickAccess/CarPlayAssistSession.swift | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift index 94ebc9ab0d..608e7217de 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift @@ -466,36 +466,37 @@ final class CarPlayAssistSession: NSObject { AudioServicesPlaySystemSound(soundID) } + private func documentedSystemSoundID(named fileName: String) -> SystemSoundID? { + let normalizedFileName = URL(fileURLWithPath: fileName) + .deletingPathExtension() + .lastPathComponent + .lowercased() + + switch normalizedFileName { + case "tink": + return 1103 + case "tock": + return 1104 + default: + return nil + } + } + private func makeSystemSoundID( named fileName: String, candidateSubdirectories: [String] ) -> SystemSoundID? { - let soundsRoot = URL(fileURLWithPath: "/System/Library/Audio/UISounds", isDirectory: true) - - for subdirectory in candidateSubdirectories { - let candidateURL: URL - if subdirectory.isEmpty { - candidateURL = soundsRoot.appendingPathComponent(fileName) - } else { - candidateURL = soundsRoot - .appendingPathComponent(subdirectory, isDirectory: true) - .appendingPathComponent(fileName) - } - - guard FileManager.default.fileExists(atPath: candidateURL.path) else { continue } + _ = candidateSubdirectories - var soundID: SystemSoundID = 0 - let status = AudioServicesCreateSystemSoundID(candidateURL as CFURL, &soundID) - if status == kAudioServicesNoError { - return soundID - } - - Current.Log - .error( - "CarPlay Assist failed to create system sound ID for \(candidateURL.lastPathComponent): \(status)" - ) + if let soundID = documentedSystemSoundID(named: fileName) { + return soundID } + Current.Log + .error( + "CarPlay Assist does not support loading UI sounds from private system paths. Unsupported sound: \(fileName)" + ) + return nil } From 79569cf5ade4474048b8cc1cf559d93e69221477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 15:51:01 +0200 Subject: [PATCH 17/23] Update MockAudioRecorder.swift --- Tests/App/Assist/Mocks/MockAudioRecorder.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/App/Assist/Mocks/MockAudioRecorder.swift b/Tests/App/Assist/Mocks/MockAudioRecorder.swift index 9d1522cf24..0548e47f27 100644 --- a/Tests/App/Assist/Mocks/MockAudioRecorder.swift +++ b/Tests/App/Assist/Mocks/MockAudioRecorder.swift @@ -1,9 +1,10 @@ @testable import HomeAssistant final class MockAudioRecorder: AudioRecorderProtocol { + weak var delegate: AudioRecorderDelegate? var audioSampleRate: Double? - + var managesAudioSession: Bool = true var startRecordingCalled = false var stopRecordingCalled = false From 6a36f7812345901a4471f3ffbf30c9f82c2b040c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 15:53:38 +0200 Subject: [PATCH 18/23] Update MockAudioRecorder.swift --- Tests/App/Assist/Mocks/MockAudioRecorder.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/App/Assist/Mocks/MockAudioRecorder.swift b/Tests/App/Assist/Mocks/MockAudioRecorder.swift index 0548e47f27..f4abebade6 100644 --- a/Tests/App/Assist/Mocks/MockAudioRecorder.swift +++ b/Tests/App/Assist/Mocks/MockAudioRecorder.swift @@ -1,7 +1,6 @@ @testable import HomeAssistant final class MockAudioRecorder: AudioRecorderProtocol { - weak var delegate: AudioRecorderDelegate? var audioSampleRate: Double? var managesAudioSession: Bool = true From 5b3135de08c699e730f4e48ca35b569fc37efc57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 16:10:15 +0200 Subject: [PATCH 19/23] Update testing.rb --- fastlane/lanes/testing.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/fastlane/lanes/testing.rb b/fastlane/lanes/testing.rb index ab083bfc13..718c5c66fc 100644 --- a/fastlane/lanes/testing.rb +++ b/fastlane/lanes/testing.rb @@ -39,6 +39,7 @@ scheme: 'Tests-Unit', result_bundle: true, skip_package_dependencies_resolution: true, + skip_detect_devices: true, destination: 'platform=iOS Simulator,name=iPhone 17,OS=26.4' ) end From 5d433cf8e888b5f21f4dd9a6296cb5b12e89ee6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 16:52:46 +0200 Subject: [PATCH 20/23] Update testing.rb --- fastlane/lanes/testing.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/lanes/testing.rb b/fastlane/lanes/testing.rb index 718c5c66fc..4b39b3f930 100644 --- a/fastlane/lanes/testing.rb +++ b/fastlane/lanes/testing.rb @@ -40,6 +40,6 @@ result_bundle: true, skip_package_dependencies_resolution: true, skip_detect_devices: true, - destination: 'platform=iOS Simulator,name=iPhone 17,OS=26.4' + destination: 'platform=iOS Simulator,name=iPhone 17,OS=latest' ) end From 9c228793f622383ffc77249b292deaeac8ccf041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 17:19:26 +0200 Subject: [PATCH 21/23] Update CarPlayAssistSession.swift --- .../Templates/QuickAccess/CarPlayAssistSession.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift index 608e7217de..ab8b86f788 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift @@ -61,7 +61,7 @@ final class CarPlayAssistSession: NSObject { self?.restartRecording() } let helpButton = CPButton( - image: makeActionButtonImage(icon: .commentQuestionIcon, color: .white) + image: makeActionButtonImage(icon: .commentQuestionIcon, color: .gray) ) { [weak self] _ in self?.showPlaybackHelp() } @@ -69,7 +69,10 @@ final class CarPlayAssistSession: NSObject { let idleState = CPVoiceControlState( identifier: VoiceControlStateID.idle.rawValue, titleVariants: [L10n.Assist.Carplay.TapToRecord.title], - image: .messageProcessingOutline.withTintColor(.haPrimary), + image: MaterialDesignIcons.messageProcessingOutlineIcon.carPlayIcon( + color: .haPrimary, + context: .assistStateIndicator + ), repeats: false ) idleState.actionButtons = [retryButton, helpButton] From c431d1e8418f2e489608af7accd27661c7a35697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 17:44:02 +0200 Subject: [PATCH 22/23] Use system sounds --- .../QuickAccess/CarPlayAssistSession.swift | 78 +------------------ 1 file changed, 4 insertions(+), 74 deletions(-) diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift index ab8b86f788..ca69580b0b 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift @@ -38,8 +38,6 @@ final class CarPlayAssistSession: NSObject { private var audioRecorder: AudioRecorderProtocol private var recordingIndicatorPlayer: AVAudioPlayer? private var ttsAudioPlayer: AVAudioPlayer? - private var processingIndicatorSoundID: SystemSoundID? - private var errorIndicatorSoundID: SystemSoundID? private let ttsPlayer = AVPlayer() private var ttsPlayerItemStatusObservation: NSKeyValueObservation? private var ttsPlayerTimeControlObservation: NSKeyValueObservation? @@ -130,8 +128,6 @@ final class CarPlayAssistSession: NSObject { deinit { NotificationCenter.default.removeObserver(self) - disposeSystemSoundIfNeeded(&processingIndicatorSoundID) - disposeSystemSoundIfNeeded(&errorIndicatorSoundID) } func start() { @@ -434,79 +430,13 @@ final class CarPlayAssistSession: NSObject { } private func playProcessingIndicatorToneIfNeeded() { - playSystemLibrarySoundIfNeeded( - named: "SiriStopSuccess_Haptic.caf", - candidateSubdirectories: ["", "nano"], - soundID: &processingIndicatorSoundID - ) + // SystemSoundID values are tracked in https://github.com/TUNER88/iOSSystemSoundsLibrary. + AudioServicesPlaySystemSound(1405) // SiriStopSuccess_Haptic.caf } private func playErrorIndicatorToneIfNeeded() { - playSystemLibrarySoundIfNeeded( - named: "PINUnexpected.caf", - candidateSubdirectories: [""], - soundID: &errorIndicatorSoundID - ) - } - - private func playSystemLibrarySoundIfNeeded( - named fileName: String, - candidateSubdirectories: [String], - soundID: inout SystemSoundID? - ) { - if soundID == nil { - soundID = makeSystemSoundID( - named: fileName, - candidateSubdirectories: candidateSubdirectories - ) - } - - guard let soundID else { - Current.Log.error("CarPlay Assist could not load system sound \(fileName)") - return - } - - AudioServicesPlaySystemSound(soundID) - } - - private func documentedSystemSoundID(named fileName: String) -> SystemSoundID? { - let normalizedFileName = URL(fileURLWithPath: fileName) - .deletingPathExtension() - .lastPathComponent - .lowercased() - - switch normalizedFileName { - case "tink": - return 1103 - case "tock": - return 1104 - default: - return nil - } - } - - private func makeSystemSoundID( - named fileName: String, - candidateSubdirectories: [String] - ) -> SystemSoundID? { - _ = candidateSubdirectories - - if let soundID = documentedSystemSoundID(named: fileName) { - return soundID - } - - Current.Log - .error( - "CarPlay Assist does not support loading UI sounds from private system paths. Unsupported sound: \(fileName)" - ) - - return nil - } - - private func disposeSystemSoundIfNeeded(_ soundID: inout SystemSoundID?) { - guard let resolvedSoundID = soundID else { return } - AudioServicesDisposeSystemSoundID(resolvedSoundID) - soundID = nil + // SystemSoundID values are tracked in https://github.com/TUNER88/iOSSystemSoundsLibrary. + AudioServicesPlaySystemSound(1343) // PINUnexpected.caf } // MARK: - TTS Playback From d1b156e31fe2feff1cb44ff5e9458a3499afae59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 6 May 2026 17:46:19 +0200 Subject: [PATCH 23/23] Add tests --- HomeAssistant.xcodeproj/project.pbxproj | 4 + .../CarPlayAssistDebugSettings.test.swift | 169 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 Tests/Shared/CarPlayAssistDebugSettings.test.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index e70fb76865..11299da7e6 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1009,6 +1009,7 @@ 42BC581A2FAA2C8F0080EE09 /* center_button_press.flac in Resources */ = {isa = PBXBuildFile; fileRef = 42BC58192FAA2C8F0080EE09 /* center_button_press.flac */; }; 42BC581C2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BC581B2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift */; }; 42BC581D2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BC581B2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift */; }; + 42BC58202FAA501E0080EE09 /* CarPlayAssistDebugSettings.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BC581F2FAA501E0080EE09 /* CarPlayAssistDebugSettings.test.swift */; }; 42BF7F302DF867E600875A0F /* HAAppEntityAppIntentEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BF7F2F2DF867E600875A0F /* HAAppEntityAppIntentEntity.swift */; }; 42BF7F312DF867E600875A0F /* HAAppEntityAppIntentEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BF7F2F2DF867E600875A0F /* HAAppEntityAppIntentEntity.swift */; }; 42BF8DB12EC4E16900DCB7E7 /* AssistSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BF8DB02EC4E16900DCB7E7 /* AssistSceneDelegate.swift */; }; @@ -2877,6 +2878,7 @@ 42BB53312CAA0B3C00680ED8 /* WatchConfigV1.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = WatchConfigV1.sqlite; sourceTree = ""; }; 42BC58192FAA2C8F0080EE09 /* center_button_press.flac */ = {isa = PBXFileReference; lastKnownFileType = file; path = center_button_press.flac; sourceTree = ""; }; 42BC581B2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayAssistDebugSettings.swift; sourceTree = ""; }; + 42BC581F2FAA501E0080EE09 /* CarPlayAssistDebugSettings.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayAssistDebugSettings.test.swift; sourceTree = ""; }; 42BE698E2C46D37800745ECA /* UIScreen+PerfectCornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScreen+PerfectCornerRadius.swift"; sourceTree = ""; }; 42BF7F2F2DF867E600875A0F /* HAAppEntityAppIntentEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAAppEntityAppIntentEntity.swift; sourceTree = ""; }; 42BF8DAF2EC4D69600DCB7E7 /* copilot-instructions.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "copilot-instructions.md"; sourceTree = ""; }; @@ -7315,6 +7317,7 @@ 480E9A5D40714BBAA81B15F7 /* ClientCertificate.test.swift */, 114CBAEA2839FC2500A9BAFF /* SecurityExceptions.test.swift */, 114CBAEC283AB92D00A9BAFF /* SecTrust+TestAdditions.swift */, + 42BC581F2FAA501E0080EE09 /* CarPlayAssistDebugSettings.test.swift */, 42EEEFE42E2792B20080E973 /* Service.test.swift */, ); path = Shared; @@ -10458,6 +10461,7 @@ 11CB98C8249DE24100B05222 /* PedometerSensor.test.swift in Sources */, 8DFA3DEE4881E59961C3B5E2 /* BarometerSensor.test.swift in Sources */, 42EEEFE52E2792B20080E973 /* Service.test.swift in Sources */, + 42BC58202FAA501E0080EE09 /* CarPlayAssistDebugSettings.test.swift in Sources */, 118511C224B25BEB00D18F60 /* WebhookManager.test.swift in Sources */, 114CBAEB2839FC2500A9BAFF /* SecurityExceptions.test.swift in Sources */, 11CD94BB24B2D2C100BA801D /* WebhookResponseUnhandled.test.swift in Sources */, diff --git a/Tests/Shared/CarPlayAssistDebugSettings.test.swift b/Tests/Shared/CarPlayAssistDebugSettings.test.swift new file mode 100644 index 0000000000..359fe29da2 --- /dev/null +++ b/Tests/Shared/CarPlayAssistDebugSettings.test.swift @@ -0,0 +1,169 @@ +import AVFoundation +@testable import Shared +import Testing + +@Suite(.serialized) +struct CarPlayAssistDebugSettingsTests { + init() { + Self.removeStoredSettings() + } + + @Test func defaultsWhenNothingStored() { + #expect(Current.settingsStore.carPlayAssistDebugSettings == CarPlayAssistDebugSettings.default) + } + + @Test func roundTripsStoredSettings() { + defer { Self.removeStoredSettings() } + + let settings = CarPlayAssistDebugSettings( + audioCategory: .playback, + audioMode: .spokenAudio, + preferredSampleRate: .rate48000, + allowBluetoothHFP: false, + allowBluetoothA2DP: false, + duckOthers: true, + interruptSpokenAudio: true, + playRecordingIndicatorTone: false, + recorderManagesAudioSession: true, + ttsPlaybackStrategy: .downloadedAVAudioPlayer, + ttsReconfigureAudioSession: true, + ttsDeactivateBeforeReconfigure: true, + ttsActivateAudioSession: false, + ttsCategory: .record, + ttsMode: .measurement, + ttsAllowBluetoothHFP: false, + ttsAllowBluetoothA2DP: false, + ttsDuckOthers: true, + ttsInterruptSpokenAudio: false, + avPlayerAutomaticallyWaitsToMinimizeStalling: false, + ttsPlaybackDelay: .ms500 + ) + + Current.settingsStore.carPlayAssistDebugSettings = settings + + #expect(Current.settingsStore.carPlayAssistDebugSettings == settings) + } + + @Test func resetRestoresDefaults() { + defer { Self.removeStoredSettings() } + + Current.settingsStore.carPlayAssistDebugSettings = CarPlayAssistDebugSettings( + audioCategory: .record, + audioMode: .measurement, + preferredSampleRate: .rate44100, + allowBluetoothHFP: false, + allowBluetoothA2DP: false, + duckOthers: true, + interruptSpokenAudio: true, + playRecordingIndicatorTone: false, + recorderManagesAudioSession: true, + ttsPlaybackStrategy: .downloadedAVAudioPlayer, + ttsReconfigureAudioSession: true, + ttsDeactivateBeforeReconfigure: true, + ttsActivateAudioSession: false, + ttsCategory: .playback, + ttsMode: .spokenAudio, + ttsAllowBluetoothHFP: false, + ttsAllowBluetoothA2DP: false, + ttsDuckOthers: true, + ttsInterruptSpokenAudio: false, + avPlayerAutomaticallyWaitsToMinimizeStalling: false, + ttsPlaybackDelay: .ms1000 + ) + + Current.settingsStore.resetCarPlayAssistDebugSettings() + + #expect(Current.settingsStore.carPlayAssistDebugSettings == CarPlayAssistDebugSettings.default) + } + + @Test func invalidPersistedValuesFallBackToDefaults() { + defer { Self.removeStoredSettings() } + + let defaults = CarPlayAssistDebugSettings.default + let prefs = Current.settingsStore.prefs + prefs.set("not-a-category", forKey: "carPlayAssistAudioCategory") + prefs.set("not-a-mode", forKey: "carPlayAssistAudioMode") + prefs.set(12345, forKey: "carPlayAssistPreferredSampleRate") + prefs.set("not-a-strategy", forKey: "carPlayAssistTTSPlaybackStrategy") + prefs.set(12345, forKey: "carPlayAssistTTSPlaybackDelay") + prefs.set(true, forKey: "carPlayAssistDuckOthers") + + let settings = Current.settingsStore.carPlayAssistDebugSettings + + #expect(settings.audioCategory == defaults.audioCategory) + #expect(settings.audioMode == defaults.audioMode) + #expect(settings.preferredSampleRate == defaults.preferredSampleRate) + #expect(settings.ttsPlaybackStrategy == defaults.ttsPlaybackStrategy) + #expect(settings.ttsPlaybackDelay == defaults.ttsPlaybackDelay) + #expect(settings.duckOthers) + } + + @Test func audioSessionMappingsMatchDebugOptions() { + #expect(CarPlayAssistAudioCategory.playAndRecord.avCategory == AVAudioSession.Category.playAndRecord) + #expect(CarPlayAssistAudioCategory.playback.avCategory == AVAudioSession.Category.playback) + #expect(CarPlayAssistAudioCategory.record.avCategory == AVAudioSession.Category.record) + #expect(CarPlayAssistAudioCategory.playAndRecord.title.isEmpty == false) + #expect(CarPlayAssistAudioCategory.playback.title.isEmpty == false) + #expect(CarPlayAssistAudioCategory.record.title.isEmpty == false) + + #expect(CarPlayAssistAudioMode.default.avMode == AVAudioSession.Mode.default) + #expect(CarPlayAssistAudioMode.voiceChat.avMode == AVAudioSession.Mode.voiceChat) + #expect(CarPlayAssistAudioMode.voicePrompt.avMode == AVAudioSession.Mode.voicePrompt) + #expect(CarPlayAssistAudioMode.spokenAudio.avMode == AVAudioSession.Mode.spokenAudio) + #expect(CarPlayAssistAudioMode.measurement.avMode == AVAudioSession.Mode.measurement) + #expect(CarPlayAssistAudioMode.default.title.isEmpty == false) + #expect(CarPlayAssistAudioMode.voiceChat.title.isEmpty == false) + #expect(CarPlayAssistAudioMode.voicePrompt.title.isEmpty == false) + #expect(CarPlayAssistAudioMode.spokenAudio.title.isEmpty == false) + #expect(CarPlayAssistAudioMode.measurement.title.isEmpty == false) + } + + @Test func displayValuesExposeExpectedUnits() { + #expect(CarPlayAssistPreferredSampleRate.rate16000.title == "16000 Hz") + #expect(CarPlayAssistPreferredSampleRate.rate24000.value == 24000) + #expect(CarPlayAssistPreferredSampleRate.rate44100.value == 44100) + #expect(CarPlayAssistPreferredSampleRate.rate48000.title == "48000 Hz") + + #expect(CarPlayAssistTTSPlaybackStrategy.avPlayer.title.isEmpty == false) + #expect(CarPlayAssistTTSPlaybackStrategy.downloadedAVAudioPlayer.title.isEmpty == false) + + #expect(CarPlayAssistPlaybackDelay.none.title.isEmpty == false) + #expect(CarPlayAssistPlaybackDelay.none.seconds == 0) + #expect(CarPlayAssistPlaybackDelay.ms100.title == "100 ms") + #expect(CarPlayAssistPlaybackDelay.ms100.seconds == 0.1) + #expect(CarPlayAssistPlaybackDelay.ms250.title == "250 ms") + #expect(CarPlayAssistPlaybackDelay.ms250.seconds == 0.25) + #expect(CarPlayAssistPlaybackDelay.ms500.title == "500 ms") + #expect(CarPlayAssistPlaybackDelay.ms500.seconds == 0.5) + #expect(CarPlayAssistPlaybackDelay.ms1000.title == "1000 ms") + #expect(CarPlayAssistPlaybackDelay.ms1000.seconds == 1) + } + + private static let settingsKeys = [ + "carPlayAssistAudioCategory", + "carPlayAssistAudioMode", + "carPlayAssistPreferredSampleRate", + "carPlayAssistAllowBluetoothHFP", + "carPlayAssistAllowBluetoothA2DP", + "carPlayAssistDuckOthers", + "carPlayAssistInterruptSpokenAudio", + "carPlayAssistPlayRecordingIndicatorTone", + "carPlayAssistRecorderManagesAudioSession", + "carPlayAssistTTSPlaybackStrategy", + "carPlayAssistTTSReconfigureAudioSession", + "carPlayAssistTTSDeactivateBeforeReconfigure", + "carPlayAssistTTSActivateAudioSession", + "carPlayAssistTTSCategory", + "carPlayAssistTTSMode", + "carPlayAssistTTSAllowBluetoothHFP", + "carPlayAssistTTSAllowBluetoothA2DP", + "carPlayAssistTTSDuckOthers", + "carPlayAssistTTSInterruptSpokenAudio", + "carPlayAssistAVPlayerAutomaticallyWaitsToMinimizeStalling", + "carPlayAssistTTSPlaybackDelay", + ] + + private static func removeStoredSettings() { + settingsKeys.forEach { Current.settingsStore.prefs.removeObject(forKey: $0) } + } +}