diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4f0f62186..b66898c6d5 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,10 +134,13 @@ 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 + - 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,11 @@ jobs: with: path: vendor/bundle key: >- - ${{ runner.os }}-gems-${{ env.ImageVersion }}-${{ env.DEVELOPER_DIR }}-${{ hashFiles('**/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 @@ -202,10 +209,13 @@ 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 + - 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 +234,11 @@ jobs: with: path: vendor/bundle key: >- - ${{ runner.os }}-gems-${{ env.ImageVersion }}-${{ env.DEVELOPER_DIR }}-${{ hashFiles('**/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 diff --git a/.github/workflows/distribute.yml b/.github/workflows/distribute.yml index 213ca1e5b9..332d9b5575 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,13 +16,16 @@ env: jobs: build: - runs-on: macos-15 + runs-on: macos-26 strategy: fail-fast: false matrix: 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 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 7b552ae103..11299da7e6 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 */; }; @@ -1005,6 +1006,10 @@ 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 */; }; + 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 */; }; @@ -2636,6 +2641,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 = ""; }; @@ -2870,6 +2876,9 @@ 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 = ""; }; + 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 = ""; }; @@ -6024,6 +6033,14 @@ path = Settings; sourceTree = ""; }; + 42BC58182FAA2C740080EE09 /* Assist */ = { + isa = PBXGroup; + children = ( + 42BC58192FAA2C8F0080EE09 /* center_button_press.flac */, + ); + path = Assist; + sourceTree = ""; + }; 42BE698B2C4691E000745ECA /* Views */ = { isa = PBXGroup; children = ( @@ -6289,6 +6306,7 @@ 504A2C852F8133E6002A3C0E /* CarPlayTabsSelectionView.swift */, 42ABB0BA2C888BB10081461D /* CarPlayConfigurationViewModel.swift */, 42ABB0B82C888AA10081461D /* CarPlayConfig.swift */, + 429C07712FAB2D31000302D1 /* CarPlayAssistSettingsView.swift */, ); path = CarPlay; sourceTree = ""; @@ -6654,6 +6672,7 @@ B60614B31D1F116D00249C11 /* Sounds */ = { isa = PBXGroup; children = ( + 42BC58182FAA2C740080EE09 /* Assist */, B60615531D1F117700249C11 /* Alexa */, B606159A1D1F117700249C11 /* Generic */, B60614B41D1F117700249C11 /* MorganFreeman */, @@ -7298,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; @@ -7401,6 +7421,7 @@ children = ( D0C884792122A65800CCB501 /* SettingsStore.swift */, 4238DCA32DD1F1E300126434 /* AppSessionValues.swift */, + 42BC581B2FAA38D10080EE09 /* CarPlayAssistDebugSettings.swift */, ); path = Settings; sourceTree = ""; @@ -7487,7 +7508,6 @@ 6BC23DE131CA8813C2DBD657 /* IconSearchPicker.swift */, 5BFD0D30836C69840AB63A8A /* LoadingButton.swift */, ); - name = Complications; path = Complications; sourceTree = ""; }; @@ -7507,7 +7527,6 @@ 99EC7EF1136575D0E7A17091 /* NotificationIdentifierField.swift */, 208A5362BE60377368ACB1D6 /* RealmResultsObserver.swift */, ); - name = Components; path = Components; sourceTree = ""; }; @@ -8358,6 +8377,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 */, @@ -9624,6 +9644,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 */, @@ -9798,6 +9819,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 */, @@ -10131,6 +10153,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 */, @@ -10438,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/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/Resources/Sounds/Assist/center_button_press.flac b/Sources/App/Resources/Sounds/Assist/center_button_press.flac new file mode 100644 index 0000000000..40202bbfad Binary files /dev/null and b/Sources/App/Resources/Sounds/Assist/center_button_press.flac differ diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 3b203c2913..cc13ec7c31 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..027a0401c2 100644 --- a/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift +++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift @@ -3,13 +3,40 @@ import SFSafeSymbols import Shared import StoreKit 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 @@ -39,6 +66,7 @@ struct CarPlayConfigurationView: View { carPlayLogo tabsSection itemsSection + advancedSection resetView } .navigationTitle("CarPlay") @@ -46,7 +74,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) @@ -59,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) } @@ -91,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) } } @@ -129,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) } @@ -192,6 +249,22 @@ struct CarPlayConfigurationView: View { Button(L10n.noLabel, role: .cancel) {} } } + + private var advancedSection: some View { + NavigationLink { + CarPlayAdvancedSettingsView() + } label: { + 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/DebugView.swift b/Sources/App/Settings/DebugView.swift index b0f8df1cd9..8dd329387c 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,17 @@ struct DebugView: View { } } + private var carPlayDebugSection: some View { + NavigationLink { + CarPlayDebugSettingsView() + } label: { + linkContent( + image: .init(systemSymbol: .carFill), + title: L10n.CarPlay.Debug.Settings.rowTitle + ) + } + } + private var developerSection: some View { Section { Toggle("Toasts handled by the app", isOn: Binding( @@ -549,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 { @@ -584,6 +597,158 @@ 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 + ttsPlaybackSection + ttsSessionSection + resetSection + } + .navigationTitle(L10n.CarPlay.Debug.Settings.navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .onChange(of: settings) { updatedSettings in + Current.settingsStore.carPlayAssistDebugSettings = updatedSettings + } + } + + private var assistSessionSection: some View { + Section { + Picker(L10n.CarPlay.Debug.Settings.AssistSession.audioCategory, selection: $settings.audioCategory) { + ForEach(CarPlayAssistAudioCategory.allCases, id: \.self) { category in + Text(category.title).tag(category) + } + } + + Picker(L10n.CarPlay.Debug.Settings.AssistSession.audioMode, selection: $settings.audioMode) { + ForEach(CarPlayAssistAudioMode.allCases, id: \.self) { mode in + Text(mode.title).tag(mode) + } + } + + Picker( + L10n.CarPlay.Debug.Settings.AssistSession.preferredSampleRate, + selection: $settings.preferredSampleRate + ) { + ForEach(CarPlayAssistPreferredSampleRate.allCases, id: \.self) { sampleRate in + Text(sampleRate.title).tag(sampleRate) + } + } + + 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(L10n.CarPlay.Debug.Settings.AssistSession.title) + } footer: { + Text(L10n.CarPlay.Debug.Settings.AssistSession.footer) + } + } + + private var ttsPlaybackSection: some View { + Section { + Picker(L10n.CarPlay.Debug.Settings.TtsPlayback.playbackStrategy, selection: $settings.ttsPlaybackStrategy) { + ForEach(CarPlayAssistTTSPlaybackStrategy.allCases, id: \.self) { strategy in + Text(strategy.title).tag(strategy) + } + } + + Picker(L10n.CarPlay.Debug.Settings.TtsPlayback.playbackDelay, selection: $settings.ttsPlaybackDelay) { + ForEach(CarPlayAssistPlaybackDelay.allCases, id: \.self) { delay in + Text(delay.title).tag(delay) + } + } + + Toggle( + L10n.CarPlay.Debug.Settings.TtsPlayback.avplayerWaitsToMinimizeStalling, + isOn: $settings.avPlayerAutomaticallyWaitsToMinimizeStalling + ) + } header: { + Text(L10n.CarPlay.Debug.Settings.TtsPlayback.title) + } footer: { + Text(L10n.CarPlay.Debug.Settings.TtsPlayback.footer) + } + } + + private var ttsSessionSection: some View { + Section { + 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(L10n.CarPlay.Debug.Settings.TtsAudioSession.category, selection: $settings.ttsCategory) { + ForEach(CarPlayAssistAudioCategory.allCases, id: \.self) { category in + Text(category.title).tag(category) + } + } + + Picker(L10n.CarPlay.Debug.Settings.TtsAudioSession.mode, selection: $settings.ttsMode) { + ForEach(CarPlayAssistAudioMode.allCases, id: \.self) { mode in + Text(mode.title).tag(mode) + } + } + + 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(L10n.CarPlay.Debug.Settings.TtsAudioSession.title) + } footer: { + Text(L10n.CarPlay.Debug.Settings.TtsAudioSession.footer) + } + } + + private var resetSection: some View { + Section { + 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) {} + } + } + } +} + private struct DeleteKeychainAlertModifier: ViewModifier { @Binding var isPresented: Bool @Binding var confirmationText: String 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 } } diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift index 2594629cf4..ca69580b0b 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift @@ -1,14 +1,16 @@ +import AudioToolbox import AVFoundation import CarPlay 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 @@ -21,17 +23,24 @@ final class CarPlayAssistSession: NSObject { } private enum VoiceControlStateID: String { + case idle case recording case processing case responding + case error } 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 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. @@ -39,48 +48,82 @@ final class CarPlayAssistSession: NSObject { private var canSendAudioData = false private var state: State = .recording private var isStopped = false + private var postDismissAction: (() -> Void)? - private let server: Server private let pipelineId: String - private let pipelineName: String private lazy var template: CPVoiceControlTemplate = { + let retryButton = CPButton( + image: makeActionButtonImage(icon: .microphoneIcon, color: .haPrimary) + ) { [weak self] _ in + self?.restartRecording() + } + let helpButton = CPButton( + image: makeActionButtonImage(icon: .commentQuestionIcon, color: .gray) + ) { [weak self] _ in + self?.showPlaybackHelp() + } + + let idleState = CPVoiceControlState( + identifier: VoiceControlStateID.idle.rawValue, + titleVariants: [L10n.Assist.Carplay.TapToRecord.title], + image: MaterialDesignIcons.messageProcessingOutlineIcon.carPlayIcon( + color: .haPrimary, + context: .assistStateIndicator + ), + repeats: false + ) + 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 ) - return CPVoiceControlTemplate(voiceControlStates: [recordingState, processingState, respondingState]) + let errorState = CPVoiceControlState( + identifier: VoiceControlStateID.error.rawValue, + titleVariants: [L10n.errorLabel], + image: MaterialDesignIcons.alertCircleIcon.carPlayIcon(color: .systemRed, context: .assistStateIndicator), + repeats: false + ) + errorState.actionButtons = [retryButton, helpButton] + + return CPVoiceControlTemplate( + voiceControlStates: [recordingState, processingState, respondingState, idleState, errorState] + ) }() init( 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.carPlayAssistDebugSettings + .recorderManagesAudioSession + registerForAudioSessionNotifications() } deinit { @@ -95,6 +138,7 @@ final class CarPlayAssistSession: NSObject { isStopped = false canSendAudioData = false } + configureAudioSessionForAssist() activateVoiceControlState(for: .recording) interfaceController?.presentTemplate(template, animated: true, completion: nil) audioRecorder.startRecording() @@ -119,44 +163,325 @@ 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 { - 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 + private func configureAudioSessionForAssist() { + let settings = Current.settingsStore.carPlayAssistDebugSettings + do { + try audioSession.setCategory( + settings.audioCategory.avCategory, + mode: settings.audioMode.avMode, + options: makeAudioSessionOptions( + allowBluetoothHFP: settings.allowBluetoothHFP, + allowBluetoothA2DP: settings.allowBluetoothA2DP, + duckOthers: settings.duckOthers, + interruptSpokenAudio: settings.interruptSpokenAudio + ) + ) + try audioSession.setPreferredSampleRate(settings.preferredSampleRate.value) + try audioSession.setActive(true) + logCurrentAudioRoute(context: "activated") + } catch { + Current.Log.error("CarPlay Assist failed to configure audio session: \(error.localizedDescription)") + } + } + + private func configureAudioSessionForTTSIfNeeded() { + let settings = Current.settingsStore.carPlayAssistDebugSettings + guard settings.ttsReconfigureAudioSession else { return } + + do { + if settings.ttsDeactivateBeforeReconfigure { + try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + } + + try audioSession.setCategory( + settings.ttsCategory.avCategory, + mode: settings.ttsMode.avMode, + options: makeAudioSessionOptions( + allowBluetoothHFP: settings.ttsAllowBluetoothHFP, + allowBluetoothA2DP: settings.ttsAllowBluetoothA2DP, + duckOthers: settings.ttsDuckOthers, + interruptSpokenAudio: settings.ttsInterruptSpokenAudio + ) + ) + + if settings.ttsActivateAudioSession { + 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 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.carPlayAssistDebugSettings.playRecordingIndicatorTone 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) + 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() } 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) } + } + private func playProcessingIndicatorToneIfNeeded() { + // SystemSoundID values are tracked in https://github.com/TUNER88/iOSSystemSoundsLibrary. + AudioServicesPlaySystemSound(1405) // SiriStopSuccess_Haptic.caf + } + + private func playErrorIndicatorToneIfNeeded() { + // SystemSoundID values are tracked in https://github.com/TUNER88/iOSSystemSoundsLibrary. + AudioServicesPlaySystemSound(1343) // PINUnexpected.caf + } + + // MARK: - TTS Playback + + /// Plays TTS audio using the already active conversational audio session to preserve the car route. + private func playTTS(url: URL) { + 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 + 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.carPlayAssistDebugSettings.ttsPlaybackStrategy { + 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 + .carPlayAssistDebugSettings + .avPlayerAutomaticallyWaitsToMinimizeStalling ttsPlayer.replaceCurrentItem(with: playerItem) + observeTTSPlayer(item: playerItem) + Current.Log.info("CarPlay Assist starting AVPlayer TTS for URL: \(url.absoluteString)") ttsPlayer.play() NotificationCenter.default.addObserver( @@ -165,6 +490,113 @@ 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)") + enterErrorState(message: error.localizedDescription) + return + } + + guard let data else { + Current.Log.error("CarPlay Assist downloaded empty TTS audio data") + enterErrorState(message: "Downloaded empty TTS audio data") + 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") + enterErrorState(message: "AVAudioPlayer failed to start TTS playback") + } + } catch { + Current.Log + .error("CarPlay Assist failed to create AVAudioPlayer for TTS: \(error.localizedDescription)") + enterErrorState(message: error.localizedDescription) + } + } + }.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") + 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) { @@ -174,7 +606,7 @@ final class CarPlayAssistSession: NSObject { assistService.resetShouldStartListeningAgainAfterPlaybackEnd() restartRecording() } else { - stop() + enterIdleState() } } @@ -183,6 +615,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: @@ -190,7 +624,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) @@ -206,16 +640,57 @@ 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) + } + + 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)") + playErrorIndicatorToneIfNeeded() + activateVoiceControlState(for: .error(message)) + } } // MARK: - AudioRecorderDelegate -@available(iOS 16.0, *) +@available(iOS 26.4, *) extension CarPlayAssistSession: AudioRecorderDelegate { func didStartRecording(with sampleRate: Double) { + playRecordingIndicatorToneIfNeeded() assistService.assist(source: .audio( pipelineId: pipelineId, audioSampleRate: sampleRate, @@ -241,13 +716,35 @@ extension CarPlayAssistSession: AudioRecorderDelegate { } guard shouldHandle else { return } Current.Log.error("CarPlay Assist recording failed: \(error.localizedDescription)") - stop() + enterErrorState(message: error.localizedDescription) + } +} + +@available(iOS 26.4, *) +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 { + enterIdleState() + } + } + + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + Current.Log + .error("CarPlay Assist AVAudioPlayer decode error: \(error?.localizedDescription ?? "unknown error")") + enterErrorState(message: error?.localizedDescription ?? "AVAudioPlayer decode error") } } // MARK: - AssistServiceDelegate -@available(iOS 16.0, *) +@available(iOS 26.4, *) extension CarPlayAssistSession: AssistServiceDelegate { func didReceiveGreenLightForAudioInput() { stateQueue.sync { canSendAudioData = true } @@ -264,6 +761,7 @@ extension CarPlayAssistSession: AssistServiceDelegate { guard shouldHandleSttEnd else { return } audioRecorder.stopRecording() assistService.finishSendingAudio() + playProcessingIndicatorToneIfNeeded() activateVoiceControlState(for: .processing) } } @@ -293,7 +791,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) } } diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift index 83a02adc48..db6d4ecfb0 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 { @@ -547,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/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..a9053018f3 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 new file mode 100644 index 0000000000..de1d3f3a6c --- /dev/null +++ b/Sources/Shared/Settings/CarPlayAssistDebugSettings.swift @@ -0,0 +1,166 @@ +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: + L10n.CarPlay.Debug.Settings.Option.AudioCategory.playAndRecord + case .playback: + L10n.CarPlay.Debug.Settings.Option.AudioCategory.playback + case .record: + L10n.CarPlay.Debug.Settings.Option.AudioCategory.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 { + 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 + } + } +} + +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: + L10n.CarPlay.Debug.Settings.Option.TtsPlaybackStrategy.stream + case .downloadedAVAudioPlayer: + L10n.CarPlay.Debug.Settings.Option.TtsPlaybackStrategy.downloadAndPlay + } + } +} + +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: + L10n.CarPlay.Debug.Settings.Option.PlaybackDelay.none + default: + "\(rawValue) ms" + } + } + + public var seconds: Double { + Double(rawValue) / 1000.0 + } +} + +public struct CarPlayAssistDebugSettings: Equatable { + 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 1cc470b247..5ed3f2b5fc 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -509,8 +509,154 @@ public class SettingsStore { } } + 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.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 func resetCarPlayAssistDebugSettings() { + carPlayAssistDebugSettings = .default + } + // MARK: - Private helpers + private func carPlayAssistBool(key: String, default defaultValue: Bool) -> Bool { + guard prefs.object(forKey: key) != nil else { + return defaultValue + } + return prefs.bool(forKey: key) + } + + 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 + } + + 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 + } + private var defaultDeviceID: String { let baseID = removeSpecialCharsFromString(text: Current.device.deviceName()) .replacingOccurrences(of: " ", with: "_") diff --git a/Tests/App/Assist/Mocks/MockAudioRecorder.swift b/Tests/App/Assist/Mocks/MockAudioRecorder.swift index 9d1522cf24..f4abebade6 100644 --- a/Tests/App/Assist/Mocks/MockAudioRecorder.swift +++ b/Tests/App/Assist/Mocks/MockAudioRecorder.swift @@ -3,7 +3,7 @@ final class MockAudioRecorder: AudioRecorderProtocol { weak var delegate: AudioRecorderDelegate? var audioSampleRate: Double? - + var managesAudioSession: Bool = true var startRecordingCalled = false var stopRecordingCalled = false 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) } + } +} diff --git a/fastlane/lanes/testing.rb b/fastlane/lanes/testing.rb index 14e8a28caf..4b39b3f930 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, - destination: 'platform=iOS Simulator,name=iPhone 17,OS=26.2' + skip_detect_devices: true, + destination: 'platform=iOS Simulator,name=iPhone 17,OS=latest' ) end