diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e3e6322..c39f095 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -107,11 +107,17 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Restore pinned ASR assets + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + .build/asr_runtime + .build/asr_test_assets + key: asr-assets-${{ hashFiles('scripts/fetch_asr_runtime.sh', 'scripts/fetch_asr_test_assets.sh') }} - name: Prepare untraced build inputs run: | rustup target add aarch64-apple-ios aarch64-apple-ios-sim - scripts/build_ios_rust_ffi.sh - scripts/fetch_asr_runtime.sh + scripts/prepare_ios_build_inputs.sh - name: Initialize CodeQL uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: diff --git a/README.md b/README.md index 532fb2d..8087f3b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ scripts/check.sh # Everything the Verify CI job runs. | Smoke-launch a built app | `scripts/smoke_app.sh [--demo \| --installed]` | — | | Prove the installed speech service | `scripts/check_speech_engine.sh [APP]` (run by every install) | A signed app | | Install the iOS app on a phone | `scripts/install_ios.sh` | Unlocked iPhone in Developer Mode, `.env.local` | -| Prepare the iOS starter model | `scripts/prepare_ios_asr_model_package.sh ` | Network during preparation only | +| Prepare an extra iOS model package | `scripts/prepare_ios_asr_model_package.sh ` | Network during preparation only | | Benchmark ASR models | `scripts/benchmark_asr.sh [--model m.gguf] [--manifest m.tsv]` | — | Signing values live in the ignored `.env.local` (copy `.env.example`). @@ -52,7 +52,8 @@ Calm, tactile, precise: a studio utility, not a settings form. Neutral surfaces, system typography, no decorative borders, and one strong action per workflow. Color appears only for active state, required attention, or errors. The icon, Signal Bridge, is three generic nodes joined by one amber signal -path ([source](packaging/app_icon_source.png)). Details: +path ([source](packaging/app_icon_source.png)); iOS Voice Input crops the same +art full-bleed and paints amber only while recording. Details: [UX specification](docs/ux_spec.md). ## Documentation diff --git a/Tests/untested_sources.txt b/Tests/untested_sources.txt index 89faaa9..e9277ae 100644 --- a/Tests/untested_sources.txt +++ b/Tests/untested_sources.txt @@ -16,7 +16,11 @@ Sources/HardwareControllerApp/profiles_view.swift | View; edits in `profile_edit Sources/HardwareControllerApp/shortcut_recorder.swift | AppKit key-capture view; shortcut values in `profile_tests`. Sources/HardwareControllerApp/voice_history_view.swift | View; state in `voice_history_model_test`. apps/ios/voice_input/app/voice_input_history_view.swift | View; state in `voice_input_history_model_test`. -apps/ios/voice_input/app/voice_input_model_library_view.swift | View; state in `voice_input_model_library_model_test`. +apps/ios/voice_input/app/voice_input_dictate_view.swift | View; state in `voice_input_dictation_presentation_test`, driven by `voice_input_ui_test`. +apps/ios/voice_input/app/voice_input_root_view.swift | Tab container; driven by `voice_input_ui_test`. +apps/ios/voice_input/app/voice_input_settings_view.swift | View; state in `voice_input_history_model_test`, driven by `voice_input_ui_test`. +apps/ios/voice_input/app/voice_input_theme.swift | Style constant. +apps/ios/voice_input/app/voice_input_model_library_view.swift | View; state in `voice_input_model_library_model_test`, driven by `voice_input_ui_test`. apps/ios/voice_input/app/voice_input_onboarding_view.swift | View; policy in `voice_input_onboarding_policy_test`. apps/ios/voice_input/keyboard/keyboard_view_controller.swift | UIKit host; policy in `voice_input_keyboard_policy_test`. apps/ios/voice_input/widgets/voice_input_widgets.swift | WidgetKit declarations. diff --git a/apps/ios/voice_input/README.md b/apps/ios/voice_input/README.md index c87f553..e35daa5 100644 --- a/apps/ios/voice_input/README.md +++ b/apps/ios/voice_input/README.md @@ -8,7 +8,8 @@ scripts/install_ios.sh # Build, install, and launch on a connected iPhone ``` Pick a simulator with `HC_IOS_SIMULATOR_UDID= scripts/check_ios.sh`. The -check grants microphone access only to Voice Input on that simulator. +check grants microphone access only to Voice Input on that simulator and runs +the UI tests with `jfk.wav` in place of the microphone (Debug builds only). | Path | Owns | | --- | --- | @@ -16,7 +17,8 @@ check grants microphone access only to Voice Input on that simulator. | `keyboard/` | QWERTY keyboard, voice status, one insertion attempt, recovery. | | `system_capture/`, `widgets/` | Control Center control, App Intents, Live Activity. | | `shared/` | Bounded Keychain handoff shared by app and extensions. | -| `tests/`, `ui_tests/` | Unit tests and simulator UI tests. | +| `resources/` | App icon (Signal Bridge, cropped full-bleed) and notices. | +| `tests/`, `ui_tests/` | Unit tests; UI tests that dictate a recorded clip end to end. | | `project.yml` | XcodeGen source; regenerate with `scripts/generate_ios_project.sh`. | Behavior and proofs: [user guide](../../../docs/user_guide.md#ios-voice-input), diff --git a/apps/ios/voice_input/VoiceInput.xcodeproj/project.pbxproj b/apps/ios/voice_input/VoiceInput.xcodeproj/project.pbxproj index 24dc39b..a5b1bc8 100644 --- a/apps/ios/voice_input/VoiceInput.xcodeproj/project.pbxproj +++ b/apps/ios/voice_input/VoiceInput.xcodeproj/project.pbxproj @@ -11,19 +11,23 @@ 02C0343D214F602D38BC6382 /* voice_input_app_shortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E9AB745A5A0B0A29495FB7 /* voice_input_app_shortcuts.swift */; }; 052D02235EA636F0387E6EB4 /* TranscribeCpp.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65CBDBEFE70172D32C47262 /* TranscribeCpp.xcframework */; }; 0689EC2180798B82F344AA66 /* voice_input_insertion_recovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E55D9A1E5484487CECB7774 /* voice_input_insertion_recovery.swift */; }; + 069039816BD8BE287BDDA9C8 /* voice_input_dictation_presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EFBA741056D2F6CF27092CD /* voice_input_dictation_presentation.swift */; }; 0A3E522F44E124926B436BEB /* voice_input_style_mapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFEFF4EE6C988820D5DBF4AB /* voice_input_style_mapping.swift */; }; 0ABE9E88C9F0EED5AF773CDD /* voice_formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC5BAFE6FF8BFED876860BE /* voice_formatting.swift */; }; 0C05FD7EFBF98D8F4DAD49AC /* voice_input_document_pipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACFCF5B6116626A6EB019379 /* voice_input_document_pipeline.swift */; }; 0D83794D4515E4DF6333B808 /* voice_input_style_mapping_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544F3B0559B14EA6685C26A6 /* voice_input_style_mapping_test.swift */; }; + 0DD105BEFD41BF8439E2BD2D /* voice_input_settings_view.swift in Sources */ = {isa = PBXBuildFile; fileRef = 188696B6B6F97B2623965D45 /* voice_input_settings_view.swift */; }; 156FB66905F210C9BC2A28CE /* voice_input_history_repository.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC966D05F9C0C75AA86BE4B0 /* voice_input_history_repository.swift */; }; 1813242CF685BE1D25FA1CF7 /* voice_input_delivery_target.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1708ECC55A0234D0EF342089 /* voice_input_delivery_target.swift */; }; 1A60C1343093EE3B448BEA3C /* third_party_notices.txt in Resources */ = {isa = PBXBuildFile; fileRef = D984A06C273B150645A02A0E /* third_party_notices.txt */; }; 20048EFD42C1BB5DB859C5C5 /* HardwareControllerCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 692A77389CA17AB7CF7F04C4 /* HardwareControllerCore.framework */; }; 20222C2E444F3A0714A90036 /* voice_input_history_repository_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91B79A1F79664844287B554 /* voice_input_history_repository_test.swift */; }; + 2243D499929E52F37AED8335 /* voice_input_dictate_view.swift in Sources */ = {isa = PBXBuildFile; fileRef = E05229C3364697617D502989 /* voice_input_dictate_view.swift */; }; 2440A70E14B03E46D446B2E4 /* voice_input_app.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66894E9CB12ADD64305532DB /* voice_input_app.swift */; }; 248E111228FD356C7B0761A9 /* voice_input_model_package_validator_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = C553B7934DAA688A399765CF /* voice_input_model_package_validator_test.swift */; }; 26C34303BC1D32843C3121B2 /* voice_input_history_session.swift in Sources */ = {isa = PBXBuildFile; fileRef = B28A1A631095C6D76FEDD356 /* voice_input_history_session.swift */; }; 270E6555BB1489D7D34DE3ED /* voice_input_session_finalizer_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8121E61C52377107D508F8 /* voice_input_session_finalizer_test.swift */; }; + 2784394584546AAEECE78B95 /* voice_input_ui_test_hooks_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CAA81614D08BC8D9B4A3E9B /* voice_input_ui_test_hooks_test.swift */; }; 2C028748C4B8D074577F130F /* voice_input_history_audio_player_model.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC72B88EC8E75C1607A6E164 /* voice_input_history_audio_player_model.swift */; }; 35440F5AD76F3BD99A03E69F /* voice_input_capture_service_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12CE8032228EE5BB97E4FCE0 /* voice_input_capture_service_test.swift */; }; 3765B4DC9A8E0AE682C5D795 /* voice_formatted_document_builder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FC7415074EA515761E3F82D /* voice_formatted_document_builder.swift */; }; @@ -32,6 +36,7 @@ 39B5ED77194467F901FBD4FA /* voice_input_style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5730264B057CBE4C0B33BB /* voice_input_style.swift */; }; 403192A59E0FC2748AC21CFF /* voice_input_lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8344AF8ACDAB066D7290B6 /* voice_input_lifecycle.swift */; }; 453F44F50EEAF923A612DC44 /* voice_input_live_audio_tap_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E80C3A99EB8CBD76D72CA99 /* voice_input_live_audio_tap_test.swift */; }; + 46449AD40F4C280C255AD9CC /* assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6698685ACF5BAECC5D940D0E /* assets.xcassets */; }; 4A1D19C1F59B97D2B32A516F /* voice_input_app_model.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA699775DF46AC81E02D6271 /* voice_input_app_model.swift */; }; 4C9DC40969E1C9ACDB780066 /* voice_input_activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C842A307B0A0140A0178DEA0 /* voice_input_activity.swift */; }; 5048EB9AF1894E72B098BDEA /* voice_input_asr_model_registry_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE5854528F7B9366D6CE8AA /* voice_input_asr_model_registry_test.swift */; }; @@ -72,17 +77,21 @@ 997A36CA88066AC910D3E93A /* voice_input_style_preference.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2F4842EA73A1B5F266FBEC7 /* voice_input_style_preference.swift */; }; 9B55E33ABB5247EE809E96CD /* local_ai_provider_kind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 661DBA058E96E22130B681B0 /* local_ai_provider_kind.swift */; }; 9C90EE04F11C6A3A34CB6C23 /* personal_dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 725F5BB88E6E3AC39D387DB6 /* personal_dictionary.swift */; }; + 9DD98B66CE46ED2780AAC67D /* starter_asr_model in Resources */ = {isa = PBXBuildFile; fileRef = F6D2F5553D90902529BEC4D8 /* starter_asr_model */; }; 9E6B5EB0F96B87BCF2991CA5 /* voice_input_asr_workflow_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8710132CC58D01E271258655 /* voice_input_asr_workflow_test.swift */; }; 9FAAE9B6C46841D53A29847E /* voice_input_local_transcriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA1E80BDAEC9549CBFAAB49 /* voice_input_local_transcriber.swift */; }; 9FBC75AA92E1CB05DBF073D1 /* voice_input_history_audio_player_model_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7123485BE89DAC67B2C3451 /* voice_input_history_audio_player_model_test.swift */; }; A644DD8B9766C5E6137FC1F9 /* voice_input_asr_workflow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5768072590C38554047CC0F /* voice_input_asr_workflow.swift */; }; ABE23293CA06AEE1FB74ECCE /* VoiceInputShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0123BE2D32E45C9A1A736155 /* VoiceInputShared.framework */; }; ACF8F70673D62E07224D147A /* voice_input_model_package_stager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3760E9B21A975A8A98C93D /* voice_input_model_package_stager.swift */; }; + AE365A316BD3AFEFBBDD75FE /* voice_input_theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A09F9722E196FE3137A439D7 /* voice_input_theme.swift */; }; B1B82228A41A3412B882982C /* voice_input_system_capture_intents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E06E252FCFBB5CBDEDA1BD /* voice_input_system_capture_intents.swift */; }; B38520F5D89D0EC038D8A8DD /* voice_input_capture_service.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B91A570D429EEB46217A80C /* voice_input_capture_service.swift */; }; + B517F4ECE303FBF6054DBF34 /* voice_input_starter_model.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42CE3568045B6705385AA62 /* voice_input_starter_model.swift */; }; B5DAF11C777DBBB15C26A056 /* voice_input_model_package_installer_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0289EFF8A21EF431945174AF /* voice_input_model_package_installer_test.swift */; }; B62E671FD839ECD7F343A1BC /* voice_asr_bridge.c in Sources */ = {isa = PBXBuildFile; fileRef = BC8E456D0C0E3557DFDD75B7 /* voice_asr_bridge.c */; }; B6FAC327B196D0065BAC7D24 /* portable_voice_validator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A0D2B7F18E14C7DC3280DF /* portable_voice_validator.swift */; }; + B965FC2CE8984CF53DAB7E2A /* voice_input_starter_model_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50091BF1A07BAF46B4583FE6 /* voice_input_starter_model_test.swift */; }; BAFB5F8313ED06A20812FC98 /* voice_input_system_capture_intents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E06E252FCFBB5CBDEDA1BD /* voice_input_system_capture_intents.swift */; }; BD1D8F99CD1C7F8D6E6818F4 /* voice_history_retention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D025266467687E8B7FC7E78 /* voice_history_retention.swift */; }; BD3872A886B0362F2568FC88 /* VoiceInputShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0123BE2D32E45C9A1A736155 /* VoiceInputShared.framework */; }; @@ -92,6 +101,7 @@ C9CD662D7B1BA9E8264B26F2 /* VoiceInputShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0123BE2D32E45C9A1A736155 /* VoiceInputShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C9F9DCC491990F05F058BEA8 /* voice_input_system_control_reloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6EADFB3B5D6639DCD2B7ECF /* voice_input_system_control_reloader.swift */; }; CC64B99825542AD17E27CE9E /* voice_input_lifecycle_policy_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = E73827118DA96919FED1F547 /* voice_input_lifecycle_policy_test.swift */; }; + CDC6C2A0560AFDF985DE21A9 /* voice_input_ui_test_hooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB986919BBFA21E33388B38 /* voice_input_ui_test_hooks.swift */; }; CFE52B091FD3A71C14E34A97 /* voice_input_environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AC71BC68D1A1B8B71EDD8A8 /* voice_input_environment.swift */; }; D5959E7ADC42529F67A2ABED /* voice_spoken_edit.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FB32FBAE2A5D044818C786 /* voice_spoken_edit.swift */; }; D5B92E0B0A8743726CCB4BA5 /* voice_history_retention_planner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3D153AB72B493B089597C4 /* voice_history_retention_planner.swift */; }; @@ -101,8 +111,10 @@ DB0EEC91A3C3C870C7A12C0A /* VoiceFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76B49DD809855BED87F883A1 /* VoiceFFI.xcframework */; }; DBA837A7FCD5552582CBC488 /* voice_input_system_capture_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 459A3DD21E947DFBD5C87F2C /* voice_input_system_capture_test.swift */; }; DF51039613B6FB4B71794A20 /* HardwareControllerCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 692A77389CA17AB7CF7F04C4 /* HardwareControllerCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + E0D151C85EC5FDA71DC0FEBB /* voice_input_root_view.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AFEE79BD3448950EB170D03 /* voice_input_root_view.swift */; }; E260A9446CD7BB8988EB1B10 /* VoiceASRBridge.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7C0027EDFC4A041191FBAAF9 /* VoiceASRBridge.framework */; }; E2F2A4BC4EF4B857702A04F9 /* voice_input_keychain_store_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11013B8B033E9437866234F1 /* voice_input_keychain_store_test.swift */; }; + E30DBDBC7A126328C05EB009 /* voice_input_dictation_presentation_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAF6AB2B01EEBB45EC9487B0 /* voice_input_dictation_presentation_test.swift */; }; E32EF7A11D5F7BBDEF95C11D /* voice_input_host_field.swift in Sources */ = {isa = PBXBuildFile; fileRef = 094BDC550872DC0563812EE4 /* voice_input_host_field.swift */; }; E7A673873A5CCA8AFA1A4537 /* voice_input_delivery_target_policy_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7BCCF89180627F5AEA6CF08 /* voice_input_delivery_target_policy_test.swift */; }; E8E0BFE1797F40BCB13961E8 /* voice_input_capture_boundaries.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF89B7BFDA6EE4D8E1EC873B /* voice_input_capture_boundaries.swift */; }; @@ -271,17 +283,21 @@ 06EB9098ABABEC3A4D678776 /* voice_input_style_preference_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_style_preference_test.swift; sourceTree = ""; }; 094BDC550872DC0563812EE4 /* voice_input_host_field.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_host_field.swift; sourceTree = ""; }; 0D025266467687E8B7FC7E78 /* voice_history_retention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = voice_history_retention.swift; path = ../../../Sources/HardwareControllerCore/voice_history_retention.swift; sourceTree = ""; }; + 0EFBA741056D2F6CF27092CD /* voice_input_dictation_presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_dictation_presentation.swift; sourceTree = ""; }; 0F5730264B057CBE4C0B33BB /* voice_input_style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_style.swift; sourceTree = ""; }; 11013B8B033E9437866234F1 /* voice_input_keychain_store_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_keychain_store_test.swift; sourceTree = ""; }; 11E594DE72AD43B775B93413 /* voice_input_local_copy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_local_copy.swift; sourceTree = ""; }; 11E9AB745A5A0B0A29495FB7 /* voice_input_app_shortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_app_shortcuts.swift; sourceTree = ""; }; 12CE8032228EE5BB97E4FCE0 /* voice_input_capture_service_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_capture_service_test.swift; sourceTree = ""; }; 1708ECC55A0234D0EF342089 /* voice_input_delivery_target.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_delivery_target.swift; sourceTree = ""; }; + 188696B6B6F97B2623965D45 /* voice_input_settings_view.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_settings_view.swift; sourceTree = ""; }; + 1CAA81614D08BC8D9B4A3E9B /* voice_input_ui_test_hooks_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_ui_test_hooks_test.swift; sourceTree = ""; }; 1CCC0C83D88B8C773AAF1690 /* voice_input_document_pipeline_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_document_pipeline_test.swift; sourceTree = ""; }; 1D6CF255C19A75FF98CAA350 /* HardwareControllerVoiceFFI.framework */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.framework; path = HardwareControllerVoiceFFI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 23BB17096DE2EC193AB541C9 /* voice_input_local_transcriber_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_local_transcriber_test.swift; sourceTree = ""; }; 24351BDC6ACA4CA02189AE53 /* voice_input_app_model_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_app_model_test.swift; sourceTree = ""; }; 2761F315707E81BC58E239D8 /* voice_input_model_library_model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_library_model.swift; sourceTree = ""; }; + 2CB986919BBFA21E33388B38 /* voice_input_ui_test_hooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_ui_test_hooks.swift; sourceTree = ""; }; 2DB1D8B9506FDB924CFE42AF /* voice_input_store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_store.swift; sourceTree = ""; }; 2E50A5416BA4B684E5EE13EE /* voice_input_history_retention_preferences_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_history_retention_preferences_test.swift; sourceTree = ""; }; 2E55D9A1E5484487CECB7774 /* voice_input_insertion_recovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_insertion_recovery.swift; sourceTree = ""; }; @@ -295,6 +311,7 @@ 459A3DD21E947DFBD5C87F2C /* voice_input_system_capture_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_system_capture_test.swift; sourceTree = ""; }; 4C4D086A77C159F47E36FA8B /* voice_input_model_library_view.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_library_view.swift; sourceTree = ""; }; 4D3D153AB72B493B089597C4 /* voice_history_retention_planner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_history_retention_planner.swift; sourceTree = ""; }; + 50091BF1A07BAF46B4583FE6 /* voice_input_starter_model_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_starter_model_test.swift; sourceTree = ""; }; 544F3B0559B14EA6685C26A6 /* voice_input_style_mapping_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_style_mapping_test.swift; sourceTree = ""; }; 55CC42AC07A3DC0E467CB0B5 /* voice_input_model_package_installer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_package_installer.swift; sourceTree = ""; }; 5606DA40395C7E4F69D32F4E /* voice_asr_bridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = voice_asr_bridge.h; path = ../../../Sources/voice_asr_bridge/include/voice_asr_bridge.h; sourceTree = ""; }; @@ -302,6 +319,7 @@ 659DA20B14ABA097FDD7322B /* voice_input_lifecycle_notification_mapper_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_lifecycle_notification_mapper_test.swift; sourceTree = ""; }; 661DBA058E96E22130B681B0 /* local_ai_provider_kind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = local_ai_provider_kind.swift; path = ../../../Sources/HardwareControllerCore/local_ai_provider_kind.swift; sourceTree = ""; }; 66894E9CB12ADD64305532DB /* voice_input_app.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_app.swift; sourceTree = ""; }; + 6698685ACF5BAECC5D940D0E /* assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = assets.xcassets; sourceTree = ""; }; 692A77389CA17AB7CF7F04C4 /* HardwareControllerCore.framework */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.framework; path = HardwareControllerCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6A193C9D9D6CEADAB85C8ED1 /* voice_input_model_package_fixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_package_fixture.swift; sourceTree = ""; }; 6E80C3A99EB8CBD76D72CA99 /* voice_input_live_audio_tap_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_live_audio_tap_test.swift; sourceTree = ""; }; @@ -326,8 +344,10 @@ 94CCB83B95650A36ED0CB4A4 /* voice_input_lifecycle_notification_mapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_lifecycle_notification_mapper.swift; sourceTree = ""; }; 9A3760E9B21A975A8A98C93D /* voice_input_model_package_stager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_package_stager.swift; sourceTree = ""; }; 9A8344AF8ACDAB066D7290B6 /* voice_input_lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_lifecycle.swift; sourceTree = ""; }; + 9AFEE79BD3448950EB170D03 /* voice_input_root_view.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_root_view.swift; sourceTree = ""; }; 9B9EDA822613D13F8604F58F /* voice_input_widgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_widgets.swift; sourceTree = ""; }; 9E9ADA862D5509C651719B52 /* voice_input_history_view.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_history_view.swift; sourceTree = ""; }; + A09F9722E196FE3137A439D7 /* voice_input_theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_theme.swift; sourceTree = ""; }; A32BB963BE2685D5364B725F /* VoiceInput.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = VoiceInput.app; sourceTree = BUILT_PRODUCTS_DIR; }; A657531BE5596A20AB1D1FAE /* voice_input_onboarding_view.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_onboarding_view.swift; sourceTree = ""; }; A7123485BE89DAC67B2C3451 /* voice_input_history_audio_player_model_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_history_audio_player_model_test.swift; sourceTree = ""; }; @@ -338,6 +358,7 @@ B28A1A631095C6D76FEDD356 /* voice_input_history_session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_history_session.swift; sourceTree = ""; }; B2F4842EA73A1B5F266FBEC7 /* voice_input_style_preference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_style_preference.swift; sourceTree = ""; }; B428772494717A951306D51A /* voice_input_insertion_recovery_policy_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_insertion_recovery_policy_test.swift; sourceTree = ""; }; + B42CE3568045B6705385AA62 /* voice_input_starter_model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_starter_model.swift; sourceTree = ""; }; BAE5854528F7B9366D6CE8AA /* voice_input_asr_model_registry_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_asr_model_registry_test.swift; sourceTree = ""; }; BC72B88EC8E75C1607A6E164 /* voice_input_history_audio_player_model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_history_audio_player_model.swift; sourceTree = ""; }; BC8E456D0C0E3557DFDD75B7 /* voice_asr_bridge.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = voice_asr_bridge.c; path = ../../../Sources/voice_asr_bridge/voice_asr_bridge.c; sourceTree = ""; }; @@ -355,6 +376,7 @@ D984A06C273B150645A02A0E /* third_party_notices.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = third_party_notices.txt; sourceTree = ""; }; DAC1D9F56D811C3B343F7854 /* voice_input_ui_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_ui_test.swift; sourceTree = ""; }; DDC5BAFE6FF8BFED876860BE /* voice_formatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = voice_formatting.swift; path = ../../../Sources/HardwareControllerCore/voice_formatting.swift; sourceTree = ""; }; + E05229C3364697617D502989 /* voice_input_dictate_view.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_dictate_view.swift; sourceTree = ""; }; E1FB32FBAE2A5D044818C786 /* voice_spoken_edit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = voice_spoken_edit.swift; path = ../../../Sources/HardwareControllerCore/voice_spoken_edit.swift; sourceTree = ""; }; E55F975B55AECC156411BBF6 /* voice_input_onboarding_policy_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_onboarding_policy_test.swift; sourceTree = ""; }; E59D05A6E89992CBAB7EB9E0 /* voice_input_host_field_policy_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_host_field_policy_test.swift; sourceTree = ""; }; @@ -364,6 +386,8 @@ EA699775DF46AC81E02D6271 /* voice_input_app_model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_app_model.swift; sourceTree = ""; }; EC966D05F9C0C75AA86BE4B0 /* voice_input_history_repository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_history_repository.swift; sourceTree = ""; }; F6CFBF1348B03A8F045AB741 /* voice_input_session_finalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_session_finalizer.swift; sourceTree = ""; }; + F6D2F5553D90902529BEC4D8 /* starter_asr_model */ = {isa = PBXFileReference; lastKnownFileType = folder; name = starter_asr_model; path = ../../../.build/ios_voice_input/starter_asr_model; sourceTree = SOURCE_ROOT; }; + FAF6AB2B01EEBB45EC9487B0 /* voice_input_dictation_presentation_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_dictation_presentation_test.swift; sourceTree = ""; }; FF4E6158219BA1EF466A8CDD /* voice_spoken_edit_replayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = voice_spoken_edit_replayer.swift; path = ../../../Sources/HardwareControllerCore/voice_spoken_edit_replayer.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -450,6 +474,14 @@ name = Products; sourceTree = ""; }; + 1025618E971D4C838D7E3388 /* ios_voice_input */ = { + isa = PBXGroup; + children = ( + F6D2F5553D90902529BEC4D8 /* starter_asr_model */, + ); + path = ios_voice_input; + sourceTree = ""; + }; 14722B40F2EC04393799602F /* portable */ = { isa = PBXGroup; children = ( @@ -476,6 +508,14 @@ name = fixtures; sourceTree = ""; }; + 5B842F3DE08CAD78FC3F6E5E /* .build */ = { + isa = PBXGroup; + children = ( + 1025618E971D4C838D7E3388 /* ios_voice_input */, + ); + path = .build; + sourceTree = ""; + }; 6082C63DDE294E38C4461A87 /* shared */ = { isa = PBXGroup; children = ( @@ -509,6 +549,7 @@ children = ( 964543A807DF541DC3171B63 /* app */, 2D06AB3ED3D5E179F9EC7F4C /* fixtures */, + F5E7F5F75BD9A8179F561DFD /* hardware_controller */, 74E58174B1B6693A3967EF5A /* keyboard */, 14722B40F2EC04393799602F /* portable */, DAC190110174095D0BAF2536 /* portable_voice_core */, @@ -533,6 +574,8 @@ C5768072590C38554047CC0F /* voice_input_asr_workflow.swift */, CF89B7BFDA6EE4D8E1EC873B /* voice_input_capture_boundaries.swift */, 3B91A570D429EEB46217A80C /* voice_input_capture_service.swift */, + E05229C3364697617D502989 /* voice_input_dictate_view.swift */, + 0EFBA741056D2F6CF27092CD /* voice_input_dictation_presentation.swift */, ACFCF5B6116626A6EB019379 /* voice_input_document_pipeline.swift */, BC72B88EC8E75C1607A6E164 /* voice_input_history_audio_player_model.swift */, 90BEFBB956AD1AC6913E534D /* voice_input_history_model.swift */, @@ -549,9 +592,14 @@ 55CC42AC07A3DC0E467CB0B5 /* voice_input_model_package_installer.swift */, 9A3760E9B21A975A8A98C93D /* voice_input_model_package_stager.swift */, A657531BE5596A20AB1D1FAE /* voice_input_onboarding_view.swift */, + 9AFEE79BD3448950EB170D03 /* voice_input_root_view.swift */, F6CFBF1348B03A8F045AB741 /* voice_input_session_finalizer.swift */, + 188696B6B6F97B2623965D45 /* voice_input_settings_view.swift */, + B42CE3568045B6705385AA62 /* voice_input_starter_model.swift */, CFEFF4EE6C988820D5DBF4AB /* voice_input_style_mapping.swift */, C6EADFB3B5D6639DCD2B7ECF /* voice_input_system_control_reloader.swift */, + A09F9722E196FE3137A439D7 /* voice_input_theme.swift */, + 2CB986919BBFA21E33388B38 /* voice_input_ui_test_hooks.swift */, ); path = app; sourceTree = ""; @@ -568,6 +616,7 @@ BE79D75FC5EC83C94C5C1B94 /* resources */ = { isa = PBXGroup; children = ( + 6698685ACF5BAECC5D940D0E /* assets.xcassets */, D984A06C273B150645A02A0E /* third_party_notices.txt */, ); path = resources; @@ -610,6 +659,15 @@ path = widgets; sourceTree = ""; }; + F5E7F5F75BD9A8179F561DFD /* hardware_controller */ = { + isa = PBXGroup; + children = ( + 5B842F3DE08CAD78FC3F6E5E /* .build */, + ); + name = hardware_controller; + path = ../../..; + sourceTree = ""; + }; FB9A0028DAC7DC73F973BA36 /* tests */ = { isa = PBXGroup; children = ( @@ -618,6 +676,7 @@ 8710132CC58D01E271258655 /* voice_input_asr_workflow_test.swift */, 12CE8032228EE5BB97E4FCE0 /* voice_input_capture_service_test.swift */, D7BCCF89180627F5AEA6CF08 /* voice_input_delivery_target_policy_test.swift */, + FAF6AB2B01EEBB45EC9487B0 /* voice_input_dictation_presentation_test.swift */, 1CCC0C83D88B8C773AAF1690 /* voice_input_document_pipeline_test.swift */, A7123485BE89DAC67B2C3451 /* voice_input_history_audio_player_model_test.swift */, 7BC7E40456DA743C56A2E637 /* voice_input_history_model_test.swift */, @@ -639,10 +698,12 @@ C553B7934DAA688A399765CF /* voice_input_model_package_validator_test.swift */, E55F975B55AECC156411BBF6 /* voice_input_onboarding_policy_test.swift */, CA8121E61C52377107D508F8 /* voice_input_session_finalizer_test.swift */, + 50091BF1A07BAF46B4583FE6 /* voice_input_starter_model_test.swift */, 544F3B0559B14EA6685C26A6 /* voice_input_style_mapping_test.swift */, 06EB9098ABABEC3A4D678776 /* voice_input_style_preference_test.swift */, 7DFDF7EBFC4891F604797237 /* voice_input_style_test.swift */, 459A3DD21E947DFBD5C87F2C /* voice_input_system_capture_test.swift */, + 1CAA81614D08BC8D9B4A3E9B /* voice_input_ui_test_hooks_test.swift */, ); path = tests; sourceTree = ""; @@ -894,6 +955,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 46449AD40F4C280C255AD9CC /* assets.xcassets in Resources */, + 9DD98B66CE46ED2780AAC67D /* starter_asr_model in Resources */, 1A60C1343093EE3B448BEA3C /* third_party_notices.txt in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -919,6 +982,7 @@ 9E6B5EB0F96B87BCF2991CA5 /* voice_input_asr_workflow_test.swift in Sources */, 35440F5AD76F3BD99A03E69F /* voice_input_capture_service_test.swift in Sources */, E7A673873A5CCA8AFA1A4537 /* voice_input_delivery_target_policy_test.swift in Sources */, + E30DBDBC7A126328C05EB009 /* voice_input_dictation_presentation_test.swift in Sources */, 73A5C53DFB572A01EBE14294 /* voice_input_document_pipeline_test.swift in Sources */, 9FBC75AA92E1CB05DBF073D1 /* voice_input_history_audio_player_model_test.swift in Sources */, 7B763C2403C0C8BBBCE35BDC /* voice_input_history_model_test.swift in Sources */, @@ -940,10 +1004,12 @@ 248E111228FD356C7B0761A9 /* voice_input_model_package_validator_test.swift in Sources */, 54931E3159D6424A6A947FEE /* voice_input_onboarding_policy_test.swift in Sources */, 270E6555BB1489D7D34DE3ED /* voice_input_session_finalizer_test.swift in Sources */, + B965FC2CE8984CF53DAB7E2A /* voice_input_starter_model_test.swift in Sources */, 0D83794D4515E4DF6333B808 /* voice_input_style_mapping_test.swift in Sources */, FFB39ADEB1D09C4A5EB54F7E /* voice_input_style_preference_test.swift in Sources */, 528E6DF8FA11984EFC0D8A69 /* voice_input_style_test.swift in Sources */, DBA837A7FCD5552582CBC488 /* voice_input_system_capture_test.swift in Sources */, + 2784394584546AAEECE78B95 /* voice_input_ui_test_hooks_test.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1013,6 +1079,8 @@ A644DD8B9766C5E6137FC1F9 /* voice_input_asr_workflow.swift in Sources */, E8E0BFE1797F40BCB13961E8 /* voice_input_capture_boundaries.swift in Sources */, B38520F5D89D0EC038D8A8DD /* voice_input_capture_service.swift in Sources */, + 2243D499929E52F37AED8335 /* voice_input_dictate_view.swift in Sources */, + 069039816BD8BE287BDDA9C8 /* voice_input_dictation_presentation.swift in Sources */, 0C05FD7EFBF98D8F4DAD49AC /* voice_input_document_pipeline.swift in Sources */, 2C028748C4B8D074577F130F /* voice_input_history_audio_player_model.swift in Sources */, 37D39F173867AD372946A783 /* voice_input_history_model.swift in Sources */, @@ -1029,10 +1097,15 @@ FEC66FC78A64BD691A1F8309 /* voice_input_model_package_installer.swift in Sources */, ACF8F70673D62E07224D147A /* voice_input_model_package_stager.swift in Sources */, 75E70A2A8B3AF50404F4A49F /* voice_input_onboarding_view.swift in Sources */, + E0D151C85EC5FDA71DC0FEBB /* voice_input_root_view.swift in Sources */, C0C1A2D27B1F77673F8ABCD8 /* voice_input_session_finalizer.swift in Sources */, + 0DD105BEFD41BF8439E2BD2D /* voice_input_settings_view.swift in Sources */, + B517F4ECE303FBF6054DBF34 /* voice_input_starter_model.swift in Sources */, 0A3E522F44E124926B436BEB /* voice_input_style_mapping.swift in Sources */, BAFB5F8313ED06A20812FC98 /* voice_input_system_capture_intents.swift in Sources */, C9F9DCC491990F05F058BEA8 /* voice_input_system_control_reloader.swift in Sources */, + AE365A316BD3AFEFBBDD75FE /* voice_input_theme.swift in Sources */, + CDC6C2A0560AFDF985DE21A9 /* voice_input_ui_test_hooks.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1133,6 +1206,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = config/app.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; FRAMEWORK_SEARCH_PATHS = ( @@ -1308,6 +1382,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = config/app.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; FRAMEWORK_SEARCH_PATHS = ( diff --git a/apps/ios/voice_input/app/voice_input_app.swift b/apps/ios/voice_input/app/voice_input_app.swift index 1fb1bf5..f0c391d 100644 --- a/apps/ios/voice_input/app/voice_input_app.swift +++ b/apps/ios/voice_input/app/voice_input_app.swift @@ -1,5 +1,4 @@ import AVFAudio -import AppIntents import HardwareControllerCore import SwiftUI import UIKit @@ -16,6 +15,11 @@ struct VoiceInputApp: App { @MainActor init() { let store = VoiceInputKeychainStore() + #if DEBUG + if VoiceInputUITestHooks.shouldReset(arguments: ProcessInfo.processInfo.arguments) { + Self.resetForUITest(store: store) + } + #endif guard let applicationSupport = FileManager.default.urls( for: .applicationSupportDirectory, @@ -92,6 +96,17 @@ struct VoiceInputApp: App { historyRepository = nil historyInitializationError = error.localizedDescription } + var recorderFactory: any VoiceInputAudioRecorderCreating = + VoiceInputSystemAudioRecorderFactory() + var liveAudioTap: (any VoiceInputLiveAudioTapping)? = VoiceInputSystemLiveAudioTap() + #if DEBUG + if let fixtureURL = VoiceInputUITestHooks.audioFixtureURL( + environment: ProcessInfo.processInfo.environment + ) { + recorderFactory = VoiceInputFixtureAudioRecorderFactory(fixtureURL: fixtureURL) + liveAudioTap = nil + } + #endif let service = VoiceInputCaptureService( store: store, captureDirectoryURL: historyRoot.appendingPathComponent("audio", isDirectory: true), @@ -100,7 +115,8 @@ struct VoiceInputApp: App { VoiceInputSessionFinalizer(history: $0) }, recoveryStore: historyRepository, - liveAudioTap: VoiceInputSystemLiveAudioTap(), + recorderFactory: recorderFactory, + liveAudioTap: liveAudioTap, controlReloader: VoiceInputSystemControlReloader.reload ) _model = StateObject( @@ -109,7 +125,8 @@ struct VoiceInputApp: App { _modelLibrary = StateObject( wrappedValue: VoiceInputModelLibraryModel( manager: registry, - asrWorkflow: asrWorkflow + asrWorkflow: asrWorkflow, + starter: VoiceInputStarterModel() ) ) _history = StateObject( @@ -128,7 +145,7 @@ struct VoiceInputApp: App { var body: some Scene { WindowGroup { - VoiceInputView( + VoiceInputRootView( model: model, modelLibrary: modelLibrary, history: history, @@ -196,185 +213,28 @@ struct VoiceInputApp: App { } } } -} - -private struct VoiceInputView: View { - @ObservedObject var model: VoiceInputAppModel - @ObservedObject var modelLibrary: VoiceInputModelLibraryModel - @ObservedObject var history: VoiceInputHistoryModel - @ObservedObject var historyAudioPlayer: VoiceInputHistoryAudioPlayerModel - @Environment(\.openURL) private var openURL - var body: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 28) { - VStack(alignment: .leading, spacing: 8) { - Text("Voice anywhere. Private by default.") - .font(.largeTitle.bold()) - Text( - "Capture long thoughts without sending audio, transcripts, or context off this iPhone." - ) - .foregroundStyle(.secondary) - } - - VoiceInputOnboardingView( - step: model.onboardingStep, - microphoneAuthorization: model.microphoneAuthorization, - keyboardHandoffObserved: model.keyboardHandoffObserved, - errorMessage: model.onboardingErrorMessage, - requestMicrophone: model.requestMicrophone, - openSettings: openSettings + #if DEBUG + /// UI tests start from a fresh install without reinstalling the app. + @MainActor + private static func resetForUITest(store: VoiceInputKeychainStore) { + do { + try store.removeAll() + if let applicationSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first { + let root = applicationSupport.appendingPathComponent( + "com.longdevity.hardwarecontroller.voiceinput", + isDirectory: true ) - - VoiceInputModelLibraryView(model: modelLibrary) - - captureSection - - systemCaptureSection - - VoiceInputHistoryView( - model: history, - audioPlayer: historyAudioPlayer - ) - - if let errorMessage = model.errorMessage ?? model.snapshotErrorMessage { - Text(errorMessage) - .foregroundStyle(.red) - .accessibilityIdentifier("capture_error") + if FileManager.default.fileExists(atPath: root.path) { + try FileManager.default.removeItem(at: root) } } - .padding(24) - } - .navigationTitle("Voice Input") - .task { - await modelLibrary.refresh() - await history.refresh() - } - .task(id: model.snapshot.sequence) { - if model.snapshot.phase == .ready { - await history.refresh() - } - } - } - } - - private var captureSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Capture").font(.title2.bold()) - Spacer() - Picker( - "Style", - selection: Binding( - get: { model.selectedStyleKind }, - set: { model.selectStyle($0) } - ) - ) { - ForEach(VoiceInputStyleKind.allCases, id: \.self) { styleKind in - Text(styleKind.displayName).tag(styleKind) - } - } - .pickerStyle(.menu) - .accessibilityIdentifier("capture_style") - } - statusCard - - if let lifecycleMessage = model.lifecycleMessage { - Label(lifecycleMessage, systemImage: "info.circle") - .font(.subheadline) - .foregroundStyle(.secondary) - .accessibilityIdentifier("capture_lifecycle") - } - - Button { - if model.isRecording { - model.stop() - } else { - model.start() - } - } label: { - Label( - model.isRecording ? "Stop local capture" : "Start local capture", - systemImage: model.isRecording ? "stop.fill" : "mic.fill" - ) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) + } catch { + fatalError("UI-test reset failed: \(error)") } - .buttonStyle(.borderedProminent) - .disabled(!model.canCapture) - .accessibilityIdentifier( - model.isRecording ? "stop_capture" : "start_capture" - ) } - } - - private var systemCaptureSection: some View { - VStack(alignment: .leading, spacing: 10) { - Label("Capture without the keyboard", systemImage: "rectangle.and.hand.point.up.left") - .font(.headline) - Text( - "Add Voice Capture to Control Center, the Lock Screen, or the Action button. You can also ask Siri to start or stop Voice Input. A visible Live Activity owns background recording, and completed text stays in History." - ) - .font(.subheadline) - .foregroundStyle(.secondary) - ShortcutsLink() - .accessibilityIdentifier("open_voice_shortcuts") - } - .accessibilityElement(children: .contain) - .accessibilityIdentifier("system_capture_guidance") - } - - private func openSettings() { - guard let url = URL(string: UIApplication.openSettingsURLString) else { - return - } - openURL(url) - } - - private var statusCard: some View { - HStack(spacing: 12) { - Image(systemName: statusSymbol) - .font(.title2) - VStack(alignment: .leading, spacing: 2) { - Text(statusTitle).font(.headline) - Text(statusDetail) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - .accessibilityElement(children: .ignore) - .accessibilityLabel("Capture status") - .accessibilityValue(statusTitle) - .accessibilityIdentifier("capture_status") - } - - private var statusSymbol: String { - switch model.snapshot.phase { - case .idle: "circle" - case .recording: "waveform" - case .transcribing: "ellipsis" - case .ready: "checkmark.circle.fill" - case .interrupted: "exclamationmark.triangle" - case .failed: "xmark.circle" - } - } - - private var statusTitle: String { - model.snapshot.phase.rawValue.capitalized - } - - private var statusDetail: String { - switch model.snapshot.phase { - case .idle: "Ready for local capture." - case .recording: "Recording locally. Stop here, from the Live Activity, or from the keyboard." - case .transcribing: "Finalizing locally into History." - case .ready: "Saved in History for explicit copy, share, or later keyboard retrieval." - case .interrupted: "Capture stopped after an audio interruption. Start again manually." - case .failed: "Capture stopped with an explicit failure." - } - } + #endif } diff --git a/apps/ios/voice_input/app/voice_input_app_model.swift b/apps/ios/voice_input/app/voice_input_app_model.swift index b331c78..09b3a03 100644 --- a/apps/ios/voice_input/app/voice_input_app_model.swift +++ b/apps/ios/voice_input/app/voice_input_app_model.swift @@ -234,22 +234,35 @@ final class VoiceInputAppModel: ObservableObject { return } do { - snapshot = try await service.snapshot() - snapshotErrorMessage = nil + publish(\.snapshot, try await service.snapshot()) + publish(\.snapshotErrorMessage, nil) } catch { - snapshotErrorMessage = "The local capture state is unavailable." + publish(\.snapshotErrorMessage, "The local capture state is unavailable.") } } func refreshOnboarding() { - microphoneAuthorization = microphoneAuthorizationProvider() + publish(\.microphoneAuthorization, microphoneAuthorizationProvider()) do { - keyboardHandoffObserved = try keyboardObservedAtReader() != nil - onboardingErrorMessage = nil + publish(\.keyboardHandoffObserved, try keyboardObservedAtReader() != nil) + publish(\.onboardingErrorMessage, nil) } catch { - keyboardHandoffObserved = false - onboardingErrorMessage = + publish(\.keyboardHandoffObserved, false) + publish( + \.onboardingErrorMessage, "The local keyboard handoff is unavailable. Close and reopen the app and keyboard." + ) + } + } + + /// The refresh loop runs at 4 Hz. Publishing only real changes keeps SwiftUI + /// from redrawing, and the app idle, while nothing happens. + private func publish( + _ keyPath: ReferenceWritableKeyPath, + _ value: Value + ) { + if self[keyPath: keyPath] != value { + self[keyPath: keyPath] = value } } diff --git a/apps/ios/voice_input/app/voice_input_dictate_view.swift b/apps/ios/voice_input/app/voice_input_dictate_view.swift new file mode 100644 index 0000000..eae796b --- /dev/null +++ b/apps/ios/voice_input/app/voice_input_dictate_view.swift @@ -0,0 +1,214 @@ +import SwiftUI +import VoiceInputShared + +/// The one workflow screen: the newest text above, one record button below. +struct VoiceInputDictateView: View { + @ObservedObject var model: VoiceInputAppModel + @ObservedObject var modelLibrary: VoiceInputModelLibraryModel + let openSettings: () -> Void + @State private var recordingStartedAt: Date? + @State private var copied = false + private let localClipboard = VoiceInputSystemLocalClipboard() + + private var presentation: VoiceInputDictationPresentation { + VoiceInputDictationPresentation( + .init( + phase: model.snapshot.phase, + text: model.snapshot.text, + microphone: model.microphoneAuthorization, + isPreparingModel: modelLibrary.isPreparingStarter, + hasSpeechModel: modelLibrary.activeASRModel != nil, + errorMessage: model.errorMessage ?? model.snapshotErrorMessage + ?? modelLibrary.errorMessage, + lifecycleMessage: model.lifecycleMessage + ) + ) + } + + var body: some View { + let presentation = presentation + NavigationStack { + VStack(spacing: 0) { + resultArea(presentation.result) + controls(presentation) + } + .navigationTitle("Dictate") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { styleMenu } + } + .onChange(of: model.snapshot.phase, initial: true) { _, phase in + recordingStartedAt = phase == .recording ? (recordingStartedAt ?? .now) : nil + copied = false + } + } + } + + @ViewBuilder + private func resultArea(_ result: String?) -> some View { + if let result { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(result) + .font(.title3) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("dictation_result") + HStack(spacing: 20) { + Button { + copy(result) + } label: { + Label(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc") + } + .accessibilityIdentifier("dictation_copy") + ShareLink(item: result) { + Label("Share", systemImage: "square.and.arrow.up") + } + .accessibilityIdentifier("dictation_share") + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(24) + } + } else { + Spacer(minLength: 24) + } + } + + private func controls(_ presentation: VoiceInputDictationPresentation) -> some View { + VStack(spacing: 20) { + VStack(spacing: 6) { + Text(presentation.title) + .font(.title2.weight(.semibold)) + .accessibilityIdentifier("dictation_title") + Text(presentation.detail) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .accessibilityIdentifier("dictation_detail") + // The timer line is always laid out so starting never shifts the + // button, but it only ticks while recording so an idle app stays idle. + Group { + if let recordingStartedAt { + TimelineView(.periodic(from: recordingStartedAt, by: 1)) { context in + Text( + Duration.seconds(context.date.timeIntervalSince(recordingStartedAt)), + format: .time(pattern: .minuteSecond) + ) + } + } else { + Text(Duration.zero, format: .time(pattern: .minuteSecond)).hidden() + } + } + .font(.subheadline.monospacedDigit()) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + VoiceInputRecordButton(presentation: presentation, perform: perform) + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 32) + .frame(maxWidth: .infinity) + } + + private var styleMenu: some View { + Menu { + Picker( + "Style", + selection: Binding( + get: { model.selectedStyleKind }, + set: { model.selectStyle($0) } + ) + ) { + ForEach(VoiceInputStyleKind.allCases, id: \.self) { styleKind in + Text(styleKind.displayName).tag(styleKind) + } + } + } label: { + Text(model.selectedStyleKind.displayName) + } + .accessibilityLabel("Style") + .accessibilityValue(model.selectedStyleKind.displayName) + .accessibilityIdentifier("capture_style") + } + + private func perform(_ action: VoiceInputDictationPresentation.Action) { + switch action { + case .start: + model.start() + case .stop: + model.stop() + case .requestMicrophone: + Task { + await model.applyMicrophoneRequest() + if model.microphoneAuthorization == .authorized { + model.start() + } + } + case .openSettings: + openSettings() + case .unavailable: + break + } + } + + private func copy(_ text: String) { + do { + try localClipboard.copy(text) + copied = true + } catch { + copied = false + } + } +} + +/// Neutral at rest; the signal amber appears only while recording. +private struct VoiceInputRecordButton: View { + let presentation: VoiceInputDictationPresentation + let perform: (VoiceInputDictationPresentation.Action) -> Void + @Environment(\.colorSchemeContrast) private var contrast + + var body: some View { + Button { + perform(presentation.action) + } label: { + Image(systemName: symbol) + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(presentation.isActive ? Color.black : Color(.systemBackground)) + .frame(width: 88, height: 88) + .background( + Circle().fill(presentation.isActive ? VoiceInputTheme.signal : Color.primary) + ) + .overlay { + if contrast == .increased { + Circle().strokeBorder(Color.primary, lineWidth: 2) + } + } + .contentShape(Circle()) + } + .buttonStyle(.plain) + .opacity(presentation.action == .unavailable ? 0.35 : 1) + .disabled(presentation.action == .unavailable) + .accessibilityLabel(label) + .accessibilityIdentifier(identifier) + } + + private var symbol: String { + presentation.action == .stop ? "stop.fill" : "mic.fill" + } + + private var label: String { + switch presentation.action { + case .stop: "Stop dictation" + case .openSettings: "Open microphone settings" + case .start, .requestMicrophone, .unavailable: "Start dictation" + } + } + + /// Stable identifiers for UI tests: one per reachable role. + private var identifier: String { + presentation.action == .stop ? "stop_capture" : "start_capture" + } +} diff --git a/apps/ios/voice_input/app/voice_input_dictation_presentation.swift b/apps/ios/voice_input/app/voice_input_dictation_presentation.swift new file mode 100644 index 0000000..21ecd2a --- /dev/null +++ b/apps/ios/voice_input/app/voice_input_dictation_presentation.swift @@ -0,0 +1,85 @@ +import VoiceInputShared + +/// What the Dictate screen shows and what its one button does. Pure, so every +/// state the record button can reach is covered without a simulator. +struct VoiceInputDictationPresentation: Equatable, Sendable { + enum Action: Equatable, Sendable { + case start + case stop + case requestMicrophone + case openSettings + /// Visible but inert while capture cannot begin or is finishing. + case unavailable + } + + let title: String + let detail: String + let action: Action + /// The only state painted in the accent color. + let isActive: Bool + /// Finished text to show above the button. + let result: String? + + struct Inputs: Equatable, Sendable { + var phase: VoiceInputSnapshot.Phase + var text: String? + var microphone: VoiceInputMicrophoneAuthorization + var isPreparingModel: Bool + var hasSpeechModel: Bool + var errorMessage: String? + var lifecycleMessage: String? + } + + init(_ inputs: Inputs) { + result = inputs.phase == .ready ? inputs.text.flatMap { $0.isEmpty ? nil : $0 } : nil + isActive = inputs.phase == .recording + switch inputs.phase { + case .recording: + (title, detail, action) = ("Listening", "Tap to finish.", .stop) + return + case .transcribing: + (title, detail, action) = ("Transcribing", "On this iPhone.", .unavailable) + return + case .idle, .ready, .interrupted, .failed: + break + } + switch inputs.microphone { + case .undetermined: + (title, detail, action) = ( + "Ready", "Tap to allow the microphone and start.", .requestMicrophone + ) + return + case .denied: + (title, detail, action) = ( + "Microphone is off", "Allow it in Settings to dictate.", .openSettings + ) + return + case .authorized: + break + } + if inputs.isPreparingModel { + (title, detail, action) = ("Preparing speech model", "This takes a moment.", .unavailable) + return + } + guard inputs.hasSpeechModel else { + (title, detail, action) = ( + "No speech model", "Choose one in Settings → Speech model.", .unavailable + ) + return + } + switch inputs.phase { + case .ready: + (title, detail) = ("Done", "Saved to History.") + case .interrupted: + (title, detail) = ( + "Stopped", + inputs.lifecycleMessage ?? "Recording was interrupted. The audio is in History." + ) + case .failed: + (title, detail) = ("Didn't finish", inputs.errorMessage ?? "Try again.") + case .idle, .recording, .transcribing: + (title, detail) = ("Ready", inputs.errorMessage ?? "Tap to dictate.") + } + action = .start + } +} diff --git a/apps/ios/voice_input/app/voice_input_history_session.swift b/apps/ios/voice_input/app/voice_input_history_session.swift index ddd92b0..b91e44a 100644 --- a/apps/ios/voice_input/app/voice_input_history_session.swift +++ b/apps/ios/voice_input/app/voice_input_history_session.swift @@ -44,7 +44,7 @@ struct VoiceInputHistorySession: Codable, Equatable, Identifiable, Sendable { let timedSegments: [VoiceInputTranscriptSegment] let modelPackageID: String let modelVersion: String - let audioArtifact: VoiceInputHistoryAudioArtifact? + private(set) var audioArtifact: VoiceInputHistoryAudioArtifact? let audioExpiredAt: Date? let audioExpiredReason: VoiceHistoryAudioExpirationReason? let recoveryReason: VoiceInputCaptureInterruptionReason? @@ -220,8 +220,11 @@ struct VoiceInputHistorySession: Codable, Equatable, Identifiable, Sendable { .appendingPathComponent("\(id.uuidString.lowercased()).caf") .standardizedFileURL + // iOS may move the app container between installs and updates, so only + // the file name is durable; the directory is always the current one. guard - audioArtifact.url.standardizedFileURL == expectedURL, + audioArtifact.url.isFileURL, + audioArtifact.url.lastPathComponent == expectedURL.lastPathComponent, audioArtifact.byteCount > 0, audioArtifact.sha256.utf8.count == 64, audioArtifact.sha256.utf8.allSatisfy(Self.isLowercaseHexDigit), @@ -230,6 +233,13 @@ struct VoiceInputHistorySession: Codable, Equatable, Identifiable, Sendable { else { throw VoiceInputHistoryError.invalidSession } + var rebased = self + rebased.audioArtifact = VoiceInputHistoryAudioArtifact( + url: expectedURL, + byteCount: audioArtifact.byteCount, + sha256: audioArtifact.sha256 + ) + return rebased } else { guard audioExpiredAt != nil, audioExpiredReason != nil, !isPinned else { throw VoiceInputHistoryError.invalidSession diff --git a/apps/ios/voice_input/app/voice_input_history_view.swift b/apps/ios/voice_input/app/voice_input_history_view.swift index 91a80e7..3b3b3ad 100644 --- a/apps/ios/voice_input/app/voice_input_history_view.swift +++ b/apps/ios/voice_input/app/voice_input_history_view.swift @@ -7,295 +7,182 @@ struct VoiceInputHistoryView: View { @ObservedObject var audioPlayer: VoiceInputHistoryAudioPlayerModel var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("History") - .font(.title2.bold()) - .accessibilityIdentifier("voice_history") - Text("Recordings, recovery audio, and every transcript stage stay on this iPhone.") - .font(.subheadline) - .foregroundStyle(.secondary) - - VoiceInputHistoryStorageView(model: model) - - TextField("Search transcripts", text: $model.query) - .textFieldStyle(.roundedBorder) - .submitLabel(.search) - .onSubmit { Task { await model.search() } } - .accessibilityIdentifier("history_search") - - if model.isLoading { - ProgressView("Loading local History") - } else if model.sessions.isEmpty, model.errorMessage == nil { - ContentUnavailableView( - "No recordings", - systemImage: "waveform", - description: Text("Finished captures and recovered audio will appear here.") - ) - .accessibilityIdentifier("history_empty") + NavigationStack { + List { + if let errorMessage = model.errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .accessibilityIdentifier("history_error") + } + ForEach(model.sessions) { session in + NavigationLink { + VoiceInputHistoryDetailView( + session: session, + audioPlayer: audioPlayer, + setPinned: { isPinned in + Task { await model.setPinned(sessionID: session.id, isPinned: isPinned) } + } + ) + } label: { + VoiceInputHistoryRow(session: session) + } + } } - - if let errorMessage = model.errorMessage ?? audioPlayer.errorMessage { - Text(errorMessage) - .foregroundStyle(.red) - .accessibilityIdentifier("history_error") + .listStyle(.plain) + .overlay { + if model.sessions.isEmpty, model.errorMessage == nil, !model.isLoading { + ContentUnavailableView( + model.query.isEmpty ? "No dictations yet" : "No matches", + systemImage: "waveform", + description: Text( + model.query.isEmpty + ? "Finished dictations and recovered audio appear here." + : "Try different words." + ) + ) + .accessibilityIdentifier("history_empty") + } } - - ForEach(model.sessions) { session in - VoiceInputHistorySessionView( - session: session, - isPlaying: audioPlayer.playingSessionID == session.id, - togglePlayback: { audioPlayer.toggle(session) }, - setPinned: { isPinned in - Task { - await model.setPinned( - sessionID: session.id, - isPinned: isPinned - ) - } - } - ) + .navigationTitle("History") + .searchable(text: $model.query, prompt: "Search transcripts") + .onSubmit(of: .search) { Task { await model.search() } } + .onChange(of: model.query) { _, query in + if query.isEmpty { Task { await model.refresh() } } } + .refreshable { await model.refresh() } + .accessibilityIdentifier("voice_history") } } } -private struct VoiceInputHistoryStorageView: View { - @ObservedObject var model: VoiceInputHistoryModel +private struct VoiceInputHistoryRow: View { + let session: VoiceInputHistorySession var body: some View { - DisclosureGroup { - VStack(alignment: .leading, spacing: 10) { - Picker("Audio age", selection: ageBinding) { - Text("Don't retain").tag(Int?.some(0)) - Text("30 days").tag(Int?.some(30)) - Text("90 days").tag(Int?.some(90)) - Text("1 year").tag(Int?.some(365)) - Text("Unlimited").tag(Int?.none) - } - .accessibilityIdentifier("history_retention_age") - - Picker("Audio size", selection: byteBinding) { - Text("Don't retain").tag(Int64?.some(0)) - Text("512 MiB").tag(Int64?.some(512 * 1_024 * 1_024)) - Text("1 GiB").tag(Int64?.some(1_024 * 1_024 * 1_024)) - Text("2 GiB").tag(Int64?.some(2 * 1_024 * 1_024 * 1_024)) - Text("Unlimited").tag(Int64?.none) - } - .accessibilityIdentifier("history_retention_size") - - Picker("Recordings", selection: countBinding) { - Text("Don't retain").tag(Int?.some(0)) - Text("500").tag(Int?.some(500)) - Text("2,000").tag(Int?.some(2_000)) - Text("5,000").tag(Int?.some(5_000)) - Text("Unlimited").tag(Int?.none) - } - .accessibilityIdentifier("history_retention_count") - - Text( - "The first limit reached—or less than 1 GiB of free space—expires the oldest unpinned audio. Transcripts remain searchable." - ) - .font(.caption) - .foregroundStyle(.secondary) - - if model.isUpdatingRetention { - ProgressView("Updating local storage") + VStack(alignment: .leading, spacing: 4) { + Text(session.isRecovery ? "Recovered recording" : session.formattedText) + .lineLimit(3) + .foregroundStyle(session.isRecovery ? .secondary : .primary) + HStack(spacing: 6) { + Text(session.endedAt, format: .relative(presentation: .named)) + if !session.isRecovery { + Text("·") + Text(session.style.kind.displayName) } - - if let maintenanceMessage = model.maintenanceMessage { - Label(maintenanceMessage, systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityIdentifier("history_storage_status") + if session.isPinned { + Image(systemName: "pin.fill").accessibilityLabel("Pinned") } } - .pickerStyle(.menu) - .disabled(!model.canUpdateRetention || model.isUpdatingRetention) - .padding(.top, 8) - } label: { - Text("History storage") - .accessibilityIdentifier("history_storage") + .font(.caption) + .foregroundStyle(.secondary) } - } - - private var ageBinding: Binding { - Binding( - get: { model.retentionSettings.maximumAgeDays }, - set: { maximumAgeDays in - update( - VoiceHistoryRetentionSettings( - maximumAgeDays: maximumAgeDays, - maximumAudioBytes: model.retentionSettings.maximumAudioBytes, - maximumArtifactCount: model.retentionSettings.maximumArtifactCount - ) - ) - } - ) - } - - private var byteBinding: Binding { - Binding( - get: { model.retentionSettings.maximumAudioBytes }, - set: { maximumAudioBytes in - update( - VoiceHistoryRetentionSettings( - maximumAgeDays: model.retentionSettings.maximumAgeDays, - maximumAudioBytes: maximumAudioBytes, - maximumArtifactCount: model.retentionSettings.maximumArtifactCount - ) - ) - } - ) - } - - private var countBinding: Binding { - Binding( - get: { model.retentionSettings.maximumArtifactCount }, - set: { maximumArtifactCount in - update( - VoiceHistoryRetentionSettings( - maximumAgeDays: model.retentionSettings.maximumAgeDays, - maximumAudioBytes: model.retentionSettings.maximumAudioBytes, - maximumArtifactCount: maximumArtifactCount - ) - ) - } - ) - } - - private func update(_ settings: VoiceHistoryRetentionSettings) { - Task { await model.updateRetentionSettings(settings) } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("history_row") } } -private struct VoiceInputHistorySessionView: View { +private struct VoiceInputHistoryDetailView: View { let session: VoiceInputHistorySession - let isPlaying: Bool - let togglePlayback: () -> Void + @ObservedObject var audioPlayer: VoiceInputHistoryAudioPlayerModel let setPinned: (Bool) -> Void - private let localClipboard = VoiceInputSystemLocalClipboard() @State private var copyMessage: String? + private let localClipboard = VoiceInputSystemLocalClipboard() var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .firstTextBaseline) { - Text(session.endedAt, style: .date).font(.headline) - Text(session.endedAt, style: .time) - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() + List { + Section { if session.isRecovery { - Text("Recovered audio") - .font(.caption) + VStack(alignment: .leading, spacing: 6) { + Text(session.recoveryDescription ?? "Recovered recording") + Text( + session.isPinned + ? "Pinned recovery audio stays until you unpin it." + : "Play it before its 24-hour recovery window ends." + ) + .font(.subheadline) .foregroundStyle(.secondary) + } } else { - Text(session.style.kind.displayName) - .font(.caption) - .foregroundStyle(.secondary) - } - if session.isPinned { - Label("Pinned", systemImage: "pin.fill") - .font(.caption) + Text(session.formattedText) + .textSelection(.enabled) + .accessibilityIdentifier("history_text") } } - if session.isRecovery { - Label("Recovered recording", systemImage: "waveform.badge.exclamationmark") - .font(.headline) - if let recoveryDescription = session.recoveryDescription { - Text(recoveryDescription) - .foregroundStyle(.secondary) - } - Text("No transcript was created for this recovery.") - .font(.subheadline) - .foregroundStyle(.secondary) - if session.audioArtifact != nil { - Text( - session.isPinned - ? "Pinned recovery audio remains until you unpin it." - : "Play this recording before its 24-hour recovery window ends." - ) - .font(.subheadline) - .foregroundStyle(.secondary) + if !session.isRecovery { + Section { + Button { + copy() + } label: { + Label(copyMessage ?? "Copy text", systemImage: "doc.on.doc") + } + .accessibilityIdentifier("history_copy") + ShareLink(item: session.formattedText) { + Label("Share", systemImage: "square.and.arrow.up") + } + .accessibilityIdentifier("history_share") } - } else { - Text(session.formattedText) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) } - HStack { - Button(action: togglePlayback) { - Label( - isPlaying ? "Stop recording" : "Play recording", - systemImage: isPlaying ? "stop.fill" : "play.fill" - ) - } - .disabled(session.audioArtifact == nil) - + Section { if session.audioArtifact != nil { Button { - setPinned(!session.isPinned) + audioPlayer.toggle(session) } label: { Label( - session.isPinned ? "Unpin audio" : "Pin audio", - systemImage: session.isPinned ? "pin.slash" : "pin" + isPlaying ? "Stop playback" : "Play recording", + systemImage: isPlaying ? "stop.fill" : "play.fill" ) } - .accessibilityIdentifier("history_pin") - } - - if !session.isRecovery { Button { - copyTranscript() + setPinned(!session.isPinned) } label: { - Label("Copy text", systemImage: "doc.on.doc") - } - .accessibilityIdentifier("history_copy") - - ShareLink(item: session.formattedText) { - Label("Share transcript", systemImage: "square.and.arrow.up") + Label( + session.isPinned ? "Unpin audio" : "Keep audio", + systemImage: session.isPinned ? "pin.slash" : "pin" + ) } - .accessibilityIdentifier("history_share") - } - - if session.audioArtifact == nil { + .accessibilityIdentifier("history_pin") + } else { Text(session.audioExpiredReason?.displayDescription ?? "Audio unavailable") - .font(.caption) .foregroundStyle(.secondary) } - } - - if let copyMessage { - Text(copyMessage) - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityIdentifier("history_copy_status") + if let errorMessage = audioPlayer.errorMessage { + Text(errorMessage).foregroundStyle(.red) + } + } header: { + Text("Audio") } if !session.isRecovery { - DisclosureGroup("Raw transcript") { - Text(session.rawText) - .font(.subheadline) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) + Section { + DisclosureGroup("Raw transcript") { + Text(session.rawText) + .font(.subheadline) + .textSelection(.enabled) + } + } footer: { + Text( + "\(session.style.kind.displayName) · \(session.modelPackageID) \(session.modelVersion)") } - - Text("\(session.modelPackageID) · \(session.modelVersion)") - .font(.caption.monospaced()) - .foregroundStyle(.secondary) } } - .padding(16) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .navigationTitle(Text(session.endedAt, format: .dateTime.month().day().hour().minute())) + .navigationBarTitleDisplayMode(.inline) + .onDisappear { audioPlayer.stop() } + } + + private var isPlaying: Bool { + audioPlayer.playingSessionID == session.id } - private func copyTranscript() { + private func copy() { do { try localClipboard.copy(session.formattedText) - copyMessage = "Copied on this device for 10 minutes." + copyMessage = "Copied for 10 minutes" } catch { - copyMessage = "This transcript is too large to copy safely." + copyMessage = "Too large to copy safely" } } } @@ -316,15 +203,15 @@ extension VoiceHistoryAudioExpirationReason { fileprivate var displayDescription: String { switch self { case .ageLimit: - "Audio expired at its age limit; transcript retained" + "Audio expired at its age limit; transcript kept." case .artifactLimit: - "Audio expired at the recording-count limit; transcript retained" + "Audio expired at the recording-count limit; transcript kept." case .byteLimit: - "Audio expired at the total-size limit; transcript retained" + "Audio expired at the total-size limit; transcript kept." case .lowDisk: - "Audio expired to protect free space; transcript retained" + "Audio expired to protect free space; transcript kept." case .recoveryLimit: - "Recovered audio expired after 24 hours" + "Recovered audio expired after 24 hours." } } } diff --git a/apps/ios/voice_input/app/voice_input_model_library_model.swift b/apps/ios/voice_input/app/voice_input_model_library_model.swift index 8b47bb4..589508d 100644 --- a/apps/ios/voice_input/app/voice_input_model_library_model.swift +++ b/apps/ios/voice_input/app/voice_input_model_library_model.swift @@ -20,10 +20,15 @@ final class VoiceInputModelLibraryModel: ObservableObject { @Published private(set) var activeASRModel: VoiceInputInstalledModelPackage? @Published private(set) var isImporting = false @Published private(set) var isRemoving = false + @Published private(set) var isPreparingStarter = false @Published private(set) var errorMessage: String? private let manager: any VoiceInputModelManaging private let asrWorkflow: VoiceInputASRWorkflow? + private let starter: VoiceInputStarterModel? + /// Provisioning runs once per launch, so removing the starter is not undone + /// until the next launch finds the library without a speech model. + private var starterProvisioningAttempted = false convenience init() { guard @@ -53,19 +58,40 @@ final class VoiceInputModelLibraryModel: ObservableObject { init( manager: any VoiceInputModelManaging, - asrWorkflow: VoiceInputASRWorkflow? = nil + asrWorkflow: VoiceInputASRWorkflow? = nil, + starter: VoiceInputStarterModel? = nil ) { self.manager = manager self.asrWorkflow = asrWorkflow + self.starter = starter } func refresh() async { + let starterError = await provisionStarterOnce() do { packages = try await manager.installedPackages() await refreshSelection() } catch { errorMessage = error.localizedDescription } + if let starterError { + errorMessage = starterError + } + } + + private func provisionStarterOnce() async -> String? { + guard let starter, !starterProvisioningAttempted else { + return nil + } + starterProvisioningAttempted = true + isPreparingStarter = true + defer { isPreparingStarter = false } + do { + _ = try await starter.provisionIfNeeded(manager: manager) + return nil + } catch { + return "The built-in speech model could not be installed. \(error.localizedDescription)" + } } func importPackage(from source: URL) async { diff --git a/apps/ios/voice_input/app/voice_input_model_library_view.swift b/apps/ios/voice_input/app/voice_input_model_library_view.swift index 0267afa..15c15ee 100644 --- a/apps/ios/voice_input/app/voice_input_model_library_view.swift +++ b/apps/ios/voice_input/app/voice_input_model_library_view.swift @@ -2,50 +2,42 @@ import HardwareControllerVoiceFFI import SwiftUI import UniformTypeIdentifiers -struct VoiceInputModelLibraryView: View { +/// Installed speech models, the active choice, import, and removal. +struct VoiceInputModelLibrarySection: View { @ObservedObject var model: VoiceInputModelLibraryModel @State private var isImporterPresented = false @State private var pendingRemoval: VoiceInputInstalledModelPackage? @State private var pickerErrorMessage: String? var body: some View { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Local models") - .font(.title2.bold()) - .accessibilityIdentifier("local_model_library") - Text("Validated packages stay in this app's private storage.") - .font(.subheadline) - .foregroundStyle(.secondary) + Section { + if model.isPreparingStarter { + LabeledContent("Installing built-in model") { ProgressView() } } - - if model.packages.isEmpty { - Text("No Model packages imported.") - .foregroundStyle(.secondary) - } else { - ForEach(model.packages, id: \.rootURL) { installed in - packageCard(installed) - } + ForEach(model.packages, id: \.rootURL) { installed in + packageRow(installed) } - Button { isImporterPresented = true } label: { Label( - model.isImporting ? "Importing package" : "Import Model package", + model.isImporting ? "Importing…" : "Import Model package…", systemImage: "square.and.arrow.down" ) } - .buttonStyle(.bordered) .disabled(model.isImporting || model.isRemoving) .accessibilityIdentifier("import_model_package") if let errorMessage = model.errorMessage ?? pickerErrorMessage { Text(errorMessage) - .font(.footnote) .foregroundStyle(.red) .accessibilityIdentifier("model_package_error") } + } header: { + Text("Speech model") + .accessibilityIdentifier("local_model_library") + } footer: { + Text("Models are verified and stay in this app's private storage.") } .fileImporter( isPresented: $isImporterPresented, @@ -54,7 +46,7 @@ struct VoiceInputModelLibraryView: View { onCompletion: handleSelection ) .confirmationDialog( - "Remove Model package?", + "Remove this model?", isPresented: removalConfirmationIsPresented, titleVisibility: .visible ) { @@ -63,104 +55,61 @@ struct VoiceInputModelLibraryView: View { return } pendingRemoval = nil - Task { - await model.removePackage(installed) - } - } - Button("Cancel", role: .cancel) { - pendingRemoval = nil + Task { await model.removePackage(installed) } } + Button("Cancel", role: .cancel) { pendingRemoval = nil } } message: { Text("The original imported folder is not changed.") } } - private func packageCard( - _ installed: VoiceInputInstalledModelPackage - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline) { - Text(installed.package.displayName) - .font(.headline) + private func packageRow(_ installed: VoiceInputInstalledModelPackage) -> some View { + let isActive = model.isActiveASRModel(installed) + let canSelect = VoiceInputASRModelRegistry.isCompatible(installed.package) && !isActive + return Button { + guard canSelect else { + return + } + Task { await model.selectASRModel(installed) } + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(installed.package.displayName) + .foregroundStyle(.primary) + Text(packageSummary(installed)) + .font(.caption) + .foregroundStyle(.secondary) + } Spacer() - if model.isActiveASRModel(installed) { - Text("Active") - .font(.caption.bold()) + if isActive { + Image(systemName: "checkmark") + .accessibilityLabel("Active") .accessibilityIdentifier("active_asr_model") } - Text("v\(installed.package.version)") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - Text(packageSummary(installed.package)) - .font(.subheadline) - .foregroundStyle(.secondary) - Text(installed.publisherVerified ? "Pinned manifest" : "Manual import") - .font(.caption) - .foregroundStyle(.secondary) - if canSelectForASR(installed.package) && !model.isActiveASRModel(installed) { - Button { - Task { - await model.selectASRModel(installed) - } - } label: { - Label("Use for speech to text", systemImage: "checkmark.circle") - } - .disabled(model.isImporting || model.isRemoving) - .accessibilityIdentifier("select_asr_model") - } - Button(role: .destructive) { - pendingRemoval = installed - } label: { - Label( - model.isRemoving ? "Removing package" : "Remove package", - systemImage: "trash" - ) } - .disabled(model.isImporting || model.isRemoving) - .accessibilityIdentifier("remove_model_package") } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - .quaternary, - in: RoundedRectangle(cornerRadius: 16, style: .continuous) - ) - .accessibilityIdentifier("model_package_card") - } - - private func canSelectForASR(_ package: PortableModelPackage) -> Bool { - VoiceInputASRModelRegistry.isCompatible(package) + .disabled(model.isImporting || model.isRemoving) + .accessibilityHint(canSelect ? "Use for speech to text" : "") + .accessibilityIdentifier(canSelect ? "select_asr_model" : "model_package_card") + .swipeActions { + Button("Remove", role: .destructive) { pendingRemoval = installed } + .accessibilityIdentifier("remove_model_package") + } + .contextMenu { + Button("Remove", systemImage: "trash", role: .destructive) { pendingRemoval = installed } + } } - private func packageSummary(_ package: PortableModelPackage) -> String { + private func packageSummary(_ installed: VoiceInputInstalledModelPackage) -> String { + let package = installed.package let languages = - package.languages.isEmpty - ? "No declared language" - : package.languages.joined(separator: ", ") + package.languages.isEmpty ? "No declared language" : package.languages.joined(separator: ", ") let bytes = ByteCountFormatter.string( fromByteCount: Int64(clamping: package.verifiedBytes), countStyle: .file ) - return "\(stageName(package.stage)) · \(runtimeName(package.runtime)) · \(languages) · \(bytes)" - } - - private func stageName(_ stage: PortableModelStage) -> String { - switch stage { - case .asr: "Speech to text" - case .formatting: "Formatting" - case .vad: "Voice activity" - } - } - - private func runtimeName(_ runtime: PortableModelRuntime) -> String { - switch runtime { - case .sherpaONNX: "sherpa-onnx" - case .whisperCPP: "whisper.cpp" - case .mistralRS: "mistral.rs" - case .llamaCPP: "llama.cpp" - case .transcribeCPP: "transcribe.cpp" - } + let provenance = installed.publisherVerified ? "Verified" : "Manual import" + return "\(languages) · \(bytes) · \(provenance)" } private func handleSelection(_ result: Result<[URL], Error>) { @@ -171,9 +120,7 @@ struct VoiceInputModelLibraryView: View { pickerErrorMessage = "Choose one Model package folder." return } - Task { - await model.importPackage(from: source) - } + Task { await model.importPackage(from: source) } case .failure: pickerErrorMessage = "The Model package picker did not return a folder." } diff --git a/apps/ios/voice_input/app/voice_input_model_package_installer.swift b/apps/ios/voice_input/app/voice_input_model_package_installer.swift index 0a350e7..9da2f48 100644 --- a/apps/ios/voice_input/app/voice_input_model_package_installer.swift +++ b/apps/ios/voice_input/app/voice_input_model_package_installer.swift @@ -241,9 +241,12 @@ actor VoiceInputModelPackageInstaller { else { throw VoiceInputModelPackageInstallError.identityConflict } + // Directory enumeration on a device spells the container through + // /private/var, while constructed paths use /var. One canonical spelling + // keeps install, listing, selection, and removal comparable. return VoiceInputInstalledModelPackage( package: package, - rootURL: url, + rootURL: installedURL(for: package), publisherVerified: record?.publisherVerified ?? false ) } diff --git a/apps/ios/voice_input/app/voice_input_onboarding_view.swift b/apps/ios/voice_input/app/voice_input_onboarding_view.swift index f2266c1..3b242bd 100644 --- a/apps/ios/voice_input/app/voice_input_onboarding_view.swift +++ b/apps/ios/voice_input/app/voice_input_onboarding_view.swift @@ -1,8 +1,8 @@ import SwiftUI import VoiceInputShared -struct VoiceInputOnboardingView: View { - let step: VoiceInputOnboardingStep +/// Setup status: the microphone and the optional Voice Keyboard. +struct VoiceInputSetupSection: View { let microphoneAuthorization: VoiceInputMicrophoneAuthorization let keyboardHandoffObserved: Bool let errorMessage: String? @@ -10,104 +10,45 @@ struct VoiceInputOnboardingView: View { let openSettings: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Set up local voice").font(.title2.bold()) - Spacer() - if step == .ready { - Label("Ready", systemImage: "checkmark.circle.fill") - .font(.subheadline.weight(.semibold)) - .accessibilityIdentifier("onboarding_ready") + Section { + LabeledContent { + switch microphoneAuthorization { + case .undetermined: + Button("Allow", action: requestMicrophone) + .accessibilityIdentifier("request_microphone") + case .denied: + Button("Open Settings", action: openSettings) + .accessibilityIdentifier("open_microphone_settings") + case .authorized: + Text("Allowed") } + } label: { + Label("Microphone", systemImage: "mic") } - setupRow( - symbol: "lock.shield", - title: "Local processing", - detail: "No account, cloud inference, analytics, or remote storage.", - complete: true - ) - - setupRow( - symbol: "mic", - title: "Microphone", - detail: microphoneDetail, - complete: microphoneAuthorization == .authorized - ) - - if step == .requestMicrophone { - Button("Allow microphone", action: requestMicrophone) - .buttonStyle(.borderedProminent) - .accessibilityIdentifier("request_microphone") - } else if step == .openMicrophoneSettings { - Button("Open microphone settings", action: openSettings) - .buttonStyle(.bordered) - .accessibilityIdentifier("open_microphone_settings") + LabeledContent { + if keyboardHandoffObserved { + Text("Ready") + } else { + Button("Open Settings", action: openSettings) + .accessibilityIdentifier("open_keyboard_settings") + } + } label: { + Label("Voice Keyboard", systemImage: "keyboard") } - setupRow( - symbol: "keyboard", - title: "Voice Keyboard", - detail: keyboardDetail, - complete: keyboardHandoffObserved - ) - if let errorMessage { Text(errorMessage) - .font(.footnote) .foregroundStyle(.red) .accessibilityIdentifier("onboarding_error") } - - if microphoneAuthorization == .authorized && !keyboardHandoffObserved { - Button("Open Settings", action: openSettings) - .buttonStyle(.bordered) - .accessibilityIdentifier("open_keyboard_settings") + } header: { + Text("Setup") + } footer: { + if !keyboardHandoffObserved { Text( - "In Settings: General → Keyboard → Keyboards → Add New Keyboard → Voice Keyboard. Then enable Full Access for same-device handoff." + "To dictate into any app: Settings → General → Keyboard → Keyboards → Add New Keyboard → Voice Keyboard, then turn on Allow Full Access." ) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(18) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) - .accessibilityIdentifier("onboarding_card") - } - - private var microphoneDetail: String { - switch microphoneAuthorization { - case .undetermined: - return "Requested only when you choose Allow microphone." - case .denied: - return "Denied. Typing still works; enable access in Settings to record." - case .authorized: - return "Allowed for visibly owned, local capture only." - } - } - - private var keyboardDetail: String { - if keyboardHandoffObserved { - return "This keyboard completed a Full Access handoff check on this device." - } - return "Works as QWERTY without Full Access; voice handoff needs it." - } - - private func setupRow( - symbol: String, - title: String, - detail: String, - complete: Bool - ) -> some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: complete ? "checkmark.circle.fill" : symbol) - .frame(width: 24) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - Text(title).font(.headline) - Text(detail) - .font(.subheadline) - .foregroundStyle(.secondary) } } } diff --git a/apps/ios/voice_input/app/voice_input_root_view.swift b/apps/ios/voice_input/app/voice_input_root_view.swift new file mode 100644 index 0000000..4e22316 --- /dev/null +++ b/apps/ios/voice_input/app/voice_input_root_view.swift @@ -0,0 +1,50 @@ +import SwiftUI +import UIKit + +/// Dictate is the workflow; History and Settings stay one tap away. +struct VoiceInputRootView: View { + @ObservedObject var model: VoiceInputAppModel + @ObservedObject var modelLibrary: VoiceInputModelLibraryModel + @ObservedObject var history: VoiceInputHistoryModel + @ObservedObject var historyAudioPlayer: VoiceInputHistoryAudioPlayerModel + @Environment(\.openURL) private var openURL + + var body: some View { + TabView { + Tab("Dictate", systemImage: "mic") { + VoiceInputDictateView( + model: model, + modelLibrary: modelLibrary, + openSettings: openSettings + ) + } + Tab("History", systemImage: "clock") { + VoiceInputHistoryView(model: history, audioPlayer: historyAudioPlayer) + } + Tab("Settings", systemImage: "gearshape") { + VoiceInputSettingsView( + model: model, + modelLibrary: modelLibrary, + history: history, + openSettings: openSettings + ) + } + } + .task { + await modelLibrary.refresh() + await history.refresh() + } + .task(id: model.snapshot.sequence) { + if model.snapshot.phase == .ready { + await history.refresh() + } + } + } + + private func openSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { + return + } + openURL(url) + } +} diff --git a/apps/ios/voice_input/app/voice_input_settings_view.swift b/apps/ios/voice_input/app/voice_input_settings_view.swift new file mode 100644 index 0000000..6d2245a --- /dev/null +++ b/apps/ios/voice_input/app/voice_input_settings_view.swift @@ -0,0 +1,156 @@ +import AppIntents +import HardwareControllerCore +import SwiftUI + +struct VoiceInputSettingsView: View { + @ObservedObject var model: VoiceInputAppModel + @ObservedObject var modelLibrary: VoiceInputModelLibraryModel + @ObservedObject var history: VoiceInputHistoryModel + let openSettings: () -> Void + + var body: some View { + NavigationStack { + Form { + VoiceInputSetupSection( + microphoneAuthorization: model.microphoneAuthorization, + keyboardHandoffObserved: model.keyboardHandoffObserved, + errorMessage: model.onboardingErrorMessage, + requestMicrophone: model.requestMicrophone, + openSettings: openSettings + ) + VoiceInputModelLibrarySection(model: modelLibrary) + VoiceInputHistoryStorageSection(model: history) + Section { + ShortcutsLink() + .accessibilityIdentifier("open_voice_shortcuts") + } header: { + Text("Without the keyboard") + } footer: { + Text( + "Add Voice Capture to Control Center, the Lock Screen, or the Action button, or ask Siri. A Live Activity shows while recording continues in the background." + ) + } + Section { + NavigationLink("Third-party notices") { VoiceInputNoticesView() } + } footer: { + Text( + "Audio, transcripts, and models never leave this iPhone. No account, analytics, or network access." + ) + } + } + .navigationTitle("Settings") + } + } +} + +private struct VoiceInputHistoryStorageSection: View { + @ObservedObject var model: VoiceInputHistoryModel + + var body: some View { + Section { + Picker("Keep audio for", selection: ageBinding) { + Text("Don't keep").tag(Int?.some(0)) + Text("30 days").tag(Int?.some(30)) + Text("90 days").tag(Int?.some(90)) + Text("1 year").tag(Int?.some(365)) + Text("Forever").tag(Int?.none) + } + .accessibilityIdentifier("history_retention_age") + Picker("Audio up to", selection: byteBinding) { + Text("Don't keep").tag(Int64?.some(0)) + Text("512 MiB").tag(Int64?.some(512 * 1_024 * 1_024)) + Text("1 GiB").tag(Int64?.some(1_024 * 1_024 * 1_024)) + Text("2 GiB").tag(Int64?.some(2 * 1_024 * 1_024 * 1_024)) + Text("Unlimited").tag(Int64?.none) + } + .accessibilityIdentifier("history_retention_size") + Picker("Recordings", selection: countBinding) { + Text("Don't keep").tag(Int?.some(0)) + Text("500").tag(Int?.some(500)) + Text("2,000").tag(Int?.some(2_000)) + Text("5,000").tag(Int?.some(5_000)) + Text("Unlimited").tag(Int?.none) + } + .accessibilityIdentifier("history_retention_count") + if let maintenanceMessage = model.maintenanceMessage { + Label(maintenanceMessage, systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + .accessibilityIdentifier("history_storage_status") + } + } header: { + Text("History storage") + .accessibilityIdentifier("history_storage") + } footer: { + Text( + "The first limit reached, or less than 1 GiB free, removes the oldest unpinned audio. Transcripts stay searchable." + ) + } + .disabled(!model.canUpdateRetention || model.isUpdatingRetention) + } + + private var ageBinding: Binding { + binding(\.maximumAgeDays) { settings, value in + VoiceHistoryRetentionSettings( + maximumAgeDays: value, + maximumAudioBytes: settings.maximumAudioBytes, + maximumArtifactCount: settings.maximumArtifactCount + ) + } + } + + private var byteBinding: Binding { + binding(\.maximumAudioBytes) { settings, value in + VoiceHistoryRetentionSettings( + maximumAgeDays: settings.maximumAgeDays, + maximumAudioBytes: value, + maximumArtifactCount: settings.maximumArtifactCount + ) + } + } + + private var countBinding: Binding { + binding(\.maximumArtifactCount) { settings, value in + VoiceHistoryRetentionSettings( + maximumAgeDays: settings.maximumAgeDays, + maximumAudioBytes: settings.maximumAudioBytes, + maximumArtifactCount: value + ) + } + } + + private func binding( + _ keyPath: KeyPath, + replacing: @escaping (VoiceHistoryRetentionSettings, Value) -> VoiceHistoryRetentionSettings + ) -> Binding { + Binding( + get: { model.retentionSettings[keyPath: keyPath] }, + set: { value in + let settings = replacing(model.retentionSettings, value) + Task { await model.updateRetentionSettings(settings) } + } + ) + } +} + +private struct VoiceInputNoticesView: View { + var body: some View { + ScrollView { + Text(notices) + .font(.caption.monospaced()) + .textSelection(.enabled) + .padding() + } + .navigationTitle("Third-party notices") + .navigationBarTitleDisplayMode(.inline) + } + + private var notices: String { + guard + let url = Bundle.main.url(forResource: "third_party_notices", withExtension: "txt"), + let text = try? String(contentsOf: url, encoding: .utf8) + else { + return "Notices are unavailable in this build." + } + return text + } +} diff --git a/apps/ios/voice_input/app/voice_input_starter_model.swift b/apps/ios/voice_input/app/voice_input_starter_model.swift new file mode 100644 index 0000000..4fe9812 --- /dev/null +++ b/apps/ios/voice_input/app/voice_input_starter_model.swift @@ -0,0 +1,77 @@ +import Foundation + +/// The speech model shipped inside the app, so a fresh install dictates +/// without preparing a Model package on a Mac. +/// +/// The bundled copy still goes through Model-package admission. Its manifest +/// digest is pinned here, so the installer records it as publisher verified and +/// rejects any bundle whose bytes drifted from `scripts/generate_ios_project.sh`. +struct VoiceInputStarterModel: Sendable { + enum Outcome: Equatable, Sendable { + /// A speech model was already active or the user owns the choice. + case unchanged + case installed + } + + static let resourceName = "starter_asr_model" + static let manifestSHA256 = Data( + [ + 0x86, 0xd6, 0x4c, 0x22, 0x7a, 0xd2, 0x4c, 0xb9, + 0xcf, 0xf4, 0x6f, 0x34, 0xf5, 0xa7, 0xbb, 0x47, + 0x6b, 0xbf, 0xe5, 0xc3, 0x24, 0xc7, 0xd1, 0x7f, + 0xc2, 0x1b, 0xac, 0x1e, 0x15, 0x52, 0x3c, 0x25, + ] + ) + + let packageURL: URL + + init?(bundle: Bundle = .main) { + guard let url = bundle.url(forResource: Self.resourceName, withExtension: nil) else { + return nil + } + packageURL = url + } + + init(packageURL: URL) { + self.packageURL = packageURL + } + + /// Installs and selects the starter only while no speech model is active and + /// the library holds no other compatible model; a model the user installed + /// keeps priority. An unselected starter left by an earlier launch is + /// selected, since installing it again is idempotent. + func provisionIfNeeded( + manager: any VoiceInputModelManaging + ) async throws -> Outcome { + guard try await !hasActiveModel(manager) else { + return .unchanged + } + let installed = try await manager.installedPackages() + guard + !installed.contains(where: { + VoiceInputASRModelRegistry.isCompatible($0.package) + && $0.package.manifestSHA256 != Self.manifestSHA256 + }) + else { + return .unchanged + } + let starter = try await manager.install( + from: packageURL, + expectedManifestSHA256: Self.manifestSHA256 + ) + try await manager.selectASRModel(starter) + return .installed + } + + /// An invalid saved selection still throws: the user must resolve it. + private func hasActiveModel(_ manager: any VoiceInputModelManaging) async throws -> Bool { + do { + _ = try await manager.selectedASRModel() + return true + } catch VoiceInputASRModelRegistryError.noSelection, + VoiceInputASRModelRegistryError.packageNotInstalled + { + return false + } + } +} diff --git a/apps/ios/voice_input/app/voice_input_theme.swift b/apps/ios/voice_input/app/voice_input_theme.swift new file mode 100644 index 0000000..fbfae2e --- /dev/null +++ b/apps/ios/voice_input/app/voice_input_theme.swift @@ -0,0 +1,7 @@ +import SwiftUI + +/// Voice Input inherits the repository style: neutral surfaces, system type, +/// and the icon's signal amber reserved for an active recording. +enum VoiceInputTheme { + static let signal = Color(red: 0.98, green: 0.69, blue: 0.13) +} diff --git a/apps/ios/voice_input/app/voice_input_ui_test_hooks.swift b/apps/ios/voice_input/app/voice_input_ui_test_hooks.swift new file mode 100644 index 0000000..f2b320a --- /dev/null +++ b/apps/ios/voice_input/app/voice_input_ui_test_hooks.swift @@ -0,0 +1,52 @@ +#if DEBUG + import Foundation + + /// Debug-only seams that let UI tests drive the real capture, ASR, + /// formatting, and History path deterministically. Release builds compile + /// none of this, so no launch input can replace the microphone there. + enum VoiceInputUITestHooks { + static let resetArgument = "-voice_input_reset_state" + static let audioFixtureEnvironmentKey = "VOICE_INPUT_AUDIO_FIXTURE" + + static func shouldReset(arguments: [String]) -> Bool { + arguments.contains(resetArgument) + } + + /// Only an absolute path to an existing file qualifies; anything else + /// leaves the system microphone in place. + static func audioFixtureURL( + environment: [String: String], + fileExists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } + ) -> URL? { + guard + let path = environment[audioFixtureEnvironmentKey], + path.hasPrefix("/"), + fileExists(path) + else { + return nil + } + return URL(fileURLWithPath: path) + } + } + + /// Places a recorded clip where the recorder would write, so everything + /// after the microphone runs unchanged. + struct VoiceInputFixtureAudioRecorderFactory: VoiceInputAudioRecorderCreating { + let fixtureURL: URL + + func makeRecorder(at url: URL) throws -> any VoiceInputAudioRecording { + try FileManager.default.copyItem(at: fixtureURL, to: url) + return VoiceInputFixtureAudioRecorder() + } + } + + private struct VoiceInputFixtureAudioRecorder: VoiceInputAudioRecording { + func prepareToRecord() {} + + func record() -> Bool { + true + } + + func stop() {} + } +#endif diff --git a/apps/ios/voice_input/project.yml b/apps/ios/voice_input/project.yml index 753ee35..90edca6 100644 --- a/apps/ios/voice_input/project.yml +++ b/apps/ios/voice_input/project.yml @@ -21,6 +21,11 @@ targets: - system_capture - path: resources/third_party_notices.txt buildPhase: resources + - path: resources/assets.xcassets + # Generated by scripts/generate_ios_project.sh from pinned assets. + - path: ../../../.build/ios_voice_input/starter_asr_model + type: folder + buildPhase: resources dependencies: - target: VoiceInputShared - target: HardwareControllerCore @@ -53,6 +58,9 @@ targets: UILaunchScreen: {} settings: base: + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + # Monochrome accent: color is reserved for an active recording. + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor OTHER_LDFLAGS: $(inherited) -lsqlite3 PRODUCT_BUNDLE_IDENTIFIER: com.longdevity.hardwarecontroller.voiceinput TARGETED_DEVICE_FAMILY: 1 diff --git a/apps/ios/voice_input/resources/assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/voice_input/resources/assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..1ddee08 --- /dev/null +++ b/apps/ios/voice_input/resources/assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { "alpha" : "1.000", "blue" : "0.000", "green" : "0.000", "red" : "0.000" } + }, + "idiom" : "universal" + }, + { + "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], + "color" : { + "color-space" : "srgb", + "components" : { "alpha" : "1.000", "blue" : "1.000", "green" : "1.000", "red" : "1.000" } + }, + "idiom" : "universal" + } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/apps/ios/voice_input/resources/assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/voice_input/resources/assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f1cfba4 --- /dev/null +++ b/apps/ios/voice_input/resources/assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "app_icon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/voice_input/resources/assets.xcassets/AppIcon.appiconset/app_icon.png b/apps/ios/voice_input/resources/assets.xcassets/AppIcon.appiconset/app_icon.png new file mode 100644 index 0000000..e18941e Binary files /dev/null and b/apps/ios/voice_input/resources/assets.xcassets/AppIcon.appiconset/app_icon.png differ diff --git a/apps/ios/voice_input/resources/assets.xcassets/Contents.json b/apps/ios/voice_input/resources/assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/apps/ios/voice_input/resources/assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/voice_input/tests/voice_input_app_model_test.swift b/apps/ios/voice_input/tests/voice_input_app_model_test.swift index f5df4f2..610646d 100644 --- a/apps/ios/voice_input/tests/voice_input_app_model_test.swift +++ b/apps/ios/voice_input/tests/voice_input_app_model_test.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import VoiceInputShared import XCTest @@ -21,6 +22,23 @@ final class VoiceInputAppModelTest: XCTestCase { XCTAssertNil(model.onboardingErrorMessage) } + @MainActor + func testAnUnchangedRefreshPublishesNothing() { + let model = VoiceInputAppModel( + microphoneAuthorizationProvider: { .authorized }, + microphonePermissionRequester: { true }, + keyboardObservedAtReader: { nil } + ) + model.refreshOnboarding() + var publications = 0 + let subscription = model.objectWillChange.sink { publications += 1 } + + model.refreshOnboarding() + + XCTAssertEqual(publications, 0) + subscription.cancel() + } + @MainActor func testRefreshSurfacesKeyboardHandoffFailure() { let model = VoiceInputAppModel( diff --git a/apps/ios/voice_input/tests/voice_input_asr_model_registry_test.swift b/apps/ios/voice_input/tests/voice_input_asr_model_registry_test.swift index 39c957d..7599128 100644 --- a/apps/ios/voice_input/tests/voice_input_asr_model_registry_test.swift +++ b/apps/ios/voice_input/tests/voice_input_asr_model_registry_test.swift @@ -26,6 +26,32 @@ final class VoiceInputASRModelRegistryTest: XCTestCase { XCTAssertEqual(selected, installed) } + /// iOS devices reach the container through a symlink (/var → /private/var), + /// so enumerated and constructed paths differ in spelling. + func testSymlinkedLibraryRootStillSelectsAndRemoves() async throws { + let fixture = try transcribeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let real = fixture.root.appendingPathComponent("real", isDirectory: true) + let link = fixture.root.appendingPathComponent("link", isDirectory: true) + try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real) + let installer = VoiceInputModelPackageInstaller( + rootURL: link.appendingPathComponent("models", isDirectory: true) + ) + let registry = VoiceInputASRModelRegistry( + installer: installer, + selectionURL: fixture.selectionURL + ) + let installed = try await registry.install(from: fixture.source, expectedManifestSHA256: nil) + + try await registry.selectASRModel(installed) + let selected = try await registry.selectedASRModel() + XCTAssertEqual(selected, installed) + try await registry.remove(selected) + let remaining = try await registry.installedPackages() + XCTAssertEqual(remaining, []) + } + func testWrongRuntimeCannotBecomeActive() async throws { let fixture = try fixture() defer { try? FileManager.default.removeItem(at: fixture.root) } diff --git a/apps/ios/voice_input/tests/voice_input_dictation_presentation_test.swift b/apps/ios/voice_input/tests/voice_input_dictation_presentation_test.swift new file mode 100644 index 0000000..0e4150f --- /dev/null +++ b/apps/ios/voice_input/tests/voice_input_dictation_presentation_test.swift @@ -0,0 +1,100 @@ +import VoiceInputShared +import XCTest + +@testable import VoiceInput + +final class VoiceInputDictationPresentationTest: XCTestCase { + private let ready = VoiceInputDictationPresentation.Inputs( + phase: .idle, + text: nil, + microphone: .authorized, + isPreparingModel: false, + hasSpeechModel: true, + errorMessage: nil, + lifecycleMessage: nil + ) + + private func present( + _ change: (inout VoiceInputDictationPresentation.Inputs) -> Void = { _ in } + ) -> VoiceInputDictationPresentation { + var inputs = ready + change(&inputs) + return VoiceInputDictationPresentation(inputs) + } + + func testEachStateMapsToOneTitleAndAction() { + let cases: + [( + String, (inout VoiceInputDictationPresentation.Inputs) -> Void, String, + VoiceInputDictationPresentation.Action + )] = [ + ("idle", { _ in }, "Ready", .start), + ("recording", { $0.phase = .recording }, "Listening", .stop), + ("transcribing", { $0.phase = .transcribing }, "Transcribing", .unavailable), + ("ready", { $0.phase = .ready }, "Done", .start), + ("interrupted", { $0.phase = .interrupted }, "Stopped", .start), + ("failed", { $0.phase = .failed }, "Didn't finish", .start), + ("mic undetermined", { $0.microphone = .undetermined }, "Ready", .requestMicrophone), + ("mic denied", { $0.microphone = .denied }, "Microphone is off", .openSettings), + ("preparing", { $0.isPreparingModel = true }, "Preparing speech model", .unavailable), + ("no model", { $0.hasSpeechModel = false }, "No speech model", .unavailable), + ] + for (name, change, title, action) in cases { + let presentation = present(change) + XCTAssertEqual(presentation.title, title, name) + XCTAssertEqual(presentation.action, action, name) + } + } + + func testAnActiveRecordingOutranksSetupState() { + let presentation = present { + $0.phase = .recording + $0.microphone = .denied + $0.hasSpeechModel = false + } + XCTAssertEqual(presentation.action, .stop) + XCTAssertTrue(presentation.isActive) + } + + func testOnlyRecordingIsActive() { + for phase in [VoiceInputSnapshot.Phase.idle, .transcribing, .ready, .failed] { + XCTAssertFalse(present { $0.phase = phase }.isActive) + } + } + + func testResultShowsOnlyNonEmptyReadyText() { + XCTAssertEqual( + present { + $0.phase = .ready + $0.text = "Hello." + }.result, "Hello.") + XCTAssertNil( + present { + $0.phase = .ready + $0.text = "" + }.result) + XCTAssertNil( + present { + $0.phase = .idle + $0.text = "Stale." + }.result) + } + + func testMessagesExplainFailuresAndInterruptions() { + XCTAssertEqual( + present { + $0.phase = .failed + $0.errorMessage = "Recorder failed." + }.detail, + "Recorder failed." + ) + XCTAssertEqual( + present { + $0.phase = .interrupted + $0.lifecycleMessage = "A call stopped capture." + } + .detail, + "A call stopped capture." + ) + } +} diff --git a/apps/ios/voice_input/tests/voice_input_history_session_test.swift b/apps/ios/voice_input/tests/voice_input_history_session_test.swift index 5c2b2c1..b5f7353 100644 --- a/apps/ios/voice_input/tests/voice_input_history_session_test.swift +++ b/apps/ios/voice_input/tests/voice_input_history_session_test.swift @@ -21,6 +21,25 @@ final class VoiceInputHistorySessionTest: XCTestCase { ) } + func testAudioFromAMovedAppContainerRebasesOntoTheCurrentDirectory() throws { + let sessionID = UUID() + let fileName = "\(sessionID.uuidString.lowercased()).caf" + let current = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let previous = URL(fileURLWithPath: "/private/var/containers/old/audio", isDirectory: true) + let session = try Self.session( + id: sessionID, + audioURL: previous.appendingPathComponent(fileName) + ) + + let validated = try session.validated(audioDirectoryURL: current) + + XCTAssertEqual( + validated.audioArtifact?.url, + current.appendingPathComponent(fileName).standardizedFileURL + ) + } + func testExpiredAudioRetainsAValidSearchableSession() throws { let sessionID = UUID() let audioDirectory = FileManager.default.temporaryDirectory diff --git a/apps/ios/voice_input/tests/voice_input_model_library_model_test.swift b/apps/ios/voice_input/tests/voice_input_model_library_model_test.swift index 87a47be..6c6ec73 100644 --- a/apps/ios/voice_input/tests/voice_input_model_library_model_test.swift +++ b/apps/ios/voice_input/tests/voice_input_model_library_model_test.swift @@ -121,6 +121,26 @@ final class VoiceInputModelLibraryModelTest: XCTestCase { ) } + @MainActor + func testRefreshProvisionsTheStarterOncePerLaunch() async throws { + let temporary = temporaryDirectory() + let registry = manager( + installer: VoiceInputModelPackageInstaller( + rootURL: temporary.appendingPathComponent("models")), + root: temporary + ) + let model = VoiceInputModelLibraryModel(manager: registry, starter: VoiceInputStarterModel()) + + await model.refresh() + let starter = try XCTUnwrap(model.activeASRModel) + XCTAssertFalse(model.isPreparingStarter) + await model.removePackage(starter) + await model.refresh() + + XCTAssertEqual(model.packages, []) + XCTAssertNil(model.activeASRModel) + } + private func temporaryDirectory() -> URL { FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/apps/ios/voice_input/tests/voice_input_starter_model_test.swift b/apps/ios/voice_input/tests/voice_input_starter_model_test.swift new file mode 100644 index 0000000..a10b153 --- /dev/null +++ b/apps/ios/voice_input/tests/voice_input_starter_model_test.swift @@ -0,0 +1,109 @@ +import CryptoKit +import Foundation +import XCTest + +@testable import VoiceInput + +final class VoiceInputStarterModelTest: XCTestCase { + func testBundledManifestMatchesThePinnedDigest() throws { + let starter = try XCTUnwrap(VoiceInputStarterModel()) + let manifest = try Data( + contentsOf: starter.packageURL.appendingPathComponent("manifest.json") + ) + XCTAssertEqual(Data(SHA256.hash(data: manifest)), VoiceInputStarterModel.manifestSHA256) + } + + func testEmptyLibraryInstallsAndSelectsTheVerifiedStarter() async throws { + let registry = makeRegistry() + + let outcome = try await XCTUnwrap(VoiceInputStarterModel()) + .provisionIfNeeded(manager: registry) + + XCTAssertEqual(outcome, .installed) + let selected = try await registry.selectedASRModel() + XCTAssertEqual(selected.package.packageID, "com.longdevity.asr.whisper_tiny_en") + XCTAssertTrue(selected.publisherVerified) + } + + func testActiveSelectionIsLeftAlone() async throws { + let registry = makeRegistry() + let starter = try XCTUnwrap(VoiceInputStarterModel()) + _ = try await starter.provisionIfNeeded(manager: registry) + + let outcome = try await starter.provisionIfNeeded(manager: registry) + + XCTAssertEqual(outcome, .unchanged) + } + + func testAnUnselectedCompatibleModelKeepsTheUsersChoice() async throws { + let root = temporaryDirectory() + let registry = makeRegistry(root: root) + let starter = try XCTUnwrap(VoiceInputStarterModel()) + let source = root.appendingPathComponent("user_model") + try copyVoiceInputModelFixture(to: source) + let manifestURL = source.appendingPathComponent("manifest.json") + try String(contentsOf: manifestURL, encoding: .utf8) + .replacingOccurrences(of: "\"sherpa_onnx\"", with: "\"transcribe_cpp\"") + .write(to: manifestURL, atomically: true, encoding: .utf8) + _ = try await registry.install(from: source, expectedManifestSHA256: nil) + + let outcome = try await starter.provisionIfNeeded(manager: registry) + + XCTAssertEqual(outcome, .unchanged) + do { + _ = try await registry.selectedASRModel() + XCTFail("Provisioning must not select a model the user installed.") + } catch VoiceInputASRModelRegistryError.noSelection {} + } + + func testAnUnselectedStarterFromAnEarlierLaunchIsSelected() async throws { + let registry = makeRegistry() + let starter = try XCTUnwrap(VoiceInputStarterModel()) + _ = try await registry.install( + from: starter.packageURL, + expectedManifestSHA256: VoiceInputStarterModel.manifestSHA256 + ) + + let outcome = try await starter.provisionIfNeeded(manager: registry) + + XCTAssertEqual(outcome, .installed) + let selected = try await registry.selectedASRModel() + XCTAssertTrue(selected.publisherVerified) + } + + func testTamperedBundleFailsAdmission() async throws { + let root = temporaryDirectory() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appendingPathComponent("starter") + try FileManager.default.copyItem( + at: try XCTUnwrap(VoiceInputStarterModel()).packageURL, + to: source + ) + try Data("tampered".utf8).write(to: source.appendingPathComponent("NOTICE.txt")) + let registry = makeRegistry() + + do { + _ = try await VoiceInputStarterModel(packageURL: source) + .provisionIfNeeded(manager: registry) + XCTFail("A drifted starter must not install.") + } catch { + let installed = try await registry.installedPackages() + XCTAssertEqual(installed, []) + } + } + + private func makeRegistry(root: URL? = nil) -> VoiceInputASRModelRegistry { + let root = root ?? temporaryDirectory() + return VoiceInputASRModelRegistry( + installer: VoiceInputModelPackageInstaller( + rootURL: root.appendingPathComponent("models") + ), + selectionURL: root.appendingPathComponent("active_asr.json") + ) + } + + private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + } +} diff --git a/apps/ios/voice_input/tests/voice_input_ui_test_hooks_test.swift b/apps/ios/voice_input/tests/voice_input_ui_test_hooks_test.swift new file mode 100644 index 0000000..e65d32c --- /dev/null +++ b/apps/ios/voice_input/tests/voice_input_ui_test_hooks_test.swift @@ -0,0 +1,45 @@ +import Foundation +import XCTest + +@testable import VoiceInput + +final class VoiceInputUITestHooksTest: XCTestCase { + private let key = VoiceInputUITestHooks.audioFixtureEnvironmentKey + + func testResetNeedsTheExactArgument() { + XCTAssertTrue( + VoiceInputUITestHooks.shouldReset(arguments: ["app", "-voice_input_reset_state"])) + XCTAssertFalse(VoiceInputUITestHooks.shouldReset(arguments: ["app", "-voice_input_reset"])) + } + + func testFixtureNeedsAnExistingAbsolutePath() { + let exists: (String) -> Bool = { $0 == "/fixtures/jfk.wav" } + XCTAssertEqual( + VoiceInputUITestHooks.audioFixtureURL( + environment: [key: "/fixtures/jfk.wav"], + fileExists: exists + ), + URL(fileURLWithPath: "/fixtures/jfk.wav") + ) + for environment in [[:], [key: "jfk.wav"], [key: "/fixtures/missing.wav"]] { + XCTAssertNil( + VoiceInputUITestHooks.audioFixtureURL(environment: environment, fileExists: exists) + ) + } + } + + func testFixtureRecorderPlacesTheClipAtTheCaptureURL() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let fixture = root.appendingPathComponent("clip.wav") + try Data("audio".utf8).write(to: fixture) + let capture = root.appendingPathComponent("session.partial") + + let recorder = try VoiceInputFixtureAudioRecorderFactory(fixtureURL: fixture) + .makeRecorder(at: capture) + + XCTAssertTrue(recorder.record()) + XCTAssertEqual(try Data(contentsOf: capture), Data("audio".utf8)) + } +} diff --git a/apps/ios/voice_input/ui_tests/voice_input_ui_test.swift b/apps/ios/voice_input/ui_tests/voice_input_ui_test.swift index fe8ded5..b537988 100644 --- a/apps/ios/voice_input/ui_tests/voice_input_ui_test.swift +++ b/apps/ios/voice_input/ui_tests/voice_input_ui_test.swift @@ -3,119 +3,101 @@ import XCTest final class VoiceInputUITest: XCTestCase { /// Shared CI simulators stall for seconds under load; a passing wait returns /// as soon as its condition holds, so only failures pay this deadline. - private static let timeout: TimeInterval = 30 + private static let timeout: TimeInterval = 60 + private static let jfkPhrase = "ask not what your country can do for you" - @MainActor - func testColdLaunchExplainsLocalOnboardingAndKeyboardBoundary() { - let app = XCUIApplication() - app.launch() - - XCTAssertTrue(app.navigationBars["Voice Input"].waitForExistence(timeout: Self.timeout)) - XCTAssertTrue(app.buttons["start_capture"].exists) - XCTAssertTrue(app.staticTexts["Voice anywhere. Private by default."].exists) - XCTAssertTrue(app.staticTexts["Local processing"].exists) - XCTAssertTrue(app.staticTexts["Voice Keyboard"].exists) - XCTAssertTrue(app.descendants(matching: .any)["onboarding_card"].exists) - XCTAssertTrue(app.descendants(matching: .any)["local_model_library"].exists) - XCTAssertTrue(app.buttons["import_model_package"].exists) - XCTAssertTrue(app.descendants(matching: .any)["capture_status"].exists) - XCTAssertTrue(app.descendants(matching: .any)["capture_style"].exists) - XCTAssertTrue( - app.descendants(matching: .any)["system_capture_guidance"].exists - ) - let history = app.descendants(matching: .any)["voice_history"] - let search = app.textFields["history_search"] - for _ in 0..<6 where !search.exists { - app.swipeUp() - } - XCTAssertTrue(history.waitForExistence(timeout: Self.timeout)) - XCTAssertTrue(search.waitForExistence(timeout: Self.timeout)) + override func setUp() { + continueAfterFailure = false } + /// The whole product path from a fresh install: bundled model provisioning, + /// capture, transcribe.cpp ASR, formatting, History, and search. Only the + /// microphone is replaced, by a recorded clip passed through the runner. @MainActor - func testLocalCaptureRequiresASelectedModelBeforeRecording() { - let app = XCUIApplication() - app.launch() + func testFreshInstallDictatesIntoSearchableHistory() throws { + let app = try launch(reset: true, fixture: true) let start = app.buttons["start_capture"] XCTAssertTrue(start.waitForExistence(timeout: Self.timeout)) + wait(for: start, toBe: "isEnabled == true") start.tap() - - let captureStatus = app.descendants(matching: .any)["capture_status"] - let failed = NSPredicate(format: "value == 'Failed'") - expectation(for: failed, evaluatedWith: captureStatus) - waitForExpectations(timeout: Self.timeout) - XCTAssertTrue(app.staticTexts["capture_error"].exists) - XCTAssertFalse(app.buttons["stop_capture"].exists) + let stop = app.buttons["stop_capture"] + XCTAssertTrue(stop.waitForExistence(timeout: Self.timeout)) + stop.tap() + + let result = app.staticTexts["dictation_result"] + XCTAssertTrue(result.waitForExistence(timeout: Self.timeout)) + XCTAssertTrue(result.label.localizedCaseInsensitiveContains(Self.jfkPhrase), result.label) + XCTAssertEqual(app.staticTexts["dictation_title"].label, "Done") + XCTAssertTrue(app.buttons["dictation_copy"].isHittable) + attachScreenshot(app, named: "dictation_result") + + app.tabBars.buttons["History"].tap() + let row = app.descendants(matching: .any)["history_row"].firstMatch + XCTAssertTrue(row.waitForExistence(timeout: Self.timeout)) + XCTAssertTrue(row.label.localizedCaseInsensitiveContains("fellow Americans"), row.label) + XCTAssertFalse(app.staticTexts["history_error"].exists) + + let search = app.searchFields["Search transcripts"] + search.tap() + search.typeText("country\n") + XCTAssertTrue(row.waitForExistence(timeout: Self.timeout)) + row.tap() + let detail = app.staticTexts["history_text"] + XCTAssertTrue(detail.waitForExistence(timeout: Self.timeout)) + XCTAssertTrue(detail.label.localizedCaseInsensitiveContains(Self.jfkPhrase)) + XCTAssertTrue(app.buttons["history_copy"].exists) + attachScreenshot(app, named: "history_detail") } @MainActor - func testSystemCaptureGuidanceAndShortcutsAreReachable() { - let app = XCUIApplication() - app.launch() + func testSettingsShowTheVerifiedModelSetupAndStorage() throws { + let app = try launch(reset: true, fixture: false) - let guidance = app.descendants(matching: .any)["system_capture_guidance"] - for _ in 0..<8 where !guidance.isHittable { - app.swipeUp() + app.tabBars.buttons["Settings"].tap() + XCTAssertTrue( + app.descendants(matching: .any)["active_asr_model"].waitForExistence(timeout: Self.timeout) + ) + XCTAssertTrue(app.staticTexts["Whisper Tiny English"].exists) + XCTAssertTrue(app.buttons["import_model_package"].exists) + for label in ["Keep audio for", "Audio up to", "Recordings"] { + // A Form menu Picker is exposed as a button labeled ", <value>". + let picker = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", label)) + .firstMatch + for _ in 0..<6 where !picker.isHittable { + app.swipeUp() + } + XCTAssertTrue(picker.isHittable, label) } - - XCTAssertTrue(guidance.isHittable) let shortcuts = app.buttons["open_voice_shortcuts"] + for _ in 0..<6 where !shortcuts.isHittable { + app.swipeUp() + } XCTAssertTrue(shortcuts.isHittable) - XCTAssertEqual(shortcuts.label, "Voice Input shortcuts") - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "system_capture_guidance" - attachment.lifetime = .keepAlways - add(attachment) + attachScreenshot(app, named: "settings") } @MainActor - func testStyleMenuIsReachableAndExposesEveryCanonicalStyle() { - let app = XCUIApplication() - app.launch() + func testStyleMenuOffersEveryCanonicalStyle() throws { + let app = try launch(reset: false, fixture: false) - let style = app.descendants(matching: .any)["capture_style"] - for _ in 0..<6 where !style.isHittable { - app.swipeUp() - } - XCTAssertTrue(style.isHittable) + let style = app.buttons["capture_style"] + XCTAssertTrue(style.waitForExistence(timeout: Self.timeout)) style.tap() for label in ["Natural", "Casual", "Formal", "Technical", "Verbatim"] { - XCTAssertTrue(app.buttons[label].waitForExistence(timeout: Self.timeout)) - } - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "style_menu" - attachment.lifetime = .keepAlways - add(attachment) - } - - @MainActor - func testHistoryStorageExposesConfigurableLocalCaps() { - let app = XCUIApplication() - app.launch() - - let storage = app.buttons["history_storage"] - for _ in 0..<8 where !storage.isHittable { - app.swipeUp() - } - XCTAssertTrue(storage.isHittable) - storage.tap() - - for identifier in [ - "history_retention_age", - "history_retention_size", - "history_retention_count", - ] { - XCTAssertTrue( - app.descendants(matching: .any)[identifier] - .waitForExistence(timeout: Self.timeout) - ) + XCTAssertTrue(app.buttons[label].waitForExistence(timeout: Self.timeout), label) } + app.buttons["Formal"].tap() + wait(for: style, toBe: "value == 'Formal'") + style.tap() + let natural = app.buttons["Natural"] + XCTAssertTrue(natural.waitForExistence(timeout: Self.timeout)) + natural.tap() + wait(for: style, toBe: "value == 'Natural'") } @MainActor - func testLargeTextKeepsCaptureControlReachable() { + func testLargeTextKeepsTheRecordButtonReachable() throws { let app = XCUIApplication() app.launchArguments += [ "-UIPreferredContentSizeCategoryName", @@ -124,10 +106,40 @@ final class VoiceInputUITest: XCTestCase { app.launch() let start = app.buttons["start_capture"] - for _ in 0..<6 where !start.isHittable { - app.swipeUp() + XCTAssertTrue(start.waitForExistence(timeout: Self.timeout)) + XCTAssertTrue(start.isHittable) + attachScreenshot(app, named: "large_text") + } + + @MainActor + private func launch(reset: Bool, fixture: Bool) throws -> XCUIApplication { + let app = XCUIApplication() + if reset { + app.launchArguments.append("-voice_input_reset_state") + } + if fixture { + // scripts/check_ios.sh passes it as TEST_RUNNER_VOICE_INPUT_AUDIO_FIXTURE. + let path = try XCTUnwrap( + ProcessInfo.processInfo.environment["VOICE_INPUT_AUDIO_FIXTURE"], + "Run through scripts/check_ios.sh, which supplies the recorded clip." + ) + app.launchEnvironment["VOICE_INPUT_AUDIO_FIXTURE"] = path } + app.launch() + return app + } - XCTAssertTrue(start.isHittable) + @MainActor + private func wait(for element: XCUIElement, toBe predicate: String) { + expectation(for: NSPredicate(format: predicate), evaluatedWith: element) + waitForExpectations(timeout: Self.timeout) + } + + @MainActor + private func attachScreenshot(_ app: XCUIApplication, named name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) } } diff --git a/docs/architecture.md b/docs/architecture.md index 65a80e9..dad30a5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,7 +121,7 @@ monotonic sequence numbers ([0040](decisions/0040_ios_keyboard_activation_and_ha | macOS History | `voice/` SQLite + CAF | Actor-owned connections with a five-second contention bound; startup reconciliation before retention ([0032](decisions/0032_voice_history_crash_recovery.md)); age/bytes/count/low-disk retention protects active, pinned, and recovery audio ([0031](decisions/0031_bounded_voice_history_audio.md)); one Rust retention policy for both platforms ([0057](decisions/0057_single_rust_retention_policy.md)). | | iOS History | SQLite + CAF, Data Protection, excluded from backup | Commit before publish; same retention policy with iOS defaults ([0049](decisions/0049_ios_offline_storage_enforcement.md)). | | Portable archive | `.voice_history` V1 directory | Verified in Rust before Swift restore; identical imports are no-ops ([0038](decisions/0038_portable_voice_history_archives.md)). | -| iOS Model packages | Private, Data Protection, no backup | Rust admission with digests and limits; never implicitly evicted ([0041](decisions/0041_ios_model_package_admission.md)). | +| iOS Model packages | Private, Data Protection, no backup | Rust admission with digests and limits; never implicitly evicted ([0041](decisions/0041_ios_model_package_admission.md)); a pinned starter is bundled and admitted on first launch ([0061](decisions/0061_ios_bundled_starter_model.md)). | | macOS Model package | `Application Support/…/SpeechModels`, one package plus `installed.json` | Pinned size and SHA-256 per file, then Rust admission; the record switches packages in one atomic rename. | ## Permissions and security diff --git a/docs/decisions/0061_ios_bundled_starter_model.md b/docs/decisions/0061_ios_bundled_starter_model.md new file mode 100644 index 0000000..ea7703b --- /dev/null +++ b/docs/decisions/0061_ios_bundled_starter_model.md @@ -0,0 +1,48 @@ +# 0061: Bundle the iOS starter speech model + +- **Status:** Accepted +- **Date:** 2026-11-02 +- **Amends:** [0041](0041_ios_model_package_admission.md) + +## Context + +A fresh Voice Input install could not dictate. The only model path was to run +`scripts/prepare_ios_asr_model_package.sh` on a Mac, copy the folder into +Files, then import and select it. The owner's own device never got past +"Failed". The app may not download models (no network entitlement). + +## Decision matrix + +| Criterion | Bundle and admit on first launch | Mac-prepared Files import only | Load straight from the app bundle | +| --- | --- | --- | --- | +| Works on first launch | 🟢 Yes. | 🔴 Needs a Mac and three manual steps. | 🟢 Yes. | +| Keeps one admission path (0041) | 🟢 Same Rust validator and installer. | 🟢 Same. | 🔴 Second, unvalidated runtime path. | +| Provenance | 🟢 Pinned manifest digest; shown **Verified**. | 🟠 **Manual import**. | 🟡 Code signature only. | +| App size | 🟠 +45 MB (Whisper Tiny + Silero VAD). | 🟢 None. | 🟠 +45 MB. | +| Storage | 🟠 Bundle copy plus private copy. | 🟢 One copy. | 🟢 One copy. | + +## Decision + +`scripts/generate_ios_project.sh` regenerates the starter package from the +digest-pinned assets on every generation, and the app bundles it as +`starter_asr_model`. On the first Model-library refresh of each launch, when no +speech model is selected and the library holds no compatible package, the app +installs the starter through the normal installer with its manifest SHA-256 +pinned in `VoiceInputStarterModel`, then selects it. An invalid saved selection +still surfaces as an error. A model the user installed always wins; removing +the starter lasts until a later launch finds no compatible model. + +Imported Model packages remain supported for larger or other models. + +## Consequences + +- Dictation works on a fresh install with no Mac-side step. +- A unit test fails if the bundled manifest drifts from the pinned digest; the + installer refuses drifted bytes at runtime. +- History stores audio by file name under the current container, because iOS + can move the container between installs and updates. +- Installed packages carry one canonical root path. On devices, directory + listing spells the container `/private/var/...` while constructed paths use + `/var/...`; the mismatch made every selection, including manual imports, + fail with "no longer installed". The simulator has no such link, so a + symlinked-root test now covers it. diff --git a/docs/user_guide.md b/docs/user_guide.md index 857a8b7..b003759 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -164,12 +164,16 @@ recovery copy; files from a newer app are left untouched. ## iOS: Voice Input -1. Install with `scripts/install_ios.sh` and open **Voice Input**. -2. Prepare the starter model on the Mac with - `scripts/prepare_ios_asr_model_package.sh <dir>`, copy the folder into - Files, then **Local models → Import Model package → Use for speech to text**. -3. Enable the **Voice Input** keyboard (with Full Access for the result - handoff). +1. Install with `scripts/install_ios.sh` and open **Voice Input**. The + built-in Whisper Tiny English model installs itself on first launch. +2. On **Dictate**, tap the button, allow the microphone, speak, tap again. The + text appears with **Copy** and **Share** and is saved to **History**. +3. To dictate into other apps, enable the **Voice Keyboard** with Full Access + (**Settings → Setup** shows the steps). + +Other models: prepare one on the Mac with +`scripts/prepare_ios_asr_model_package.sh <dir>`, copy it into Files, then +**Settings → Speech model → Import Model package…** and tap it to use it. The app records and transcribes; iOS forbids microphone use inside a keyboard. Start from the keyboard, the app, Control Center, the Lock Screen, the Action diff --git a/docs/ux_spec.md b/docs/ux_spec.md index d67e9e4..657d723 100644 --- a/docs/ux_spec.md +++ b/docs/ux_spec.md @@ -17,6 +17,8 @@ typography, spacing, and position carry everything else. | History | Searchable session list plus evidence detail. Prior results are immutable; reruns and re-delivery are explicit; delete is visually distinct. Expired audio names its rule. | | Profiles | Stable list and editor; active state is separate from selection. | | General | Native form sections: Speech recognition (first), Appearance, microphone, Local AI, Voice chord, History storage, Launch at Login. Apple stays visibly marked as the less accurate engine while selected. | +| iOS Dictate | One centered record button: primary-colored at rest, amber only while recording. Status title and one line above it; the timer line is always reserved so nothing moves. Finished text above with quiet **Copy** and **Share**. | +| iOS History / Settings | Native searchable list with detail; Settings is a Form: Setup, Speech model, History storage, Without the keyboard, notices. | | Transcript HUD | Click-through, nonactivating; only while a target cannot show live text and text exists; anchored to the caret or one stable edge, never the pointer. | ## Behavior diff --git a/docs/voice_cujs.md b/docs/voice_cujs.md index ba3e2f1..d06d8f9 100644 --- a/docs/voice_cujs.md +++ b/docs/voice_cujs.md @@ -52,17 +52,18 @@ events. | ID | Journey | Contract | Proof | | --- | --- | --- | --- | -| I1 | Onboard locally | Explain before asking; exact denial recovery; import and select a verified Model package. | `voice_input_onboarding_policy_test`, `voice_input_model_package_{stager,validator,installer}_test`, `testColdLaunchExplainsLocalOnboardingAndKeyboardBoundary` | +| I0 | Dictate in the app | Fresh install to text: capture, ASR, formatting, result with Copy/Share, History search; survives a moved container. | `testFreshInstallDictatesIntoSearchableHistory` (recorded clip replaces only the microphone), `voice_input_history_session_test` | +| I1 | Onboard locally | A fresh install provisions the pinned starter model; microphone asked on first tap with exact denial recovery; imported packages still admitted. | `voice_input_starter_model_test`, `voice_input_asr_model_registry_test`, `voice_input_dictation_presentation_test`, `voice_input_onboarding_policy_test`, `voice_input_model_package_{stager,validator,installer}_test`, `testSettingsShowTheVerifiedModelSetupAndStorage` | | I2 | Dictate from the keyboard | Real local ASR, live pause-cut decoding, History commit before publish, one insertion. | `voice_input_asr_workflow_test`, `voice_input_local_transcriber_test`, `voice_input_live_audio_tap_test`, `voice_input_session_finalizer_test`, `voice_asr_bridge_test.c` | | I3 | Start when the app is cold | System surfaces foreground the app honestly. | `voice_input_system_capture_test`, `scripts/check_ios_system_capture_metadata.sh` | | I4 | Type without Full Access | QWERTY works; voice explains what it needs. | `voice_input_keyboard_policy_test` | | I5 | Respect unsupported fields | Constrained or sensitive fields disable voice only. | `voice_input_host_field_policy_test`, `voice_input_delivery_target_policy_test` | -| I6 | Choose a Style | Keyboard Style travels with its exact session. | `voice_input_style_test`, `voice_input_style_mapping_test`, `voice_input_style_preference_test`, `testStyleMenuIsReachableAndExposesEveryCanonicalStyle` | +| I6 | Choose a Style | Keyboard Style travels with its exact session. | `voice_input_style_test`, `voice_input_style_mapping_test`, `voice_input_style_preference_test`, `testStyleMenuOffersEveryCanonicalStyle` | | I7 | Survive interruptions | Typed lifecycle policy; no auto-resume; partial audio recovered. | `voice_input_lifecycle_policy_test`, `voice_input_lifecycle_notification_mapper_test`, `voice_input_capture_service_test` | | I8 | Detect a stale service | Heartbeats expire within 3 s; stale state cannot revive delivery. | `voice_input_keychain_store_test`, `voice_input_keyboard_policy_test` | | I9 | Recover failed insertion | One retry or a ten-minute local copy, only for the exact result and field. | `voice_input_insertion_recovery_policy_test` | -| I10 | Stay offline within limits | No network code; age, bytes, count, reserve; Models never evicted implicitly. | `voice_input_history_repository_test`, `voice_input_history_retention_preferences_test`, `scripts/check_ios_local_only.sh`, `testHistoryStorageExposesConfigurableLocalCaps` | -| I11 | Capture without the keyboard | Control Center, Lock Screen, Action button, Siri, Live Activity stop. | `voice_input_system_capture_test`, `testSystemCaptureGuidanceAndShortcutsAreReachable` | +| I10 | Stay offline within limits | No network code; age, bytes, count, reserve; Models never evicted implicitly. | `voice_input_history_repository_test`, `voice_input_history_retention_preferences_test`, `scripts/check_ios_local_only.sh`, `testSettingsShowTheVerifiedModelSetupAndStorage` | +| I11 | Capture without the keyboard | Control Center, Lock Screen, Action button, Siri, Live Activity stop. | `voice_input_system_capture_test`, `testSettingsShowTheVerifiedModelSetupAndStorage` | ## Accepted defaults diff --git a/scripts/check_ios.sh b/scripts/check_ios.sh index 314e80c..df7e7de 100755 --- a/scripts/check_ios.sh +++ b/scripts/check_ios.sh @@ -5,12 +5,12 @@ set -euo pipefail repo_root="$(cd "$(dirname "$0")/.." && pwd)" cd "$repo_root" +command -v jq >/dev/null || { + print -u2 "jq is required to select a simulator and report test failures." + exit 1 +} simulator_udid="${HC_IOS_SIMULATOR_UDID:-}" if [[ -z "$simulator_udid" ]]; then - command -v jq >/dev/null || { - print -u2 "jq or HC_IOS_SIMULATOR_UDID is required to select an iOS simulator." - exit 1 - } simulator_udid="$(xcrun simctl list devices available -j \ | jq -r ' ([.devices[][] | select(.isAvailable == true and .name == "iPhone 17 Pro")][0] @@ -54,7 +54,17 @@ xcodebuild build-for-testing -quiet "${xcode_test_flags[@]}" \ xcrun simctl bootstatus "$simulator_udid" -b xcrun simctl privacy "$simulator_udid" grant microphone \ com.longdevity.hardwarecontroller.voiceinput -xcodebuild test-without-building -quiet "${xcode_test_flags[@]}" +# UI tests drive real capture with this recorded clip in place of the microphone. +jfk_fixture="$(scripts/fetch_asr_test_assets.sh | grep '/jfk\.wav$')" +result_bundle="$derived_data/tests.xcresult" +if ! TEST_RUNNER_VOICE_INPUT_AUDIO_FIXTURE="$jfk_fixture" \ + xcodebuild test-without-building -quiet "${xcode_test_flags[@]}" \ + -resultBundlePath "$result_bundle"; then + # -quiet hides assertion text; print each failure so CI logs are actionable. + xcrun xcresulttool get test-results summary --path "$result_bundle" --compact \ + | jq -r '.testFailures[] | "\(.testIdentifierString): \(.failureText)"' >&2 + exit 1 +fi scripts/check_ios_system_capture_metadata.sh \ "$derived_data/Build/Products/Debug-iphonesimulator/VoiceInput.app" diff --git a/scripts/classify_changes.sh b/scripts/classify_changes.sh index 50e9b14..35844f2 100755 --- a/scripts/classify_changes.sh +++ b/scripts/classify_changes.sh @@ -41,7 +41,7 @@ for file in "${changed[@]}"; do scripts/fetch_asr_runtime.sh) lane[ios]=true; lane[codeql_ios]=true; lane[codeql_macos]=true ;; # Only inputs to the traced iOS build need the iOS CodeQL lane. - apps/ios/*|scripts/build_ios_rust_ffi.sh|scripts/generate_ios_project.sh) + apps/ios/*|scripts/build_ios_rust_ffi.sh|scripts/generate_ios_project.sh|scripts/prepare_ios_build_inputs.sh|scripts/prepare_ios_asr_model_package.sh|scripts/fetch_asr_test_assets.sh) lane[ios]=true; lane[codeql_ios]=true ;; scripts/*ios*|scripts/*asr*) lane[ios]=true ;; diff --git a/scripts/classify_changes_test.sh b/scripts/classify_changes_test.sh index 698996b..bc5ae1e 100755 --- a/scripts/classify_changes_test.sh +++ b/scripts/classify_changes_test.sh @@ -25,6 +25,8 @@ expect "apps/ios/voice_input/app/voice_input_app.swift" "macos=true ios=true cod expect "Tests/HardwareControllerMacTests/action_executor_tests.swift" "macos=true ios=false codeql_macos=false codeql_ios=false" expect "scripts/check_ios.sh" "macos=true ios=true codeql_macos=false codeql_ios=false" expect "scripts/build_ios_rust_ffi.sh" "macos=true ios=true codeql_macos=false codeql_ios=true" +expect "scripts/prepare_ios_build_inputs.sh" "macos=true ios=true codeql_macos=false codeql_ios=true" +expect "scripts/fetch_asr_test_assets.sh" "macos=true ios=true codeql_macos=false codeql_ios=true" expect "scripts/fetch_asr_runtime.sh" "macos=true ios=true codeql_macos=true codeql_ios=true" expect "Sources/hardware_controller_asr_service_kit/asr_service_core.swift" "macos=true ios=false codeql_macos=true codeql_ios=false" expect "scripts/check.sh" "macos=true ios=false codeql_macos=false codeql_ios=false" diff --git a/scripts/generate_ios_project.sh b/scripts/generate_ios_project.sh index 1ba4dc2..a2b653a 100755 --- a/scripts/generate_ios_project.sh +++ b/scripts/generate_ios_project.sh @@ -10,8 +10,7 @@ command -v xcodegen >/dev/null || { exit 1 } -scripts/build_ios_rust_ffi.sh -scripts/fetch_asr_runtime.sh +scripts/prepare_ios_build_inputs.sh xcodegen generate \ --spec apps/ios/voice_input/project.yml \ diff --git a/scripts/prepare_ios_build_inputs.sh b/scripts/prepare_ios_build_inputs.sh new file mode 100755 index 0000000..07e2d32 --- /dev/null +++ b/scripts/prepare_ios_build_inputs.sh @@ -0,0 +1,17 @@ +#!/bin/zsh + +# Builds every ignored input the iOS project references, so project +# generation and CI analysis builds cannot drift apart. + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_root" + +scripts/build_ios_rust_ffi.sh +scripts/fetch_asr_runtime.sh +# The app bundles this starter so a fresh install dictates without Mac-side +# preparation; regenerating keeps it identical to the pinned assets. +starter_model=.build/ios_voice_input/starter_asr_model +rm -rf "$starter_model" +scripts/prepare_ios_asr_model_package.sh "$starter_model" >/dev/null