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.