Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
49 changes: 49 additions & 0 deletions App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
16 changes: 16 additions & 0 deletions App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecentDictations>
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions App/BlurtiOS/BlurtiOS/Sources/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
28 changes: 28 additions & 0 deletions App/BlurtiOS/BlurtiOSCore/AIConsent.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
35 changes: 35 additions & 0 deletions App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
17 changes: 12 additions & 5 deletions App/BlurtiOS/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion App/BlurtiOS/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down