From ec4bc6bfcb6b82ed02bb3fd9fbc911e582dc9325 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 17:03:47 -0500 Subject: [PATCH] iOS: ask before sending audio to AssemblyAI App Review 5.1.2(i) requires clearly disclosing where personal data goes, third-party AI included, and getting explicit permission first. The listening window now won't open until the user agrees, in a sheet that names AssemblyAI, lists what is sent (the voice while dictating, the text before the cursor, recent dictations, key terms with contact names) and links the privacy policy. Every path to the microphone goes through startListening(), so one gate covers the button, the keyboard's blurt://start and in-app dictation. Consent is versioned (AIConsent): sending something new means a new version, and everyone is asked again. Settings' About section links the privacy policy too. The debug -BlurtStartListening flag grants consent, since scripted runs can't tap through the sheet. Co-Authored-By: Claude Opus 5.5 --- .../BlurtiOS/Sources/BlurtiOSApp.swift | 4 +- .../BlurtiOS/Sources/ConsentView.swift | 49 +++++++++++++++++++ .../Sources/DictationCoordinator.swift | 16 ++++++ App/BlurtiOS/BlurtiOS/Sources/HomeView.swift | 3 ++ .../BlurtiOS/Sources/SettingsView.swift | 1 + App/BlurtiOS/BlurtiOSCore/AIConsent.swift | 28 +++++++++++ .../BlurtiOSTests/AIConsentTests.swift | 35 +++++++++++++ App/BlurtiOS/DESIGN.md | 17 +++++-- App/BlurtiOS/README.md | 2 +- 9 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift create mode 100644 App/BlurtiOS/BlurtiOSCore/AIConsent.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index dc33e93d..a24e3de0 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -71,7 +71,9 @@ struct BlurtiOSApp: App { #if DEBUG // `-BlurtStartListening` opens the mic at launch, so the listening // state can be screenshotted without a tap (see scripts/ios-sim.sh). - if CommandLine.arguments.contains("-BlurtStartListening") { await coordinator.startListening() } + // Asking for it stands in for consent, which a screenshot or probe run + // can't tap through; debug builds only. + if CommandLine.arguments.contains("-BlurtStartListening") { await coordinator.grantConsent() } #endif } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift b/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift new file mode 100644 index 00000000..bc83ca04 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift @@ -0,0 +1,49 @@ +import BlurtiOSCore +import SwiftUI + +/// "Send your voice to AssemblyAI?" — asked once, before the listening window +/// first opens (App Review 5.1.2(i)). It names the third party, says plainly +/// what goes to it, and links the privacy policy. The list is the disclosure +/// `AIConsent.version` covers: change one, change the other. +struct ConsentView: View { + let allow: () -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + Text( + "Blurt turns your voice into text with AssemblyAI, a third-party AI service. " + + "It records only while you dictate.") + } + Section { + Label("Your voice while you dictate", systemImage: "waveform") + Label("The text just before your cursor", systemImage: "text.cursor") + Label("Your recent dictations", systemImage: "clock.arrow.circlepath") + Label("Your key terms, including contact names", systemImage: "person.text.rectangle") + } header: { + Eyebrow("Sent to AssemblyAI") + } footer: { + Text("The context helps AssemblyAI spell names right and continue your sentence.") + } + Section { + Link("AssemblyAI's privacy policy", destination: AIConsent.privacyPolicyURL) + } + } + .brandForm() + .navigationTitle("Before you blurt") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Not now") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Allow") { + dismiss() + allow() + } + } + } + } + .tint(BlurtBrand.accent) + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index 738df78f..7565afad 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift @@ -33,6 +33,9 @@ final class DictationCoordinator { var termPackUnreadable = false /// The window can't open without a key: the last "Start listening" said so. var needsKey = false + /// The window can't open until the user agrees to AssemblyAI processing their + /// voice (`AIConsent`); `ConsentView` asks while this is set. + var needsConsent = false @ObservationIgnored private let session: DictationSession @ObservationIgnored private let recents: AsyncStream @@ -99,6 +102,11 @@ final class DictationCoordinator { apiKey.refreshStatus() needsKey = !apiKey.hasAPIKey guard !needsKey else { return } + // Every way to the microphone comes through here — the button, the + // keyboard's `blurt://start`, and in-app dictation, which needs the window + // open — so this one gate covers them all. + needsConsent = !AIConsent.isGranted() + guard !needsConsent else { return } guard await AVAudioApplication.requestRecordPermission() else { microphoneDenied = true return @@ -107,6 +115,14 @@ final class DictationCoordinator { await window.open() } + /// The user agreed in `ConsentView`: record it and carry on opening the + /// window they asked for. + func grantConsent() async { + AIConsent.grant() + needsConsent = false + await startListening() + } + /// Closes the window. A dictation in flight is cancelled first, upload and /// all — closing the feed alone would end the utterance and let the request /// complete, transcribing (and billing) audio nobody asked for. diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift index 19b3a438..dba9d8db 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -52,6 +52,9 @@ struct HomeView: View { .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $showsKeyEntry) { KeyEntryView(apiKey: coordinator.apiKey) } .sheet(isPresented: $showsSettings) { SettingsView(coordinator: coordinator) } + .sheet(isPresented: Binding(get: { coordinator.needsConsent }, set: { coordinator.needsConsent = $0 })) { + ConsentView { Task { await coordinator.grantConsent() } } + } .sheet(item: Binding(get: { coordinator.pendingTermPack }, set: { coordinator.pendingTermPack = $0 })) { ImportTermsView(pack: $0) } diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index 8a7e95f5..5f5a5e6d 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -137,6 +137,7 @@ struct SettingsView: View { Section { LabeledContent("Version", value: Self.version) Link("Blurt on GitHub", destination: URL(string: "https://github.com/AssemblyAI/blurt") ?? URL(filePath: "/")) + Link("AssemblyAI's privacy policy", destination: AIConsent.privacyPolicyURL) Text("Powered by AssemblyAI").foregroundStyle(BlurtBrand.muted) } header: { Eyebrow("About") diff --git a/App/BlurtiOS/BlurtiOSCore/AIConsent.swift b/App/BlurtiOS/BlurtiOSCore/AIConsent.swift new file mode 100644 index 00000000..6e7e347b --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/AIConsent.swift @@ -0,0 +1,28 @@ +import Foundation + +/// The user's go-ahead to send their voice, and the text that gives it context, +/// to AssemblyAI. App Review 5.1.2(i) requires clearly disclosing where personal +/// data goes, third-party AI included, and getting explicit permission first, so +/// the listening window won't open until this is granted. +/// +/// Versioned: consent covers what is sent, so sending something new (another +/// kind of context) means raising `version` and updating the copy that lists it +/// (`ConsentView`), and everyone is asked again. +package nonisolated enum AIConsent { + /// The version of the disclosure in `ConsentView`: 1 is audio, the text before + /// the cursor, recent dictations, and key terms with contact names. + package static let version = 1 + package static let defaultsKey = "AIConsentVersion" + package static let privacyPolicyURL = + URL(string: "https://www.assemblyai.com/legal/privacy-policy") ?? URL(filePath: "/") + + /// Whether the user agreed to the current disclosure. Agreeing to an older one + /// isn't enough: it didn't list everything that is sent now. + package static func isGranted(in defaults: UserDefaults = .standard) -> Bool { + defaults.integer(forKey: defaultsKey) >= version + } + + package static func grant(in defaults: UserDefaults = .standard) { + defaults.set(version, forKey: defaultsKey) + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift b/App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift new file mode 100644 index 00000000..bb206d5f --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing + +@testable import BlurtiOSCore + +/// The consent gate over its own defaults suite, never the app's. +@Suite("AI consent") +struct AIConsentTests { + private func freshDefaults() throws -> UserDefaults { + let name = "AIConsentTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test("nothing is granted until the user agrees, and agreeing sticks") + func grant() throws { + let defaults = try freshDefaults() + #expect(!AIConsent.isGranted(in: defaults)) + AIConsent.grant(in: defaults) + #expect(AIConsent.isGranted(in: defaults)) + } + + @Test("agreeing to an older disclosure doesn't cover the current one") + func olderVersion() throws { + let defaults = try freshDefaults() + defaults.set(AIConsent.version - 1, forKey: AIConsent.defaultsKey) + #expect(!AIConsent.isGranted(in: defaults)) + } + + @Test("the privacy policy link is AssemblyAI's") + func privacyPolicy() { + #expect(AIConsent.privacyPolicyURL.host() == "www.assemblyai.com") + } +} diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index c179f07d..d89286a1 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -690,14 +690,21 @@ time, a copy button); `POWERED BY ASSEMBLYAI`. **Settings** (`SettingsView`): a form on the page, its sections under eyebrows — Keyboard (layout, theme, hands-free), Listening (the window), Transcription (enhanced transcripts, output styles, key terms with the -contact-name count, sharing), Account (sign-in stub; the API key in debug -builds), About. The theme picker shows each theme's two faces side by side. -Styles, the import sheet and the key entry share the form's chrome -(`brandForm()`). +contact-name count, sharing), Account (the API key, and where to get one), +About (with AssemblyAI's privacy policy). The theme picker shows each theme's +two faces side by side. Styles, the import sheet, the key entry and the consent +sheet share the form's chrome (`brandForm()`). + +**Consent** (`ConsentView`, `AIConsent`): before the listening window first +opens, a sheet names AssemblyAI, lists what is sent (the voice while +dictating, the text before the cursor, recent dictations, key terms with +contact names) and links the privacy policy; `ALLOW` records it and opens the +window. App Review 5.1.2(i). The list is what `AIConsent.version` covers: +sending anything new means a new version, and everyone is asked again. `-BlurtSettings` opens Settings as the root for a screenshot; `xcrun simctl ui booted appearance dark` before any capture for the dark appearance; -`-BlurtStartListening` opens the mic at launch. +`-BlurtStartListening` opens the mic at launch (and grants AI consent, which a scripted run can't tap through). ## Feedback and accessibility diff --git a/App/BlurtiOS/README.md b/App/BlurtiOS/README.md index 361af67a..b24bc878 100644 --- a/App/BlurtiOS/README.md +++ b/App/BlurtiOS/README.md @@ -134,7 +134,7 @@ shows its console. owns them. 4. Plug the phone in (Developer Mode on: Settings → Privacy & Security), pick it as the run destination, run the `BlurtiOS` scheme. On the phone, trust the developer app (Settings → - General → VPN & Device Management). In the app: **Paste your key**, **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** + General → VPN & Device Management). In the app: **Paste your key**, **Allow** sending your voice to AssemblyAI, **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** (a phone may open straight on Blurt's page): turn on Blurt and **Allow Full Access**. 5. Back in the app, **Start listening**, then go to Messages, hold the globe key, pick Blurt, and tap the mic. Tap again to stop, or hold it while you talk. The words land in the field.