diff --git a/ios/App/ChatView.swift b/ios/App/ChatView.swift index fcd92e2eff..ceebe3dcf6 100644 --- a/ios/App/ChatView.swift +++ b/ios/App/ChatView.swift @@ -14,12 +14,14 @@ import CompanionCore // isn't. The App target is iOS; CompanionCore is where the portable half // lives. import UIKit +import AVFoundation struct ChatView: View { let chat: Chat @EnvironmentObject private var session: Session @Environment(\.dismiss) private var dismiss @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.scenePhase) private var scenePhase @State private var draft = "" @State private var showingTasks = false @State private var showingComputer = false @@ -27,6 +29,7 @@ struct ChatView: View { @State private var showingProfile = false @State private var shareFile: ShareFile? @FocusState private var composerFocused: Bool + @StateObject private var dictation = SpeechDictation() /// The opening beat: the island grows with the bot's face in it, then /// shrinks away as the face settles into the header. `facePhase` is 1 /// with the face in the island, 0 with it home in the header. @@ -250,6 +253,37 @@ struct ChatView: View { // bit here rather than leaving a badge on an open conversation. if unread { Task { await session.markRead(current) } } } + .onDisappear { dictation.stop() } + .onChange(of: scenePhase) { _, phase in + if phase != .active { dictation.stop() } + } + .onChange(of: showingComputer) { _, shown in + if shown { dictation.stop() } + } + .onChange(of: showingTasks) { _, shown in + if shown { dictation.stop() } + } + .onChange(of: showingProfile) { _, shown in + if shown { dictation.stop() } + } + .onChange(of: showingPlus) { _, shown in + if shown { dictation.stop() } + } + .onReceive(NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification)) { note in + let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] + let value = (raw as? NSNumber)?.uintValue ?? (raw as? UInt) + if value == AVAudioSession.InterruptionType.began.rawValue { + dictation.stop() + } + } + .onChange(of: dictation.transcript) { _, spoken in + // Always join against the text frozen at capture start. A newer + // partial then replaces the older partial instead of duplicating it. + draft = Dictation.draft(base: dictation.base, transcript: spoken) + } + .onChange(of: dictation.isListening) { _, listening in + if listening { composerFocused = false } + } .sheet(isPresented: $showingTasks) { if case let .bot(bot) = current { TaskManagerView(bot: bot) } } @@ -509,6 +543,9 @@ struct ChatView: View { } private func submit() { + // This also cancels an in-flight permission prompt before it can + // open the microphone after the message has already been sent. + dictation.stop() let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } draft = "" @@ -517,66 +554,99 @@ struct ChatView: View { // MARK: - Composer - /// A round + and a glass pill with the send button inside it. + /// A round + and a glass pill with dictation and send inside it. private var composer: some View { - GlassGroup(spacing: 10) { - HStack(alignment: .bottom, spacing: 10) { - Button { - composerFocused = false - withAnimation(.snappy(duration: 0.28)) { showingPlus.toggle() } - } label: { - Image(systemName: "plus") - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(showingPlus ? Color(uiColor: .systemBackground) : Color.primary) - .rotationEffect(.degrees(showingPlus ? 45 : 0)) - .frame(width: 44, height: 44) - .background(Circle().fill(showingPlus ? Color.primary : Color.clear)) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .glassCapsule() - .accessibilityLabel(showingPlus ? "Close" : "More") - - HStack(alignment: .bottom, spacing: 6) { - TextField("Ask \(current.name)", text: $draft, axis: .vertical) - .lineLimit(1...5) - .font(.system(size: 17)) - .padding(.leading, 16) - .padding(.vertical, 11) - .focused($composerFocused) - .submitLabel(.send) - // Return sends, Shift+Return breaks the line — the shape - // every chat app has. `.ignored` hands the keypress back to - // the text field, which is what inserts the newline; there is - // no way to type one otherwise once Return is claimed. - .onKeyPress(.return, phases: .down) { press in - guard !press.modifiers.contains(.shift) else { return .ignored } - submit() - return .handled - } - // software keyboards have no Shift+Return, so their Return - // key is a send — which is what `.submitLabel(.send)` promises - .onSubmit(submit) + VStack(spacing: 6) { + if let error = dictation.error { + Text(error) + .font(.system(size: 13)) + .foregroundStyle(.orange) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + GlassGroup(spacing: 10) { + HStack(alignment: .bottom, spacing: 10) { Button { - submit() + dictation.stop() + composerFocused = false + withAnimation(.snappy(duration: 0.28)) { showingPlus.toggle() } } label: { - Image(systemName: "arrow.up") - .font(.system(size: 15, weight: .bold)) - .foregroundStyle(canSend ? Color.white : Color.secondary) - .frame(width: 32, height: 32) - .background( - Circle().fill(canSend ? BubbleColor.mine : Color.secondary.opacity(0.18)) - ) + Image(systemName: "plus") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(showingPlus ? Color(uiColor: .systemBackground) : Color.primary) + .rotationEffect(.degrees(showingPlus ? 45 : 0)) + .frame(width: 44, height: 44) + .background(Circle().fill(showingPlus ? Color.primary : Color.clear)) + .contentShape(Circle()) } .buttonStyle(.plain) - .disabled(!canSend) - .padding(.trailing, 6) - .padding(.bottom, 6) - .animation(.easeOut(duration: 0.15), value: canSend) + .glassCapsule() + .accessibilityLabel(showingPlus ? "Close" : "More") + + HStack(alignment: .bottom, spacing: 6) { + TextField( + dictation.isListening ? "Listening…" : "Ask \(current.name)", + text: $draft, + axis: .vertical + ) + .lineLimit(1...5) + .font(.system(size: 17)) + .padding(.leading, 16) + .padding(.vertical, 11) + .focused($composerFocused) + .submitLabel(.send) + // Partial transcripts rebuild from a frozen base; + // prevent competing edits without dimming the text. + .allowsHitTesting(!dictation.isListening && !dictation.isStarting) + .onKeyPress(.return, phases: .down) { press in + guard !press.modifiers.contains(.shift) else { return .ignored } + submit() + return .handled + } + .onSubmit(submit) + + Button { + composerFocused = false + dictation.toggle(capturing: draft) + } label: { + Image(systemName: dictation.isListening ? "mic.fill" : "mic") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(dictation.isListening ? Color.red : Color.primary) + .frame(width: 32, height: 32) + .background( + Circle().fill( + dictation.isListening + ? Color.red.opacity(0.2) + : Color.secondary.opacity(0.12) + ) + ) + .symbolEffect(.pulse, isActive: dictation.isListening) + } + .buttonStyle(.plain) + .padding(.bottom, 6) + .accessibilityLabel(dictation.isListening ? "Stop dictation" : "Start dictation") + + Button { + submit() + } label: { + Image(systemName: "arrow.up") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(canSend ? Color.white : Color.secondary) + .frame(width: 32, height: 32) + .background( + Circle().fill(canSend ? BubbleColor.mine : Color.secondary.opacity(0.18)) + ) + } + .buttonStyle(.plain) + .disabled(!canSend) + .padding(.trailing, 6) + .padding(.bottom, 6) + .animation(.easeOut(duration: 0.15), value: canSend) + } + .frame(minHeight: 44) + .glassCapsule(interactive: false) } - .frame(minHeight: 44) - .glassCapsule(interactive: false) } } .padding(.horizontal, 12) diff --git a/ios/App/SpeechDictation.swift b/ios/App/SpeechDictation.swift new file mode 100644 index 0000000000..9d67532b51 --- /dev/null +++ b/ios/App/SpeechDictation.swift @@ -0,0 +1,258 @@ +// On-device dictation for the composer. +// +// Same engine as the desktop helper (`electron/resources/speech-helper.swift`): +// `SFSpeechRecognizer` on an `AVAudioEngine` tap, partials streamed into the +// text field, press to stop. Composer mode, not call mode — there is no +// silence endpointing. The phone is better at this than the Mac was: the +// recognizer is in the same process as the field, so there is no helper +// binary, no TCC bundle dance, and no `open -W`. +// +// On-device when the recognizer supports it, so talking to a bot does not +// become talking to Apple's servers. Locales come from `Dictation.localeCandidates` +// rather than a hardcoded en-US, for the same reason the desktop helper +// stopped hardcoding one. +// +// Lives in the app target on purpose. CompanionCore is Foundation-only so +// `swift test` can run without a simulator; Speech and AVAudioEngine are +// the opposite of that. +import AVFoundation +import Combine +import Speech +import CompanionCore + +@MainActor +final class SpeechDictation: ObservableObject { + private static let cancellationCodes: [String: Set] = [ + "kLSRErrorDomain": [209, 216], + "kAFAssistantErrorDomain": [216], + ] + + @Published private(set) var isListening = false + /// True from `start` until capture is running or the attempt fails. + /// Publishing it keeps the frozen composer base protected while the + /// system permission sheets are still in flight. + @Published private(set) var isStarting = false + @Published private(set) var transcript = "" + @Published private(set) var error: String? + + /// Composer text captured when listening started. Frozen for the + /// session so each partial replaces the last rather than stacking. + /// ChatView reads this from `onChange(of: transcript)` and must not + /// substitute the live draft. + private(set) var base = "" + + private var recognizer: SFSpeechRecognizer? + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var tapInstalled = false + private var stopping = false + /// Bumped on every start/stop so an authorization that finishes after + /// the user already cancelled cannot open the mic. + private var generation = 0 + private var startTask: Task? + + func toggle(capturing base: String) { + if isListening || isStarting { + stop() + } else { + start(base: base) + } + } + + private func start(base: String) { + guard !isListening, !isStarting else { return } + error = nil + self.base = base.trimmingCharacters(in: .whitespacesAndNewlines) + transcript = "" + isStarting = true + generation += 1 + let gen = generation + startTask = Task { await actuallyStart(generation: gen) } + } + + func stop() { + startTask?.cancel() + startTask = nil + generation += 1 + isStarting = false + stopping = true + isListening = false + teardown() + } + + // MARK: - Authorization + + private func actuallyStart(generation gen: Int) async { + let speech = await requestSpeechAuthorization() + guard gen == generation, !Task.isCancelled else { + return + } + guard speech == .authorized else { + isStarting = false + error = Self.speechDeniedMessage + return + } + + let mic = await AVAudioApplication.requestRecordPermission() + guard gen == generation, !Task.isCancelled else { + return + } + guard mic else { + isStarting = false + error = Self.micDeniedMessage + return + } + + do { + try beginCapture(generation: gen) + isStarting = false + } catch CaptureError.noRecognizer { + isStarting = false + error = "Dictation isn't available for this language." + teardown() + } catch { + isStarting = false + self.error = "Couldn't start the microphone." + teardown() + } + } + + private func requestSpeechAuthorization() async -> SFSpeechRecognizerAuthorizationStatus { + await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } + } + + // MARK: - Capture + + private func beginCapture(generation gen: Int) throws { + let recognizer = Dictation.localeCandidates() + .compactMap { SFSpeechRecognizer(locale: $0) } + .first { $0.isAvailable } + guard let recognizer else { + throw CaptureError.noRecognizer + } + self.recognizer = recognizer + + let session = AVAudioSession.sharedInstance() + // `.record` rather than `.playAndRecord`: this is composer + // dictation, not a call, and holding the playback route would + // duck whatever else is on the phone for no reason. + try session.setCategory(.record, mode: .measurement) + try session.setActive(true, options: .notifyOthersOnDeactivation) + + let engine = AVAudioEngine() + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + // The desktop helper does not set this (it is a CLI talking to an + // older Speech.framework), but a chat message is better with the + // commas the recognizer already knows about. + request.addsPunctuation = true + request.taskHint = .dictation + if recognizer.supportsOnDeviceRecognition { + request.requiresOnDeviceRecognition = true + } + + // Keep the engine on self before start() so a throw still has + // something for teardown to remove the tap from. A local engine + // that fails to start would leave tapInstalled true and the next + // teardown would removeTap on a new engine that has none — which + // is an exception, not a no-op. + audioEngine = engine + recognitionRequest = request + + // The tap format is only valid after the session is active. + // Installing against a 0-channel format is the usual "it works + // in the sample and fails here" failure. + let input = engine.inputNode + let format = input.outputFormat(forBus: 0) + guard format.channelCount > 0 else { + throw CaptureError.silentInput + } + input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in + request.append(buffer) + } + tapInstalled = true + engine.prepare() + try engine.start() + + stopping = false + isListening = true + + recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, recognitionError in + Task { @MainActor in + self?.handle( + result: result, + recognitionError: recognitionError, + generation: gen + ) + } + } + } + + private func handle( + result: SFSpeechRecognitionResult?, + recognitionError: Error?, + generation gen: Int + ) { + // A cancelled task can still deliver a partial or a 209 after the + // next session has already started. `isListening` is true then too, + // so generation is what keeps this callback from rewriting the + // new draft or stopping the new capture. + guard gen == generation, !stopping, isListening else { return } + if let result { + transcript = result.bestTranscription.formattedString + // Composer dictation does not wait for isFinal — the last + // partial is what you send. If the recognizer finalizes on + // its own (rare without endAudio), just stop listening. + if result.isFinal { + stop() + return + } + } + guard let recognitionError else { return } + let ns = recognitionError as NSError + // Speech uses separate internal domains for local-recognizer and + // assistant cancellation. Keep the observed codes scoped to their + // domains: other values (notably assistant 1110, no speech) are real + // recognition failures and should remain visible. + if Self.cancellationCodes[ns.domain]?.contains(ns.code) == true { + stop() + return + } + self.error = "Couldn't transcribe that." + stop() + } + + private func teardown() { + // Drop the tap before ending the request: a buffer that arrives + // after endAudio() can fail the task instead of being ignored. + if let engine = audioEngine { + if tapInstalled { + engine.inputNode.removeTap(onBus: 0) + tapInstalled = false + } + if engine.isRunning { engine.stop() } + } + recognitionTask?.cancel() + recognitionTask = nil + recognitionRequest?.endAudio() + recognitionRequest = nil + audioEngine = nil + recognizer = nil + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + private enum CaptureError: Error { + case silentInput + case noRecognizer + } + + static let speechDeniedMessage = + "Dictation needs Speech Recognition access. Enable it in Settings → OpenMausMobile." + static let micDeniedMessage = + "Dictation needs Microphone access. Enable it in Settings → OpenMausMobile." +} diff --git a/ios/README.md b/ios/README.md index 53b9a294cb..d153268412 100644 --- a/ios/README.md +++ b/ios/README.md @@ -44,11 +44,13 @@ ios/ SSE.swift line parser + URLSession event stream Client.swift every call the phone is allowed to make Store.swift the fold: frames → state + Dictation.swift composer text + transcript join Tests/CompanionCoreTests/ Fixtures/ captured from a real server — do not hand-edit DecodingTests.swift the contract with the harness SSETests.swift the parser, which is where this goes wrong StoreTests.swift the fold + DictationTests.swift partials replace, they do not stack App/ SwiftUI, and everything that needs a device CompanionApp.swift entry; owns when the stream lives and dies Session.swift connection, lifecycle, actions @@ -64,6 +66,7 @@ ios/ UpdatesSheet.swift the pill opened: needs you / working / to review NewGroupSheet.swift make a room from the phone ChatView.swift transcript, tailed bubbles, approval cards, composer + SpeechDictation.swift on-device speech recognition, press-to-stop ComputerView.swift opt-in live view of a bot's computer MarkdownText.swift the supported Markdown presentation layer SettingsView.swift status, and unpair @@ -171,9 +174,10 @@ the host computer remain unreachable through the companion. `.ignored` for the shifted case hands the keypress back to the text field, which is the only thing that can insert the newline once Return is claimed. Software keyboards have no Shift+Return, so there `.onSubmit` sends. -- **No affordance without a feature behind it.** The reference design this was - modelled on has a composer mic; there is no dictation here, so it is not - drawn. Search covers the SQLite transcript store and opens the exact task, +- **Composer dictation is the mic.** Tap to talk, tap to stop, then edit or + send. Recognition stays on-device when the phone supports it, and the mic + remains visible so another spoken sentence can be appended. Search covers + the SQLite transcript store and opens the exact task, branch, and message; the roster's "+" creates the same basic bot the desktop endpoint creates, then opens it. @@ -182,7 +186,8 @@ the host computer remain unreachable through the companion. The live connection is foreground-only. Notification frames produce native banners, sounds, time-sensitive approval alerts, and an app badge while connected; the resume cursor replays alerts missed during a short background pause. There is -no APNs delivery after the app is terminated, no voice/call mode, and no hosted relay. +no APNs delivery after the app is terminated, no call mode or spoken replies, +and no hosted relay. Composer dictation is available. Task management, SQLite transcript search, transcript sharing, reactions, and edit/version controls use narrow companion routes and the computer remains the source of truth. Tailscale is supported diff --git a/ios/Sources/CompanionCore/Dictation.swift b/ios/Sources/CompanionCore/Dictation.swift new file mode 100644 index 0000000000..a306e16332 --- /dev/null +++ b/ios/Sources/CompanionCore/Dictation.swift @@ -0,0 +1,66 @@ +// Composer dictation, the half that has no microphone. +// +// The Speech session lives in the app target — it needs AVFoundation and a +// device. What lives here is the contract between that session and the text +// field, because that is where the decisions are and where they can be +// tested without a phone: +// +// - Partials *replace* each other after the text that was already in the +// composer. They never stack. The desktop helper works the same way +// (`src/components/Composer.tsx`): the base is frozen when the mic goes +// on, and every transcript line is `base + " " + spoken`. +// - The recognizer's locale is the user's language, not a hardcoded +// English. A French speaker talking to an en-US recognizer gets +// nonsense, which is how this went wrong on the desktop the first time +// (`electron/resources/speech-helper.swift`). Same candidate list here. +import Foundation + +public enum Dictation { + private static let maximumPreferredLanguages = 3 + + /// Combine already-typed composer text with the current transcript. + /// + /// `base` is whatever was in the field when listening started, frozen + /// for the session. Pass that every time, not the live draft — passing + /// the live draft would append each partial onto the last one. + public static func draft(base: String, transcript: String) -> String { + let typed = base.trimmingCharacters(in: .whitespacesAndNewlines) + let spoken = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + if spoken.isEmpty { return typed } + if typed.isEmpty { return spoken } + return "\(typed) \(spoken)" + } + + /// Locales to try, in order. First available recognizer wins. + /// + /// Preferred languages, then the current locale, then en-US as a last + /// resort so a device with no speech support for the user's language + /// still has something to attempt rather than failing closed with no + /// explanation. + public static func localeCandidates( + preferredLanguages: [String] = Locale.preferredLanguages, + current: Locale = .current + ) -> [Locale] { + var seen = Set() + var result: [Locale] = [] + func add(_ locale: Locale) { + // "en-US" and "en_US" are the same recognizer. Canonicalize so + // the fallback does not add a duplicate of a locale we already + // tried under a different identifier spelling. + let key = canonicalIdentifier(locale) + guard seen.insert(key).inserted else { return } + result.append(locale) + } + for language in preferredLanguages.prefix(maximumPreferredLanguages) { + add(Locale(identifier: language)) + } + add(current) + add(Locale(identifier: "en-US")) + return result + } + + /// Lowercased BCP-47 with underscores, so `en-US` and `en_US` collide. + public static func canonicalIdentifier(_ locale: Locale) -> String { + locale.identifier.lowercased().replacingOccurrences(of: "-", with: "_") + } +} diff --git a/ios/TESTING.md b/ios/TESTING.md index 88b4c61a98..a23fc93939 100644 --- a/ios/TESTING.md +++ b/ios/TESTING.md @@ -220,7 +220,12 @@ On the phone, in order: then come back. The transcript should catch up *without* a visible reload — that is the resumable stream doing its job. Watch the harness log to confirm it replayed rather than re-hydrated. -6. **Revoke.** Remove the device in Settings → Companion on the computer. The +6. **Dictate.** Open a chat, tap the mic, speak, and tap it again. Partial + words should replace each other in the composer rather than duplicate, + and the result should remain editable before sending. The first attempt + requests Microphone and Speech Recognition access. Locking or + backgrounding the phone mid-sentence must release the mic. +7. **Revoke.** Remove the device in Settings → Companion on the computer. The phone should land on "This phone was unpaired" rather than silently failing. --- @@ -274,7 +279,8 @@ Not built yet, so not bugs: - **Nothing arrives after the app is terminated.** Live and replayed notification frames now become native alerts and badges, but closed-app push still needs an APNs relay with project-owned Apple credentials. -- **No voice or routine management.** Tasks, SQLite transcript search/export, +- **No call mode, spoken replies, or routine management.** Composer dictation, + tasks, SQLite transcript search/export, reactions, and edit/version switching are available from the conversation UI. (Two entries that used to sit on this list have since shipped: replies stream diff --git a/ios/Tests/CompanionCoreTests/DictationTests.swift b/ios/Tests/CompanionCoreTests/DictationTests.swift new file mode 100644 index 0000000000..bbec9a015f --- /dev/null +++ b/ios/Tests/CompanionCoreTests/DictationTests.swift @@ -0,0 +1,94 @@ +// Composer dictation: how typed text and a live transcript share a field. +// +// The Speech session is in App/ and needs a device. The join is the part +// with a decision in it — partials replace, they do not stack — and getting +// that wrong is a composer that writes "hello hello hello world" as you +// talk. Same shape as the desktop: freeze the base when the mic goes on, +// and every subsequent transcript is `base + spoken`. +import XCTest +@testable import CompanionCore + +final class DictationTests: XCTestCase { + func testEmptyComposerTakesTheTranscript() { + XCTAssertEqual(Dictation.draft(base: "", transcript: "hello"), "hello") + } + + func testEmptyTranscriptLeavesTheBase() { + // The first callback has not arrived yet. Wiping the field in that + // window would look like the mic deleted what you had typed. + XCTAssertEqual(Dictation.draft(base: "please look", transcript: ""), "please look") + XCTAssertEqual(Dictation.draft(base: "please look", transcript: " "), "please look") + } + + func testSpokenTextAppendsAfterTypedText() { + XCTAssertEqual(Dictation.draft(base: "please", transcript: "look at the logs"), "please look at the logs") + } + + func testWhitespaceAroundEitherSideIsTrimmed() { + XCTAssertEqual(Dictation.draft(base: " please ", transcript: " look "), "please look") + } + + /// The contract ChatView has to keep: the base is the text at the + /// moment listening started, not the live draft. Re-joining against + /// that frozen base is how a later partial replaces an earlier one + /// instead of concatenating onto it. + func testALaterPartialReplacesAnEarlierOne() { + let base = "please" + XCTAssertEqual(Dictation.draft(base: base, transcript: "look"), "please look") + XCTAssertEqual(Dictation.draft(base: base, transcript: "look at the logs"), "please look at the logs") + } + + func testBothEmptyStaysEmpty() { + XCTAssertEqual(Dictation.draft(base: "", transcript: ""), "") + XCTAssertEqual(Dictation.draft(base: " ", transcript: "\n"), "") + } + + // MARK: - Locale candidates + + func testPreferredLanguageComesFirst() { + let locales = Dictation.localeCandidates( + preferredLanguages: ["fr-FR", "de-DE"], + current: Locale(identifier: "en-US") + ) + XCTAssertEqual(Dictation.canonicalIdentifier(locales[0]), "fr_fr") + XCTAssertTrue(locales.map(Dictation.canonicalIdentifier).contains("en_us")) + } + + func testEnglishIsNotDuplicatedWhenItIsAlreadyPreferred() { + let locales = Dictation.localeCandidates( + preferredLanguages: ["en-US"], + current: Locale(identifier: "en-US") + ) + let keys = locales.map(Dictation.canonicalIdentifier) + XCTAssertEqual(keys, ["en_us"]) + } + + func testHyphenAndUnderscoreAreTheSameCandidate() { + let locales = Dictation.localeCandidates( + preferredLanguages: ["en-US"], + current: Locale(identifier: "en_US") + ) + XCTAssertEqual(locales.map(Dictation.canonicalIdentifier), ["en_us"]) + } + + func testEnglishIsTheLastResortWhenNothingElseIsOffered() { + let locales = Dictation.localeCandidates( + preferredLanguages: [], + current: Locale(identifier: "ja-JP") + ) + XCTAssertEqual(Dictation.canonicalIdentifier(locales[0]), "ja_jp") + XCTAssertEqual(locales.last.map(Dictation.canonicalIdentifier), "en_us") + } + + func testPreferredLanguageProbingIsBounded() { + let locales = Dictation.localeCandidates( + preferredLanguages: ["fr-FR", "de-DE", "it-IT", "es-ES", "pt-BR"], + current: Locale(identifier: "ja-JP") + ) + let keys = locales.map(Dictation.canonicalIdentifier) + XCTAssertEqual(Array(keys.prefix(3)), ["fr_fr", "de_de", "it_it"]) + XCTAssertFalse(keys.contains("es_es")) + XCTAssertFalse(keys.contains("pt_br")) + XCTAssertEqual(Array(keys.suffix(2)), ["ja_jp", "en_us"]) + } +} diff --git a/ios/project.yml b/ios/project.yml index 5857c81a9b..7247ea38a0 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -80,6 +80,11 @@ targets: NSCameraUsageDescription: >- OpenMausBot uses the camera only to scan a pairing QR code shown on your computer. + NSMicrophoneUsageDescription: >- + OpenMausMobile listens while you dictate a message to a bot. + NSSpeechRecognitionUsageDescription: >- + OpenMausMobile converts your speech into text so you can send it + to a bot. Recognition runs on this phone when it can. NSBonjourServices: - _openmausbot._tcp # The companion listener is plain HTTP on a local address. ATS has