From 10bc457c72e1351657a28b08320f412a05b11298 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:22:20 +0800 Subject: [PATCH] feat(app): add resilient Flow recovery and privacy-safe analytics Unify PiP recovery across startup and foreground transitions, surface actionable status, and add privacy-safe analytics plus the refreshed onboarding and account experience. Update documentation assets and advance the release build to 84. --- CHANGELOG.md | 3 + OSGKeyboard/Info.plist | 4 + OSGKeyboard/PrivacyInfo.xcprivacy | 39 + OSGKeyboard/Resources/PrivacyPolicy.html | 12 +- .../Services/AnalyticsHostService.swift | 284 ++++ OSGKeyboard/Services/AppURLHandler.swift | 10 + .../FlowPictureInPictureController.swift | 183 ++- OSGKeyboard/Services/FlowSessionManager.swift | 528 +++++-- .../Views/Account/AccountCenterView.swift | 15 +- .../AccountCreditPurchaseManager.swift | 12 +- .../Account/AccountSessionCoordinator.swift | 19 +- .../Views/Account/LiveAccountServices.swift | 3 + OSGKeyboard/Views/HomeView.swift | 181 ++- OSGKeyboard/Views/MainAppRoot.swift | 31 +- .../Views/OnboardingExperienceView.swift | 1063 +++++++++++++ .../Views/SettingsSecondaryPages.swift | 27 + OSGKeyboard/en.lproj/Localizable.strings | 60 + OSGKeyboard/zh-Hans.lproj/Localizable.strings | 60 + OSGKeyboardExt/KeyboardViewController.swift | 4 + OSGKeyboardExt/PrivacyInfo.xcprivacy | 39 +- .../Services/AnalyticsExtensionService.swift | 110 ++ .../Services/KeyboardConfigSync.swift | 1 + .../Services/KeyboardFlowCoordinator.swift | 13 +- .../Services/KeyboardTextInserter.swift | 1 + OSGKeyboardExt/Views/AIKeyboardView.swift | 10 +- OSGKeyboardExt/en.lproj/Keyboard.strings | 1 + OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 1 + .../AnalyticsExtensionPrivacyTests.swift | 138 ++ OSGKeyboardExtTests/ManagedGatewayTests.swift | 21 + .../Configuration/ConfigurationStore.swift | 11 +- .../LiveConfigurationStore.swift | 6 +- .../Features/Analytics/AnalyticsClient.swift | 404 +++++ .../Analytics/AnalyticsDependencies.swift | 227 +++ .../Features/Analytics/AnalyticsModels.swift | 446 ++++++ .../Analytics/AnalyticsRepository.swift | 1397 +++++++++++++++++ .../AnalyticsUploadCoordinator.swift | 484 ++++++ .../ManagedGateway/ManagedGatewayModels.swift | 7 + .../ManagedGateway/ManagedLLMClient.swift | 8 +- .../Models/AppGroupConfiguration.swift | 6 +- .../Models/FlowSession/FlowCommand.swift | 8 +- .../Models/FlowUtteranceRequest.swift | 4 + .../Services/AIQuestionService.swift | 111 +- .../Services/AppGroupStore.swift | 10 +- .../Services/KeyboardSetupBridge.swift | 63 + .../Services/KeyboardState.swift | 3 + .../Services/PolishingService.swift | 86 +- .../AnalyticsAIOperationTests.swift | 84 + OSGKeyboardTests/AnalyticsModelTests.swift | 165 ++ .../AnalyticsRepositoryTests.swift | 461 ++++++ OSGKeyboardTests/AnalyticsTestSupport.swift | 267 ++++ .../AnalyticsUploadCoordinatorTests.swift | 308 ++++ .../AppGroupOnboardingStoreTests.swift | 44 + .../FlowHomePiPStatusPolicyTests.swift | 97 ++ .../FlowPiPRecoveryPolicyTests.swift | 149 ++ OSGKeyboardTests/FlowSessionBridgeTests.swift | 4 +- README.md | 2 +- Scripts/device-pip-stress.sh | 67 +- Tests/suite-manifest.json | 15 + docs/APPSTORE_METADATA.md | 45 +- docs/assets/site.css | 231 +-- docs/assets/site.js | 16 +- docs/assets/whats-new/osg-credits-en.png | Bin 0 -> 75502 bytes docs/assets/whats-new/osg-credits-zh.png | Bin 0 -> 64904 bytes docs/assets/whats-new/referral-rewards-en.png | Bin 0 -> 62469 bytes docs/assets/whats-new/referral-rewards-zh.png | Bin 0 -> 55128 bytes docs/index.html | 290 ++-- docs/osgkeyboardversion.html | 74 +- docs/privacy/index.html | 10 +- project.yml | 6 +- 69 files changed, 7891 insertions(+), 598 deletions(-) create mode 100644 OSGKeyboard/Services/AnalyticsHostService.swift create mode 100644 OSGKeyboard/Views/OnboardingExperienceView.swift create mode 100644 OSGKeyboardExt/Services/AnalyticsExtensionService.swift create mode 100644 OSGKeyboardExtTests/AnalyticsExtensionPrivacyTests.swift create mode 100644 OSGKeyboardShared/Features/Analytics/AnalyticsClient.swift create mode 100644 OSGKeyboardShared/Features/Analytics/AnalyticsDependencies.swift create mode 100644 OSGKeyboardShared/Features/Analytics/AnalyticsModels.swift create mode 100644 OSGKeyboardShared/Features/Analytics/AnalyticsRepository.swift create mode 100644 OSGKeyboardShared/Features/Analytics/AnalyticsUploadCoordinator.swift create mode 100644 OSGKeyboardTests/AnalyticsAIOperationTests.swift create mode 100644 OSGKeyboardTests/AnalyticsModelTests.swift create mode 100644 OSGKeyboardTests/AnalyticsRepositoryTests.swift create mode 100644 OSGKeyboardTests/AnalyticsTestSupport.swift create mode 100644 OSGKeyboardTests/AnalyticsUploadCoordinatorTests.swift create mode 100644 OSGKeyboardTests/FlowHomePiPStatusPolicyTests.swift create mode 100644 OSGKeyboardTests/FlowPiPRecoveryPolicyTests.swift create mode 100644 docs/assets/whats-new/osg-credits-en.png create mode 100644 docs/assets/whats-new/osg-credits-zh.png create mode 100644 docs/assets/whats-new/referral-rewards-en.png create mode 100644 docs/assets/whats-new/referral-rewards-zh.png diff --git a/CHANGELOG.md b/CHANGELOG.md index abfb5d4..a0e281f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Privacy-preserving product analytics**: add an optional first-party event queue with cross-process SQLite durability, idempotent retry, fixed privacy-safe dimensions, and a device-local opt-out that clears pending events. / **隐私友好的产品分析**:新增可选的第一方事件队列,通过跨进程 SQLite 持久化、幂等重试与固定隐私安全维度可靠投递,并提供清除待发送事件的本机退出开关。 - **Managed-cloud consent**: the first switch to OSG credits now explains which audio, text, context, and account data leaves the device and requires explicit agreement before enabling the service. / **托管云确认**:首次切换到 OSG 积分时会说明哪些音频、文字、上下文与账号数据会离开设备,并在用户明确同意后才启用服务。 +- **Free OOBE polish**: the first-run lesson defaults to on-device transcription plus OSG managed polish, sends an authenticated OOBE purpose tag, and uses a server-enforced one-time complimentary request without reserving or settling credits. / **免费 OOBE 润色**:首次教学默认使用端侧转写与 OSG 托管润色,发送经过身份验证的 OOBE 用途标签,并由服务端执行一次性免费资格,不预扣也不结算积分。 ### Changed +- **AI Agent first run**: replace the technical six-step setup with a concise privacy, permission, and keyboard flow that presents OSGKeyboard as an AI Agent voice keyboard, teaches on the real keyboard, and verifies the first voice insertion before completion. / **AI Agent 首次体验**:以简洁的隐私、权限与键盘流程取代技术化的六步配置,突出 AI Agent 语音输入法定位,并在真实键盘中完成教学、验证首次语音上屏后再结束。 - **Focused skill icons**: remove math, indices, arrows, shapes, commerce, keyboard, media, text-formatting, automotive, device, and variable-rendering categories from the custom-skill symbol picker. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。 ### Fixed diff --git a/OSGKeyboard/Info.plist b/OSGKeyboard/Info.plist index 92517bb..14241d5 100644 --- a/OSGKeyboard/Info.plist +++ b/OSGKeyboard/Info.plist @@ -115,6 +115,10 @@ audio + BGTaskSchedulerPermittedIdentifiers + + com.osgkeyboard.ios.analytics-sync + UILaunchScreen UIColorName diff --git a/OSGKeyboard/PrivacyInfo.xcprivacy b/OSGKeyboard/PrivacyInfo.xcprivacy index d22832f..d20d362 100644 --- a/OSGKeyboard/PrivacyInfo.xcprivacy +++ b/OSGKeyboard/PrivacyInfo.xcprivacy @@ -73,6 +73,45 @@ NSPrivacyCollectedDataTypePurposeAppFunctionality + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeProductInteraction + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherUsageData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + NSPrivacyAccessedAPITypes diff --git a/OSGKeyboard/Resources/PrivacyPolicy.html b/OSGKeyboard/Resources/PrivacyPolicy.html index 7c6048c..91e7869 100644 --- a/OSGKeyboard/Resources/PrivacyPolicy.html +++ b/OSGKeyboard/Resources/PrivacyPolicy.html @@ -22,7 +22,7 @@

中文 · English

OSGKeyboard Privacy Policy

-

Last updated: August 19, 2026 · v2.0

+

Last updated: August 20, 2026 · v2.0

OSGKeyboard provides an iOS custom keyboard and a macOS menu-bar dictation app. iOS uses Apple's on-device speech APIs by default; macOS can use a downloaded local Qwen3-ASR model. The core local and user-configured-provider features do not require an OSGKeyboard account. On iOS, you may optionally sign in with Apple and choose managed credits instead of supplying provider credentials. This policy explains both paths.

What we collect

@@ -35,6 +35,7 @@
  • API credentials — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).
  • Optional OSGKeyboard account and purchases — no account is required for local transcription or user-configured providers. If you choose Sign in with Apple, the account service processes the Apple identity token, a pseudonymous OSGKeyboard account identifier, optional display name, device-integrity evidence, credit balance, referral state, and StoreKit transaction identifiers needed to verify consumable credit purchases. Account session tokens stay in the main app's private Keychain; the keyboard extension receives only a short-lived, scope-limited managed-service grant. You can sign out or delete the account in the app.
  • App preferences — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors eligible preferences, usage statistics, and voice history through your private iCloud account. Clipboard-history consent and its suggestion-strip switch stay device-local and are not activated by iCloud settings sync.
  • +
  • Optional first-party product analytics — enabled by default and removable at any time in Settings. OSGKeyboard sends only fixed event names and fixed categories about app/keyboard sessions, AI feature outcomes and duration ranges, purchase-page interactions, and invitation actions, together with an app-scoped random installation identifier and safe app/OS version labels. It never includes keyboard input, audio, transcripts, cursor context, prompts, model output, contacts, file or device names, Apple identifiers, email, credentials, advertising identifiers, or free-form properties. Turning analytics off deletes queued events; turning it back on creates a new installation identifier.
  • Optional clipboard history — off by default. When you turn it on, the keyboard may read text from the clipboard on this device or from Universal Clipboard; iOS does not provide a reliable way to distinguish those sources. Up to 15 accepted text items are stored only in the local App Group shared by this device’s host app and keyboard extension. Turning history off stops capture, turns off the suggestion strip, and keeps existing items. Reset Settings also keeps them; only the separate confirmed “Clear clipboard history” action removes them. There is no fixed expiry. Secure fields immediately hide clipboard UI and are not captured. Conservative filters reject common OTP shapes, private-key headers, JWTs, Bearer tokens, recognizable provider-key prefixes, and common Luhn-valid 16-digit card numbers, but cannot identify every password or secret. A rejected item can still be pasted through iOS; it is simply not added to history. Clipboard history is not automatically sent to AI. If you insert an item and then actively use polish, the inserted text may be included as context sent to the provider you configured.
  • Personal dictionary and typing learning — personal terms may sync through your private iCloud account when enabled. Chinese candidate frequencies and English suggestion choices remain in the local App Group and can be cleared separately without deleting the personal dictionary. OSGKeyboard does not upload this learning data.
  • macOS local speech models — when selected, Qwen3-ASR model files are downloaded from Hugging Face and remain in the Mac app's local application-support directory until you remove them. Local inference does not send your audio to Hugging Face or OSGKeyboard.
  • @@ -44,7 +45,7 @@

    What we do not collect

    @@ -62,6 +63,7 @@

    Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; eligible preferences, statistics, dictionary data, and voice history may sync through your private iCloud account.

    Voice history — successful transcripts may be saved in the main app’s History tab (up to 300 entries). With iCloud settings sync enabled, history may also sync across your devices.

    Clipboard history — stays in this device’s App Group, is capped at 15 entries, does not sync through iCloud, and has no fixed expiry. Turning the feature off or resetting settings keeps existing items. Use the separately confirmed clear action to delete them.

    +

    Product analytics — queued events expire locally after 34 days and the service accepts events no older than 35 days. Account deletion and analytics opt-out delete the local queue. Analytics identifiers are not used for cross-app tracking or advertising.

    Typing learning and Mac models — Chinese and English learning data stays local and can be cleared from typing settings. Downloaded Mac speech models remain until you remove the model or delete its local files.

    Optional account — account profile, credit ledger, StoreKit audit records, and limited anti-abuse records are retained by the account service while the account is active. Account deletion removes the active profile and credentials; pseudonymous ledger, purchase-audit, and time-limited anti-abuse records may remain where required to prevent replay, fraud, or abuse.

    @@ -71,7 +73,7 @@

    OSGKeyboard 隐私政策

    -

    更新日期:2026 年 8 月 19 日 · v2.0

    +

    更新日期:2026 年 8 月 20 日 · v2.0

    OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧语音能力;macOS 可使用下载到本机的 Qwen3-ASR 模型。本地能力和用户自备服务商能力无需登录 OSGKeyboard 账号。iOS 用户也可以选择 Apple 登录并使用托管积分,无需自行填写服务商凭证。本政策同时说明这两条可选路径。

    我们处理的数据

    @@ -84,6 +86,7 @@
  • API 凭证 — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。
  • 可选 OSGKeyboard 账号与购买 — 本地转写和用户自备服务商无需账号。若你选择 Apple 登录,账号服务会处理 Apple 身份令牌、匿名化的 OSGKeyboard 账号标识、可选昵称、设备完整性证明、积分余额、邀请状态,以及核验消耗型积分包所需的 StoreKit 交易标识。账号会话令牌只保存在主 App 私有 Keychain;键盘扩展仅获得短时、限权的托管服务凭证。你可以在 App 内退出或注销账号。
  • 应用偏好 — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像可同步的偏好、统计与语音历史。剪贴板历史采集许可与建议条开关仅属于本机,不会被 iCloud 设置同步开启。
  • +
  • 可选第一方产品分析 — 默认开启,可随时在设置中关闭。OSGKeyboard 只发送固定事件名和固定分类,用于了解 App/键盘会话、AI 功能结果与耗时区间、购买页交互及邀请操作,并携带 App 范围内随机生成的安装标识和安全的 App/系统版本标识。绝不包含键盘输入、音频、转录、光标上下文、Prompt、模型输出、联系人、文件名、设备名、Apple 标识、邮箱、凭证、广告标识符或自由形式属性。关闭后会删除待发送事件;重新开启会生成新的安装标识。
  • 可选剪贴板历史 — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。
  • 个性词库与本地输入学习 — 个性词条可在开启后经你的私有 iCloud 账户同步。中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除且不影响个性词库,OSGKeyboard 不会上传这些学习数据。
  • macOS 本地语音模型 — 选择后,Qwen3-ASR 模型会从 Hugging Face 下载到 Mac 本地应用支持目录,直到你主动移除。本地推理不会把录音发送给 Hugging Face 或 OSGKeyboard。
  • @@ -93,7 +96,7 @@

    我们不收集的内容

    @@ -111,6 +114,7 @@

    设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;可同步的偏好、统计、词库与语音历史可能经私有 iCloud 账户同步。

    语音历史 — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。

    剪贴板历史 — 仅保存在本机 App Group,上限 15 条,不经 iCloud 同步,也没有固定过期时间。关闭功能或重置设置会保留已有记录;需使用单独确认的清空操作才能删除。

    +

    产品分析 — 待发送事件会在本地满 34 天后过期,服务端只接受 35 天以内的事件。注销账号或关闭产品分析会删除本地队列。分析标识不会用于跨 App 跟踪或广告。

    输入学习与 Mac 模型 — 中英文输入学习数据仅留本机,可在输入设置中单独清除。下载的 Mac 语音模型会保留到你移除模型或删除本地文件。

    可选账号 — 账号有效期间,账号服务会保留资料、积分账本、StoreKit 审计记录及有限的反滥用记录。注销会删除有效资料与凭证;为防止交易重放、欺诈或滥用,匿名账本、购买审计及有时限的反滥用记录可能在必要范围内继续保留。

    diff --git a/OSGKeyboard/Services/AnalyticsHostService.swift b/OSGKeyboard/Services/AnalyticsHostService.swift new file mode 100644 index 0000000..993c58c --- /dev/null +++ b/OSGKeyboard/Services/AnalyticsHostService.swift @@ -0,0 +1,284 @@ +// AnalyticsHostService.swift +// OSGKeyboard · Main App +// +// Host-only analytics lifecycle. The shared module owns the wire contract, +// SQLite queue and uploader; this type only connects iOS lifecycle signals. + +import BackgroundTasks +import Foundation +import Network +import OSGKeyboardHostSupport +import OSGKeyboardShared +import OSLog +import UIKit + +actor AnalyticsUploadSignal: AnalyticsUploadTriggering { + typealias Action = @Sendable () async -> Void + + private var action: Action? + private var isScheduled = false + private let debounce: Duration + + init(debounce: Duration = .milliseconds(250)) { + self.debounce = debounce + } + + func install(_ action: @escaping Action) { + self.action = action + } + + func requestUpload() { + guard !isScheduled, let action else { return } + isScheduled = true + Task { + try? await Task.sleep(for: debounce) + guard !Task.isCancelled else { + uploadFinished() + return + } + await action() + uploadFinished() + } + } + + private func uploadFinished() { + isScheduled = false + } +} + +final class HostAnalyticsBearerBridge: AnalyticsBearerProviding, @unchecked Sendable { + static let shared = HostAnalyticsBearerBridge() + + private let lock = NSLock() + private var provider: (any AnalyticsBearerProviding)? + + func install(_ provider: any AnalyticsBearerProviding) { + lock.lock() + self.provider = provider + lock.unlock() + } + + func bearerToken() async throws -> String? { + try await currentProvider()?.bearerToken() + } + + func refreshBearerToken( + afterUnauthorizedAccessToken failedToken: String? + ) async throws -> String? { + try await currentProvider()?.refreshBearerToken( + afterUnauthorizedAccessToken: failedToken + ) + } + + private func currentProvider() -> (any AnalyticsBearerProviding)? { + lock.lock() + defer { lock.unlock() } + return provider + } +} + +struct AccountAnalyticsBearerProvider: AnalyticsBearerProviding { + let apiClient: AccountAPIClient + + func bearerToken() async throws -> String? { + guard try await apiClient.currentSession() != nil else { return nil } + return try await apiClient.accessTokenForAuthorizedRequest() + } + + func refreshBearerToken( + afterUnauthorizedAccessToken failedToken: String? + ) async throws -> String? { + guard let failedToken, !failedToken.isEmpty else { return nil } + return try await apiClient.refreshAccessToken( + afterUnauthorizedAccessToken: failedToken + ) + } +} + +struct HostAnalyticsLogger: AnalyticsLogging { + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.osgkeyboard.ios", + category: "analytics" + ) + + func log(_ entry: AnalyticsUploadLogEntry) { + let statusCode = entry.statusCode ?? 0 + let errorCategory = entry.errorCategory?.rawValue ?? "none" + logger.info( + "outcome=\(entry.outcome.rawValue, privacy: .public) count=\(entry.eventCount, privacy: .public) status=\(statusCode, privacy: .public) attempt=\(entry.attempt, privacy: .public) error=\(errorCategory, privacy: .public)" + ) + } +} + +@MainActor +final class AnalyticsHostService: ObservableObject { + static let shared = AnalyticsHostService() + static let backgroundTaskIdentifier = "com.osgkeyboard.ios.analytics-sync" + + @Published private(set) var isEnabled = true + + let client: any AnalyticsClient + + private let runtime: AnalyticsRuntime + private let uploadSignal: AnalyticsUploadSignal + private let pathMonitor = NWPathMonitor() + private let monitorQueue = DispatchQueue( + label: "com.osgkeyboard.analytics.network", + qos: .utility + ) + private var isMonitoringNetwork = false + private var foregroundUploadTask: Task? + + init( + bearerProvider: any AnalyticsBearerProviding = HostAnalyticsBearerBridge.shared, + network: any AnalyticsNetworking = URLSessionAnalyticsNetwork() + ) { + let signal = AnalyticsUploadSignal() + uploadSignal = signal + let runtime = AnalyticsRuntime.mainApp( + environment: Self.environment, + uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint), + network: network, + bearerProvider: bearerProvider, + trigger: signal, + logger: HostAnalyticsLogger() + ) + self.runtime = runtime + client = runtime.client + + Task { + await signal.install { + await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 4) + } + } + } + + func prepare(firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel) { + startNetworkMonitoringIfNeeded() + Task { + await runtime.prepare( + firstOpenAcquisitionChannel: firstOpenAcquisitionChannel + ) + let enabled = await runtime.isEnabled() + await MainActor.run { + self.isEnabled = enabled + } + client.recordSessionActivity() + await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 8) + } + } + + func appDidBecomeActive() { + client.recordSessionActivity() + requestImmediateUpload(maximumBatches: 8) + } + + func appDidEnterBackground() { + Self.scheduleBackgroundRefresh() + let identifier = UIApplication.shared.beginBackgroundTask( + withName: "analytics-sync" + ) + guard identifier != .invalid else { return } + let task = Task { + await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 2) + await MainActor.run { + UIApplication.shared.endBackgroundTask(identifier) + } + } + foregroundUploadTask = task + } + + func observeAuthenticatedAccount(_ accountID: UUID) async { + _ = await runtime.observeAccount(stableIdentifier: accountID.uuidString) + await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 8) + } + + func handleAccountDeletion() async { + await runtime.handleAccountDeletion() + } + + func setEnabled(_ enabled: Bool) { + Task { + await runtime.setEnabled(enabled) + let current = await runtime.isEnabled() + await MainActor.run { + self.isEnabled = current + } + if current { + await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 4) + } + } + } + + func refreshEnabledState() { + Task { + let current = await runtime.isEnabled() + await MainActor.run { + self.isEnabled = current + } + } + } + + func requestImmediateUpload(maximumBatches: Int = 4) { + foregroundUploadTask?.cancel() + foregroundUploadTask = Task { + await runtime.uploadCoordinator.uploadAvailableEvents( + maximumBatches: maximumBatches + ) + } + } + + static func registerBackgroundTask() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: backgroundTaskIdentifier, + using: nil + ) { task in + guard let refreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + let operation = Task { + await AnalyticsHostService.shared.runtime.uploadCoordinator + .uploadAvailableEvents(maximumBatches: 4) + refreshTask.setTaskCompleted(success: !Task.isCancelled) + scheduleBackgroundRefresh() + } + refreshTask.expirationHandler = { + operation.cancel() + } + } + } + + static func scheduleBackgroundRefresh() { + let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + try? BGTaskScheduler.shared.submit(request) + } + + private func startNetworkMonitoringIfNeeded() { + guard !isMonitoringNetwork else { return } + isMonitoringNetwork = true + pathMonitor.pathUpdateHandler = { path in + guard path.status == .satisfied else { return } + Task { @MainActor in + AnalyticsHostService.shared.requestImmediateUpload(maximumBatches: 8) + } + } + pathMonitor.start(queue: monitorQueue) + } + + private static let endpoint = URL( + string: "https://account.osglab.com/v1/analytics/events" + )! + + private static var environment: AnalyticsEnvironment { + let appVersion = Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String ?? "unknown" + let version = ProcessInfo.processInfo.operatingSystemVersion + return AnalyticsEnvironment( + appVersion: appVersion, + osVersion: "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + ) + } +} diff --git a/OSGKeyboard/Services/AppURLHandler.swift b/OSGKeyboard/Services/AppURLHandler.swift index 6add671..4c2e777 100644 --- a/OSGKeyboard/Services/AppURLHandler.swift +++ b/OSGKeyboard/Services/AppURLHandler.swift @@ -43,6 +43,16 @@ final class AppOpenURLRouter { } final class AppURLHandler: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [ + UIApplication.LaunchOptionsKey: Any + ]? = nil + ) -> Bool { + AnalyticsHostService.registerBackgroundTask() + return true + } + /// SwiftUI `@main` apps get no scene delegate by default. Attach ours so /// scene-based URL delivery — the only iOS 26 path to `sourceApplication` — /// reaches `AppSceneDelegate`. We deliberately do NOT create a window here; diff --git a/OSGKeyboard/Services/FlowPictureInPictureController.swift b/OSGKeyboard/Services/FlowPictureInPictureController.swift index bbac2b8..f837187 100644 --- a/OSGKeyboard/Services/FlowPictureInPictureController.swift +++ b/OSGKeyboard/Services/FlowPictureInPictureController.swift @@ -36,14 +36,45 @@ enum FlowPiPStartOutcome: Equatable, Sendable { case failed(FlowPiPStartFailure) } +/// User-facing lifecycle published by `FlowSessionManager`. +enum FlowPiPLifecycleState: Equatable, Sendable { + case inactive + case preparing(attempt: Int, total: Int) + case recovering(attempt: Int, total: Int) + case active + case waitingForForeground + case failed(FlowPiPStartFailure) +} + @MainActor -final class FlowPictureInPictureController: NSObject { - /// User closed the PiP window — host should end the Flow session. - var onUserDismissed: (() -> Void)? +protocol FlowPictureInPictureControlling: AnyObject { + var onUnexpectedStop: (() -> Void)? { get set } + var isPictureInPictureActive: Bool { get } + var generation: UInt64 { get } + + func attachHostView(_ view: UIView) + func startAndWait( + hostTimeout: TimeInterval, + activeTimeout: TimeInterval + ) async -> FlowPiPStartOutcome + func stop() + func resetGeneration() + func prepareForBackgroundAutoStart() async + func reassertKeepAliveAudioSession() async -> Bool + func updateWaveformLevels(_ levels: [Float]) +} + +@MainActor +final class FlowPictureInPictureController: NSObject, FlowPictureInPictureControlling { + /// PiP disappeared without an in-process teardown. AVKit deliberately does + /// not distinguish a user close from a system stop or another app takeover. + var onUnexpectedStop: (() -> Void)? private(set) var isPictureInPictureActive = false /// True once a host UIView has been attached (may still be awaiting a window). private(set) var hasHostView = false + /// Monotonic identity for the currently configured AVKit controller. + private(set) var generation: UInt64 = 0 let displayLayer = AVSampleBufferDisplayLayer() @@ -115,29 +146,7 @@ final class FlowPictureInPictureController: NSObject { @discardableResult func start() async -> Bool { - lastSystemStartFailure = nil - guard AVPictureInPictureController.isPictureInPictureSupported() else { - return false - } - guard hasHostView else { return false } - - // Required before constructing the controller; without an active - // session, `isPictureInPicturePossible` stays false forever. - guard await activateAudioSessionForPiP() else { - return false - } - - // If a controller was somehow created before audio activation, rebuild. - if pipController != nil, !didActivateAudioSessionBeforeController { - pipController = nil - } - configureControllerIfNeeded() - if !usesLowPowerVideoCallPiP { - warmLogoCacheIfNeeded() - animationStartedAt = CACurrentMediaTime() - startFramePump() - } - guard pipController != nil else { return false } + guard await prepareControllerForStart() else { return false } if pipController?.isPictureInPictureActive == true { isPictureInPictureActive = true @@ -145,6 +154,7 @@ final class FlowPictureInPictureController: NSObject { return true } + guard pipController?.isPictureInPicturePossible == true else { return false } if !usesLowPowerVideoCallPiP { // Prime a few frames before asking the system to start PiP. enqueueGuideFrame() @@ -164,17 +174,23 @@ final class FlowPictureInPictureController: NSObject { ) async -> FlowPiPStartOutcome { if isPictureInPictureActive { return .started } - guard AVPictureInPictureController.isPictureInPictureSupported() else { + let deadline = Date().addingTimeInterval(activeTimeout) + let supportsPiP = await waitForPictureInPictureSupport( + timeout: min(max(0, deadline.timeIntervalSinceNow), 0.8) + ) + guard supportsPiP else { return .failed(.unsupported) } - let hostReady = await waitForHostInWindow(timeout: hostTimeout) + let hostReady = await waitForHostInWindow( + timeout: min(hostTimeout, max(0, deadline.timeIntervalSinceNow)) + ) guard hostReady else { return .failed(.hostNotReady) } lastSystemStartFailure = nil - guard await start() else { + guard await prepareControllerForStart() else { stopFramePump() if lastSystemStartFailure != nil { return .failed(.systemRejected) @@ -182,17 +198,49 @@ final class FlowPictureInPictureController: NSObject { return .failed(hasHostView ? .notPossible : .hostNotReady) } - let deadline = Date().addingTimeInterval(activeTimeout) while Date() < deadline { if isPictureInPictureActive { return .started } if pipController?.isPictureInPictureActive == true { isPictureInPictureActive = true return .started } - if let pipController, pipController.isPictureInPicturePossible { - pipController.startPictureInPicture() - } else { + if pipController?.isPictureInPicturePossible == true { + break + } + try? await Task.sleep(nanoseconds: 50_000_000) + } + + guard pipController?.isPictureInPicturePossible == true else { + FlowDiagnostics.log( + "PiP generation \(generation) never became possible" + ) + return .failed(.notPossible) + } + + if !usesLowPowerVideoCallPiP { + // Prime a few frames before asking the system to start PiP. + enqueueGuideFrame() + enqueueGuideFrame() + pipController?.invalidatePlaybackState() + } + // Issue immediately when possible. iOS 27 beta can silently ignore a + // request without sending either delegate callback, so retry at a + // conservative cadence while keeping this controller generation alive. + pipController?.startPictureInPicture() + var lastStartRequestAt = Date() + var startRequestCount = 1 + + while Date() < deadline { + if isPictureInPictureActive { return .started } + if pipController?.isPictureInPictureActive == true { + isPictureInPictureActive = true + return .started + } + if pipController?.isPictureInPicturePossible == true, + Date().timeIntervalSince(lastStartRequestAt) >= 0.25 { pipController?.startPictureInPicture() + lastStartRequestAt = Date() + startRequestCount += 1 } try? await Task.sleep(nanoseconds: 50_000_000) } @@ -213,16 +261,24 @@ final class FlowPictureInPictureController: NSObject { failure = .timedOut } FlowDiagnostics.log( - "PiP startAndWait failed: \(failure) possible=\(pipController?.isPictureInPicturePossible == true)" + "PiP startAndWait failed: \(failure) possible=\(pipController?.isPictureInPicturePossible == true) " + + "requests=\(startRequestCount)" ) - stop() return .failed(failure) } func stop() { + resetGeneration() + } + + /// Fully releases the AVKit content source so the next attempt cannot + /// inherit a controller that the system has already rejected. + func resetGeneration() { isStoppingProgrammatically = true stopFramePump() - pipController?.stopPictureInPicture() + let controller = pipController + controller?.delegate = nil + controller?.stopPictureInPicture() if !usesLowPowerVideoCallPiP { displayLayer.sampleBufferRenderer.flush( removingDisplayedImage: true, @@ -232,6 +288,10 @@ final class FlowPictureInPictureController: NSObject { isPictureInPictureActive = false animationStartedAt = nil lastSystemStartFailure = nil + pipController = nil + videoCallContentController = nil + transparentContentView = nil + didActivateAudioSessionBeforeController = false isStoppingProgrammatically = false } @@ -277,6 +337,32 @@ final class FlowPictureInPictureController: NSObject { /// Set once we successfully activate audio before building the controller. private var didActivateAudioSessionBeforeController = false + private func prepareControllerForStart() async -> Bool { + lastSystemStartFailure = nil + guard AVPictureInPictureController.isPictureInPictureSupported() else { + return false + } + guard hasHostView else { return false } + + // Required before constructing the controller; without an active + // session, `isPictureInPicturePossible` stays false forever. + guard await activateAudioSessionForPiP() else { + return false + } + + // If a controller was somehow created before audio activation, rebuild. + if pipController != nil, !didActivateAudioSessionBeforeController { + resetGeneration() + } + configureControllerIfNeeded() + if !usesLowPowerVideoCallPiP { + warmLogoCacheIfNeeded() + animationStartedAt = CACurrentMediaTime() + startFramePump() + } + return pipController != nil + } + @discardableResult private func activateAudioSessionForPiP() async -> Bool { // The first PiP start uses playback. After capture establishes a @@ -289,6 +375,24 @@ final class FlowPictureInPictureController: NSObject { return false } + private func waitForPictureInPictureSupport(timeout: TimeInterval) async -> Bool { + if AVPictureInPictureController.isPictureInPictureSupported() { + return true + } +#if targetEnvironment(simulator) + return false +#else + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + try? await Task.sleep(nanoseconds: 200_000_000) + if AVPictureInPictureController.isPictureInPictureSupported() { + return true + } + } + return AVPictureInPictureController.isPictureInPictureSupported() +#endif + } + private func waitForHostInWindow(timeout: TimeInterval) async -> Bool { if isHostInWindowHierarchy { return true } let deadline = Date().addingTimeInterval(timeout) @@ -341,7 +445,9 @@ final class FlowPictureInPictureController: NSObject { controller.canStartPictureInPictureAutomaticallyFromInline = true controller.requiresLinearPlayback = true pipController = controller + generation &+= 1 didActivateAudioSessionBeforeController = true + FlowDiagnostics.log("PiP controller generation \(generation) configured") } private func releaseAudioSessionForLowPowerPiP() { @@ -642,6 +748,7 @@ extension FlowPictureInPictureController: @preconcurrency AVPictureInPictureCont func pictureInPictureControllerDidStartPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { + guard pictureInPictureController === pipController else { return } isPictureInPictureActive = true lastSystemStartFailure = nil releaseAudioSessionForLowPowerPiP() @@ -650,16 +757,18 @@ extension FlowPictureInPictureController: @preconcurrency AVPictureInPictureCont func pictureInPictureControllerDidStopPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { + guard pictureInPictureController === pipController else { return } isPictureInPictureActive = false stopFramePump() guard !isStoppingProgrammatically else { return } - onUserDismissed?() + onUnexpectedStop?() } func pictureInPictureController( _ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error ) { + guard pictureInPictureController === pipController else { return } // Sample-buffer fallback may need warm-up retries. VideoCall PiP uses // the automatic-inline path plus the bounded startAndWait fallback. lastSystemStartFailure = error diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index 2512c55..c708827 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -31,6 +31,67 @@ enum FlowUtteranceLifecyclePolicy { } } +enum FlowPiPActivationTrigger: String, Sendable { + case coldStart + case foreground + case existingSession + case startupRecovery + case unexpectedStop + case healthCheck + case manualRetry +} + +enum FlowPiPReconciliationDecision: Equatable, Sendable { + case noSessionIntent + case alreadyActive + case waitForForeground + case startRecovery +} + +enum FlowPiPRecoveryPolicy { + static let maxAttempts = 3 + static let totalBudget: TimeInterval = 5 + + static func reconciliationDecision( + wantsActiveSession: Bool, + isAppForeground: Bool, + isPictureInPictureActive: Bool + ) -> FlowPiPReconciliationDecision { + guard wantsActiveSession else { return .noSessionIntent } + if isPictureInPictureActive { return .alreadyActive } + return isAppForeground ? .startRecovery : .waitForForeground + } + + static func canContinue( + operation: UInt64, + currentOperation: UInt64, + wantsActiveSession: Bool, + isAppForeground: Bool, + taskIsCancelled: Bool + ) -> Bool { + operation == currentOperation + && wantsActiveSession + && isAppForeground + && !taskIsCancelled + } + + static func retryDelay(beforeAttempt attempt: Int) -> TimeInterval { + switch attempt { + case 1: return 0 + case 2: return 0.25 + default: return 0.6 + } + } + + static func hostTimeout(attempt: Int, remainingBudget: TimeInterval) -> TimeInterval { + min(attempt == 1 ? 1.5 : 0.25, max(0, remainingBudget)) + } + + static func activeTimeout(remainingBudget: TimeInterval) -> TimeInterval { + min(1.4, max(0, remainingBudget)) + } +} + @MainActor final class FlowSessionManager: ObservableObject { private struct PrefilledAIRequest { @@ -45,12 +106,14 @@ final class FlowSessionManager: ObservableObject { @Published private(set) var sessionExpiresAt: Date? /// Non-nil when continuous capture failed or permissions are missing. @Published private(set) var sessionWarning: String? + @Published private(set) var pipLifecycleState: FlowPiPLifecycleState = .inactive private let capture = FlowContinuousCapture() - private let pipController = FlowPictureInPictureController() + private let pipController: any FlowPictureInPictureControlling private let store = AppGroupStore() + private let analyticsClient: any AnalyticsClient /// Cloud / local polish via the user-configured LLM provider. - private let polisher = PolishingService() + private let polisher: PolishingService /// AI-mode turns are intentionally process-local and never persisted. private let aiConversations = AIConversationStore() /// Cached ASR instance. The only on-device backend is iOS @@ -98,6 +161,7 @@ final class FlowSessionManager: ObservableObject { private var pendingSourceHistoryEntryID: UUID? private var pendingSourceHistoryEntryRevision: Int64? private var pendingAIConversationID: UUID? + private var pendingManagedRequestPurpose: ManagedGatewayRequestPurpose? private var pendingProcessingDeadlineAt: TimeInterval? private var pendingStopUtteranceId: UUID? private var currentCommandSeq: Int64 = 0 @@ -110,6 +174,7 @@ final class FlowSessionManager: ObservableObject { @Published private(set) var isUtteranceProcessing = false private var finalizeTask: Task? private var asrTask: Task? + private var transcriptionAnalyticsOperation: (any AnalyticsAIOperation)? private var utteranceSafetyTask: Task? private var chunkedPipeline: ChunkedUtterancePipeline? private var currentPartial = "" @@ -131,7 +196,10 @@ final class FlowSessionManager: ObservableObject { private var isAppForeground = false /// True while handling a keyboard-initiated `startflow` cold start. private var isColdStartHandoff = false - private var coldStartRecoveryTask: Task? + /// User intent outlives a transient PiP loss; only explicit teardown clears it. + private var wantsActiveSession = false + /// Invalidates late async completions after retry, stop, or process teardown. + private var pipRecoveryOperation: UInt64 = 0 var shouldDeferHostHeavyWork: Bool { isStarting || startingUtteranceId != nil @@ -148,7 +216,13 @@ final class FlowSessionManager: ObservableObject { /// recreate the `@StateObject`-owned manager within the same process). private static var didRunLaunchReconciliation = false - init() { + init( + analyticsClient: any AnalyticsClient = NoopAnalyticsClient(), + pipController: any FlowPictureInPictureControlling = FlowPictureInPictureController() + ) { + self.analyticsClient = analyticsClient + self.polisher = PolishingService(analyticsClient: analyticsClient) + self.pipController = pipController // Sessions are (re)started explicitly on app foreground via // `activateOnForeground()`. We deliberately do NOT silently reattach a // stored session here — after a force-quit that would resurrect capture @@ -194,10 +268,9 @@ final class FlowSessionManager: ObservableObject { kind: .recognitionInterrupted ) } - pipController.onUserDismissed = { [weak self] in - guard let self, self.isActive else { return } - self.debug("PiP dismissed by user — ending Flow session") - self.endSession() + pipController.onUnexpectedStop = { [weak self] in + guard let self, self.wantsActiveSession else { return } + self.handleUnexpectedPiPStop() } FlowTerminationCoordinator.register(self) } @@ -257,25 +330,25 @@ final class FlowSessionManager: ObservableObject { } if isActive { + wantsActiveSession = true extendSession(duration: duration) - refreshHostReady() + if pipController.isPictureInPictureActive { + pipLifecycleState = .active + refreshHostReady() + } else { + ensurePiPActive( + trigger: coldStart ? .coldStart : .existingSession, + duration: duration + ) + } return } - guard !isStarting else { - traceState("startSession.ignored", extra: "reason=alreadyStarting") - return - } - // Claim the flag synchronously: on a cold start the URL router and - // `activateOnForeground()` both fire in the same runloop turn, and - // setting it inside the async body let two start bodies interleave. - isStarting = true - - startTask?.cancel() - startTask = Task { @MainActor [weak self] in - await self?.startSessionAsync(duration: duration) - self?.handleColdStartAfterSessionReady() - } + wantsActiveSession = true + ensurePiPActive( + trigger: coldStart ? .coldStart : .foreground, + duration: duration + ) } /// Clears App Group Flow state left behind when the host process was killed @@ -339,9 +412,19 @@ final class FlowSessionManager: ObservableObject { ) } + /// User-requested recovery always discards the rejected AVKit generation. + func retryPiPRecovery() { + guard AppGroup.isAvailable else { return } + guard AppPermissions.flowRequirementsMet else { + sessionWarning = permissionWarningMessage() + FlowSessionBridge.setHostReady(false) + return + } + wantsActiveSession = true + ensurePiPActive(trigger: .manualRetry, duration: nil, forceReset: true) + } + private func completeColdStartHandoff() { - coldStartRecoveryTask?.cancel() - coldStartRecoveryTask = nil isColdStartHandoff = false if isActive { refreshHostReady() @@ -360,9 +443,10 @@ final class FlowSessionManager: ObservableObject { ) } + wantsActiveSession = false + pipRecoveryOperation &+= 1 + pipLifecycleState = .inactive isColdStartHandoff = false - coldStartRecoveryTask?.cancel() - coldStartRecoveryTask = nil startTask?.cancel() startTask = nil startupAudioHealthTask?.cancel() @@ -433,7 +517,7 @@ final class FlowSessionManager: ObservableObject { } func endSession() { - guard isActive else { return } + guard isActive || isStarting || wantsActiveSession else { return } debug("Flow session ended") if isUtteranceRecording || isUtteranceProcessing, claimTerminal(utteranceId: currentUtteranceId) { @@ -444,9 +528,10 @@ final class FlowSessionManager: ObservableObject { ) } + wantsActiveSession = false + pipRecoveryOperation &+= 1 + pipLifecycleState = .inactive isColdStartHandoff = false - coldStartRecoveryTask?.cancel() - coldStartRecoveryTask = nil startTask?.cancel() startTask = nil startupAudioHealthTask?.cancel() @@ -493,6 +578,7 @@ final class FlowSessionManager: ObservableObject { FlowSessionBridge.markSessionInactive() FlowSessionDarwin.postSessionChanged() isActive = false + isStarting = false sessionExpiresAt = nil sessionWarning = nil currentPartial = "" @@ -516,19 +602,27 @@ final class FlowSessionManager: ObservableObject { case .active: setAppForeground(true) resumeAfterForeground() + if wantsActiveSession, !pipController.isPictureInPictureActive { + ensurePiPActive(trigger: .foreground, duration: nil) + } case .inactive: + setAppForeground(false) writeHeartbeatIfActive() - if isActive { + if isActive, pipController.isPictureInPictureActive { Task { @MainActor [weak self] in await self?.pipController.prepareForBackgroundAutoStart() } + } else if wantsActiveSession { + pausePiPRecoveryUntilForeground() } case .background: setAppForeground(false) - if isActive { + if isActive, pipController.isPictureInPictureActive { Task { @MainActor [weak self] in await self?.pipController.prepareForBackgroundAutoStart() } + } else if wantsActiveSession { + pausePiPRecoveryUntilForeground() } if isColdStartHandoff { completeColdStartHandoff() @@ -549,6 +643,33 @@ final class FlowSessionManager: ObservableObject { FlowSessionBridge.writeHeartbeat() } + private func handleUnexpectedPiPStop() { + guard wantsActiveSession else { return } + traceState("pip.unexpectedStop") + pipLifecycleState = .waitingForForeground + sessionWarning = nil + if isUtteranceRecording { + failUtterance( + message: AppL10n.string("flow.error.recognitionInterrupted"), + kind: .recognitionInterrupted + ) + } + FlowSessionBridge.setHostReady(false) + refreshHostReady() + // Do not immediately fight another app for the system-owned PiP slot. + // The scene-active path or the existing heartbeat performs reconciliation. + } + + private func pausePiPRecoveryUntilForeground() { + startTask?.cancel() + startTask = nil + pipRecoveryOperation &+= 1 + pipController.resetGeneration() + isStarting = false + pipLifecycleState = .waitingForForeground + refreshHostReady() + } + private func resumeAfterForeground() { guard isActive else { return @@ -723,36 +844,192 @@ final class FlowSessionManager: ObservableObject { // MARK: - Session start - private func startSessionAsync(duration: TimeInterval?) async { - // `isStarting` was claimed synchronously in `startSession()`. - defer { isStarting = false } + private func ensurePiPActive( + trigger: FlowPiPActivationTrigger, + duration: TimeInterval?, + forceReset: Bool = false + ) { + switch FlowPiPRecoveryPolicy.reconciliationDecision( + wantsActiveSession: wantsActiveSession, + isAppForeground: isAppForeground, + isPictureInPictureActive: pipController.isPictureInPictureActive + ) { + case .noSessionIntent: + return + case .alreadyActive: + pipLifecycleState = .active + sessionWarning = nil + if isActive { + refreshHostReady() + } else { + activateFlowSessionAfterPiPProof(duration: duration) + } + handleColdStartAfterSessionReady() + return + case .waitForForeground: + pipLifecycleState = .waitingForForeground + FlowSessionBridge.setHostReady(false) + refreshHostReady() + return + case .startRecovery: + break + } + + guard startTask == nil else { + traceState( + "pipRecovery.coalesced", + extra: "trigger=\(trigger.rawValue)" + ) + return + } + + // Claim the flag synchronously: on a cold start the URL router and + // `activateOnForeground()` both fire in the same runloop turn, and + // setting it inside the async body let two start bodies interleave. + isStarting = true + pipRecoveryOperation &+= 1 + let operation = pipRecoveryOperation + let recoveringExistingSession = isActive + let resetFirstGeneration = forceReset + || recoveringExistingSession + || pipController.generation > 0 + pipLifecycleState = recoveringExistingSession + ? .recovering(attempt: 1, total: FlowPiPRecoveryPolicy.maxAttempts) + : .preparing(attempt: 1, total: FlowPiPRecoveryPolicy.maxAttempts) + + startTask = Task { @MainActor [weak self] in + await self?.startSessionAsync( + duration: duration, + trigger: trigger, + operation: operation, + resetFirstGeneration: resetFirstGeneration + ) + } + } + + private func startSessionAsync( + duration: TimeInterval?, + trigger: FlowPiPActivationTrigger, + operation: UInt64, + resetFirstGeneration: Bool + ) async { + // `isStarting` was claimed synchronously in `ensurePiPActive()`. + defer { + if operation == pipRecoveryOperation { + isStarting = false + startTask = nil + } + } guard !Task.isCancelled else { return } - traceState("startSessionAsync.begin") + traceState("startSessionAsync.begin", extra: "trigger=\(trigger.rawValue)") sessionWarning = nil guard AppPermissions.flowRequirementsMet else { sessionWarning = permissionWarningMessage() + pipLifecycleState = .failed(.notPossible) traceState("startSessionAsync.blocked", extra: "reason=permissions") FlowSessionBridge.setHostReady(false) isColdStartHandoff = false return } - switch await pipController.startAndWait() { - case .started: - activateFlowSessionAfterPiPProof(duration: duration) - traceState("startSessionAsync.ready") - debug("Flow session started (PiP keep-alive), mic released between utterances") - case .failed(let failure): - let message = AppL10n.string(failure.localizationKey) - sessionWarning = message - traceState("startSessionAsync.failed", extra: "reason=pipUnavailable failure=\(failure)") - FlowSessionBridge.setHostReady(false) - if isColdStartHandoff { - scheduleColdStartRecovery(duration: duration) + let deadline = Date().addingTimeInterval(FlowPiPRecoveryPolicy.totalBudget) + var lastFailure: FlowPiPStartFailure = .timedOut + + for attempt in 1...FlowPiPRecoveryPolicy.maxAttempts { + guard FlowPiPRecoveryPolicy.canContinue( + operation: operation, + currentOperation: pipRecoveryOperation, + wantsActiveSession: wantsActiveSession, + isAppForeground: isAppForeground, + taskIsCancelled: Task.isCancelled + ) else { + if operation == pipRecoveryOperation, + wantsActiveSession, + !Task.isCancelled, + !isAppForeground { + pipLifecycleState = .waitingForForeground + FlowSessionBridge.setHostReady(false) + } + return + } + + let delay = FlowPiPRecoveryPolicy.retryDelay(beforeAttempt: attempt) + if delay > 0 { + try? await Task.sleep( + nanoseconds: UInt64(delay * 1_000_000_000) + ) + } + guard FlowPiPRecoveryPolicy.canContinue( + operation: operation, + currentOperation: pipRecoveryOperation, + wantsActiveSession: wantsActiveSession, + isAppForeground: isAppForeground, + taskIsCancelled: Task.isCancelled + ) else { return } + + if resetFirstGeneration || attempt > 1 { + pipController.resetGeneration() + } + + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { break } + pipLifecycleState = isActive + ? .recovering(attempt: attempt, total: FlowPiPRecoveryPolicy.maxAttempts) + : .preparing(attempt: attempt, total: FlowPiPRecoveryPolicy.maxAttempts) + traceState( + "pipRecovery.attempt", + extra: "trigger=\(trigger.rawValue) attempt=\(attempt) generation=\(pipController.generation)" + ) + + let outcome = await pipController.startAndWait( + hostTimeout: FlowPiPRecoveryPolicy.hostTimeout( + attempt: attempt, + remainingBudget: remaining + ), + activeTimeout: FlowPiPRecoveryPolicy.activeTimeout( + remainingBudget: deadline.timeIntervalSinceNow + ) + ) + guard FlowPiPRecoveryPolicy.canContinue( + operation: operation, + currentOperation: pipRecoveryOperation, + wantsActiveSession: wantsActiveSession, + isAppForeground: isAppForeground, + taskIsCancelled: Task.isCancelled + ) else { return } + + switch outcome { + case .started: + sessionWarning = nil + pipLifecycleState = .active + if isActive { + refreshHostReady() + } else { + activateFlowSessionAfterPiPProof(duration: duration) + } + traceState( + "startSessionAsync.ready", + extra: "trigger=\(trigger.rawValue) attempt=\(attempt) generation=\(pipController.generation)" + ) + handleColdStartAfterSessionReady() + debug("Flow session started (PiP keep-alive), mic released between utterances") + return + case .failed(let failure): + lastFailure = failure + traceState( + "startSessionAsync.failed", + extra: "reason=pipUnavailable trigger=\(trigger.rawValue) attempt=\(attempt) failure=\(failure)" + ) } - debug("PiP keep-alive failed to start: \(failure)") } + + pipController.resetGeneration() + pipLifecycleState = .failed(lastFailure) + sessionWarning = AppL10n.string(lastFailure.localizationKey) + FlowSessionBridge.setHostReady(false) + refreshHostReady() + debug("PiP keep-alive failed after bounded recovery: \(lastFailure)") } private func activateFlowSessionAfterPiPProof(duration: TimeInterval?) { @@ -762,6 +1039,8 @@ final class FlowSessionManager: ObservableObject { FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId) FlowSessionDarwin.postSessionChanged() isActive = true + wantsActiveSession = true + pipLifecycleState = .active // Low-profile PiP is a system-owned keep-alive surface. Keeping the // display awake wastes far more power than the static PiP itself. ScreenWakeLock.release() @@ -862,18 +1141,10 @@ final class FlowSessionManager: ObservableObject { guard isColdStartHandoff, isActive else { return } sessionWarning = nil if !pipController.isPictureInPictureActive { - switch await pipController.startAndWait() { - case .started: - break - case .failed(let failure): - let message = AppL10n.string(failure.localizationKey) - sessionWarning = message - FlowSessionBridge.setHostReady(false) - scheduleColdStartRecovery(duration: nil) - debug("existing PiP session failed cold-start restart: \(failure)") - return - } + ensurePiPActive(trigger: .existingSession, duration: nil) + return } + pipLifecycleState = .active refreshHostReady() handleColdStartAfterSessionReady() } @@ -895,7 +1166,9 @@ final class FlowSessionManager: ObservableObject { let message = AppL10n.string("flow.session.error.notPossible") sessionWarning = message FlowSessionBridge.setHostReady(false) - scheduleColdStartRecovery(duration: nil) + if !pipController.isPictureInPictureActive { + scheduleColdStartRecovery(duration: nil) + } debug("cold-start blocked: host ready contract not published") return } @@ -927,31 +1200,14 @@ final class FlowSessionManager: ObservableObject { /// bounce the audio session and rebuild. Force-quit relaunches routinely /// inherit stale mediaserverd state that only a rebuild clears. private func scheduleColdStartRecovery(duration: TimeInterval?) { - coldStartRecoveryTask?.cancel() - coldStartRecoveryTask = Task { @MainActor [weak self] in - guard let self else { return } - // The failed controller has been fully torn down. Give the app - // scene and Pegasus service a short settling interval before the - // one bounded cold-start retry creates a new controller generation. - try? await Task.sleep(nanoseconds: 400_000_000) - guard !Task.isCancelled, self.isColdStartHandoff else { return } - let outcome = await self.pipController.startAndWait() - self.traceState("coldStartRecovery.pip", extra: "outcome=\(outcome)") - guard !Task.isCancelled, self.isColdStartHandoff else { return } - switch outcome { - case .started: - if self.isActive { - self.sessionWarning = nil - self.refreshHostReady() - self.handleColdStartAfterSessionReady() - } else { - self.activateFlowSessionAfterPiPProof(duration: duration) - self.handleColdStartAfterSessionReady() - } - case .failed(let failure): - self.sessionWarning = AppL10n.string(failure.localizationKey) - } - } + // The failed controller has been fully torn down. Give the app + // scene and Pegasus service a short settling interval before the + // one bounded cold-start retry creates a new controller generation. + ensurePiPActive( + trigger: .startupRecovery, + duration: duration, + forceReset: true + ) } private func bindSessionASRIfNeeded(force: Bool = false) { @@ -1245,6 +1501,7 @@ final class FlowSessionManager: ObservableObject { ) ) currentUtteranceMode = command.resolvedUtteranceMode + pendingManagedRequestPurpose = command.managedRequestPurpose pendingAIConversationID = currentUtteranceMode == .aiQuestion ? command.aiConversationID : nil @@ -1357,6 +1614,7 @@ final class FlowSessionManager: ObservableObject { commandSeq: command.commandSeq ) else { return } currentUtteranceMode = .aiQuestion + pendingManagedRequestPurpose = command.managedRequestPurpose pendingAIConversationID = command.aiConversationID pendingEditSourceText = nil pendingSourceHistoryEntryID = nil @@ -1435,7 +1693,9 @@ final class FlowSessionManager: ObservableObject { store: pipelineStore, conversations: aiConversations, taskKind: request.taskKind, - thinkingEnabled: request.thinkingEnabled + thinkingEnabled: request.thinkingEnabled, + analyticsClient: analyticsClient, + analyticsFeature: Self.analyticsFeature(for: request.taskKind) ) aiAnswerStreamThrottle = AIAnswerStreamThrottle() let answer = try await service.answer( @@ -1610,6 +1870,13 @@ final class FlowSessionManager: ObservableObject { kind: FlowSessionKeys.TranscriptionErrorKind = .generic, status: FlowResult.Status = .error ) { + finishTranscriptionAnalytics( + success: false, + failureCategory: Self.transcriptionFailureCategory( + kind: kind, + status: status + ) + ) guard let activeSessionId, let currentUtteranceId else { return } FlowSessionBridge.writeResult( FlowResult( @@ -1901,6 +2168,11 @@ final class FlowSessionManager: ObservableObject { ) let cloudASRForStreaming = useStreaming ? cloudService : nil + transcriptionAnalyticsOperation?.cancel() + transcriptionAnalyticsOperation = analyticsClient.startAIFeature( + .transcription, + executionMode: analyticsExecutionMode + ) asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in let outcome: ChunkedUtterancePipelineOutcome @@ -2220,6 +2492,7 @@ final class FlowSessionManager: ObservableObject { pendingSourceHistoryEntryID = nil pendingSourceHistoryEntryRevision = nil pendingAIConversationID = nil + pendingManagedRequestPurpose = nil pendingProcessingDeadlineAt = nil currentUtteranceMode = .dictation } @@ -2237,6 +2510,7 @@ final class FlowSessionManager: ObservableObject { let sourceHistoryEntryID = pendingSourceHistoryEntryID let sourceHistoryEntryRevision = pendingSourceHistoryEntryRevision let aiConversationID = pendingAIConversationID + let managedRequestPurpose = pendingManagedRequestPurpose let processingDeadlineAt = pendingProcessingDeadlineAt let asrEngineMode = sessionASREngineMode ?? store.engineMode // ALWAYS clear the processing gate for this utterance. The previous @@ -2250,6 +2524,7 @@ final class FlowSessionManager: ObservableObject { pendingSourceHistoryEntryID = nil pendingSourceHistoryEntryRevision = nil pendingAIConversationID = nil + pendingManagedRequestPurpose = nil pendingProcessingDeadlineAt = nil if currentUtteranceMode == utteranceMode { currentUtteranceMode = .dictation @@ -2366,6 +2641,7 @@ final class FlowSessionManager: ObservableObject { ) return } + finishTranscriptionAnalytics(success: true) // Re-read App Group at finalize so dictionary and translation changes // from the keyboard extension are visible before local correction, @@ -2427,7 +2703,9 @@ final class FlowSessionManager: ObservableObject { let service = try AIQuestionService.configured( store: pipelineStore, conversations: aiConversations, - taskKind: .aiQuestion + taskKind: .aiQuestion, + analyticsClient: analyticsClient, + analyticsFeature: .aiAssistant ) aiAnswerStreamThrottle = AIAnswerStreamThrottle() let publishUtteranceId = finalizeUtteranceId @@ -2564,6 +2842,7 @@ final class FlowSessionManager: ObservableObject { systemPrompt: instructionPrompt?.system, providerIdOverride: pipelineStore.polishProviderIdOverride, taskKind: isEditLastInput ? .editLastInput : nil, + requestPurpose: managedRequestPurpose, context: isInstructionMode ? nil : polishContext, timeoutLimit: processingDeadlineAt.map { max(0.1, $0 - Date().timeIntervalSince1970) @@ -2868,6 +3147,13 @@ final class FlowSessionManager: ObservableObject { status: FlowResult.Status = .error, aiConversationID: UUID? = nil ) { + finishTranscriptionAnalytics( + success: false, + failureCategory: Self.transcriptionFailureCategory( + kind: kind, + status: status + ) + ) guard let sessionId, let utteranceId else { return } FlowTrace.warn( "host.deliveredError", @@ -2892,6 +3178,59 @@ final class FlowSessionManager: ObservableObject { ) } + private static func analyticsFeature( + for taskKind: ManagedGatewayTaskKind + ) -> AnalyticsFeature { + switch taskKind { + case .aiQuestion: + return .hotword + case .clipboardTransform, .customSkill, .agentPlanning: + return .agent + case .dictationPolish, .translation, .editLastInput: + return .other + } + } + + private var analyticsExecutionMode: AnalyticsExecutionMode { + if store.engineMode == "local" { + return .local + } + return store.credentialSource == .managed ? .managed : .byok + } + + private func finishTranscriptionAnalytics( + success: Bool, + failureCategory: AnalyticsFailureCategory = .unknown + ) { + guard let operation = transcriptionAnalyticsOperation else { return } + transcriptionAnalyticsOperation = nil + if success { + operation.succeed() + } else { + operation.fail(category: failureCategory) + } + } + + private static func transcriptionFailureCategory( + kind: FlowSessionKeys.TranscriptionErrorKind, + status: FlowResult.Status + ) -> AnalyticsFailureCategory { + if status == .aborted { + return .cancelled + } + if status == .timeout { + return .timeout + } + switch kind { + case .noSpeech, .discardedEmpty: + return .validation + case .recognitionInterrupted: + return .cancelled + case .asrFailed, .audioUnavailable, .generic: + return .provider + } + } + private static var lastResultRevision: Int64 = 0 private static func resultRevision() -> Int64 { @@ -3030,6 +3369,7 @@ final class FlowSessionManager: ObservableObject { systemPrompt: String? = nil, providerIdOverride: String?, taskKind: ManagedGatewayTaskKind? = nil, + requestPurpose: ManagedGatewayRequestPurpose? = nil, context: PolishContext?, timeoutLimit: TimeInterval? = nil ) async throws -> PolishingService.PolishOutcome { @@ -3042,6 +3382,7 @@ final class FlowSessionManager: ObservableObject { systemPrompt: systemPrompt, providerIdOverride: providerIdOverride, taskKind: taskKind, + requestPurpose: requestPurpose, context: context ) } @@ -3081,6 +3422,14 @@ final class FlowSessionManager: ObservableObject { self.isUtteranceRecording || self.isUtteranceProcessing { await self.reactivateCaptureIfNeeded() } + if self.wantsActiveSession, + !self.pipController.isPictureInPictureActive { + if self.isAppForeground { + self.ensurePiPActive(trigger: .healthCheck, duration: nil) + } else { + self.pipLifecycleState = .waitingForForeground + } + } FlowSessionBridge.writeHeartbeat() self.refreshHostReady() try? await Task.sleep(nanoseconds: 1_000_000_000) @@ -3123,6 +3472,9 @@ final class FlowSessionManager: ObservableObject { "event=\(event)", "active=\(isActive)", "starting=\(isStarting)", + "wantsActive=\(wantsActiveSession)", + "pipState=\(String(describing: pipLifecycleState))", + "pipGeneration=\(pipController.generation)", "coldStart=\(isColdStartHandoff)", "sessionId=\(sessionId)", "utteranceId=\(utteranceId)", diff --git a/OSGKeyboard/Views/Account/AccountCenterView.swift b/OSGKeyboard/Views/Account/AccountCenterView.swift index 96c2e7c..b5ec61d 100644 --- a/OSGKeyboard/Views/Account/AccountCenterView.swift +++ b/OSGKeyboard/Views/Account/AccountCenterView.swift @@ -505,6 +505,7 @@ private struct AccountCreditsSection: View { private struct AccountCreditPurchaseSection: View { @Environment(\.themePalette) private var palette @ObservedObject var manager: AccountCreditPurchaseManager + @State private var didRecordPurchaseView = false let accountID: UUID let onGranted: () -> Void @@ -540,6 +541,11 @@ private struct AccountCreditPurchaseSection: View { guard current != nil, current != previous else { return } onGranted() } + .onAppear { + guard !didRecordPurchaseView else { return } + didRecordPurchaseView = true + manager.recordPurchaseViewed() + } } private func purchaseOption(_ option: AccountCreditPurchaseOption) -> some View { @@ -777,10 +783,17 @@ private struct AccountInvitationActivityView: UIViewControllerRepresentable { let invitationURL: URL func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController( + let controller = UIActivityViewController( activityItems: [invitationURL], applicationActivities: nil ) + controller.completionWithItemsHandler = { _, completed, _, _ in + guard completed else { return } + Task { @MainActor in + AnalyticsHostService.shared.client.recordReferralShared() + } + } + return controller } func updateUIViewController( diff --git a/OSGKeyboard/Views/Account/AccountCreditPurchaseManager.swift b/OSGKeyboard/Views/Account/AccountCreditPurchaseManager.swift index 7288e30..1d878c9 100644 --- a/OSGKeyboard/Views/Account/AccountCreditPurchaseManager.swift +++ b/OSGKeyboard/Views/Account/AccountCreditPurchaseManager.swift @@ -7,6 +7,7 @@ import Combine import Foundation import OSGKeyboardHostSupport +import OSGKeyboardShared struct AccountCreditPurchaseOption: Identifiable, Equatable { var id: String { productID } @@ -43,6 +44,7 @@ final class AccountCreditPurchaseManager: ObservableObject { private let service: any AccountCenterServicing private let store: any AccountCreditStore + private let analyticsClient: any AnalyticsClient private var updatesTask: Task? private var activeAccountID: UUID? private var configuredProductIDs: Set = [] @@ -50,10 +52,12 @@ final class AccountCreditPurchaseManager: ObservableObject { init( service: any AccountCenterServicing, - store: any AccountCreditStore = LiveAccountCreditStore() + store: any AccountCreditStore = LiveAccountCreditStore(), + analyticsClient: any AnalyticsClient = NoopAnalyticsClient() ) { self.service = service self.store = store + self.analyticsClient = analyticsClient } deinit { @@ -100,6 +104,7 @@ final class AccountCreditPurchaseManager: ObservableObject { return false } state = .purchasing(productID: productID) + analyticsClient.recordPurchaseStarted() do { switch try await store.purchase(productID: productID, accountID: accountID) { case .success(let verification): @@ -109,6 +114,7 @@ final class AccountCreditPurchaseManager: ObservableObject { state = .pending case .userCancelled: state = .idle + analyticsClient.recordPurchaseCancelled() @unknown default: state = .failed(messageKey: "account.storekit.error.unknown") } @@ -118,6 +124,10 @@ final class AccountCreditPurchaseManager: ObservableObject { return false } + func recordPurchaseViewed() { + analyticsClient.recordPurchaseViewed() + } + func clearTransientState() { switch state { case .succeeded, .failed, .pending: diff --git a/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift b/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift index efb4122..748e696 100644 --- a/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift +++ b/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift @@ -6,6 +6,7 @@ import Combine import Foundation +import OSGKeyboardShared @MainActor final class AccountSessionCoordinator: ObservableObject { @@ -46,6 +47,9 @@ final class AccountSessionCoordinator: ObservableObject { private let sessionService: any AccountSessionServicing private let centerService: any AccountCenterServicing private let pendingReferralStore: any PendingReferralCodeStoring + private let analyticsClient: any AnalyticsClient + private let onAccountAuthenticated: (UUID) async -> Void + private let onAccountDeleted: () async -> Void private let accountRefreshInterval: TimeInterval private let now: () -> Date private var didAttemptRestore = false @@ -57,15 +61,22 @@ final class AccountSessionCoordinator: ObservableObject { pendingReferralStore: any PendingReferralCodeStoring = UserDefaultsPendingReferralCodeStore(), accountRefreshInterval: TimeInterval = 10 * 60, - now: @escaping () -> Date = Date.init + now: @escaping () -> Date = Date.init, + analyticsClient: any AnalyticsClient = NoopAnalyticsClient(), + onAccountAuthenticated: @escaping (UUID) async -> Void = { _ in }, + onAccountDeleted: @escaping () async -> Void = {} ) { sessionService = dependencies.sessionService centerService = dependencies.centerService creditPurchases = AccountCreditPurchaseManager( service: dependencies.centerService, - store: creditStore + store: creditStore, + analyticsClient: analyticsClient ) self.pendingReferralStore = pendingReferralStore + self.analyticsClient = analyticsClient + self.onAccountAuthenticated = onAccountAuthenticated + self.onAccountDeleted = onAccountDeleted self.accountRefreshInterval = accountRefreshInterval self.now = now pendingReferralCode = pendingReferralStore.code @@ -94,6 +105,7 @@ final class AccountSessionCoordinator: ObservableObject { sessionPhase = .signedOut return } + await onAccountAuthenticated(session.accountID) sessionPhase = .signedIn(session) await redeemPendingReferralIfNeeded() await refreshAccountData(force: true) @@ -114,6 +126,7 @@ final class AccountSessionCoordinator: ObservableObject { pendingReferralCode = code shouldPresentAccountCenter = true operationErrorKey = nil + analyticsClient.recordInviteOpened() if isSignedIn { Task { @@ -138,6 +151,7 @@ final class AccountSessionCoordinator: ObservableObject { do { let session = try await sessionService.signIn(with: payload) + await onAccountAuthenticated(session.accountID) sessionPhase = .signedIn(session) await redeemPendingReferralIfNeeded() await refreshAccountData(force: true) @@ -260,6 +274,7 @@ final class AccountSessionCoordinator: ObservableObject { do { try await sessionService.deleteAccount(with: payload) + await onAccountDeleted() creditPurchases.reset() clearAccountRefreshState() sessionPhase = .signedOut diff --git a/OSGKeyboard/Views/Account/LiveAccountServices.swift b/OSGKeyboard/Views/Account/LiveAccountServices.swift index 588b67f..91fe84b 100644 --- a/OSGKeyboard/Views/Account/LiveAccountServices.swift +++ b/OSGKeyboard/Views/Account/LiveAccountServices.swift @@ -22,6 +22,9 @@ enum LiveAccountDependencyFactory { let sessionVault = HostPrivateAccountKeychain(descriptor: descriptor) let apiClient = AccountAPIClient(sessionVault: sessionVault) + HostAnalyticsBearerBridge.shared.install( + AccountAnalyticsBearerProvider(apiClient: apiClient) + ) let integrity = DeviceIntegrityCoordinator( apiClient: apiClient, keyStateStore: sessionVault diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index e80fb29..1b69bdd 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -14,6 +14,68 @@ private enum HomeRoute: Hashable { case dictionary } +enum FlowHomePiPStatusDescriptor: Equatable { + case text(localizationKey: String) + case progress(localizationKey: String, attempt: Int, total: Int) +} + +enum FlowHomePiPStatusPolicy { + static func descriptor( + lifecycle: FlowPiPLifecycleState, + isStarting: Bool, + isRecording: Bool, + isProcessing: Bool, + isActive: Bool, + isHostReady: Bool + ) -> FlowHomePiPStatusDescriptor { + switch lifecycle { + case .preparing(let attempt, let total): + return .progress( + localizationKey: "home.flow.preparingProgress", + attempt: attempt, + total: total + ) + case .recovering(let attempt, let total): + return .progress( + localizationKey: "home.flow.recoveringProgress", + attempt: attempt, + total: total + ) + case .waitingForForeground: + return .text(localizationKey: "home.flow.waitingForForeground") + case .failed: + return .text(localizationKey: "home.flow.recoveryFailed") + case .inactive, .active: + break + } + if isStarting { + return .text(localizationKey: "home.flow.starting") + } + if isRecording { + return .text(localizationKey: "home.flow.recording") + } + if isProcessing { + return .text(localizationKey: "home.flow.processing") + } + if isActive, isHostReady { + return .text(localizationKey: "home.flow.label") + } + if isActive { + // Session flag is up but the ready contract is not — do not lie. + return .text(localizationKey: "home.flow.notReady") + } + return .text(localizationKey: "home.flow.inactive") + } + + static func canRetry( + lifecycle: FlowPiPLifecycleState, + needsPermissionSetup: Bool + ) -> Bool { + guard case .failed = lifecycle else { return false } + return !needsPermissionSetup + } +} + struct HomeView: View { @Environment(\.themePalette) private var palette: ThemePalette @Environment(\.scenePhase) private var scenePhase @@ -55,6 +117,23 @@ struct HomeView: View { !sessionIsLive && !needsPermissionSetup } + private var canRetryPiP: Bool { + FlowHomePiPStatusPolicy.canRetry( + lifecycle: flowManager.pipLifecycleState, + needsPermissionSetup: needsPermissionSetup + ) + } + + private var canEndFlowSession: Bool { + if flowManager.isActive || flowManager.isStarting { return true } + switch flowManager.pipLifecycleState { + case .waitingForForeground, .failed: + return true + case .inactive, .preparing, .recovering, .active: + return false + } + } + private var shouldShowKeyboardHint: Bool { !keyboardHintDismissed && !KeyboardSetupBridge.isReadyForOnboardingSkip @@ -125,11 +204,11 @@ struct HomeView: View { .padding(.top, logoTopPadding) .padding(.bottom, logoBottomPadding) - if showsFlowSessionExtras { - flowSessionExtras - .padding(.horizontal, Spacing.lg) - .padding(.bottom, extrasBottomPadding) - } + // Engine + Flow status now stays directly below the + // logo so startup and recovery are always visible. + scrollStatusFooter + .padding(.horizontal, Spacing.lg) + .padding(.bottom, extrasBottomPadding) HomeUsageStatsSection(layout: .stacked, compact: isCompact) .padding(.horizontal, Spacing.lg) @@ -138,11 +217,6 @@ struct HomeView: View { homeLibrarySection .padding(.horizontal, Spacing.lg) .padding(.bottom, Spacing.xl) - - // Scrolls with the page (not pinned); clearance comes from - // `tabBarScrollBottomPadding` so the dock never covers it. - scrollStatusFooter - .padding(.horizontal, Spacing.lg) } .frame(maxWidth: .infinity) .tabBarScrollBottomPadding() @@ -152,14 +226,14 @@ struct HomeView: View { } } - /// Engine + Flow status — last content in the scroll stack. + /// Engine + Flow status — visible near the logo instead of at scroll bottom. private var scrollStatusFooter: some View { - VStack(spacing: Spacing.xs) { + setupGuidanceCard { engineStatusLine flowStatusFooter + Divider() + flowSessionExtras } - .frame(maxWidth: .infinity, alignment: .center) - .multilineTextAlignment(.center) } // MARK: - Wide layout (iPad / regular width) @@ -169,16 +243,11 @@ struct HomeView: View { VStack(alignment: .leading, spacing: Spacing.lg) { wideHeroHeader - if showsFlowSessionExtras { - flowSessionExtras - } + scrollStatusFooter HomeUsageStatsSection(layout: .split) homeLibrarySection - - scrollStatusFooter - .frame(maxWidth: .infinity) } .padding(.horizontal, WideLayoutMetrics.pageHorizontalInset) .padding(.top, Spacing.sm) @@ -466,7 +535,26 @@ struct HomeView: View { if needsAPIKeySetup { // 无按钮:引导卡片已提示去设置填 API Key。 EmptyView() - } else if flowManager.isActive { + } else if canRetryPiP { + Button { + flowManager.retryPiPRecovery() + } label: { + Text("home.flow.retry") + .font(TypeStyle.caption2) + .foregroundStyle(palette.accent) + } + .buttonStyle(.plain) + .padding(.leading, Spacing.xs) + Button { + flowManager.endSession() + } label: { + Text("home.flow.endShort") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textSecondary) + } + .buttonStyle(.plain) + .padding(.leading, Spacing.xs) + } else if canEndFlowSession { Button { flowManager.endSession() } label: { @@ -477,8 +565,8 @@ struct HomeView: View { .buttonStyle(.plain) .padding(.leading, Spacing.xs) } else if canManuallyStartSession { - // Sessions no longer auto-start on foreground (keyboard survival). - // After a manual stop / expiry, Start is the explicit re-entry. + // Foreground activation is automatic. After an explicit stop + // while staying on this screen, Start is the explicit re-entry. Button { flowManager.activateOnForeground( reason: "HomeView.startButton", @@ -498,18 +586,10 @@ struct HomeView: View { // MARK: - Flow extras (warnings / hints) - private var showsFlowSessionExtras: Bool { - needsPermissionSetup - || flowManager.sessionWarning != nil - || needsAPIKeySetup - || shouldShowKeyboardHint - || !flowManager.isActive - } - @ViewBuilder private var flowSessionExtras: some View { if needsPermissionSetup { - setupGuidanceCard { + VStack(alignment: .leading, spacing: Spacing.sm) { Text(AppPermissions.homePermissionGuidanceMessage) .font(TypeStyle.caption2) .foregroundStyle(palette.warning) @@ -526,14 +606,14 @@ struct HomeView: View { .buttonStyle(.plain) } } else if let warning = flowManager.sessionWarning { - setupGuidanceCard { + VStack(alignment: .leading, spacing: Spacing.sm) { Text(warning) .font(TypeStyle.caption2) .foregroundStyle(palette.warning) .fixedSize(horizontal: false, vertical: true) } } else if needsAPIKeySetup { - setupGuidanceCard { + VStack(alignment: .leading, spacing: Spacing.sm) { Text(config.isLocalEngine ? "home.setup.polishKeyMissing" : "home.setup.cloudIncomplete") @@ -542,7 +622,7 @@ struct HomeView: View { .fixedSize(horizontal: false, vertical: true) } } else if shouldShowKeyboardHint { - setupGuidanceCard { + VStack(alignment: .leading, spacing: Spacing.sm) { Text("home.setup.keyboardHint") .font(TypeStyle.caption2) .foregroundStyle(palette.textSecondary) @@ -584,6 +664,8 @@ struct HomeView: View { if needsAPIKeySetup { return palette.warning } if flowManager.isUtteranceRecording { return palette.accent } if flowManager.isUtteranceProcessing { return palette.accent } + if case .failed = flowManager.pipLifecycleState { return palette.warning } + if case .waitingForForeground = flowManager.pipLifecycleState { return palette.warning } if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent } if flowManager.isStarting { return palette.accent } if needsPermissionSetup { return palette.warning } @@ -598,23 +680,20 @@ struct HomeView: View { /// with the OS), so the previous downloading / warming / failed /// states collapse into the cloud-engine branch. private var flowCapsuleStatusMessage: String { - if flowManager.isStarting { - return AppL10n.string("home.flow.starting") + let descriptor = FlowHomePiPStatusPolicy.descriptor( + lifecycle: flowManager.pipLifecycleState, + isStarting: flowManager.isStarting, + isRecording: flowManager.isUtteranceRecording, + isProcessing: flowManager.isUtteranceProcessing, + isActive: flowManager.isActive, + isHostReady: FlowSessionBridge.isHostReady() + ) + switch descriptor { + case .text(let localizationKey): + return AppL10n.string(localizationKey) + case .progress(let localizationKey, let attempt, let total): + return AppL10n.format(localizationKey, attempt, total) } - if flowManager.isUtteranceRecording { - return AppL10n.string("home.flow.recording") - } - if flowManager.isUtteranceProcessing { - return AppL10n.string("home.flow.processing") - } - if flowManager.isActive, FlowSessionBridge.isHostReady() { - return AppL10n.string("home.flow.label") - } - if flowManager.isActive { - // Session flag is up but the ready contract is not — do not lie. - return AppL10n.string("home.flow.notReady") - } - return AppL10n.string("home.flow.inactive") } private var engineStatusLine: some View { diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift index 45d2a00..e87984d 100644 --- a/OSGKeyboard/Views/MainAppRoot.swift +++ b/OSGKeyboard/Views/MainAppRoot.swift @@ -16,15 +16,30 @@ struct MainAppRoot: View { // `@ObservedObject` keeps subscriptions correct across Settings replay. @ObservedObject private var config = ProviderConfig.shared @ObservedObject private var releaseNotes = ReleaseNotesController.shared - @StateObject private var flowManager = FlowSessionManager() + @ObservedObject private var analytics = AnalyticsHostService.shared + @StateObject private var flowManager: FlowSessionManager @StateObject private var accountSession: AccountSessionCoordinator @State private var clmWarmupTask: Task? @State private var rimeStartupTask: Task? + @State private var firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel = .unknown init(accountDependencies: AccountDependencies? = nil) { let resolvedDependencies = accountDependencies ?? LiveAccountDependencyFactory.make() + let analytics = AnalyticsHostService.shared + _flowManager = StateObject( + wrappedValue: FlowSessionManager(analyticsClient: analytics.client) + ) _accountSession = StateObject( - wrappedValue: AccountSessionCoordinator(dependencies: resolvedDependencies) + wrappedValue: AccountSessionCoordinator( + dependencies: resolvedDependencies, + analyticsClient: analytics.client, + onAccountAuthenticated: { accountID in + await analytics.observeAuthenticatedAccount(accountID) + }, + onAccountDeleted: { + await analytics.handleAccountDeletion() + } + ) ) } @@ -35,6 +50,7 @@ struct MainAppRoot: View { .environment(\.locale, config.uiLanguage.swiftUILocale) .environmentObject(flowManager) .environmentObject(accountSession) + .environmentObject(analytics) .background { FlowPiPHostView { view in flowManager.attachPiPHostView(view) @@ -65,6 +81,9 @@ struct MainAppRoot: View { AppOpenURLRouter.shared.register { url in handleIncomingURL(url) } + analytics.prepare( + firstOpenAcquisitionChannel: firstOpenAcquisitionChannel + ) OSGDiag.log( "MainAppRoot.onAppear scene=\(String(describing: scenePhase)) " + "onboarding=\(config.hasCompletedOnboarding) \(OSGDiag.memoryTag())", @@ -131,6 +150,11 @@ struct MainAppRoot: View { } .onChange(of: scenePhase) { _, phase in flowManager.handleScenePhase(phase) + if phase == .active { + analytics.appDidBecomeActive() + } else if phase == .background { + analytics.appDidEnterBackground() + } guard phase == .active else { clmWarmupTask?.cancel() clmWarmupTask = nil @@ -200,7 +224,7 @@ struct MainAppRoot: View { MainTabView() .id("main") } else { - OnboardingView(config: config) + OnboardingExperienceView(config: config) .id("onboarding") } } @@ -246,6 +270,7 @@ struct MainAppRoot: View { private func handleIncomingURL(_ url: URL) { if accountSession.handleIncomingURL(url) { + firstOpenAcquisitionChannel = .referral NotificationCenter.default.post(name: .osgOpenAccountDeepLink, object: nil) return } diff --git a/OSGKeyboard/Views/OnboardingExperienceView.swift b/OSGKeyboard/Views/OnboardingExperienceView.swift new file mode 100644 index 0000000..29db1b7 --- /dev/null +++ b/OSGKeyboard/Views/OnboardingExperienceView.swift @@ -0,0 +1,1063 @@ +// OnboardingExperienceView.swift +// OSGKeyboard · Main App +// +// A short, outcome-led first run: +// 1) Explain the product and its data boundaries. +// 2) Request the two permissions needed by Flow. +// 3) Guide keyboard installation with a visual system-settings preview. +// 4) Teach on the real keyboard inside a real host text field. +// 5) Celebrate the first verified voice insertion. + +import OSGKeyboardShared +import SwiftUI +import UIKit + +private enum OnboardingExperienceStep: Int, CaseIterable { + case introduction + case permissions + case keyboard + case practice + case complete +} + +struct OnboardingExperienceView: View { + @Environment(\.scenePhase) private var scenePhase + @Environment(\.themePalette) private var palette + @EnvironmentObject private var flowManager: FlowSessionManager + @EnvironmentObject private var accountSession: AccountSessionCoordinator + + @ObservedObject var config: ProviderConfig + @ObservedObject private var deployment = RimeDeploymentController.shared + + @State private var micStatus = AppPermissions.micStatus + @State private var speechStatus = AppPermissions.speechStatus + @State private var keyboardAppeared = KeyboardSetupBridge.hasAppeared + @State private var keyboardReady = KeyboardSetupBridge.isReadyForOnboardingSkip + @State private var hasOpenedKeyboardSettings = false + @State private var isRequestingPermissions = false + @State private var practiceText = "" + @State private var practiceStartedAt: Date? + @State private var didVerifyVoiceInsertion = false + @State private var isPreparingManagedPractice = false + @State private var managedPracticeReady = false + @State private var managedPracticeFailed = false + @FocusState private var practiceFieldFocused: Bool + + private static let migrationKey = "onboarding.experience.v2.migrated" + + private var currentStep: OnboardingExperienceStep { + OnboardingExperienceStep(rawValue: config.onboardingPage) ?? .introduction + } + + var body: some View { + ZStack { + palette.background.ignoresSafeArea() + ambientBackground + + VStack(spacing: 0) { + page + .frame(maxWidth: .infinity, maxHeight: .infinity) + .id(currentStep) + .transition(.opacity.combined(with: .move(edge: .trailing))) + + if currentStep != .practice { + bottomAction + .padding(.horizontal, Spacing.lg) + .padding(.bottom, Spacing.lg) + } + } + } + .onAppear { + migrateLegacyProgressIfNeeded() + applyPrivacySafeDefaultsIfNeeded() + refreshState() + if currentStep == .practice { + beginPractice() + } + } + .onDisappear { + endPractice() + } + .onChange(of: scenePhase) { _, phase in + guard phase == .active else { return } + refreshState() + if currentStep == .practice { + beginPracticeIfNeeded() + } + } + .onChange(of: currentStep) { previous, current in + if previous == .practice { + endPractice() + } + if current == .practice { + beginPractice() + } + } + .onChange(of: accountSession.isSignedIn) { _, isSignedIn in + guard isSignedIn, currentStep == .practice, + config.hasAcknowledgedCloudSharing else { return } + prepareManagedPractice() + } + .onChange(of: config.hasAcknowledgedCloudSharing) { _, acknowledged in + guard acknowledged, currentStep == .practice, + accountSession.isSignedIn else { return } + prepareManagedPractice() + } + .task(id: currentStep) { + guard currentStep == .practice else { return } + await monitorPractice() + } + } + + @ViewBuilder + private var page: some View { + switch currentStep { + case .introduction: + introductionPage + case .permissions: + permissionsPage + case .keyboard: + keyboardPage + case .practice: + practicePage + case .complete: + completePage + } + } + + private var ambientBackground: some View { + GeometryReader { geometry in + LinearGradient( + colors: [ + palette.accent.opacity(0.10), + palette.accent.opacity(0.025), + palette.background.opacity(0) + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: geometry.size.height * 0.46) + .allowsHitTesting(false) + } + .ignoresSafeArea(edges: .top) + } + + // MARK: - Introduction + + private var introductionPage: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Spacer(minLength: Spacing.xxl) + + Image("osglogo") + .resizable() + .scaledToFit() + .frame(maxWidth: 174, maxHeight: 50) + .accessibilityHidden(true) + + Text("onboarding.experience.intro.eyebrow") + .font(TypeStyle.caption) + .tracking(1.2) + .foregroundStyle(palette.accent) + .padding(.horizontal, Spacing.sm) + .frame(height: 28) + .background(palette.accentMuted, in: Capsule()) + .padding(.top, Spacing.hero) + + Text("onboarding.experience.intro.title") + .font(TypeStyle.largeTitle) + .foregroundStyle(palette.textPrimary) + .padding(.top, Spacing.md) + + Text("onboarding.experience.intro.subtitle") + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, Spacing.sm) + + VStack(spacing: Spacing.sm) { + promiseRow( + icon: "waveform", + title: "onboarding.experience.privacy.local.title", + detail: "onboarding.experience.privacy.local.body" + ) + promiseRow( + icon: "network", + title: "onboarding.experience.privacy.cloud.title", + detail: "onboarding.experience.privacy.cloud.body" + ) + promiseRow( + icon: "keyboard", + title: "onboarding.experience.privacy.anywhere.title", + detail: "onboarding.experience.privacy.anywhere.body" + ) + } + .padding(.top, Spacing.xxl) + + if let privacyURL = LegalLinks.privacyPolicyURL { + Link(destination: privacyURL) { + Text("legal.privacyPolicy") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .underline() + } + .padding(.top, Spacing.lg) + } + } + .padding(.horizontal, Spacing.xl) + .padding(.bottom, Spacing.xl) + .frame(maxWidth: 560, alignment: .leading) + .frame(maxWidth: .infinity) + } + } + + private func promiseRow( + icon: String, + title: LocalizedStringKey, + detail: LocalizedStringKey + ) -> some View { + HStack(alignment: .top, spacing: Spacing.md) { + Image(systemName: icon) + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(palette.accent) + .frame(width: 42, height: 42) + .background(palette.accentMuted, in: RoundedRectangle(cornerRadius: Radius.medium)) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(TypeStyle.bodyEmph) + .foregroundStyle(palette.textPrimary) + Text(detail) + .font(TypeStyle.footnote) + .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + .padding(Spacing.md) + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + + // MARK: - Permissions + + private var permissionsPage: some View { + OnboardingExperienceShell( + title: "onboarding.experience.permissions.title", + subtitle: "onboarding.experience.permissions.subtitle", + onBack: goBack + ) { + VStack(spacing: Spacing.sm) { + permissionRow( + icon: "mic.fill", + title: "onboarding.permission.mic.title", + detail: "onboarding.permission.mic.body", + granted: micStatus == .granted, + denied: micStatus == .denied + ) + permissionRow( + icon: "waveform.badge.mic", + title: "onboarding.permission.speech.title", + detail: "onboarding.permission.speech.body", + granted: speechStatus == .granted, + denied: speechPermissionDenied + ) + } + .padding(.top, Spacing.xxl) + + Text("onboarding.experience.permissions.systemHint") + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, Spacing.lg) + .padding(.top, Spacing.lg) + + if !permissionsReady { + Button("onboarding.experience.permissions.later") { + goForward() + } + .font(TypeStyle.footnote) + .foregroundStyle(palette.textSecondary) + .padding(.top, Spacing.xl) + } + } + } + + private func permissionRow( + icon: String, + title: LocalizedStringKey, + detail: LocalizedStringKey, + granted: Bool, + denied: Bool + ) -> some View { + HStack(spacing: Spacing.md) { + Image(systemName: icon) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(palette.accent) + .frame(width: 42, height: 42) + .background(palette.accentMuted, in: RoundedRectangle(cornerRadius: Radius.medium)) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(TypeStyle.bodyEmph) + .foregroundStyle(palette.textPrimary) + Text(detail) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + + Spacer(minLength: Spacing.sm) + + Image(systemName: granted ? "checkmark.circle.fill" : (denied ? "exclamationmark.circle.fill" : "circle")) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(granted ? palette.accent : (denied ? palette.warning : palette.textTertiary)) + .accessibilityHidden(true) + } + .padding(Spacing.md) + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + + // MARK: - Keyboard setup + + private var keyboardPage: some View { + OnboardingExperienceShell( + title: "onboarding.experience.keyboard.title", + subtitle: "onboarding.experience.keyboard.subtitle", + onBack: goBack + ) { + KeyboardSettingsPreview(isReady: keyboardReady || hasOpenedKeyboardSettings) + .padding(.top, Spacing.xxl) + + HStack(spacing: Spacing.xs) { + Image(systemName: keyboardReady ? "checkmark.circle.fill" : "info.circle") + .foregroundStyle(keyboardReady ? palette.accent : palette.textSecondary) + Text( + keyboardReady + ? "onboarding.experience.keyboard.ready" + : "onboarding.experience.keyboard.fullAccess" + ) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + .padding(.top, Spacing.lg) + + if hasOpenedKeyboardSettings, !keyboardReady { + Button("onboarding.experience.keyboard.openAgain") { + openKeyboardSettings() + } + .font(TypeStyle.footnote) + .foregroundStyle(palette.textSecondary) + .padding(.top, Spacing.lg) + } + + resourceStatus + .padding(.top, Spacing.md) + } + .onAppear { + deployment.deployNow(reason: "onboarding.experience.keyboard") + } + } + + @ViewBuilder + private var resourceStatus: some View { + switch deployment.status { + case .deploying: + Label("onboarding.enable.resources.preparing", systemImage: "hourglass") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + case .ready: + EmptyView() + case .failed: + Button { + deployment.deployNow(force: true, reason: "onboarding.experience.retry") + } label: { + Label("onboarding.enable.resources.retry", systemImage: "arrow.clockwise") + .font(TypeStyle.caption) + } + .foregroundStyle(palette.warning) + case .idle: + EmptyView() + } + } + + // MARK: - Real keyboard practice + + private var practicePage: some View { + VStack(spacing: 0) { + HStack { + Button(action: goBack) { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(palette.textPrimary) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("common.back")) + + Spacer() + + Button("onboarding.experience.practice.skip") { + goToComplete() + } + .font(TypeStyle.footnote) + .foregroundStyle(palette.textSecondary) + } + .padding(.horizontal, Spacing.sm) + + VStack(alignment: .leading, spacing: Spacing.sm) { + Text(practiceTitle) + .font(TypeStyle.title) + .foregroundStyle(palette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + + Text(practiceSubtitle) + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + if managedPracticeReady, keyboardReady { + Text("onboarding.experience.practice.sample") + .font(.system(size: 20, weight: .medium, design: .rounded)) + .foregroundStyle(palette.textPrimary) + .italic() + .padding(.top, Spacing.sm) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Spacing.xl) + .padding(.top, Spacing.sm) + + practiceContent + .padding(.horizontal, Spacing.lg) + .padding(.top, Spacing.lg) + .padding(.bottom, Spacing.sm) + + if managedPracticeReady, keyboardAppeared, !keyboardReady { + Button { + openKeyboardSettings() + } label: { + Label( + "onboarding.experience.practice.fullAccessAction", + systemImage: "arrow.up.right.square" + ) + .font(TypeStyle.caption) + .foregroundStyle(palette.warning) + } + .buttonStyle(.plain) + .padding(.bottom, Spacing.sm) + } + } + } + + @ViewBuilder + private var practiceContent: some View { + if !accountSession.isSignedIn { + VStack(spacing: Spacing.md) { + Image(systemName: "person.crop.circle.badge.checkmark") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(palette.accent) + + Text("onboarding.experience.practice.signInBody") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.center) + + AccountAppleAuthorizationButton(purpose: .signIn) + .disabled(accountSession.operation != nil) + + accountOperationError + } + .practiceSetupCard(palette: palette) + } else if !config.hasAcknowledgedCloudSharing { + VStack(spacing: Spacing.md) { + Image(systemName: "sparkles") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(palette.accent) + + Text("onboarding.experience.practice.cloudBody") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.center) + + Button { + config.hasAcknowledgedCloudSharing = true + } label: { + Text("onboarding.experience.practice.cloudAction") + .primaryButton() + } + .buttonStyle(.plain) + } + .practiceSetupCard(palette: palette) + } else if isPreparingManagedPractice { + VStack(spacing: Spacing.md) { + ProgressView() + .tint(palette.accent) + Text("onboarding.experience.practice.preparingCredits") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + .practiceSetupCard(palette: palette) + } else if managedPracticeFailed { + VStack(spacing: Spacing.md) { + Image(systemName: "arrow.clockwise.circle") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(palette.warning) + Text("onboarding.experience.practice.creditsFailed") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.center) + Button("onboarding.enable.resources.retry") { + prepareManagedPractice() + } + .font(TypeStyle.bodyEmph) + .foregroundStyle(palette.accent) + accountOperationError + } + .practiceSetupCard(palette: palette) + } else { + practiceEditor + } + } + + @ViewBuilder + private var accountOperationError: some View { + if let key = accountSession.operationErrorKey { + Text(LocalizedStringKey(key)) + .font(TypeStyle.caption2) + .foregroundStyle(palette.warning) + .multilineTextAlignment(.center) + } + } + + private var practiceEditor: some View { + VStack(spacing: 0) { + HStack(spacing: Spacing.xs) { + Image(systemName: "message.fill") + .foregroundStyle(palette.accent) + Text("onboarding.experience.practice.editorTitle") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + Spacer() + } + .padding(.horizontal, Spacing.md) + .frame(height: 42) + + Divider().overlay(palette.divider) + + TextEditor(text: $practiceText) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + .scrollContentBackground(.hidden) + .focused($practiceFieldFocused) + .padding(Spacing.sm) + .accessibilityIdentifier("onboarding.practice.textEditor") + } + .frame(maxWidth: .infinity, minHeight: 172, maxHeight: 240) + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(keyboardReady ? palette.accent.opacity(0.42) : palette.divider, lineWidth: 1) + ) + } + + private var practiceTitle: LocalizedStringKey { + if !permissionsReady { + return "onboarding.experience.practice.permissionsTitle" + } + if !accountSession.isSignedIn { + return "onboarding.experience.practice.signInTitle" + } + if !config.hasAcknowledgedCloudSharing { + return "onboarding.experience.practice.cloudTitle" + } + if isPreparingManagedPractice || managedPracticeFailed { + return "onboarding.experience.practice.creditsTitle" + } + if !keyboardAppeared { + return "onboarding.experience.practice.switchTitle" + } + if !keyboardReady { + return "onboarding.experience.practice.fullAccessTitle" + } + return "onboarding.experience.practice.speakTitle" + } + + private var practiceSubtitle: LocalizedStringKey { + if !permissionsReady { + return "onboarding.experience.practice.permissionsSubtitle" + } + if !accountSession.isSignedIn { + return "onboarding.experience.practice.signInSubtitle" + } + if !config.hasAcknowledgedCloudSharing { + return "onboarding.experience.practice.cloudSubtitle" + } + if isPreparingManagedPractice || managedPracticeFailed { + return "onboarding.experience.practice.creditsSubtitle" + } + if !keyboardAppeared { + return "onboarding.experience.practice.switchSubtitle" + } + if !keyboardReady { + return "onboarding.experience.practice.fullAccessSubtitle" + } + return "onboarding.experience.practice.speakSubtitle" + } + + // MARK: - Complete + + private var completePage: some View { + ScrollView { + VStack(spacing: 0) { + Spacer(minLength: Spacing.xxl) + + ZStack { + Circle() + .fill(palette.accentMuted) + .frame(width: 108, height: 108) + Image(systemName: didVerifyVoiceInsertion ? "checkmark" : "sparkles") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(palette.accent) + } + + Text( + didVerifyVoiceInsertion + ? "onboarding.experience.complete.verifiedTitle" + : "onboarding.experience.complete.title" + ) + .font(TypeStyle.largeTitle) + .foregroundStyle(palette.textPrimary) + .multilineTextAlignment(.center) + .padding(.top, Spacing.xxl) + + Text("onboarding.experience.complete.subtitle") + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.center) + .padding(.top, Spacing.sm) + + if !practiceText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(practiceText) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Spacing.md) + .background( + palette.surface, + in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + .padding(.top, Spacing.xl) + } + + HStack(spacing: Spacing.xs) { + capability("mic.fill", "onboarding.experience.complete.dictate") + capability("sparkles", "onboarding.experience.complete.agent") + capability("wand.and.stars", "onboarding.experience.complete.edit") + } + .padding(.top, Spacing.xxl) + } + .padding(.horizontal, Spacing.xl) + .padding(.bottom, Spacing.xl) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) + } + } + + private func capability(_ icon: String, _ title: LocalizedStringKey) -> some View { + VStack(spacing: Spacing.xs) { + Image(systemName: icon) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(palette.accent) + Text(title) + .font(TypeStyle.caption) + .foregroundStyle(palette.textPrimary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, Spacing.md) + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium)) + } + + // MARK: - Bottom action + + private var bottomAction: some View { + Button(action: performPrimaryAction) { + HStack(spacing: Spacing.xs) { + if isRequestingPermissions { + ProgressView() + .tint(palette.textOnAccent) + } + Text(primaryActionTitle) + } + .primaryButton() + } + .buttonStyle(.plain) + .disabled(isRequestingPermissions) + } + + private var primaryActionTitle: LocalizedStringKey { + switch currentStep { + case .introduction: + return "onboarding.experience.intro.action" + case .permissions: + if permissionsReady { return "common.continue" } + if micStatus == .denied || speechPermissionDenied { + return "onboarding.permission.openSettings" + } + return "common.continue" + case .keyboard: + if keyboardReady { return "onboarding.experience.keyboard.practiceAction" } + return hasOpenedKeyboardSettings + ? "common.continue" + : "onboarding.enable.openSettings" + case .practice: + return "common.continue" + case .complete: + return "onboarding.experience.complete.action" + } + } + + private func performPrimaryAction() { + switch currentStep { + case .introduction: + goForward() + case .permissions: + handlePermissionAction() + case .keyboard: + if keyboardReady || hasOpenedKeyboardSettings { + goForward() + } else { + openKeyboardSettings() + } + case .practice: + break + case .complete: + finishOnboarding() + } + } + + // MARK: - State and actions + + private var permissionsReady: Bool { + micStatus == .granted && speechStatus == .granted + } + + private var speechPermissionDenied: Bool { + speechStatus == .denied || speechStatus == .restricted + } + + private func handlePermissionAction() { + if micStatus == .denied || speechPermissionDenied { + AppPermissions.openSystemSettings() + return + } + if permissionsReady { + goForward() + return + } + + isRequestingPermissions = true + Task { @MainActor in + await AppPermissions.requestFlowPermissionsIfNeeded() + refreshState() + isRequestingPermissions = false + if permissionsReady { + goForward() + } + } + } + + private func openKeyboardSettings() { + hasOpenedKeyboardSettings = true + AppPermissions.openSystemSettings() + } + + private func beginPracticeIfNeeded() { + if practiceStartedAt == nil { + beginPractice() + } else if managedPracticeReady, permissionsReady { + KeyboardSetupBridge.setOnboardingPracticeActive(true) + flowManager.activateOnForeground(reason: "onboarding.practice.resume") + focusPracticeField() + } else if accountSession.isSignedIn, config.hasAcknowledgedCloudSharing { + prepareManagedPractice() + } + } + + private func beginPractice() { + refreshState() + practiceStartedAt = nil + didVerifyVoiceInsertion = false + managedPracticeReady = false + managedPracticeFailed = false + KeyboardSetupBridge.setOnboardingPracticeActive(false) + if accountSession.isSignedIn, config.hasAcknowledgedCloudSharing { + prepareManagedPractice() + } + } + + private func prepareManagedPractice() { + guard currentStep == .practice, + accountSession.isSignedIn, + config.hasAcknowledgedCloudSharing, + !isPreparingManagedPractice else { return } + isPreparingManagedPractice = true + managedPracticeFailed = false + managedPracticeReady = false + KeyboardSetupBridge.setOnboardingPracticeActive(false) + + // OOBE keeps audio on-device. Only the transcript is sent to the + // managed polish gateway, where the request is tagged and audited. + config.engineMode = "local" + config.modeId = "polish" + + Task { @MainActor in + let ready = await accountSession.prepareManagedGateway() + guard currentStep == .practice else { return } + isPreparingManagedPractice = false + guard ready else { + managedPracticeFailed = true + return + } + + config.credentialSource = .managed + managedPracticeReady = true + practiceStartedAt = Date() + KeyboardSetupBridge.setOnboardingPracticeActive(true) + if permissionsReady { + flowManager.activateOnForeground(reason: "onboarding.practice.managed") + } + focusPracticeField() + } + } + + private func focusPracticeField() { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(350)) + guard currentStep == .practice else { return } + practiceFieldFocused = true + } + } + + private func endPractice() { + practiceFieldFocused = false + isPreparingManagedPractice = false + managedPracticeReady = false + KeyboardSetupBridge.setOnboardingPracticeActive(false) + } + + @MainActor + private func monitorPractice() async { + while !Task.isCancelled, currentStep == .practice { + refreshState() + if let startedAt = practiceStartedAt, + let insertedAt = KeyboardSetupBridge.lastVoiceInsertionAt, + insertedAt >= startedAt { + didVerifyVoiceInsertion = true + UINotificationFeedbackGenerator().notificationOccurred(.success) + try? await Task.sleep(for: .milliseconds(650)) + guard !Task.isCancelled, currentStep == .practice else { return } + goToComplete() + return + } + try? await Task.sleep(for: .milliseconds(350)) + } + } + + private func refreshState() { + micStatus = AppPermissions.micStatus + speechStatus = AppPermissions.speechStatus + keyboardAppeared = KeyboardSetupBridge.hasAppeared + keyboardReady = KeyboardSetupBridge.isReadyForOnboardingSkip + } + + private func goForward() { + let next = min(currentStep.rawValue + 1, OnboardingExperienceStep.complete.rawValue) + withAnimation(Motion.soft) { + config.onboardingPage = next + } + } + + private func goBack() { + let previous = max(currentStep.rawValue - 1, OnboardingExperienceStep.introduction.rawValue) + withAnimation(Motion.soft) { + config.onboardingPage = previous + } + } + + private func goToComplete() { + withAnimation(Motion.soft) { + config.onboardingPage = OnboardingExperienceStep.complete.rawValue + } + } + + private func finishOnboarding() { + endPractice() + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + config.hasCompletedOnboarding = true + } + } + + private func applyPrivacySafeDefaultsIfNeeded() { + guard !config.hasCompletedOnboarding, config.onboardingPage == 0 else { return } + // First-run voice should work without asking users to understand an + // ASR provider or supply a cloud key. + if config.apiKey.isEmpty, config.engineMode == "cloud" { + config.engineMode = "local" + } + } + + private func migrateLegacyProgressIfNeeded() { + let defaults = UserDefaults.standard + guard !defaults.bool(forKey: Self.migrationKey) else { + if OnboardingExperienceStep(rawValue: config.onboardingPage) == nil { + config.onboardingPage = 0 + } + return + } + + if !config.hasCompletedOnboarding { + switch config.onboardingPage { + case 1, 2: + config.onboardingPage = OnboardingExperienceStep.permissions.rawValue + case 3: + config.onboardingPage = OnboardingExperienceStep.keyboard.rawValue + case 4, 5: + config.onboardingPage = OnboardingExperienceStep.introduction.rawValue + default: + config.onboardingPage = OnboardingExperienceStep.introduction.rawValue + } + } + defaults.set(true, forKey: Self.migrationKey) + } +} + +private extension View { + func practiceSetupCard(palette: ThemePalette) -> some View { + frame(maxWidth: .infinity, minHeight: 172) + .padding(Spacing.lg) + .background( + palette.surface, + in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } +} + +private struct OnboardingExperienceShell: View { + @Environment(\.themePalette) private var palette + + let title: LocalizedStringKey + let subtitle: LocalizedStringKey + let onBack: () -> Void + @ViewBuilder let content: Content + + var body: some View { + ScrollView { + VStack(spacing: 0) { + HStack { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(palette.textPrimary) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("common.back")) + Spacer() + } + + VStack(spacing: Spacing.sm) { + Text(title) + .font(TypeStyle.largeTitle) + .foregroundStyle(palette.textPrimary) + Text(subtitle) + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + .multilineTextAlignment(.center) + .padding(.top, Spacing.xl) + + content + + Spacer(minLength: Spacing.xl) + } + .padding(.horizontal, Spacing.xl) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) + } + } +} + +private struct KeyboardSettingsPreview: View { + @Environment(\.themePalette) private var palette + + let isReady: Bool + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("onboarding.experience.keyboard.previewTitle") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + Spacer() + } + .padding(.horizontal, Spacing.md) + .frame(height: 42) + + Divider().overlay(palette.divider) + + HStack(spacing: Spacing.md) { + Image("OSGBrandMark") + .renderingMode(.template) + .resizable() + .scaledToFit() + .foregroundStyle(palette.textPrimary) + .frame(width: 28, height: 28) + + Text("OSGKeyboard") + .font(TypeStyle.bodyEmph) + .foregroundStyle(palette.textPrimary) + + Spacer() + + ZStack(alignment: isReady ? .trailing : .leading) { + Capsule() + .fill(isReady ? palette.accent : palette.surfaceElevated) + .frame(width: 48, height: 28) + Circle() + .fill(isReady ? palette.textOnAccent : palette.textTertiary) + .frame(width: 22, height: 22) + .padding(3) + } + .animation(Motion.quick, value: isReady) + .accessibilityHidden(true) + } + .padding(Spacing.md) + } + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) + .stroke(palette.dividerStrong, lineWidth: 0.5) + ) + } +} diff --git a/OSGKeyboard/Views/SettingsSecondaryPages.swift b/OSGKeyboard/Views/SettingsSecondaryPages.swift index 7944263..6c6e106 100644 --- a/OSGKeyboard/Views/SettingsSecondaryPages.swift +++ b/OSGKeyboard/Views/SettingsSecondaryPages.swift @@ -565,11 +565,35 @@ struct ClipboardSettingsView: View { struct AboutSettingsView: View { @Environment(\.themePalette) private var palette: ThemePalette @Environment(\.openURL) private var openURL + @EnvironmentObject private var analytics: AnalyticsHostService @ObservedObject var config: ProviderConfig var body: some View { ScrollView { CardPageContent { + CardSection("settings.analytics.section") { + VStack(alignment: .leading, spacing: 0) { + Toggle( + "settings.analytics.title", + isOn: Binding( + get: { analytics.isEnabled }, + set: { analytics.setEnabled($0) } + ) + ) + .tint(palette.accent) + .settingsListRow() + + Divider().background(palette.divider) + + Text("settings.analytics.description") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .settingsListRow() + } + .surfaceCard() + } + CardSection("settings.about.title") { VStack(spacing: 0) { Button { @@ -637,5 +661,8 @@ struct AboutSettingsView: View { .navigationTitle("settings.about.title") .navigationBarTitleDisplayMode(.inline) .hidesTabBarWhenPushed() + .onAppear { + analytics.refreshEnabledState() + } } } diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index f881ab2..9476887 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -36,6 +36,58 @@ "onboarding.polish.title" = "Text polish (LLM)"; "onboarding.polish.subtitle" = "Cleans up the transcript after recognition. Can differ from the ASR provider."; "onboarding.polish.localHint" = "Fill in an API key to enable AI polish. Without a key, raw ASR text is inserted."; +"onboarding.experience.intro.eyebrow" = "AI AGENT VOICE KEYBOARD"; +"onboarding.experience.intro.title" = "Speak. It’s written."; +"onboarding.experience.intro.subtitle" = "More than dictation. OSGKeyboard understands, writes, translates, and edits—then its AI Agent helps with what comes next."; +"onboarding.experience.intro.action" = "Get Started"; +"onboarding.experience.privacy.local.title" = "On-device transcription available"; +"onboarding.experience.privacy.local.body" = "With the local engine, speech recognition stays on your device."; +"onboarding.experience.privacy.cloud.title" = "Clear cloud boundaries"; +"onboarding.experience.privacy.cloud.body" = "Audio or text is sent only when you choose cloud recognition or an AI feature, and only to the configured service."; +"onboarding.experience.privacy.anywhere.title" = "Your AI Agent, in any app"; +"onboarding.experience.privacy.anywhere.body" = "Dictate, translate, edit, and act from chat, email, notes, or code."; +"onboarding.experience.permissions.title" = "Let OSGKeyboard hear you"; +"onboarding.experience.permissions.subtitle" = "Voice input needs microphone and speech recognition access. Two system prompts will appear next."; +"onboarding.experience.permissions.systemHint" = "These permissions are used only when you start voice input."; +"onboarding.experience.permissions.later" = "Set Up Later"; +"onboarding.experience.keyboard.title" = "Add OSGKeyboard"; +"onboarding.experience.keyboard.subtitle" = "Add the keyboard in iOS Settings and turn on Allow Full Access."; +"onboarding.experience.keyboard.ready" = "OSGKeyboard and Full Access are ready"; +"onboarding.experience.keyboard.fullAccess" = "Full Access connects the keyboard to the host app microphone and optional AI services"; +"onboarding.experience.keyboard.openAgain" = "Open Settings Again"; +"onboarding.experience.keyboard.practiceAction" = "Try It Now"; +"onboarding.experience.keyboard.previewTitle" = "Keyboards"; +"onboarding.experience.practice.skip" = "Try Later"; +"onboarding.experience.practice.sample" = "“I’ll arrive tomorrow afternoon and message you when I’m there.”"; +"onboarding.experience.practice.editorTitle" = "New Message"; +"onboarding.experience.practice.permissionsTitle" = "Voice access is still needed"; +"onboarding.experience.practice.permissionsSubtitle" = "Go back to finish permission setup, then try your first voice input."; +"onboarding.experience.practice.switchTitle" = "Switch to OSGKeyboard"; +"onboarding.experience.practice.switchSubtitle" = "Tap the field below, hold the globe key, then choose OSGKeyboard."; +"onboarding.experience.practice.fullAccessTitle" = "One more setting"; +"onboarding.experience.practice.fullAccessSubtitle" = "OSGKeyboard is enabled. Turn on Allow Full Access to use voice input."; +"onboarding.experience.practice.fullAccessAction" = "Turn On Full Access"; +"onboarding.experience.practice.signInTitle" = "Sign in for a free AI writing trial"; +"onboarding.experience.practice.signInSubtitle" = "The lesson uses OSG Credits by default. This OOBE request will not deduct any credits."; +"onboarding.experience.practice.signInBody" = "Sign in with Apple to claim one free OOBE AI polish. Speech is still transcribed on your device."; +"onboarding.experience.practice.cloudTitle" = "Enable the free OOBE experience"; +"onboarding.experience.practice.cloudSubtitle" = "Only this transcript is sent to OSG managed AI and explicitly tagged as OOBE."; +"onboarding.experience.practice.cloudBody" = "The server verifies this lesson as OOBE, with no credit reservation or charge. OSG Credits becomes your default AI service afterward."; +"onboarding.experience.practice.cloudAction" = "Agree & Try for Free"; +"onboarding.experience.practice.creditsTitle" = "Preparing OSG Credits"; +"onboarding.experience.practice.creditsSubtitle" = "This OOBE lesson is completely free."; +"onboarding.experience.practice.preparingCredits" = "Preparing your free OOBE request…"; +"onboarding.experience.practice.creditsFailed" = "OSG Credits is temporarily unavailable. Try again—no credits were deducted."; +"onboarding.experience.practice.speakTitle" = "Now, say something"; +"onboarding.experience.practice.speakSubtitle" = "Tap the microphone on the keyboard, speak, then tap it again. Your text will appear here."; +"onboarding.experience.complete.verifiedTitle" = "That’s it."; +"onboarding.experience.complete.title" = "You’re ready"; +"onboarding.experience.complete.subtitle" = "Switch to OSGKeyboard in any text field to speak, write, or hand off the next step to your AI Agent."; +"onboarding.experience.complete.dictate" = "Voice to Text"; +"onboarding.experience.complete.translate" = "Translate"; +"onboarding.experience.complete.agent" = "AI Agent"; +"onboarding.experience.complete.edit" = "Voice Edit"; +"onboarding.experience.complete.action" = "Start Using"; "settings.onboarding.replay" = "Restart permission setup"; /* Common navigation */ @@ -419,6 +471,11 @@ "home.setup.keyboardHint" = "Don't see OSGKeyboard? Add it in iOS Settings."; "home.setup.keyboardHint.dismiss" = "Got it"; "home.flow.starting" = "Starting voice session…"; +"home.flow.preparingProgress" = "Preparing background session (%d/%d)…"; +"home.flow.recoveringProgress" = "Restoring background session (%d/%d)…"; +"home.flow.waitingForForeground" = "Background session paused — reopen this app to restore it"; +"home.flow.recoveryFailed" = "Background session needs attention"; +"home.flow.retry" = "Retry"; "home.flow.openSettings" = "Open Settings to grant permissions"; "home.flow.start" = "Start voice session"; "home.flow.end" = "End voice session"; @@ -805,3 +862,6 @@ "account.managed.accountCredits" = "Account credits"; "account.managed.explanation" = "This choice is independent from local or cloud speech recognition. Account sessions remain private to the host app; the keyboard receives only a short-lived, limited grant."; "account.managed.preparing" = "Preparing managed access…"; +"settings.analytics.section" = "Privacy"; +"settings.analytics.title" = "Product Analytics"; +"settings.analytics.description" = "Helps improve reliability with limited first-party events. Keyboard input, audio, transcripts, prompts, model output, credentials, and personal identifiers are never collected. Turning this off deletes queued events from this device."; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 87f193d..eb3aeee 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -36,6 +36,58 @@ "onboarding.polish.title" = "文本润色(LLM)"; "onboarding.polish.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。"; "onboarding.polish.localHint" = "填写 API Key 才能开启 AI 润色;未填写时将直接插入原始识别结果。"; +"onboarding.experience.intro.eyebrow" = "AI AGENT 语音输入法"; +"onboarding.experience.intro.title" = "开口,即成文"; +"onboarding.experience.intro.subtitle" = "不只是语音输入。OSGKeyboard 能听懂、成文、翻译和编辑,也能调用 AI Agent 帮你处理下一步。"; +"onboarding.experience.intro.action" = "开始设置"; +"onboarding.experience.privacy.local.title" = "支持设备端转写"; +"onboarding.experience.privacy.local.body" = "使用本地引擎时,语音识别在你的设备上完成。"; +"onboarding.experience.privacy.cloud.title" = "明确的云端边界"; +"onboarding.experience.privacy.cloud.body" = "只有选择云端识别或 AI 功能时,相应的语音或文字才会发送给已配置的服务。"; +"onboarding.experience.privacy.anywhere.title" = "AI Agent,随处可用"; +"onboarding.experience.privacy.anywhere.body" = "在聊天、邮件、笔记或代码编辑中,随时口述、翻译、编辑和处理任务。"; +"onboarding.experience.permissions.title" = "让 OSGKeyboard 听见你"; +"onboarding.experience.permissions.subtitle" = "语音输入需要麦克风和语音识别。接下来会依次出现两个系统提示。"; +"onboarding.experience.permissions.systemHint" = "权限只在你主动使用语音输入时生效。"; +"onboarding.experience.permissions.later" = "暂时跳过"; +"onboarding.experience.keyboard.title" = "添加 OSGKeyboard"; +"onboarding.experience.keyboard.subtitle" = "在 iOS 设置中添加键盘,并打开「允许完全访问」。"; +"onboarding.experience.keyboard.ready" = "OSGKeyboard 与完全访问均已就绪"; +"onboarding.experience.keyboard.fullAccess" = "完全访问用于连接主 App 的麦克风和可选 AI 服务"; +"onboarding.experience.keyboard.openAgain" = "再次打开设置"; +"onboarding.experience.keyboard.practiceAction" = "开始体验"; +"onboarding.experience.keyboard.previewTitle" = "键盘"; +"onboarding.experience.practice.skip" = "稍后体验"; +"onboarding.experience.practice.sample" = "“我明天下午到,到了以后给你消息。”"; +"onboarding.experience.practice.editorTitle" = "新消息"; +"onboarding.experience.practice.permissionsTitle" = "还需要语音权限"; +"onboarding.experience.practice.permissionsSubtitle" = "返回上一步完成权限设置,然后开始第一次语音输入。"; +"onboarding.experience.practice.switchTitle" = "切换到 OSGKeyboard"; +"onboarding.experience.practice.switchSubtitle" = "点一下下面的文本框,长按地球键,然后选择 OSGKeyboard。"; +"onboarding.experience.practice.fullAccessTitle" = "再完成一个设置"; +"onboarding.experience.practice.fullAccessSubtitle" = "OSGKeyboard 已启用。打开「允许完全访问」后就能使用语音。"; +"onboarding.experience.practice.fullAccessAction" = "去设置打开完全访问"; +"onboarding.experience.practice.signInTitle" = "登录,免费体验 AI 成文"; +"onboarding.experience.practice.signInSubtitle" = "教学体验默认使用 OSG 积分服务,本次 OOBE 请求不会扣除任何积分。"; +"onboarding.experience.practice.signInBody" = "使用 Apple 登录以领取一次免费的 OOBE AI 润色。语音仍在设备端转写。"; +"onboarding.experience.practice.cloudTitle" = "启用免费 OOBE 体验"; +"onboarding.experience.practice.cloudSubtitle" = "仅将本次转写文字发送到 OSG 托管 AI,并明确标记为 OOBE。"; +"onboarding.experience.practice.cloudBody" = "本次教学请求由服务端验证为 OOBE,不预扣、不结算积分。完成后,OSG 积分将作为默认 AI 服务。"; +"onboarding.experience.practice.cloudAction" = "同意并免费体验"; +"onboarding.experience.practice.creditsTitle" = "正在准备 OSG 积分服务"; +"onboarding.experience.practice.creditsSubtitle" = "本次 OOBE 教学完全免费。"; +"onboarding.experience.practice.preparingCredits" = "正在准备免费 OOBE 请求…"; +"onboarding.experience.practice.creditsFailed" = "暂时无法连接 OSG 积分服务,请重试。不会扣除积分。"; +"onboarding.experience.practice.speakTitle" = "现在,说一句话"; +"onboarding.experience.practice.speakSubtitle" = "点按键盘上的麦克风,说完后再点一次。文字会直接出现在这里。"; +"onboarding.experience.complete.verifiedTitle" = "就是这样。"; +"onboarding.experience.complete.title" = "已经准备好了"; +"onboarding.experience.complete.subtitle" = "以后在任何输入框切换到 OSGKeyboard,开口成文,也可以继续交给 AI Agent。"; +"onboarding.experience.complete.dictate" = "语音成文"; +"onboarding.experience.complete.translate" = "翻译"; +"onboarding.experience.complete.agent" = "AI Agent"; +"onboarding.experience.complete.edit" = "语音编辑"; +"onboarding.experience.complete.action" = "开始使用"; "settings.onboarding.replay" = "重新开始权限引导"; /* Common navigation */ @@ -418,6 +470,11 @@ "home.setup.keyboardHint" = "列表里没有?去系统设置里添加键盘。"; "home.setup.keyboardHint.dismiss" = "知道了"; "home.flow.starting" = "正在启动语音会话…"; +"home.flow.preparingProgress" = "正在准备后台会话(%d/%d)…"; +"home.flow.recoveringProgress" = "正在恢复后台会话(%d/%d)…"; +"home.flow.waitingForForeground" = "后台会话已暂停,重新打开本 App 后自动恢复"; +"home.flow.recoveryFailed" = "后台会话需要恢复"; +"home.flow.retry" = "重试"; "home.flow.openSettings" = "去设置打开权限"; "home.flow.start" = "启动语音会话"; "home.flow.end" = "结束语音会话"; @@ -804,3 +861,6 @@ "account.managed.accountCredits" = "账号积分"; "account.managed.explanation" = "该选择与本地或云端语音识别相互独立。账号会话仅保存在主 App,键盘只能获得短时、受限的网关授权。"; "account.managed.preparing" = "正在准备托管服务…"; +"settings.analytics.section" = "隐私"; +"settings.analytics.title" = "产品分析"; +"settings.analytics.description" = "通过有限的第一方事件帮助改进可靠性。绝不收集键盘输入、音频、转录、Prompt、模型输出、凭证或个人标识。关闭后会删除本机尚未上传的事件。"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 1713bb4..afd4fcb 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -205,6 +205,7 @@ public final class KeyboardViewController: UIInputViewController { public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + AnalyticsExtensionService.shared.keyboardWillDisappear() assistantFieldActionRefreshTask?.cancel() assistantFieldActionRefreshTask = nil clipboardCapture?.keyboardWillDisappear() @@ -305,6 +306,9 @@ public final class KeyboardViewController: UIInputViewController { public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + AnalyticsExtensionService.shared.recordPresentation( + hasFullAccess: hasFullAccess + ) OSGDiag.log( "KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())", category: "boot" diff --git a/OSGKeyboardExt/PrivacyInfo.xcprivacy b/OSGKeyboardExt/PrivacyInfo.xcprivacy index 93cac16..db59cb7 100644 --- a/OSGKeyboardExt/PrivacyInfo.xcprivacy +++ b/OSGKeyboardExt/PrivacyInfo.xcprivacy @@ -5,7 +5,44 @@ NSPrivacyTracking NSPrivacyCollectedDataTypes - + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeProductInteraction + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherUsageData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + NSPrivacyAccessedAPITypes diff --git a/OSGKeyboardExt/Services/AnalyticsExtensionService.swift b/OSGKeyboardExt/Services/AnalyticsExtensionService.swift new file mode 100644 index 0000000..9d87144 --- /dev/null +++ b/OSGKeyboardExt/Services/AnalyticsExtensionService.swift @@ -0,0 +1,110 @@ +// AnalyticsExtensionService.swift +// OSGKeyboard · Keyboard Extension +// +// The extension records into the shared SQLite queue and only attempts one +// short anonymous batch when Full Access permits network use. + +import Foundation +import OSGKeyboardShared +import OSLog + +private actor ExtensionAnalyticsUploadSignal: AnalyticsUploadTriggering { + typealias Action = @Sendable () async -> Void + + private var action: Action? + private var canUpload = false + private var isUploading = false + + func install(_ action: @escaping Action) { + self.action = action + } + + func setCanUpload(_ canUpload: Bool) { + self.canUpload = canUpload + } + + func requestUpload() { + guard canUpload, !isUploading, let action else { return } + isUploading = true + Task { + await action() + uploadFinished() + } + } + + private func uploadFinished() { + isUploading = false + } +} + +private struct ExtensionAnalyticsLogger: AnalyticsLogging { + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.osgkeyboard.ios.keyboard", + category: "analytics" + ) + + func log(_ entry: AnalyticsUploadLogEntry) { + let statusCode = entry.statusCode ?? 0 + let errorCategory = entry.errorCategory?.rawValue ?? "none" + logger.info( + "outcome=\(entry.outcome.rawValue, privacy: .public) count=\(entry.eventCount, privacy: .public) status=\(statusCode, privacy: .public) attempt=\(entry.attempt, privacy: .public) error=\(errorCategory, privacy: .public)" + ) + } +} + +final class AnalyticsExtensionService: Sendable { + static let shared = AnalyticsExtensionService() + + let client: any AnalyticsClient + + private let runtime: AnalyticsRuntime + private let uploadSignal: ExtensionAnalyticsUploadSignal + + private init() { + let signal = ExtensionAnalyticsUploadSignal() + uploadSignal = signal + let runtime = AnalyticsRuntime.keyboardExtension( + environment: Self.environment, + uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint), + trigger: signal, + logger: ExtensionAnalyticsLogger() + ) + self.runtime = runtime + client = runtime.client + + Task { + await signal.install { + await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 1) + } + } + } + + func recordPresentation(hasFullAccess: Bool) { + Task { + await uploadSignal.setCanUpload(hasFullAccess) + client.recordSessionActivity() + client.recordKeyboardActivated() + } + } + + func keyboardWillDisappear() { + Task { + await uploadSignal.setCanUpload(false) + } + } + + private static let endpoint = URL( + string: "https://account.osglab.com/v1/analytics/events" + )! + + private static var environment: AnalyticsEnvironment { + let appVersion = Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String ?? "unknown" + let version = ProcessInfo.processInfo.operatingSystemVersion + return AnalyticsEnvironment( + appVersion: appVersion, + osVersion: "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + ) + } +} diff --git a/OSGKeyboardExt/Services/KeyboardConfigSync.swift b/OSGKeyboardExt/Services/KeyboardConfigSync.swift index ca160d8..afc723c 100644 --- a/OSGKeyboardExt/Services/KeyboardConfigSync.swift +++ b/OSGKeyboardExt/Services/KeyboardConfigSync.swift @@ -92,6 +92,7 @@ final class KeyboardConfigSync { // Keychain fallback: a reboot must not resurrect the mic gate when // App Group transiently reads empty. state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding() + state.isOnboardingPracticeActive = KeyboardSetupBridge.isOnboardingPracticeActive } func persistLocale(_ id: String) { diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift index 140676d..b67531b 100644 --- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift +++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift @@ -125,6 +125,10 @@ final class KeyboardFlowCoordinator { || currentUtteranceRequest != nil } + private var voiceSetupReady: Bool { + state.hasCompletedOnboarding || state.isOnboardingPracticeActive + } + var isEditSessionActive: Bool { currentUtteranceRequest?.isEdit == true } /// Session/transcription changes are pushed in real time by Darwin @@ -159,7 +163,7 @@ final class KeyboardFlowCoordinator { /// noise used to call this again while `ready` briefly lagged). func ensurePiPReadyOnKeyboardOpen() { guard FlowHandoffPolicy.allowsProactiveHostAutoLaunch, - state.hasCompletedOnboarding, + voiceSetupReady, hasFullAccess(), AppGroup.isAvailable, !isPendingFlowStart, @@ -335,7 +339,7 @@ final class KeyboardFlowCoordinator { appGroupAvailable: AppGroup.isAvailable, hostReady: hostReady, isPreparingSession: isPendingFlowStart || hostWarming, - hasCompletedOnboarding: state.hasCompletedOnboarding + hasCompletedOnboarding: voiceSetupReady ) let signature = [ "phase=\(String(describing: state.phase))", @@ -1070,7 +1074,7 @@ final class KeyboardFlowCoordinator { } func beginFlowStart(recordAfterHandoff: Bool = false) { - guard state.hasCompletedOnboarding else { + guard voiceSetupReady else { promptFinishSetupInApp() return } @@ -1177,6 +1181,8 @@ final class KeyboardFlowCoordinator { let mode: FlowUtteranceMode? = request.mode == .dictation ? nil : request.mode + let managedRequestPurpose = request.managedRequestPurpose + ?? (state.isOnboardingPracticeActive && request.mode == .dictation ? .oobe : nil) let command = FlowCommand( sessionId: activeSessionId, utteranceId: currentUtteranceId, @@ -1194,6 +1200,7 @@ final class KeyboardFlowCoordinator { sourceHistoryEntryRevision: request.sourceHistoryEntryRevision, aiConversationID: request.aiConversationID, aiTaskKind: request.aiTaskKind, + managedRequestPurpose: managedRequestPurpose, startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil, processingDeadlineAt: action == .stopRecording && request.isEdit ? Date().timeIntervalSince1970 diff --git a/OSGKeyboardExt/Services/KeyboardTextInserter.swift b/OSGKeyboardExt/Services/KeyboardTextInserter.swift index 4fcf25f..319b904 100644 --- a/OSGKeyboardExt/Services/KeyboardTextInserter.swift +++ b/OSGKeyboardExt/Services/KeyboardTextInserter.swift @@ -88,6 +88,7 @@ final class KeyboardTextInserter { ) let inserted = separator + trimmed insertText(inserted) + KeyboardSetupBridge.markVoiceInsertion() state.noteUserDidInputText() recordLastInsertion( inserted, diff --git a/OSGKeyboardExt/Views/AIKeyboardView.swift b/OSGKeyboardExt/Views/AIKeyboardView.swift index 3231a79..b62e010 100644 --- a/OSGKeyboardExt/Views/AIKeyboardView.swift +++ b/OSGKeyboardExt/Views/AIKeyboardView.swift @@ -292,7 +292,15 @@ struct AIKeyboardView: View { @ViewBuilder private var contextArea: some View { ZStack { - if let tip = state.skillTipText, !tip.isEmpty { + if state.isOnboardingPracticeActive, activeStatus == nil { + Text(ExtL10n.string("keyboard.onboarding.practice.mic")) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + .padding(.horizontal, 14) + .frame(height: Layout.hotwordHeight) + .background(palette.accentMuted, in: Capsule()) + } else if let tip = state.skillTipText, !tip.isEmpty { IntrinsicWidthCap(maxWidth: Layout.skillTipMaxWidth) { Text(tip) .font(TypeStyle.body) diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index cd10f5b..c255b60 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -14,6 +14,7 @@ "onboarding.api.subtitle" = "Local ASR needs no key; add an API key to enable AI polish."; "onboarding.api.localReady.title" = "Local ASR is ready"; "onboarding.api.localReady.body" = "Recognition works on-device. Add an API key in Settings for AI polish."; +"keyboard.onboarding.practice.mic" = "Tap the mic, then tap again when done"; /* Common navigation */ "common.back" = "Back"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index 81e2770..e71e0db 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -14,6 +14,7 @@ "onboarding.api.subtitle" = "本地识别无需 Key;填写 API Key 后可开启 AI 润色。"; "onboarding.api.localReady.title" = "本地识别已就绪"; "onboarding.api.localReady.body" = "识别在端侧完成。请在设置中填写 API Key 以开启 AI 润色。"; +"keyboard.onboarding.practice.mic" = "点按麦克风,说完后再点一次"; /* Common navigation */ "common.back" = "返回"; diff --git a/OSGKeyboardExtTests/AnalyticsExtensionPrivacyTests.swift b/OSGKeyboardExtTests/AnalyticsExtensionPrivacyTests.swift new file mode 100644 index 0000000..513858c --- /dev/null +++ b/OSGKeyboardExtTests/AnalyticsExtensionPrivacyTests.swift @@ -0,0 +1,138 @@ +// AnalyticsExtensionPrivacyTests.swift +// OSGKeyboardExtTests +// +// The keyboard analytics runtime is anonymous and emits only typed dimensions. + +import Foundation +@testable import OSGKeyboardShared +import XCTest + +final class AnalyticsExtensionPrivacyTests: XCTestCase { + func testKeyboardRuntimeUploadsWithoutAuthorizationOrSensitiveInputFields() async throws { + let databaseURL = try temporaryDatabaseURL() + let network = ExtensionAnalyticsNetwork() + let runtime = AnalyticsRuntime.keyboardExtension( + environment: AnalyticsEnvironment(appVersion: "2.0.0", osVersion: "26.0"), + repositoryConfiguration: AnalyticsRepositoryConfiguration( + databaseURL: databaseURL + ), + uploadConfiguration: AnalyticsUploadConfiguration( + endpoint: URL(string: "https://analytics.test/v1/events")! + ), + network: network, + wallClock: ExtensionAnalyticsClock(), + monotonicClock: ExtensionAnalyticsMonotonicClock(), + uuidGenerator: ExtensionAnalyticsUUIDGenerator(), + random: ExtensionAnalyticsRandomGenerator() + ) + + runtime.client.recordKeyboardActivated() + for _ in 0..<200 { + if await runtime.repository.debugSnapshot().pendingEvents.count == 1 { + break + } + try await Task.sleep(for: .milliseconds(10)) + } + let pendingSnapshot = await runtime.repository.debugSnapshot() + XCTAssertEqual(pendingSnapshot.pendingEvents.count, 1) + + await runtime.uploadCoordinator.uploadAvailableEvents() + + let requests = await network.requests() + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(requests.count, 1) + XCTAssertNil( + request.headers.first { + $0.key.caseInsensitiveCompare("Authorization") == .orderedSame + } + ) + let upload = try JSONDecoder().decode( + AnalyticsUploadRequest.self, + from: request.body + ) + XCTAssertEqual(upload.events.count, 1) + XCTAssertEqual(upload.events.first?.surface, .keyboard) + XCTAssertEqual(upload.events.first?.eventType, .keyboardActivated) + + let root = try XCTUnwrap( + JSONSerialization.jsonObject(with: request.body) as? [String: Any] + ) + let eventObjects = try XCTUnwrap(root["events"] as? [[String: Any]]) + let event = try XCTUnwrap(eventObjects.first) + XCTAssertFalse(event.keys.contains("properties")) + XCTAssertFalse(event.keys.contains("text")) + XCTAssertFalse(event.keys.contains("prompt")) + XCTAssertFalse(event.keys.contains("transcript")) + } + + private func temporaryDatabaseURL() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("OSGKeyboardExtensionAnalyticsTests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + return directory.appendingPathComponent("analytics.sqlite3") + } +} + +private struct ExtensionAnalyticsClock: AnalyticsWallClock { + func now() -> Date { + Date(timeIntervalSince1970: 1_700_000_000) + } +} + +private struct ExtensionAnalyticsMonotonicClock: AnalyticsMonotonicClock { + func nowNanoseconds() -> UInt64 { + 0 + } +} + +private final class ExtensionAnalyticsUUIDGenerator: + AnalyticsUUIDGenerating, + @unchecked Sendable { + private let lock = NSLock() + private var value = 1 + + func makeUUID() -> UUID { + lock.withLock { + defer { value += 1 } + return UUID( + uuidString: String( + format: "00000000-0000-0000-0000-%012x", + value + ) + )! + } + } +} + +private struct ExtensionAnalyticsRandomGenerator: AnalyticsRandomGenerating { + func next(upperBound: UInt64) -> UInt64 { + 0 + } +} + +private actor ExtensionAnalyticsNetwork: AnalyticsNetworking { + private var captured: [AnalyticsHTTPRequest] = [] + + func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse { + captured.append(request) + let upload = try JSONDecoder().decode( + AnalyticsUploadRequest.self, + from: request.body + ) + return AnalyticsHTTPResponse( + statusCode: 200, + headers: [:], + body: Data( + #"{"accepted":\#(upload.events.count),"replayed":0}"#.utf8 + ) + ) + } + + func requests() -> [AnalyticsHTTPRequest] { + captured + } +} diff --git a/OSGKeyboardExtTests/ManagedGatewayTests.swift b/OSGKeyboardExtTests/ManagedGatewayTests.swift index 6c36d76..bbeee4f 100644 --- a/OSGKeyboardExtTests/ManagedGatewayTests.swift +++ b/OSGKeyboardExtTests/ManagedGatewayTests.swift @@ -209,6 +209,27 @@ final class ManagedGatewayTests: XCTestCase { XCTAssertEqual(taskKinds, cases.map { $0.1.rawValue }) } + func testManagedClientSerializesOOBERequestPurpose() async throws { + let now = Date(timeIntervalSince1970: 3_750) + let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now)) + GatewayStub.shared.enqueue(200, Data(#"{"output_text":"ok"}"#.utf8)) + let client = ManagedLLMClient( + capability: .polish, + taskKind: .dictationPolish, + requestPurpose: .oobe, + grants: makeCoordinator(store: store, now: { now }), + baseURL: baseURL, + session: stubSession() + ) + + _ = try await client.polish("input", systemPrompt: "context") + + let request = try XCTUnwrap(GatewayStub.shared.requests().last) + let body = try jsonBody(request) + XCTAssertEqual(body["taskKind"] as? String, "dictation_polish") + XCTAssertEqual(body["requestPurpose"] as? String, "oobe") + } + func testManagedTaskKindWireValuesMatchServerContract() { XCTAssertEqual( ManagedGatewayTaskKind.allCases.map(\.rawValue), diff --git a/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift b/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift index 413b82e..d91c6fe 100644 --- a/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift +++ b/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift @@ -40,7 +40,10 @@ public protocol ConfigurationStore: Sendable { /// Provider-specific ASR caches (e.g. Alibaba Fun-ASR vocabulary IDs). var cloudASRPersistence: UserDefaults { get } - func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient + func makeClient( + taskKind: ManagedGatewayTaskKind?, + requestPurpose: ManagedGatewayRequestPurpose? + ) -> LLMClient } public extension ConfigurationStore { @@ -48,6 +51,10 @@ public extension ConfigurationStore { var credentialSource: CredentialSource { .byok } func makeClient() -> LLMClient { - makeClient(taskKind: nil) + makeClient(taskKind: nil, requestPurpose: nil) + } + + func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient { + makeClient(taskKind: taskKind, requestPurpose: nil) } } diff --git a/OSGKeyboardShared/Core/Configuration/LiveConfigurationStore.swift b/OSGKeyboardShared/Core/Configuration/LiveConfigurationStore.swift index f2b004b..258a095 100644 --- a/OSGKeyboardShared/Core/Configuration/LiveConfigurationStore.swift +++ b/OSGKeyboardShared/Core/Configuration/LiveConfigurationStore.swift @@ -124,11 +124,15 @@ public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable { public var detectedAppContext: (context: AppContext, observedAt: Date)? { snapshot.detectedAppContext } public var cloudASRPersistence: UserDefaults { snapshot.cloudASRPersistence } - public func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient { + public func makeClient( + taskKind: ManagedGatewayTaskKind?, + requestPurpose: ManagedGatewayRequestPurpose? + ) -> LLMClient { if credentialSource == .managed { return ManagedLLMClient( capability: .polish, taskKind: taskKind, + requestPurpose: requestPurpose, grants: GatewayGrantCoordinator() ) } diff --git a/OSGKeyboardShared/Features/Analytics/AnalyticsClient.swift b/OSGKeyboardShared/Features/Analytics/AnalyticsClient.swift new file mode 100644 index 0000000..59e0860 --- /dev/null +++ b/OSGKeyboardShared/Features/Analytics/AnalyticsClient.swift @@ -0,0 +1,404 @@ +// AnalyticsClient.swift +// OSGKeyboard · Shared +// +// Synchronous, type-safe fire-and-forget API. Every asynchronous task owns only +// Sendable dependencies and analytics failures never escape into feature code. + +import Foundation + +public protocol AnalyticsClient: Sendable { + func recordSessionActivity() + func recordKeyboardActivated() + func recordPurchaseViewed() + func recordPurchaseStarted() + func recordPurchaseCancelled() + func recordReferralShared() + func recordInviteOpened( + acquisitionChannel: AnalyticsAcquisitionChannel, + surface: AnalyticsSurface + ) + func startAIFeature( + _ feature: AnalyticsFeature, + executionMode: AnalyticsExecutionMode + ) -> any AnalyticsAIOperation +} + +public extension AnalyticsClient { + func recordInviteOpened( + acquisitionChannel: AnalyticsAcquisitionChannel = .referral + ) { + recordInviteOpened( + acquisitionChannel: acquisitionChannel, + surface: .inviteWeb + ) + } +} + +public protocol AnalyticsAIOperation: Sendable { + func succeed() + func fail(category: AnalyticsFailureCategory) + func cancel() +} + +public final class LiveAnalyticsClient: AnalyticsClient, Sendable { + private let repository: AnalyticsRepository + private let context: AnalyticsBootstrapContext + private let monotonicClock: any AnalyticsMonotonicClock + private let trigger: any AnalyticsUploadTriggering + + public init( + repository: AnalyticsRepository, + context: AnalyticsBootstrapContext, + monotonicClock: any AnalyticsMonotonicClock = SystemAnalyticsMonotonicClock(), + trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger() + ) { + self.repository = repository + self.context = context + self.monotonicClock = monotonicClock + self.trigger = trigger + } + + public func recordSessionActivity() { + Task { + await repository.recordSessionIfNeeded(context: context) + await trigger.requestUpload() + } + } + + public func recordKeyboardActivated() { + enqueue(eventType: .keyboardActivated) + } + + public func recordPurchaseViewed() { + enqueue(eventType: .purchaseViewed) + } + + public func recordPurchaseStarted() { + enqueue(eventType: .purchaseStarted) + } + + public func recordPurchaseCancelled() { + enqueue( + eventType: .purchaseCancelled, + dimensions: AnalyticsEventDimensions(failureCategory: .cancelled) + ) + } + + public func recordReferralShared() { + enqueue(eventType: .referralShared) + } + + public func recordInviteOpened( + acquisitionChannel: AnalyticsAcquisitionChannel, + surface: AnalyticsSurface + ) { + enqueue( + eventType: .inviteOpened, + surfaceOverride: surface, + dimensions: AnalyticsEventDimensions( + acquisitionChannel: acquisitionChannel + ) + ) + } + + public func startAIFeature( + _ feature: AnalyticsFeature, + executionMode: AnalyticsExecutionMode + ) -> any AnalyticsAIOperation { + let startNanoseconds = monotonicClock.nowNanoseconds() + let dimensions = AnalyticsEventDimensions( + feature: feature, + executionMode: executionMode + ) + let startedTask = enqueue( + eventType: .aiFeatureStarted, + dimensions: dimensions + ) + return LiveAnalyticsAIOperation( + repository: repository, + context: context, + feature: feature, + executionMode: executionMode, + startNanoseconds: startNanoseconds, + startedTask: startedTask, + monotonicClock: monotonicClock, + trigger: trigger + ) + } + + @discardableResult + private func enqueue( + eventType: AnalyticsEventType, + surfaceOverride: AnalyticsSurface? = nil, + dimensions: AnalyticsEventDimensions = .none + ) -> Task { + let recordTask = Task { + await repository.record( + eventType: eventType, + context: context, + surfaceOverride: surfaceOverride, + dimensions: dimensions + ) + } + Task { + await recordTask.value + await trigger.requestUpload() + } + return recordTask + } +} + +public final class LiveAnalyticsAIOperation: AnalyticsAIOperation, @unchecked Sendable { + private let repository: AnalyticsRepository + private let context: AnalyticsBootstrapContext + private let feature: AnalyticsFeature + private let executionMode: AnalyticsExecutionMode + private let startNanoseconds: UInt64 + private let startedTask: Task + private let monotonicClock: any AnalyticsMonotonicClock + private let trigger: any AnalyticsUploadTriggering + private let terminalLock = NSLock() + private var reachedTerminalState = false + + init( + repository: AnalyticsRepository, + context: AnalyticsBootstrapContext, + feature: AnalyticsFeature, + executionMode: AnalyticsExecutionMode, + startNanoseconds: UInt64, + startedTask: Task, + monotonicClock: any AnalyticsMonotonicClock, + trigger: any AnalyticsUploadTriggering + ) { + self.repository = repository + self.context = context + self.feature = feature + self.executionMode = executionMode + self.startNanoseconds = startNanoseconds + self.startedTask = startedTask + self.monotonicClock = monotonicClock + self.trigger = trigger + } + + public func succeed() { + finish(eventType: .aiFeatureSucceeded, failureCategory: nil) + } + + public func fail(category: AnalyticsFailureCategory) { + finish(eventType: .aiFeatureFailed, failureCategory: category) + } + + public func cancel() { + fail(category: .cancelled) + } + + private func finish( + eventType: AnalyticsEventType, + failureCategory: AnalyticsFailureCategory? + ) { + terminalLock.lock() + guard !reachedTerminalState else { + terminalLock.unlock() + return + } + reachedTerminalState = true + terminalLock.unlock() + + let endNanoseconds = monotonicClock.nowNanoseconds() + let elapsed = endNanoseconds >= startNanoseconds + ? endNanoseconds - startNanoseconds + : 0 + let dimensions = AnalyticsEventDimensions( + feature: feature, + executionMode: executionMode, + failureCategory: failureCategory, + durationBucket: AnalyticsDurationBucket(elapsedNanoseconds: elapsed) + ) + Task { + // This explicit dependency guarantees STARTED reaches the repository + // before any terminal event, even when completion is immediate. + await startedTask.value + await repository.record( + eventType: eventType, + context: context, + dimensions: dimensions + ) + await trigger.requestUpload() + } + } +} + +public struct NoopAnalyticsAIOperation: AnalyticsAIOperation { + public init() {} + + public func succeed() {} + public func fail(category: AnalyticsFailureCategory) {} + public func cancel() {} +} + +public struct NoopAnalyticsClient: AnalyticsClient { + public init() {} + + public func recordSessionActivity() {} + public func recordKeyboardActivated() {} + public func recordPurchaseViewed() {} + public func recordPurchaseStarted() {} + public func recordPurchaseCancelled() {} + public func recordReferralShared() {} + + public func recordInviteOpened( + acquisitionChannel: AnalyticsAcquisitionChannel, + surface: AnalyticsSurface + ) {} + + public func startAIFeature( + _ feature: AnalyticsFeature, + executionMode: AnalyticsExecutionMode + ) -> any AnalyticsAIOperation { + NoopAnalyticsAIOperation() + } +} + +public typealias AnalyticsAISpan = any AnalyticsAIOperation + +public struct AnalyticsRuntime: Sendable { + public let repository: AnalyticsRepository + public let client: any AnalyticsClient + public let uploadCoordinator: AnalyticsUploadCoordinator + public let context: AnalyticsBootstrapContext + + private init( + surface: AnalyticsSurface, + environment: AnalyticsEnvironment, + repositoryConfiguration: AnalyticsRepositoryConfiguration, + uploadConfiguration: AnalyticsUploadConfiguration, + network: any AnalyticsNetworking, + bearerProvider: (any AnalyticsBearerProviding)?, + wallClock: any AnalyticsWallClock, + monotonicClock: any AnalyticsMonotonicClock, + uuidGenerator: any AnalyticsUUIDGenerating, + random: any AnalyticsRandomGenerating, + trigger: any AnalyticsUploadTriggering, + logger: any AnalyticsLogging + ) { + let context = AnalyticsBootstrapContext( + surface: surface, + environment: environment + ) + let repository = AnalyticsRepository( + configuration: repositoryConfiguration, + clock: wallClock, + uuidGenerator: uuidGenerator + ) + self.context = context + self.repository = repository + client = LiveAnalyticsClient( + repository: repository, + context: context, + monotonicClock: monotonicClock, + trigger: trigger + ) + uploadCoordinator = AnalyticsUploadCoordinator( + repository: repository, + configuration: uploadConfiguration, + network: network, + bearerProvider: bearerProvider, + clock: wallClock, + uuidGenerator: uuidGenerator, + random: random, + logger: logger + ) + } + + /// Main-app runtime. A bearer provider may be supplied by host-only account + /// code without making the shared framework depend on that code. + public static func mainApp( + environment: AnalyticsEnvironment, + repositoryConfiguration: AnalyticsRepositoryConfiguration = .appGroupDefault(), + uploadConfiguration: AnalyticsUploadConfiguration, + network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(), + bearerProvider: (any AnalyticsBearerProviding)? = nil, + wallClock: any AnalyticsWallClock = SystemAnalyticsWallClock(), + monotonicClock: any AnalyticsMonotonicClock = SystemAnalyticsMonotonicClock(), + uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(), + random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(), + trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger(), + logger: any AnalyticsLogging = NoopAnalyticsLogger() + ) -> Self { + Self( + surface: .app, + environment: environment, + repositoryConfiguration: repositoryConfiguration, + uploadConfiguration: uploadConfiguration, + network: network, + bearerProvider: bearerProvider, + wallClock: wallClock, + monotonicClock: monotonicClock, + uuidGenerator: uuidGenerator, + random: random, + trigger: trigger, + logger: logger + ) + } + + /// Keyboard-extension runtime. This factory intentionally has no bearer + /// parameter, so extension uploads are anonymous by construction. + public static func keyboardExtension( + environment: AnalyticsEnvironment, + repositoryConfiguration: AnalyticsRepositoryConfiguration = .appGroupDefault(), + uploadConfiguration: AnalyticsUploadConfiguration, + network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(), + wallClock: any AnalyticsWallClock = SystemAnalyticsWallClock(), + monotonicClock: any AnalyticsMonotonicClock = SystemAnalyticsMonotonicClock(), + uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(), + random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(), + trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger(), + logger: any AnalyticsLogging = NoopAnalyticsLogger() + ) -> Self { + Self( + surface: .keyboard, + environment: environment, + repositoryConfiguration: repositoryConfiguration, + uploadConfiguration: uploadConfiguration, + network: network, + bearerProvider: nil, + wallClock: wallClock, + monotonicClock: monotonicClock, + uuidGenerator: uuidGenerator, + random: random, + trigger: trigger, + logger: logger + ) + } + + public func setEnabled(_ enabled: Bool) async { + await repository.setEnabled(enabled) + } + + /// Host-only explicit initialization point. Call after processing the + /// cold-start URL so FIRST_OPEN receives the final acquisition channel. + public func prepare( + firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel = .unknown + ) async { + await repository.prepare( + using: context, + firstOpenAcquisitionChannel: firstOpenAcquisitionChannel + ) + } + + public func isEnabled() async -> Bool { + await repository.isEnabled() + } + + public func observeAccount( + stableIdentifier: String + ) async -> AnalyticsAccountObservation { + await repository.observeAccount( + stableIdentifier: stableIdentifier + ) + } + + public func handleAccountDeletion() async { + await repository.handleAccountDeletion() + } +} diff --git a/OSGKeyboardShared/Features/Analytics/AnalyticsDependencies.swift b/OSGKeyboardShared/Features/Analytics/AnalyticsDependencies.swift new file mode 100644 index 0000000..7255a46 --- /dev/null +++ b/OSGKeyboardShared/Features/Analytics/AnalyticsDependencies.swift @@ -0,0 +1,227 @@ +// AnalyticsDependencies.swift +// OSGKeyboard · Shared +// +// Injectable system boundaries used by both the host app and keyboard extension. + +import Foundation + +public protocol AnalyticsWallClock: Sendable { + func now() -> Date +} + +public protocol AnalyticsMonotonicClock: Sendable { + func nowNanoseconds() -> UInt64 +} + +public protocol AnalyticsUUIDGenerating: Sendable { + func makeUUID() -> UUID +} + +public protocol AnalyticsRandomGenerating: Sendable { + /// Returns a value in the closed range 0...upperBound. + func next(upperBound: UInt64) -> UInt64 +} + +public struct SystemAnalyticsWallClock: AnalyticsWallClock { + public init() {} + + public func now() -> Date { + Date() + } +} + +public struct SystemAnalyticsMonotonicClock: AnalyticsMonotonicClock { + public init() {} + + public func nowNanoseconds() -> UInt64 { + DispatchTime.now().uptimeNanoseconds + } +} + +public struct SystemAnalyticsUUIDGenerator: AnalyticsUUIDGenerating { + public init() {} + + public func makeUUID() -> UUID { + UUID() + } +} + +public final class SystemAnalyticsRandomGenerator: AnalyticsRandomGenerating, @unchecked Sendable { + private let lock = NSLock() + private var generator = SystemRandomNumberGenerator() + + public init() {} + + public func next(upperBound: UInt64) -> UInt64 { + guard upperBound > 0 else { return 0 } + lock.lock() + defer { lock.unlock() } + return UInt64.random(in: 0...upperBound, using: &generator) + } +} + +public struct AnalyticsHTTPRequest: Sendable { + public let url: URL + public let headers: [String: String] + public let body: Data + + public init(url: URL, headers: [String: String], body: Data) { + self.url = url + self.headers = headers + self.body = body + } +} + +public struct AnalyticsHTTPResponse: Sendable { + public let statusCode: Int + public let headers: [String: String] + public let body: Data + + public init(statusCode: Int, headers: [String: String], body: Data) { + self.statusCode = statusCode + self.headers = headers + self.body = body + } + + func header(named name: String) -> String? { + headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value + } +} + +public protocol AnalyticsNetworking: Sendable { + func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse +} + +public protocol AnalyticsBearerProviding: Sendable { + func bearerToken() async throws -> String? + func refreshBearerToken( + afterUnauthorizedAccessToken failedToken: String? + ) async throws -> String? +} + +public protocol AnalyticsUploadTriggering: Sendable { + func requestUpload() async +} + +public struct NoopAnalyticsUploadTrigger: AnalyticsUploadTriggering { + public init() {} + + public func requestUpload() async {} +} + +public enum AnalyticsUploadErrorCategory: String, Sendable { + case network + case timeout + case authentication + case rateLimited + case server + case client + case decoding + case countMismatch + case storage +} + +public struct AnalyticsUploadLogEntry: Sendable { + public enum Outcome: String, Sendable { + case uploaded + case retryScheduled + case quarantined + case skipped + } + + public let outcome: Outcome + public let eventCount: Int + public let statusCode: Int? + public let attempt: Int + public let errorCategory: AnalyticsUploadErrorCategory? + + public init( + outcome: Outcome, + eventCount: Int, + statusCode: Int? = nil, + attempt: Int = 0, + errorCategory: AnalyticsUploadErrorCategory? = nil + ) { + self.outcome = outcome + self.eventCount = eventCount + self.statusCode = statusCode + self.attempt = attempt + self.errorCategory = errorCategory + } +} + +public protocol AnalyticsLogging: Sendable { + func log(_ entry: AnalyticsUploadLogEntry) +} + +public struct NoopAnalyticsLogger: AnalyticsLogging { + public init() {} + + public func log(_ entry: AnalyticsUploadLogEntry) {} +} + +public struct AnalyticsRepositoryConfiguration: Sendable { + public static let defaultDatabaseFilename = "analytics.sqlite3" + + public let databaseURL: URL? + public let maximumEventCount: Int + public let maximumStoredBytes: Int + public let eventRetention: TimeInterval + public let busyTimeoutMilliseconds: Int32 + + public init( + databaseURL: URL?, + maximumEventCount: Int = 10_000, + maximumStoredBytes: Int = 5 * 1_024 * 1_024, + eventRetention: TimeInterval = 34 * 24 * 60 * 60, + busyTimeoutMilliseconds: Int32 = 2_000 + ) { + self.databaseURL = databaseURL + self.maximumEventCount = max(1, maximumEventCount) + self.maximumStoredBytes = max(1_024, maximumStoredBytes) + self.eventRetention = max(60, eventRetention) + self.busyTimeoutMilliseconds = max(0, busyTimeoutMilliseconds) + } + + public static func appGroupDefault( + appGroupIdentifier: String = AppGroup.identifier + ) -> Self { + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + let directory = container?.appendingPathComponent( + "Library/Application Support/Analytics", + isDirectory: true + ) + return Self( + databaseURL: directory?.appendingPathComponent(defaultDatabaseFilename) + ) + } +} + +public struct AnalyticsUploadConfiguration: Sendable { + public let endpoint: URL + public let maximumBatchCount: Int + public let maximumBodyBytes: Int + public let globalLeaseDuration: TimeInterval + public let eventLeaseDuration: TimeInterval + public let maximumBackoff: TimeInterval + + public init( + endpoint: URL, + maximumBatchCount: Int = 50, + maximumBodyBytes: Int = 60 * 1_024, + globalLeaseDuration: TimeInterval = 2 * 60, + eventLeaseDuration: TimeInterval = 5 * 60, + maximumBackoff: TimeInterval = 6 * 60 * 60 + ) { + self.endpoint = endpoint + self.maximumBatchCount = min(50, max(1, maximumBatchCount)) + self.maximumBodyBytes = min(60 * 1_024, max(1_024, maximumBodyBytes)) + // Both leases outlive the default 30-second transport timeout. The + // coordinator also renews them immediately before every request. + self.globalLeaseDuration = max(60, globalLeaseDuration) + self.eventLeaseDuration = max(60, eventLeaseDuration) + self.maximumBackoff = min(6 * 60 * 60, max(60, maximumBackoff)) + } +} diff --git a/OSGKeyboardShared/Features/Analytics/AnalyticsModels.swift b/OSGKeyboardShared/Features/Analytics/AnalyticsModels.swift new file mode 100644 index 0000000..e58ff92 --- /dev/null +++ b/OSGKeyboardShared/Features/Analytics/AnalyticsModels.swift @@ -0,0 +1,446 @@ +// AnalyticsModels.swift +// OSGKeyboard · Shared +// +// Privacy-preserving analytics wire contract. There is intentionally no +// free-form properties dictionary or user-provided text in this model. + +import Foundation + +public enum AnalyticsEventType: String, Codable, CaseIterable, Sendable { + case firstOpen = "FIRST_OPEN" + case sessionStarted = "SESSION_STARTED" + case keyboardActivated = "KEYBOARD_ACTIVATED" + case aiFeatureStarted = "AI_FEATURE_STARTED" + case aiFeatureSucceeded = "AI_FEATURE_SUCCEEDED" + case aiFeatureFailed = "AI_FEATURE_FAILED" + case purchaseViewed = "PURCHASE_VIEWED" + case purchaseStarted = "PURCHASE_STARTED" + case purchaseCancelled = "PURCHASE_CANCELLED" + case referralShared = "REFERRAL_SHARED" + case inviteOpened = "INVITE_OPENED" +} + +public enum AnalyticsSurface: String, Codable, CaseIterable, Sendable { + case app = "APP" + case keyboard = "KEYBOARD" + case inviteWeb = "INVITE_WEB" +} + +public enum AnalyticsAcquisitionChannel: String, Codable, CaseIterable, Sendable { + case appStoreOrganic = "APP_STORE_ORGANIC" + case referral = "REFERRAL" + case socialContent = "SOCIAL_CONTENT" + case unknown = "UNKNOWN" +} + +public enum AnalyticsFeature: String, Codable, CaseIterable, Sendable { + case transcription = "TRANSCRIPTION" + case polish = "POLISH" + case aiAssistant = "AI_ASSISTANT" + case agent = "AGENT" + case hotword = "HOTWORD" + case other = "OTHER" +} + +public enum AnalyticsExecutionMode: String, Codable, CaseIterable, Sendable { + case managed = "MANAGED" + case local = "LOCAL" + case byok = "BYOK" +} + +public enum AnalyticsFailureCategory: String, Codable, CaseIterable, Sendable { + case network = "NETWORK" + case provider = "PROVIDER" + case timeout = "TIMEOUT" + case cancelled = "CANCELLED" + case insufficientCredits = "INSUFFICIENT_CREDITS" + case validation = "VALIDATION" + case unknown = "UNKNOWN" +} + +public enum AnalyticsDurationBucket: String, Codable, CaseIterable, Sendable { + case lessThanOneSecond = "LT_1S" + case oneToThreeSeconds = "S1_TO_3" + case threeToTenSeconds = "S3_TO_10" + case tenToThirtySeconds = "S10_TO_30" + case thirtySecondsOrMore = "GTE_30S" + + public init(elapsedNanoseconds: UInt64) { + switch elapsedNanoseconds { + case ..<1_000_000_000: + self = .lessThanOneSecond + case ..<3_000_000_000: + self = .oneToThreeSeconds + case ..<10_000_000_000: + self = .threeToTenSeconds + case ..<30_000_000_000: + self = .tenToThirtySeconds + default: + self = .thirtySecondsOrMore + } + } +} + +public enum AnalyticsModelError: Error, Sendable { + case unknownField(String) + case invalidDimensions(AnalyticsEventType) + case invalidVersion + case invalidResponseCounts +} + +public struct AnalyticsEvent: Codable, Equatable, Sendable { + public let installationId: UUID + public let clientEventId: UUID + public let eventType: AnalyticsEventType + public let occurredAt: Date + public let surface: AnalyticsSurface + public let appVersion: String + public let osVersion: String + public let acquisitionChannel: AnalyticsAcquisitionChannel? + public let feature: AnalyticsFeature? + public let executionMode: AnalyticsExecutionMode? + public let failureCategory: AnalyticsFailureCategory? + public let durationBucket: AnalyticsDurationBucket? + + public init( + installationId: UUID, + clientEventId: UUID, + eventType: AnalyticsEventType, + occurredAt: Date, + surface: AnalyticsSurface, + appVersion: String, + osVersion: String, + acquisitionChannel: AnalyticsAcquisitionChannel? = nil, + feature: AnalyticsFeature? = nil, + executionMode: AnalyticsExecutionMode? = nil, + failureCategory: AnalyticsFailureCategory? = nil, + durationBucket: AnalyticsDurationBucket? = nil + ) throws { + guard AnalyticsEnvironment.isSafeVersion(appVersion), + AnalyticsEnvironment.isSafeVersion(osVersion) else { + throw AnalyticsModelError.invalidVersion + } + + self.installationId = installationId + self.clientEventId = clientEventId + self.eventType = eventType + self.occurredAt = occurredAt + self.surface = surface + self.appVersion = appVersion + self.osVersion = osVersion + self.acquisitionChannel = acquisitionChannel + self.feature = feature + self.executionMode = executionMode + self.failureCategory = failureCategory + self.durationBucket = durationBucket + + guard dimensionsAreAllowed else { + throw AnalyticsModelError.invalidDimensions(eventType) + } + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case installationId + case clientEventId + case eventType + case occurredAt + case surface + case appVersion + case osVersion + case acquisitionChannel + case feature + case executionMode + case failureCategory + case durationBucket + } + + public init(from decoder: Decoder) throws { + try AnalyticsCodableAllowlist.rejectUnknownKeys( + in: decoder, + allowed: Set(CodingKeys.allCases.map(\.rawValue)) + ) + let container = try decoder.container(keyedBy: CodingKeys.self) + let installationId = try container.decode(UUID.self, forKey: .installationId) + let clientEventId = try container.decode(UUID.self, forKey: .clientEventId) + let eventType = try container.decode(AnalyticsEventType.self, forKey: .eventType) + let occurredAtText = try container.decode(String.self, forKey: .occurredAt) + guard let occurredAt = AnalyticsWireDate.date(from: occurredAtText) else { + throw DecodingError.dataCorruptedError( + forKey: .occurredAt, + in: container, + debugDescription: "occurredAt must be a UTC ISO-8601 timestamp" + ) + } + try self.init( + installationId: installationId, + clientEventId: clientEventId, + eventType: eventType, + occurredAt: occurredAt, + surface: try container.decode(AnalyticsSurface.self, forKey: .surface), + appVersion: try container.decode(String.self, forKey: .appVersion), + osVersion: try container.decode(String.self, forKey: .osVersion), + acquisitionChannel: try container.decodeIfPresent( + AnalyticsAcquisitionChannel.self, + forKey: .acquisitionChannel + ), + feature: try container.decodeIfPresent(AnalyticsFeature.self, forKey: .feature), + executionMode: try container.decodeIfPresent( + AnalyticsExecutionMode.self, + forKey: .executionMode + ), + failureCategory: try container.decodeIfPresent( + AnalyticsFailureCategory.self, + forKey: .failureCategory + ), + durationBucket: try container.decodeIfPresent( + AnalyticsDurationBucket.self, + forKey: .durationBucket + ) + ) + } + + public func encode(to encoder: Encoder) throws { + guard dimensionsAreAllowed else { + throw AnalyticsModelError.invalidDimensions(eventType) + } + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(installationId, forKey: .installationId) + try container.encode(clientEventId, forKey: .clientEventId) + try container.encode(eventType, forKey: .eventType) + try container.encode(AnalyticsWireDate.string(from: occurredAt), forKey: .occurredAt) + try container.encode(surface, forKey: .surface) + try container.encode(appVersion, forKey: .appVersion) + try container.encode(osVersion, forKey: .osVersion) + try container.encodeIfPresent(acquisitionChannel, forKey: .acquisitionChannel) + try container.encodeIfPresent(feature, forKey: .feature) + try container.encodeIfPresent(executionMode, forKey: .executionMode) + try container.encodeIfPresent(failureCategory, forKey: .failureCategory) + try container.encodeIfPresent(durationBucket, forKey: .durationBucket) + } + + private var dimensionsAreAllowed: Bool { + let present = DimensionSet( + acquisitionChannel: acquisitionChannel != nil, + feature: feature != nil, + executionMode: executionMode != nil, + failureCategory: failureCategory != nil, + durationBucket: durationBucket != nil + ) + guard eventType.allowedDimensionSets.contains(present) else { + return false + } + if eventType == .purchaseCancelled { + return failureCategory == .cancelled + } + return true + } +} + +public struct AnalyticsUploadRequest: Codable, Equatable, Sendable { + public let events: [AnalyticsEvent] + + public init(events: [AnalyticsEvent]) { + self.events = events + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case events + } + + public init(from decoder: Decoder) throws { + try AnalyticsCodableAllowlist.rejectUnknownKeys( + in: decoder, + allowed: Set(CodingKeys.allCases.map(\.rawValue)) + ) + let container = try decoder.container(keyedBy: CodingKeys.self) + events = try container.decode([AnalyticsEvent].self, forKey: .events) + } +} + +public struct AnalyticsUploadResponse: Codable, Equatable, Sendable { + public let accepted: Int + public let replayed: Int + + public init(accepted: Int, replayed: Int) throws { + guard accepted >= 0, replayed >= 0 else { + throw AnalyticsModelError.invalidResponseCounts + } + self.accepted = accepted + self.replayed = replayed + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case accepted + case replayed + } + + public init(from decoder: Decoder) throws { + try AnalyticsCodableAllowlist.rejectUnknownKeys( + in: decoder, + allowed: Set(CodingKeys.allCases.map(\.rawValue)) + ) + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + accepted: container.decode(Int.self, forKey: .accepted), + replayed: container.decode(Int.self, forKey: .replayed) + ) + } +} + +public struct AnalyticsEnvironment: Equatable, Sendable { + public let appVersion: String + public let osVersion: String + + public init(appVersion: String, osVersion: String) { + self.appVersion = Self.sanitizedVersion(appVersion) + self.osVersion = Self.sanitizedVersion(osVersion) + } + + static func isSafeVersion(_ value: String) -> Bool { + !value.isEmpty + && value.utf8.count <= 32 + && value.unicodeScalars.allSatisfy { + CharacterSet(charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_") + .contains($0) + } + } + + private static func sanitizedVersion(_ value: String) -> String { + let allowed = CharacterSet( + charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_" + ) + let filtered = String(value.unicodeScalars.filter(allowed.contains).prefix(32)) + return filtered.isEmpty ? "unknown" : filtered + } +} + +private struct DimensionSet: Hashable { + let acquisitionChannel: Bool + let feature: Bool + let executionMode: Bool + let failureCategory: Bool + let durationBucket: Bool + + static let none = Self( + acquisitionChannel: false, + feature: false, + executionMode: false, + failureCategory: false, + durationBucket: false + ) +} + +private extension AnalyticsEventType { + var allowedDimensionSets: Set { + switch self { + case .firstOpen, .inviteOpened: + return [ + DimensionSet( + acquisitionChannel: true, + feature: false, + executionMode: false, + failureCategory: false, + durationBucket: false + ) + ] + case .aiFeatureStarted: + return [ + DimensionSet( + acquisitionChannel: false, + feature: true, + executionMode: true, + failureCategory: false, + durationBucket: false + ) + ] + case .aiFeatureSucceeded: + return [ + DimensionSet( + acquisitionChannel: false, + feature: true, + executionMode: true, + failureCategory: false, + durationBucket: true + ) + ] + case .aiFeatureFailed: + return [ + DimensionSet( + acquisitionChannel: false, + feature: true, + executionMode: true, + failureCategory: true, + durationBucket: true + ) + ] + case .sessionStarted, + .keyboardActivated, + .purchaseViewed, + .purchaseStarted, + .referralShared: + return [.none] + case .purchaseCancelled: + return [ + DimensionSet( + acquisitionChannel: false, + feature: false, + executionMode: false, + failureCategory: true, + durationBucket: false + ) + ] + } + } +} + +enum AnalyticsCanonicalJSON { + static func encode(_ value: T) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(value) + } +} + +enum AnalyticsWireDate { + static func string(from date: Date) -> String { + formatter.string(from: date) + } + + static func date(from value: String) -> Date? { + formatter.date(from: value) + } + + private static var formatter: ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + } +} + +private enum AnalyticsCodableAllowlist { + static func rejectUnknownKeys( + in decoder: Decoder, + allowed: Set + ) throws { + let container = try decoder.container(keyedBy: AnyCodingKey.self) + if let unknown = container.allKeys.first(where: { !allowed.contains($0.stringValue) }) { + throw AnalyticsModelError.unknownField(unknown.stringValue) + } + } +} + +private struct AnyCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + init?(intValue: Int) { + stringValue = String(intValue) + self.intValue = intValue + } +} diff --git a/OSGKeyboardShared/Features/Analytics/AnalyticsRepository.swift b/OSGKeyboardShared/Features/Analytics/AnalyticsRepository.swift new file mode 100644 index 0000000..1c9b1b5 --- /dev/null +++ b/OSGKeyboardShared/Features/Analytics/AnalyticsRepository.swift @@ -0,0 +1,1397 @@ +// AnalyticsRepository.swift +// OSGKeyboard · Shared +// +// Cross-process SQLite queue. The actor is the in-process isolation boundary; +// WAL and BEGIN IMMEDIATE provide the corresponding cross-process boundary. + +import CryptoKit +import Foundation +import SQLite3 + +public struct AnalyticsBootstrapContext: Sendable { + public let surface: AnalyticsSurface + public let environment: AnalyticsEnvironment + + public init( + surface: AnalyticsSurface, + environment: AnalyticsEnvironment + ) { + self.surface = surface + self.environment = environment + } +} + +public enum AnalyticsAccountObservation: Sendable { + case firstAccount + case unchanged + case switchedAccount + case analyticsDisabled + case unavailable +} + +public struct AnalyticsPendingEventDiagnostic: Sendable { + public let rowID: Int64 + public let eventType: AnalyticsEventType? + public let attemptCount: Int + public let nextAttemptAt: Date + public let payloadSize: Int +} + +public struct AnalyticsQuarantinedEventDiagnostic: Sendable { + public let rowID: Int64 + public let eventType: AnalyticsEventType? + public let attemptCount: Int + public let reason: String + public let payloadSize: Int +} + +public struct AnalyticsRepositoryDebugSnapshot: Sendable { + public let isAvailable: Bool + public let enabled: Bool + public let installationID: UUID? + public let firstOpenRecorded: Bool + public let pendingEvents: [AnalyticsPendingEventDiagnostic] + public let quarantinedEvents: [AnalyticsQuarantinedEventDiagnostic] +} + +struct AnalyticsEventDimensions: Sendable { + var acquisitionChannel: AnalyticsAcquisitionChannel? + var feature: AnalyticsFeature? + var executionMode: AnalyticsExecutionMode? + var failureCategory: AnalyticsFailureCategory? + var durationBucket: AnalyticsDurationBucket? + + static let none = Self() +} + +struct AnalyticsLeasedEvent: Sendable { + let rowID: Int64 + let payload: Data + let attemptCount: Int +} + +struct AnalyticsLeasedBatch: Sendable { + let leaseID: String + let events: [AnalyticsLeasedEvent] + let body: Data +} + +public actor AnalyticsRepository { + private enum MetadataKey { + static let enabled = "enabled" + static let installationID = "installationId" + static let firstOpenRecorded = "firstOpenRecorded" + static let accountFingerprint = "accountFingerprint" + static let uploadLeaseOwner = "uploadLeaseOwner" + static let uploadLeaseExpiresAt = "uploadLeaseExpiresAt" + static let expiredDropCount = "diagnostic.expiredDropCount" + static let capacityDropCount = "diagnostic.capacityDropCount" + + static func lastSession(_ surface: AnalyticsSurface) -> String { + "lastSession.\(surface.rawValue)" + } + } + + private let configuration: AnalyticsRepositoryConfiguration + private let clock: any AnalyticsWallClock + private let uuidGenerator: any AnalyticsUUIDGenerating + private var database: SQLiteDatabase? + + public init( + configuration: AnalyticsRepositoryConfiguration = .appGroupDefault(), + clock: any AnalyticsWallClock = SystemAnalyticsWallClock(), + uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator() + ) { + self.configuration = configuration + self.clock = clock + self.uuidGenerator = uuidGenerator + } + + /// The host calls this only after cold-start attribution has been resolved. + /// Installation identity, FIRST_OPEN and its marker share one transaction. + public func prepare( + using context: AnalyticsBootstrapContext, + firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel + ) { + guard context.surface == .app else { return } + guard let database = openDatabaseIfNeeded() else { return } + absorbDatabaseErrors { + try database.immediateTransaction { + _ = try ensureEnabled(database) + guard try isEnabled(database) else { return } + let installationID = try ensureInstallation(database) + guard try metadata( + for: MetadataKey.firstOpenRecorded, + database: database + ) == nil else { + return + } + let event = try AnalyticsEvent( + installationId: installationID, + clientEventId: uuidGenerator.makeUUID(), + eventType: .firstOpen, + occurredAt: clock.now(), + surface: .app, + appVersion: context.environment.appVersion, + osVersion: context.environment.osVersion, + acquisitionChannel: firstOpenAcquisitionChannel + ) + try insert(event, database: database) + try setMetadata( + "1", + for: MetadataKey.firstOpenRecorded, + database: database + ) + try enforceStoragePolicy(database) + } + } + } + + public func isEnabled() -> Bool { + guard let database = openDatabaseIfNeeded() else { return true } + do { + return try database.immediateTransaction { + try ensureEnabled(database) + } + } catch { + return true + } + } + + /// Read-only diagnostics for tests and support tooling. Event payload content + /// is deliberately excluded from this public snapshot. + public func debugSnapshot() -> AnalyticsRepositoryDebugSnapshot { + guard let database = openDatabaseIfNeeded() else { + return Self.unavailableDebugSnapshot + } + do { + return try database.immediateTransaction { + let enabled = try ensureEnabled(database) + let installationID = try metadata( + for: MetadataKey.installationID, + database: database + ).flatMap(UUID.init(uuidString:)) + let firstOpenRecorded = try metadata( + for: MetadataKey.firstOpenRecorded, + database: database + ) == "1" + let pending = try database.query( + """ + SELECT id, event_type, attempt_count, next_attempt_at, payload_size + FROM pending_events + ORDER BY id ASC + """ + ).compactMap { row -> AnalyticsPendingEventDiagnostic? in + guard let rowID = row.int64(at: 0), + let eventTypeText = row.text(at: 1), + let attemptCount = row.int64(at: 2), + let nextAttemptAt = row.double(at: 3), + let payloadSize = row.int64(at: 4) else { + return nil + } + return AnalyticsPendingEventDiagnostic( + rowID: rowID, + eventType: AnalyticsEventType(rawValue: eventTypeText), + attemptCount: Int(attemptCount), + nextAttemptAt: Date(timeIntervalSince1970: nextAttemptAt), + payloadSize: Int(payloadSize) + ) + } + let quarantined = try database.query( + """ + SELECT id, event_type, attempt_count, reason, payload_size + FROM quarantined_events + ORDER BY id ASC + """ + ).compactMap { row -> AnalyticsQuarantinedEventDiagnostic? in + guard let rowID = row.int64(at: 0), + let eventTypeText = row.text(at: 1), + let attemptCount = row.int64(at: 2), + let reason = row.text(at: 3), + let payloadSize = row.int64(at: 4) else { + return nil + } + return AnalyticsQuarantinedEventDiagnostic( + rowID: rowID, + eventType: AnalyticsEventType(rawValue: eventTypeText), + attemptCount: Int(attemptCount), + reason: reason, + payloadSize: Int(payloadSize) + ) + } + return AnalyticsRepositoryDebugSnapshot( + isAvailable: true, + enabled: enabled, + installationID: installationID, + firstOpenRecorded: firstOpenRecorded, + pendingEvents: pending, + quarantinedEvents: quarantined + ) + } + } catch { + return Self.unavailableDebugSnapshot + } + } + + /// Internal raw-byte access is intentionally limited to @testable imports. + func debugPayloadBytes(rowID: Int64, quarantined: Bool = false) -> Data? { + guard let database = openDatabaseIfNeeded() else { return nil } + let table = quarantined ? "quarantined_events" : "pending_events" + do { + return try database.query( + "SELECT payload FROM \(table) WHERE id = ?", + bindings: [.int64(rowID)] + ).first?.data(at: 0) + } catch { + return nil + } + } + + public func setEnabled(_ enabled: Bool) { + guard let database = openDatabaseIfNeeded() else { return } + absorbDatabaseErrors { + try database.immediateTransaction { + let wasEnabled = try ensureEnabled(database) + guard wasEnabled != enabled else { + if enabled { + _ = try ensureInstallation(database) + } + return + } + + if enabled { + try setMetadata("1", for: MetadataKey.enabled, database: database) + try rotateInstallation(database) + } else { + try setMetadata("0", for: MetadataKey.enabled, database: database) + try clearQueues(database) + try clearUploadLease(database) + } + } + } + } + + public func observeAccount( + stableIdentifier: String + ) -> AnalyticsAccountObservation { + guard let database = openDatabaseIfNeeded() else { return .unavailable } + do { + return try database.immediateTransaction { + _ = try ensureEnabled(database) + guard try isEnabled(database) else { return .analyticsDisabled } + _ = try ensureInstallation(database) + + let fingerprint = Self.accountFingerprint(stableIdentifier) + guard let existing = try metadata( + for: MetadataKey.accountFingerprint, + database: database + ) else { + try setMetadata( + fingerprint, + for: MetadataKey.accountFingerprint, + database: database + ) + return .firstAccount + } + guard existing != fingerprint else { return .unchanged } + + try setMetadata( + fingerprint, + for: MetadataKey.accountFingerprint, + database: database + ) + try rotateInstallation(database) + return .switchedAccount + } + } catch { + return .unavailable + } + } + + /// Account deletion creates a fresh anonymous analytics identity and removes + /// queued events while preserving the once-per-install FIRST_OPEN marker. + public func handleAccountDeletion() { + guard let database = openDatabaseIfNeeded() else { return } + absorbDatabaseErrors { + try database.immediateTransaction { + _ = try ensureEnabled(database) + try removeMetadata( + for: MetadataKey.accountFingerprint, + database: database + ) + guard try isEnabled(database) else { + try clearQueues(database) + return + } + try rotateInstallation(database) + } + } + } + + func record( + eventType: AnalyticsEventType, + context: AnalyticsBootstrapContext, + surfaceOverride: AnalyticsSurface? = nil, + dimensions: AnalyticsEventDimensions = .none + ) { + guard let database = openDatabaseIfNeeded() else { return } + absorbDatabaseErrors { + try database.immediateTransaction { + _ = try ensureEnabled(database) + guard try isEnabled(database) else { return } + let installationID = try ensureInstallation(database) + let event = try AnalyticsEvent( + installationId: installationID, + clientEventId: uuidGenerator.makeUUID(), + eventType: eventType, + occurredAt: clock.now(), + surface: surfaceOverride ?? context.surface, + appVersion: context.environment.appVersion, + osVersion: context.environment.osVersion, + acquisitionChannel: dimensions.acquisitionChannel, + feature: dimensions.feature, + executionMode: dimensions.executionMode, + failureCategory: dimensions.failureCategory, + durationBucket: dimensions.durationBucket + ) + try insert(event, database: database) + try enforceStoragePolicy(database) + } + } + } + + /// Starts at most one session per surface in each rolling 30-minute window. + func recordSessionIfNeeded( + context: AnalyticsBootstrapContext + ) { + guard let database = openDatabaseIfNeeded() else { return } + absorbDatabaseErrors { + try database.immediateTransaction { + _ = try ensureEnabled(database) + guard try isEnabled(database) else { return } + let now = clock.now() + let key = MetadataKey.lastSession(context.surface) + if let text = try metadata(for: key, database: database), + let previous = TimeInterval(text) { + let elapsed = now.timeIntervalSince1970 - previous + if elapsed >= 0, elapsed < 30 * 60 { + // Session windows are based on inactivity, so every + // presentation refreshes the last-active timestamp. + try setMetadata( + String(now.timeIntervalSince1970), + for: key, + database: database + ) + return + } + } + + let installationID = try ensureInstallation(database) + let event = try AnalyticsEvent( + installationId: installationID, + clientEventId: uuidGenerator.makeUUID(), + eventType: .sessionStarted, + occurredAt: now, + surface: context.surface, + appVersion: context.environment.appVersion, + osVersion: context.environment.osVersion + ) + try insert(event, database: database) + try setMetadata( + String(now.timeIntervalSince1970), + for: key, + database: database + ) + try enforceStoragePolicy(database) + } + } + } + + func leaseBatch( + ownerID: String, + configuration upload: AnalyticsUploadConfiguration + ) -> AnalyticsLeasedBatch? { + guard let database = openDatabaseIfNeeded() else { return nil } + do { + return try database.immediateTransaction { + let now = clock.now().timeIntervalSince1970 + guard try acquireGlobalLease( + ownerID: ownerID, + expiresAt: now + upload.globalLeaseDuration, + now: now, + database: database + ) else { + return nil + } + + try releaseExpiredEventLeases(now: now, database: database) + try deleteExpiredEvents(now: now, database: database) + + let candidates = try database.query( + """ + SELECT id, payload, attempt_count + FROM pending_events + WHERE next_attempt_at <= ? AND lease_id IS NULL + ORDER BY priority DESC, created_at ASC + LIMIT ? + """, + bindings: [.double(now), .int64(Int64(upload.maximumBatchCount))] + ) + + var selected: [AnalyticsLeasedEvent] = [] + var bodySize = Self.emptyRequestBody.count + for row in candidates { + guard let rowID = row.int64(at: 0), + let payload = row.data(at: 1), + let attempts = row.int64(at: 2) else { + continue + } + let additional = payload.count + (selected.isEmpty ? 0 : 1) + guard bodySize + additional <= upload.maximumBodyBytes else { + if selected.isEmpty { + try quarantineRows( + [rowID], + reason: "oversized", + database: database + ) + } + break + } + selected.append( + AnalyticsLeasedEvent( + rowID: rowID, + payload: payload, + attemptCount: Int(attempts) + ) + ) + bodySize += additional + } + + guard !selected.isEmpty else { + try clearUploadLease(database) + return nil + } + + let leaseID = uuidGenerator.makeUUID().uuidString.lowercased() + let leaseExpiry = now + upload.eventLeaseDuration + for event in selected { + try database.execute( + """ + UPDATE pending_events + SET lease_id = ?, lease_expires_at = ? + WHERE id = ? AND lease_id IS NULL + """, + bindings: [ + .text(leaseID), + .double(leaseExpiry), + .int64(event.rowID) + ] + ) + } + return AnalyticsLeasedBatch( + leaseID: leaseID, + events: selected, + body: Self.requestBody(for: selected) + ) + } + } catch { + return nil + } + } + + func complete( + rowIDs: [Int64], + leaseID: String + ) -> Bool { + guard let database = openDatabaseIfNeeded(), !rowIDs.isEmpty else { return false } + do { + return try database.immediateTransaction { + var deleted = 0 + for rowID in rowIDs { + try database.execute( + "DELETE FROM pending_events WHERE id = ? AND lease_id = ?", + bindings: [.int64(rowID), .text(leaseID)] + ) + deleted += Int(database.changes) + } + guard deleted == rowIDs.count else { + throw SQLiteStoreError( + code: SQLITE_CONSTRAINT, + category: .constraint, + operation: "complete leased events" + ) + } + return true + } + } catch { + return false + } + } + + func quarantine( + rowIDs: [Int64], + leaseID: String, + reason: String + ) { + guard let database = openDatabaseIfNeeded(), !rowIDs.isEmpty else { return } + absorbDatabaseErrors { + try database.immediateTransaction { + try quarantineRows( + rowIDs, + leaseID: leaseID, + reason: reason, + database: database + ) + } + } + } + + func scheduleRetry( + events: [AnalyticsLeasedEvent], + leaseID: String, + delay: TimeInterval + ) { + guard let database = openDatabaseIfNeeded(), !events.isEmpty else { return } + absorbDatabaseErrors { + try database.immediateTransaction { + let nextAttempt = clock.now().addingTimeInterval(max(0, delay)) + .timeIntervalSince1970 + for event in events { + try database.execute( + """ + UPDATE pending_events + SET attempt_count = attempt_count + 1, + next_attempt_at = ?, + lease_id = NULL, + lease_expires_at = NULL + WHERE id = ? AND lease_id = ? + """, + bindings: [ + .double(nextAttempt), + .int64(event.rowID), + .text(leaseID) + ] + ) + } + } + } + } + + func releaseGlobalLease(ownerID: String) { + guard let database = openDatabaseIfNeeded() else { return } + absorbDatabaseErrors { + try database.immediateTransaction { + try releaseGlobalLease(ownerID: ownerID, database: database) + } + } + } + + func renewUploadLease( + ownerID: String, + leaseID: String, + globalLeaseDuration: TimeInterval, + eventLeaseDuration: TimeInterval + ) -> Bool { + guard let database = openDatabaseIfNeeded() else { return false } + do { + return try database.immediateTransaction { + guard try metadata( + for: MetadataKey.uploadLeaseOwner, + database: database + ) == ownerID else { + return false + } + let now = clock.now().timeIntervalSince1970 + try setMetadata( + String(now + globalLeaseDuration), + for: MetadataKey.uploadLeaseExpiresAt, + database: database + ) + try database.execute( + """ + UPDATE pending_events + SET lease_expires_at = ? + WHERE lease_id = ? + """, + bindings: [ + .double(now + eventLeaseDuration), + .text(leaseID) + ] + ) + return true + } + } catch { + return false + } + } + + func releaseEvents( + _ events: [AnalyticsLeasedEvent], + leaseID: String + ) { + guard let database = openDatabaseIfNeeded(), !events.isEmpty else { return } + absorbDatabaseErrors { + try database.immediateTransaction { + for event in events { + try database.execute( + """ + UPDATE pending_events + SET lease_id = NULL, lease_expires_at = NULL + WHERE id = ? AND lease_id = ? + """, + bindings: [.int64(event.rowID), .text(leaseID)] + ) + } + } + } + } + + // MARK: - Identity and queue policy + + private func ensureInstallation( + _ database: SQLiteDatabase + ) throws -> UUID { + if let value = try metadata(for: MetadataKey.installationID, database: database), + let identifier = UUID(uuidString: value) { + return identifier + } + + let identifier = uuidGenerator.makeUUID() + try setMetadata( + identifier.uuidString.lowercased(), + for: MetadataKey.installationID, + database: database + ) + return identifier + } + + private func rotateInstallation( + _ database: SQLiteDatabase + ) throws { + try clearQueues(database) + try clearUploadLease(database) + try removeMetadata(for: MetadataKey.installationID, database: database) + for surface in AnalyticsSurface.allCases { + try removeMetadata( + for: MetadataKey.lastSession(surface), + database: database + ) + } + _ = try ensureInstallation(database) + } + + private func insert( + _ event: AnalyticsEvent, + database: SQLiteDatabase + ) throws { + let payload = try AnalyticsCanonicalJSON.encode(event) + try database.execute( + """ + INSERT OR IGNORE INTO pending_events ( + client_event_id, event_type, occurred_at, surface, payload, + payload_size, priority, lease_id, lease_expires_at, + attempt_count, next_attempt_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0, 0, ?) + """, + bindings: [ + .text(event.clientEventId.uuidString.lowercased()), + .text(event.eventType.rawValue), + .double(event.occurredAt.timeIntervalSince1970), + .text(event.surface.rawValue), + .blob(payload), + .int64(Int64(payload.count)), + .int64(Int64(Self.priority(for: event.eventType))), + .double(clock.now().timeIntervalSince1970) + ] + ) + } + + private func enforceStoragePolicy(_ database: SQLiteDatabase) throws { + try deleteExpiredEvents( + now: clock.now().timeIntervalSince1970, + database: database + ) + while true { + let row = try database.query( + """ + SELECT + (SELECT COUNT(*) FROM pending_events) + + (SELECT COUNT(*) FROM quarantined_events), + (SELECT COALESCE(SUM(payload_size), 0) FROM pending_events) + + (SELECT COALESCE(SUM(payload_size), 0) FROM quarantined_events) + """ + ).first + let count = row?.int64(at: 0) ?? 0 + let bytes = row?.int64(at: 1) ?? 0 + guard count > Int64(configuration.maximumEventCount) + || bytes > Int64(configuration.maximumStoredBytes) else { + return + } + + // Quarantined poison events are the least valuable retained records. + try database.execute( + """ + DELETE FROM quarantined_events + WHERE id = ( + SELECT id FROM quarantined_events + ORDER BY quarantined_at ASC + LIMIT 1 + ) + """ + ) + if database.changes > 0 { + try incrementDiagnostic( + MetadataKey.capacityDropCount, + by: Int64(database.changes), + database: database + ) + continue + } + try database.execute( + """ + DELETE FROM pending_events + WHERE id = ( + SELECT id FROM pending_events + WHERE lease_id IS NULL + ORDER BY priority ASC, created_at ASC + LIMIT 1 + ) + """ + ) + guard database.changes > 0 else { return } + try incrementDiagnostic( + MetadataKey.capacityDropCount, + by: Int64(database.changes), + database: database + ) + } + } + + private func deleteExpiredEvents( + now: TimeInterval, + database: SQLiteDatabase + ) throws { + try database.execute( + "DELETE FROM pending_events WHERE occurred_at < ?", + bindings: [.double(now - configuration.eventRetention)] + ) + var deleted = Int64(database.changes) + try database.execute( + "DELETE FROM quarantined_events WHERE occurred_at < ?", + bindings: [.double(now - configuration.eventRetention)] + ) + deleted += Int64(database.changes) + try incrementDiagnostic( + MetadataKey.expiredDropCount, + by: deleted, + database: database + ) + } + + private func clearQueues(_ database: SQLiteDatabase) throws { + try database.execute("DELETE FROM pending_events") + try database.execute("DELETE FROM quarantined_events") + } + + // MARK: - Leases + + private func acquireGlobalLease( + ownerID: String, + expiresAt: TimeInterval, + now: TimeInterval, + database: SQLiteDatabase + ) throws -> Bool { + let existingOwner = try metadata( + for: MetadataKey.uploadLeaseOwner, + database: database + ) + let existingExpiry = try metadata( + for: MetadataKey.uploadLeaseExpiresAt, + database: database + ).flatMap(TimeInterval.init) ?? 0 + guard existingOwner == nil || existingOwner == ownerID || existingExpiry <= now else { + return false + } + try setMetadata(ownerID, for: MetadataKey.uploadLeaseOwner, database: database) + try setMetadata( + String(expiresAt), + for: MetadataKey.uploadLeaseExpiresAt, + database: database + ) + return true + } + + private func releaseGlobalLease( + ownerID: String, + database: SQLiteDatabase + ) throws { + guard try metadata( + for: MetadataKey.uploadLeaseOwner, + database: database + ) == ownerID else { + return + } + try clearUploadLease(database) + } + + private func clearUploadLease(_ database: SQLiteDatabase) throws { + try removeMetadata(for: MetadataKey.uploadLeaseOwner, database: database) + try removeMetadata(for: MetadataKey.uploadLeaseExpiresAt, database: database) + } + + private func releaseExpiredEventLeases( + now: TimeInterval, + database: SQLiteDatabase + ) throws { + try database.execute( + """ + UPDATE pending_events + SET lease_id = NULL, lease_expires_at = NULL + WHERE lease_expires_at IS NOT NULL AND lease_expires_at <= ? + """, + bindings: [.double(now)] + ) + } + + private func quarantineRows( + _ rowIDs: [Int64], + leaseID: String? = nil, + reason: String, + database: SQLiteDatabase + ) throws { + for rowID in rowIDs { + var bindings: [SQLiteBinding] = [ + .text(reason), + .double(clock.now().timeIntervalSince1970), + .int64(rowID) + ] + var leaseClause = "" + if let leaseID { + leaseClause = " AND lease_id = ?" + bindings.append(.text(leaseID)) + } + try database.execute( + """ + INSERT INTO quarantined_events ( + client_event_id, event_type, occurred_at, surface, payload, + payload_size, attempt_count, reason, quarantined_at + ) + SELECT client_event_id, event_type, occurred_at, surface, payload, + payload_size, attempt_count, ?, ? + FROM pending_events + WHERE id = ?\(leaseClause) + """, + bindings: bindings + ) + try database.execute( + "DELETE FROM pending_events WHERE id = ?\(leaseClause)", + bindings: leaseID.map { + [.int64(rowID), .text($0)] + } ?? [.int64(rowID)] + ) + } + try enforceStoragePolicy(database) + } + + private static let emptyRequestBody = Data(#"{"events":[]}"#.utf8) + + private static func requestBody(for events: [AnalyticsLeasedEvent]) -> Data { + var body = Data(#"{"events":["#.utf8) + for index in events.indices { + if index > 0 { + body.append(UInt8(ascii: ",")) + } + body.append(events[index].payload) + } + body.append(Data("]}".utf8)) + return body + } + + private static func priority(for eventType: AnalyticsEventType) -> Int { + switch eventType { + case .firstOpen, .aiFeatureSucceeded, .aiFeatureFailed: + return 2 + case .aiFeatureStarted, + .purchaseViewed, + .purchaseStarted, + .purchaseCancelled, + .referralShared, + .inviteOpened: + return 1 + case .sessionStarted, .keyboardActivated: + return 0 + } + } + + // MARK: - Metadata and setup + + private func openDatabaseIfNeeded() -> SQLiteDatabase? { + if let database { + return database + } + guard let url = configuration.databaseURL else { return nil } + do { + let parent = url.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: parent, + withIntermediateDirectories: true, + attributes: Self.directoryProtectionAttributes + ) + let opened = try SQLiteDatabase( + url: url, + busyTimeoutMilliseconds: configuration.busyTimeoutMilliseconds + ) + try migrate(opened) + applyFileProtection(to: url) + database = opened + return opened + } catch { + return nil + } + } + + private func migrate(_ database: SQLiteDatabase) throws { + let version = try database.scalarInt64("PRAGMA user_version") ?? 0 + guard version <= 1 else { + throw SQLiteStoreError( + code: SQLITE_MISMATCH, + category: .schema, + operation: "validate schema version" + ) + } + if version == 0 { + try database.immediateTransaction { + try database.execute( + """ + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY NOT NULL, + value BLOB NOT NULL + ) WITHOUT ROWID + """ + ) + try database.execute( + """ + CREATE TABLE IF NOT EXISTS pending_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_event_id TEXT UNIQUE NOT NULL, + event_type TEXT NOT NULL, + occurred_at REAL NOT NULL, + surface TEXT NOT NULL, + payload BLOB NOT NULL, + payload_size INTEGER NOT NULL CHECK(payload_size >= 0), + priority INTEGER NOT NULL, + lease_id TEXT, + lease_expires_at REAL, + attempt_count INTEGER NOT NULL DEFAULT 0, + next_attempt_at REAL NOT NULL DEFAULT 0, + created_at REAL NOT NULL + ) + """ + ) + try database.execute( + """ + CREATE INDEX IF NOT EXISTS pending_events_ready + ON pending_events(next_attempt_at, lease_id, priority, created_at) + """ + ) + try database.execute( + """ + CREATE INDEX IF NOT EXISTS pending_events_expiry + ON pending_events(occurred_at) + """ + ) + try database.execute( + """ + CREATE TABLE IF NOT EXISTS quarantined_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_event_id TEXT UNIQUE NOT NULL, + event_type TEXT NOT NULL, + occurred_at REAL NOT NULL, + surface TEXT NOT NULL, + payload BLOB NOT NULL, + payload_size INTEGER NOT NULL, + attempt_count INTEGER NOT NULL, + reason TEXT NOT NULL, + quarantined_at REAL NOT NULL + ) + """ + ) + try database.execute("PRAGMA user_version = 1") + } + } + } + + private func applyFileProtection(to databaseURL: URL) { + #if os(iOS) + for url in [ + databaseURL, + URL(fileURLWithPath: databaseURL.path + "-wal"), + URL(fileURLWithPath: databaseURL.path + "-shm") + ] where FileManager.default.fileExists(atPath: url.path) { + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: url.path + ) + } + #endif + } + + private static var directoryProtectionAttributes: [ + FileAttributeKey: Any + ]? { + #if os(iOS) + return [ + .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication + ] + #else + return nil + #endif + } + + @discardableResult + private func ensureEnabled(_ database: SQLiteDatabase) throws -> Bool { + if let value = try metadata(for: MetadataKey.enabled, database: database) { + return value == "1" + } + try setMetadata("1", for: MetadataKey.enabled, database: database) + return true + } + + private func isEnabled(_ database: SQLiteDatabase) throws -> Bool { + try metadata(for: MetadataKey.enabled, database: database) != "0" + } + + private func metadata( + for key: String, + database: SQLiteDatabase + ) throws -> String? { + try database.query( + "SELECT value FROM metadata WHERE key = ?", + bindings: [.text(key)] + ).first?.text(at: 0) + } + + private func setMetadata( + _ value: String, + for key: String, + database: SQLiteDatabase + ) throws { + try database.execute( + """ + INSERT INTO metadata(key, value) VALUES(?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + """, + bindings: [.text(key), .text(value)] + ) + } + + private func removeMetadata( + for key: String, + database: SQLiteDatabase + ) throws { + try database.execute( + "DELETE FROM metadata WHERE key = ?", + bindings: [.text(key)] + ) + } + + private func incrementDiagnostic( + _ key: String, + by amount: Int64, + database: SQLiteDatabase + ) throws { + guard amount > 0 else { return } + let existing = try metadata(for: key, database: database).flatMap(Int64.init) ?? 0 + try setMetadata(String(existing + amount), for: key, database: database) + } + + private func absorbDatabaseErrors(_ operation: () throws -> Void) { + do { + try operation() + } catch { + // Analytics must never alter the host app's business control flow. + } + } + + private static let unavailableDebugSnapshot = AnalyticsRepositoryDebugSnapshot( + isAvailable: false, + enabled: true, + installationID: nil, + firstOpenRecorded: false, + pendingEvents: [], + quarantinedEvents: [] + ) + + private static func accountFingerprint(_ identifier: String) -> String { + SHA256.hash(data: Data(identifier.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } +} + +// MARK: - SQLite C API boundary + +private enum SQLiteErrorCategory: Sendable { + case busy + case constraint + case corrupt + case io + case schema + case other +} + +private struct SQLiteStoreError: Error, Sendable { + let code: Int32 + let category: SQLiteErrorCategory + let operation: String +} + +private enum SQLiteBinding { + case null + case int64(Int64) + case double(Double) + case text(String) + case blob(Data) +} + +private struct SQLiteRow { + private let values: [SQLiteValue] + + init(statement: OpaquePointer) { + values = (0.. 0, let pointer = sqlite3_column_blob(statement, index) else { + return .blob(Data()) + } + return .blob(Data(bytes: pointer, count: count)) + default: + return .null + } + } + } + + func int64(at index: Int) -> Int64? { + guard values.indices.contains(index), case .int64(let value) = values[index] else { + return nil + } + return value + } + + func text(at index: Int) -> String? { + guard values.indices.contains(index) else { return nil } + switch values[index] { + case .text(let value): + return value + case .blob(let data): + return String(data: data, encoding: .utf8) + default: + return nil + } + } + + func double(at index: Int) -> Double? { + guard values.indices.contains(index) else { return nil } + switch values[index] { + case .double(let value): + return value + case .int64(let value): + return Double(value) + default: + return nil + } + } + + func data(at index: Int) -> Data? { + guard values.indices.contains(index), case .blob(let value) = values[index] else { + return nil + } + return value + } +} + +private enum SQLiteValue { + case null + case int64(Int64) + case double(Double) + case text(String) + case blob(Data) +} + +private final class SQLiteDatabase { + private static let transient = unsafeBitCast( + -1, + to: sqlite3_destructor_type.self + ) + + private var handle: OpaquePointer? + + init(url: URL, busyTimeoutMilliseconds: Int32) throws { + var opened: OpaquePointer? + let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX + let result = sqlite3_open_v2(url.path, &opened, flags, nil) + guard result == SQLITE_OK, let opened else { + if let opened { + sqlite3_close_v2(opened) + } + throw Self.error(code: result, operation: "open database") + } + handle = opened + sqlite3_extended_result_codes(opened, 1) + sqlite3_busy_timeout(opened, busyTimeoutMilliseconds) + do { + try execute("PRAGMA journal_mode = WAL") + try execute("PRAGMA synchronous = NORMAL") + try execute("PRAGMA foreign_keys = ON") + } catch { + sqlite3_close_v2(opened) + handle = nil + throw error + } + } + + deinit { + if let handle { + sqlite3_close_v2(handle) + } + } + + var changes: Int32 { + guard let handle else { return 0 } + return sqlite3_changes(handle) + } + + func execute( + _ sql: String, + bindings: [SQLiteBinding] = [] + ) throws { + let statement = try prepare(sql) + defer { sqlite3_finalize(statement) } + try bind(bindings, to: statement) + let result = sqlite3_step(statement) + guard result == SQLITE_DONE || result == SQLITE_ROW else { + throw currentError(operation: "execute statement") + } + } + + func query( + _ sql: String, + bindings: [SQLiteBinding] = [] + ) throws -> [SQLiteRow] { + let statement = try prepare(sql) + defer { sqlite3_finalize(statement) } + try bind(bindings, to: statement) + var rows: [SQLiteRow] = [] + while true { + switch sqlite3_step(statement) { + case SQLITE_ROW: + rows.append(SQLiteRow(statement: statement)) + case SQLITE_DONE: + return rows + default: + throw currentError(operation: "query statement") + } + } + } + + func scalarInt64(_ sql: String) throws -> Int64? { + try query(sql).first?.int64(at: 0) + } + + func immediateTransaction(_ operation: () throws -> T) throws -> T { + try execute("BEGIN IMMEDIATE") + do { + let value = try operation() + try execute("COMMIT") + return value + } catch { + try? execute("ROLLBACK") + throw error + } + } + + private func prepare(_ sql: String) throws -> OpaquePointer { + guard let handle else { + throw Self.error(code: SQLITE_MISUSE, operation: "prepare closed database") + } + var statement: OpaquePointer? + let result = sqlite3_prepare_v2(handle, sql, -1, &statement, nil) + guard result == SQLITE_OK, let statement else { + throw currentError(operation: "prepare statement") + } + return statement + } + + private func bind( + _ bindings: [SQLiteBinding], + to statement: OpaquePointer + ) throws { + for (offset, binding) in bindings.enumerated() { + let index = Int32(offset + 1) + let result: Int32 + switch binding { + case .null: + result = sqlite3_bind_null(statement, index) + case .int64(let value): + result = sqlite3_bind_int64(statement, index, value) + case .double(let value): + result = sqlite3_bind_double(statement, index, value) + case .text(let value): + result = sqlite3_bind_text(statement, index, value, -1, Self.transient) + case .blob(let data): + result = data.withUnsafeBytes { bytes in + sqlite3_bind_blob( + statement, + index, + bytes.baseAddress, + Int32(bytes.count), + Self.transient + ) + } + } + guard result == SQLITE_OK else { + throw currentError(operation: "bind statement") + } + } + } + + private func currentError(operation: String) -> SQLiteStoreError { + guard let handle else { + return Self.error(code: SQLITE_MISUSE, operation: operation) + } + return Self.error(code: sqlite3_extended_errcode(handle), operation: operation) + } + + private static func error( + code: Int32, + operation: String + ) -> SQLiteStoreError { + let primary = code & 0xFF + let category: SQLiteErrorCategory + switch primary { + case SQLITE_BUSY, SQLITE_LOCKED: + category = .busy + case SQLITE_CONSTRAINT: + category = .constraint + case SQLITE_CORRUPT, SQLITE_NOTADB: + category = .corrupt + case SQLITE_IOERR, SQLITE_CANTOPEN, SQLITE_FULL: + category = .io + case SQLITE_SCHEMA, SQLITE_MISMATCH: + category = .schema + default: + category = .other + } + return SQLiteStoreError(code: code, category: category, operation: operation) + } +} diff --git a/OSGKeyboardShared/Features/Analytics/AnalyticsUploadCoordinator.swift b/OSGKeyboardShared/Features/Analytics/AnalyticsUploadCoordinator.swift new file mode 100644 index 0000000..c70fe76 --- /dev/null +++ b/OSGKeyboardShared/Features/Analytics/AnalyticsUploadCoordinator.swift @@ -0,0 +1,484 @@ +// AnalyticsUploadCoordinator.swift +// OSGKeyboard · Shared +// +// Bounded uploader with cross-process leases, retry policy and poison-event +// isolation. Logs never contain payloads, identifiers, endpoints or tokens. + +import Foundation + +public final class URLSessionAnalyticsNetwork: AnalyticsNetworking, @unchecked Sendable { + private let session: URLSession + + public init(session: URLSession = .shared) { + self.session = session + } + + public func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = "POST" + urlRequest.httpBody = request.body + urlRequest.timeoutInterval = 30 + for (name, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + + let (body, response) = try await session.data(for: urlRequest) + guard let httpResponse = response as? HTTPURLResponse else { + throw AnalyticsNetworkError.nonHTTPResponse + } + var headers: [String: String] = [:] + for (key, value) in httpResponse.allHeaderFields { + headers[String(describing: key)] = String(describing: value) + } + return AnalyticsHTTPResponse( + statusCode: httpResponse.statusCode, + headers: headers, + body: body + ) + } +} + +public actor AnalyticsUploadCoordinator { + private struct AuthorizationState { + var token: String? + var didRefresh = false + } + + private let repository: AnalyticsRepository + private let configuration: AnalyticsUploadConfiguration + private let network: any AnalyticsNetworking + private let bearerProvider: (any AnalyticsBearerProviding)? + private let clock: any AnalyticsWallClock + private let uuidGenerator: any AnalyticsUUIDGenerating + private let random: any AnalyticsRandomGenerating + private let logger: any AnalyticsLogging + private var uploadInProgress = false + + public init( + repository: AnalyticsRepository, + configuration: AnalyticsUploadConfiguration, + network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(), + bearerProvider: (any AnalyticsBearerProviding)? = nil, + clock: any AnalyticsWallClock = SystemAnalyticsWallClock(), + uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(), + random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(), + logger: any AnalyticsLogging = NoopAnalyticsLogger() + ) { + self.repository = repository + self.configuration = configuration + self.network = network + self.bearerProvider = bearerProvider + self.clock = clock + self.uuidGenerator = uuidGenerator + self.random = random + self.logger = logger + } + + /// Performs a bounded drain. The default keeps extension execution time and + /// memory predictable while allowing a host app to request more batches. + public func uploadAvailableEvents(maximumBatches: Int = 1) async { + guard !uploadInProgress else { return } + uploadInProgress = true + defer { uploadInProgress = false } + + guard configuration.endpoint.scheme?.lowercased() == "https" else { + logger.log( + AnalyticsUploadLogEntry( + outcome: .skipped, + eventCount: 0, + errorCategory: .client + ) + ) + return + } + + let ownerID = uuidGenerator.makeUUID().uuidString.lowercased() + let batchLimit = max(1, maximumBatches) + for _ in 0.. AnalyticsHTTPResponse { + var headers = [ + "Accept": "application/json", + "Content-Type": "application/json" + ] + if let token, !token.isEmpty { + headers["Authorization"] = "Bearer \(token)" + } + return try await network.send( + AnalyticsHTTPRequest( + url: configuration.endpoint, + headers: headers, + body: Self.requestBody(for: events) + ) + ) + } + + private func renewLease( + events: [AnalyticsLeasedEvent], + leaseID: String, + ownerID: String + ) async -> Bool { + let renewed = await repository.renewUploadLease( + ownerID: ownerID, + leaseID: leaseID, + globalLeaseDuration: configuration.globalLeaseDuration, + eventLeaseDuration: configuration.eventLeaseDuration + ) + guard renewed else { + await repository.releaseEvents(events, leaseID: leaseID) + logger.log( + AnalyticsUploadLogEntry( + outcome: .skipped, + eventCount: events.count, + attempt: maximumAttempt(in: events), + errorCategory: .storage + ) + ) + return false + } + return true + } + + private func scheduleRetry( + events: [AnalyticsLeasedEvent], + leaseID: String, + response: AnalyticsHTTPResponse?, + category: AnalyticsUploadErrorCategory, + minimumDelay: TimeInterval = 0 + ) async { + let attempt = maximumAttempt(in: events) + 1 + let exponent = min(attempt - 1, 16) + let ceiling = min( + configuration.maximumBackoff, + pow(2, Double(exponent)) + ) + let jitterMilliseconds = random.next( + upperBound: UInt64(max(0, ceiling * 1_000)) + ) + let jitter = TimeInterval(jitterMilliseconds) / 1_000 + let retryAfter = response.flatMap(retryAfterDelay) ?? 0 + let delay = min( + configuration.maximumBackoff, + max(minimumDelay, retryAfter, jitter) + ) + await repository.scheduleRetry( + events: events, + leaseID: leaseID, + delay: delay + ) + logger.log( + AnalyticsUploadLogEntry( + outcome: .retryScheduled, + eventCount: events.count, + statusCode: response?.statusCode, + attempt: attempt, + errorCategory: category + ) + ) + } + + private func retryAfterDelay(_ response: AnalyticsHTTPResponse) -> TimeInterval? { + guard let value = response.header(named: "Retry-After")? + .trimmingCharacters(in: .whitespacesAndNewlines) else { + return nil + } + if let seconds = TimeInterval(value) { + return min(configuration.maximumBackoff, max(0, seconds)) + } + guard let date = Self.httpDateFormatter.date(from: value) else { + return nil + } + return min( + configuration.maximumBackoff, + max(0, date.timeIntervalSince(clock.now())) + ) + } + + private func maximumAttempt(in events: [AnalyticsLeasedEvent]) -> Int { + events.map(\.attemptCount).max() ?? 0 + } + + private static func requestBody(for events: [AnalyticsLeasedEvent]) -> Data { + var body = Data(#"{"events":["#.utf8) + for index in events.indices { + if index > 0 { + body.append(UInt8(ascii: ",")) + } + body.append(events[index].payload) + } + body.append(Data("]}".utf8)) + return body + } + + private static func networkCategory(for error: Error) -> AnalyticsUploadErrorCategory { + guard let urlError = error as? URLError else { return .network } + switch urlError.code { + case .timedOut: + return .timeout + case .userAuthenticationRequired, + .userCancelledAuthentication: + return .authentication + default: + return .network + } + } + + private static var httpDateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss z" + return formatter + } +} + +private enum AnalyticsNetworkError: Error { + case nonHTTPResponse +} diff --git a/OSGKeyboardShared/Features/ManagedGateway/ManagedGatewayModels.swift b/OSGKeyboardShared/Features/ManagedGateway/ManagedGatewayModels.swift index ddc81e6..282af92 100644 --- a/OSGKeyboardShared/Features/ManagedGateway/ManagedGatewayModels.swift +++ b/OSGKeyboardShared/Features/ManagedGateway/ManagedGatewayModels.swift @@ -24,6 +24,12 @@ public enum ManagedGatewayTaskKind: String, Codable, CaseIterable, Sendable { case agentPlanning = "agent_planning" } +/// Optional server-audited purpose. A purpose may affect billing only when the +/// authenticated gateway independently verifies its eligibility. +public enum ManagedGatewayRequestPurpose: String, Codable, Sendable { + case oobe +} + public struct ManagedGatewayGrantCredentials: Codable, Equatable, Sendable { public static let maximumAccessLifetime: TimeInterval = 5 * 60 @@ -143,4 +149,5 @@ struct ManagedGatewayTextRequest: Encodable, Sendable { let temperature: Double let stream: Bool let taskKind: ManagedGatewayTaskKind + let requestPurpose: ManagedGatewayRequestPurpose? } diff --git a/OSGKeyboardShared/Features/ManagedGateway/ManagedLLMClient.swift b/OSGKeyboardShared/Features/ManagedGateway/ManagedLLMClient.swift index 47b485a..5698e32 100644 --- a/OSGKeyboardShared/Features/ManagedGateway/ManagedLLMClient.swift +++ b/OSGKeyboardShared/Features/ManagedGateway/ManagedLLMClient.swift @@ -50,6 +50,7 @@ public struct ManagedLLMClient: LLMClient { public let capability: Capability public let taskKind: ManagedGatewayTaskKind + public let requestPurpose: ManagedGatewayRequestPurpose? public let requestTimeout: TimeInterval private let baseURL: URL @@ -60,6 +61,7 @@ public struct ManagedLLMClient: LLMClient { public init( capability: Capability, taskKind: ManagedGatewayTaskKind? = nil, + requestPurpose: ManagedGatewayRequestPurpose? = nil, grants: GatewayGrantCoordinator, baseURL: URL = GatewayGrantCoordinator.defaultBaseURL, session: URLSession = .shared, @@ -68,6 +70,7 @@ public struct ManagedLLMClient: LLMClient { ) { self.capability = capability self.taskKind = taskKind ?? capability.defaultTaskKind + self.requestPurpose = requestPurpose self.grants = grants self.baseURL = baseURL self.session = session @@ -287,7 +290,8 @@ public struct ManagedLLMClient: LLMClient { maxOutputTokens: min(max(attempt.options.maxTokens ?? 512, 1), 4_096), temperature: min(max(attempt.options.temperature ?? 0.2, 0), 1), stream: stream, - taskKind: taskKind + taskKind: taskKind, + requestPurpose: requestPurpose ) var request = URLRequest( @@ -419,6 +423,7 @@ public struct ManagedLLMClient: LLMClient { public enum ManagedGatewayLLMClientFactory { public static func polish( taskKind: ManagedGatewayTaskKind = .dictationPolish, + requestPurpose: ManagedGatewayRequestPurpose? = nil, grants: GatewayGrantCoordinator, baseURL: URL = GatewayGrantCoordinator.defaultBaseURL, session: URLSession = .shared @@ -426,6 +431,7 @@ public enum ManagedGatewayLLMClientFactory { ManagedLLMClient( capability: .polish, taskKind: taskKind, + requestPurpose: requestPurpose, grants: grants, baseURL: baseURL, session: session diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift index 9c489c3..45e3835 100644 --- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift +++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift @@ -193,11 +193,15 @@ public struct AppGroupConfiguration: Sendable, Equatable { ) } - public func makeClient(taskKind: ManagedGatewayTaskKind? = nil) -> LLMClient { + public func makeClient( + taskKind: ManagedGatewayTaskKind? = nil, + requestPurpose: ManagedGatewayRequestPurpose? = nil + ) -> LLMClient { if credentialSource == .managed { return ManagedLLMClient( capability: .polish, taskKind: taskKind, + requestPurpose: requestPurpose, grants: GatewayGrantCoordinator() ) } diff --git a/OSGKeyboardShared/Models/FlowSession/FlowCommand.swift b/OSGKeyboardShared/Models/FlowSession/FlowCommand.swift index 96172f7..64d7e19 100644 --- a/OSGKeyboardShared/Models/FlowSession/FlowCommand.swift +++ b/OSGKeyboardShared/Models/FlowSession/FlowCommand.swift @@ -20,8 +20,8 @@ public struct FlowCommand: Codable, Equatable, Sendable { case submitAIQuestion } - /// Wire version that includes managed-gateway AI task intent. - public static let currentProtocolVersion = 6 + /// Wire version that includes managed-gateway request purpose. + public static let currentProtocolVersion = 7 public let protocolVersion: Int public let sessionId: UUID @@ -43,6 +43,8 @@ public struct FlowCommand: Codable, Equatable, Sendable { public let aiQuestionText: String? /// Fine-grained managed-gateway intent for AI question submissions. public let aiTaskKind: ManagedGatewayTaskKind? + /// Optional server-audited purpose for managed gateway billing policy. + public let managedRequestPurpose: ManagedGatewayRequestPurpose? /// Clipboard-skill thinking override. Nil keeps AI-mode default (on). public let aiThinkingEnabled: Bool? /// Absolute wall-clock deadlines survive extension reconstruction. @@ -65,6 +67,7 @@ public struct FlowCommand: Codable, Equatable, Sendable { aiConversationID: UUID? = nil, aiQuestionText: String? = nil, aiTaskKind: ManagedGatewayTaskKind? = nil, + managedRequestPurpose: ManagedGatewayRequestPurpose? = nil, aiThinkingEnabled: Bool? = nil, startDeadlineAt: TimeInterval? = nil, processingDeadlineAt: TimeInterval? = nil @@ -84,6 +87,7 @@ public struct FlowCommand: Codable, Equatable, Sendable { self.aiConversationID = aiConversationID self.aiQuestionText = aiQuestionText self.aiTaskKind = aiTaskKind + self.managedRequestPurpose = managedRequestPurpose self.aiThinkingEnabled = aiThinkingEnabled self.startDeadlineAt = startDeadlineAt self.processingDeadlineAt = processingDeadlineAt diff --git a/OSGKeyboardShared/Models/FlowUtteranceRequest.swift b/OSGKeyboardShared/Models/FlowUtteranceRequest.swift index 914f084..de5a397 100644 --- a/OSGKeyboardShared/Models/FlowUtteranceRequest.swift +++ b/OSGKeyboardShared/Models/FlowUtteranceRequest.swift @@ -15,6 +15,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable { public let aiQuestionText: String? /// Fine-grained managed-gateway intent. Regular questions keep the default. public let aiTaskKind: ManagedGatewayTaskKind? + /// Optional server-audited purpose for managed gateway billing policy. + public let managedRequestPurpose: ManagedGatewayRequestPurpose? /// Clipboard-skill thinking override. Nil keeps AI-mode default (on). public let aiThinkingEnabled: Bool? @@ -28,6 +30,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable { aiConversationID: UUID? = nil, aiQuestionText: String? = nil, aiTaskKind: ManagedGatewayTaskKind? = nil, + managedRequestPurpose: ManagedGatewayRequestPurpose? = nil, aiThinkingEnabled: Bool? = nil ) { self.mode = mode @@ -37,6 +40,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable { self.aiConversationID = aiConversationID self.aiQuestionText = aiQuestionText self.aiTaskKind = aiTaskKind + self.managedRequestPurpose = managedRequestPurpose self.aiThinkingEnabled = aiThinkingEnabled } diff --git a/OSGKeyboardShared/Services/AIQuestionService.swift b/OSGKeyboardShared/Services/AIQuestionService.swift index 501b741..5661f02 100644 --- a/OSGKeyboardShared/Services/AIQuestionService.swift +++ b/OSGKeyboardShared/Services/AIQuestionService.swift @@ -114,22 +114,33 @@ public struct AIQuestionService: Sendable { private let client: any LLMClient private let conversations: AIConversationStore private let responseLength: AIResponseLength + private let analyticsClient: any AnalyticsClient + private let analyticsFeature: AnalyticsFeature + private let analyticsExecutionMode: AnalyticsExecutionMode public init( client: any LLMClient, conversations: AIConversationStore, - responseLength: AIResponseLength = .default + responseLength: AIResponseLength = .default, + analyticsClient: any AnalyticsClient = NoopAnalyticsClient(), + analyticsFeature: AnalyticsFeature = .aiAssistant, + analyticsExecutionMode: AnalyticsExecutionMode = .byok ) { self.client = client self.conversations = conversations self.responseLength = responseLength + self.analyticsClient = analyticsClient + self.analyticsFeature = analyticsFeature + self.analyticsExecutionMode = analyticsExecutionMode } public static func configured( store: any ConfigurationStore, conversations: AIConversationStore, taskKind: ManagedGatewayTaskKind = .aiQuestion, - thinkingEnabled: Bool = true + thinkingEnabled: Bool = true, + analyticsClient: any AnalyticsClient = NoopAnalyticsClient(), + analyticsFeature: AnalyticsFeature = .aiAssistant ) throws -> AIQuestionService { if store.credentialSource == .managed { return AIQuestionService( @@ -139,7 +150,10 @@ public struct AIQuestionService: Sendable { grants: GatewayGrantCoordinator() ), conversations: conversations, - responseLength: store.aiResponseLength + responseLength: store.aiResponseLength, + analyticsClient: analyticsClient, + analyticsFeature: analyticsFeature, + analyticsExecutionMode: .managed ) } // Same provider + baseURL + model resolution as dictation polish so the @@ -172,7 +186,10 @@ public struct AIQuestionService: Sendable { thinkingEnabled: thinkingEnabled ), conversations: conversations, - responseLength: store.aiResponseLength + responseLength: store.aiResponseLength, + analyticsClient: analyticsClient, + analyticsFeature: analyticsFeature, + analyticsExecutionMode: .byok ) } @@ -199,28 +216,38 @@ public struct AIQuestionService: Sendable { maxTokens: Self.outputTokenLimit ) - var accumulated = "" - for try await event in client.completeStreaming( - messages: messages, - timeout: Self.requestTimeout, - options: options - ) { - try Task.checkCancellation() - switch event { - case .delta(let chunk): - accumulated += chunk - let preview = Self.streamingPreview(accumulated) - onPartial?(preview) - case .restart: - accumulated = "" - onPartial?("") + let operation = analyticsClient.startAIFeature( + analyticsFeature, + executionMode: analyticsExecutionMode + ) + do { + var accumulated = "" + for try await event in client.completeStreaming( + messages: messages, + timeout: Self.requestTimeout, + options: options + ) { + try Task.checkCancellation() + switch event { + case .delta(let chunk): + accumulated += chunk + let preview = Self.streamingPreview(accumulated) + onPartial?(preview) + case .restart: + accumulated = "" + onPartial?("") + } } - } - try Task.checkCancellation() + try Task.checkCancellation() - let answer = Self.boundedAnswer(accumulated) - guard !answer.isEmpty else { throw ServiceError.emptyAnswer } - return answer + let answer = Self.boundedAnswer(accumulated) + guard !answer.isEmpty else { throw ServiceError.emptyAnswer } + operation.succeed() + return answer + } catch { + operation.fail(category: Self.analyticsFailureCategory(for: error)) + throw error + } } /// Commit only after the host wins the utterance terminal claim. Keeping @@ -254,6 +281,42 @@ public struct AIQuestionService: Sendable { return String(prefix.dropLast()) + "…" } + private static func analyticsFailureCategory( + for error: Error + ) -> AnalyticsFailureCategory { + if error is CancellationError { + return .cancelled + } + if error is ServiceError { + return .validation + } + if let error = error as? ManagedGatewayError { + switch error { + case .insufficientCredits: + return .insufficientCredits + case .timeout: + return .timeout + case .missingGrant, .scopeNotGranted, .invalidGrant: + return .validation + case .server: + return .provider + } + } + if let error = error as? LLMError { + switch error { + case .cancelled: + return .cancelled + case .transport, .rateLimited: + return .network + case .invalidURL, .noAPIKey, .decoding: + return .validation + case .http: + return .provider + } + } + return .unknown + } + /// Soft cap for live drafts — no ellipsis mid-stream. public static func streamingPreview(_ value: String) -> String { if value.count <= AIQuestionLimits.maximumAnswerCharacterCount { diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index f6a0d16..6bddf76 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -394,7 +394,13 @@ public struct AppGroupStore: @unchecked Sendable { // MARK: - Client - public func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient { - configuration.makeClient(taskKind: taskKind) + public func makeClient( + taskKind: ManagedGatewayTaskKind?, + requestPurpose: ManagedGatewayRequestPurpose? + ) -> LLMClient { + configuration.makeClient( + taskKind: taskKind, + requestPurpose: requestPurpose + ) } } diff --git a/OSGKeyboardShared/Services/KeyboardSetupBridge.swift b/OSGKeyboardShared/Services/KeyboardSetupBridge.swift index 6e633c3..9df2556 100644 --- a/OSGKeyboardShared/Services/KeyboardSetupBridge.swift +++ b/OSGKeyboardShared/Services/KeyboardSetupBridge.swift @@ -11,6 +11,8 @@ public enum KeyboardSetupBridge { private enum Key { static let fullAccessReady = "keyboard.extension.fullAccessReady" static let lastSeenAt = "keyboard.extension.lastSeenAt" + static let onboardingPracticeExpiresAt = "keyboard.onboarding.practiceExpiresAt" + static let lastVoiceInsertionAt = "keyboard.extension.lastVoiceInsertionAt" } /// True when the keyboard extension last appeared with Full Access enabled. @@ -19,11 +21,72 @@ public enum KeyboardSetupBridge { return AppGroup.defaults.bool(forKey: Key.fullAccessReady) } + /// True after the extension has appeared at least once. Unlike + /// `isReadyForOnboardingSkip`, this also covers an appearance without Full + /// Access so the host can explain the missing setting precisely. + public static var hasAppeared: Bool { + guard AppGroup.isAvailable else { return false } + return AppGroup.defaults.double(forKey: Key.lastSeenAt) > 0 + } + + /// A short-lived exception that lets the real keyboard complete its first + /// voice insertion while the host still owns the onboarding screen. + public static var isOnboardingPracticeActive: Bool { + onboardingPracticeIsActive() + } + + /// Wall clock of the most recent voice insertion issued by the extension. + /// The host compares this with the current practice start time, so an old + /// insertion can never complete a new onboarding run. + public static var lastVoiceInsertionAt: Date? { + guard AppGroup.isAvailable else { return nil } + let value = AppGroup.defaults.double(forKey: Key.lastVoiceInsertionAt) + return value > 0 ? Date(timeIntervalSince1970: value) : nil + } + + public static func onboardingPracticeIsActive( + defaults: UserDefaults? = nil, + now: Date = Date() + ) -> Bool { + guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return false } + return store.double(forKey: Key.onboardingPracticeExpiresAt) > now.timeIntervalSince1970 + } + + public static func setOnboardingPracticeActive( + _ active: Bool, + duration: TimeInterval = 30 * 60, + defaults: UserDefaults? = nil, + now: Date = Date() + ) { + guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return } + if active { + store.set( + now.addingTimeInterval(duration).timeIntervalSince1970, + forKey: Key.onboardingPracticeExpiresAt + ) + } else { + store.removeObject(forKey: Key.onboardingPracticeExpiresAt) + } + AppGroupConfigDarwin.postConfigChanged() + } + /// Called from the keyboard extension on each appearance. public static func markExtensionAppearance(hasFullAccess: Bool) { guard AppGroup.isAvailable else { return } let defaults = AppGroup.defaults defaults.set(Date().timeIntervalSince1970, forKey: Key.lastSeenAt) defaults.set(hasFullAccess, forKey: Key.fullAccessReady) + AppGroupConfigDarwin.postConfigChanged() + } + + /// Called only after a Flow transcript has been inserted into the host + /// field, not when recognition merely produced a result. + public static func markVoiceInsertion() { + guard AppGroup.isAvailable else { return } + AppGroup.defaults.set( + Date().timeIntervalSince1970, + forKey: Key.lastVoiceInsertionAt + ) + AppGroupConfigDarwin.postConfigChanged() } } diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index 911b9af..1efd9e8 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -106,6 +106,9 @@ public final class KeyboardState: ObservableObject { @Published public var flowSessionActive: Bool = false /// Unified mic color / tap / hint source for the keyboard extension. @Published public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady) + /// Short-lived host-owned practice mode. It unlocks real dictation before + /// onboarding completion, but only while the onboarding text field is live. + @Published public var isOnboardingPracticeActive: Bool = false /// When true, the mic is intentionally disabled (e.g. cloud engine /// selected but the provider-specific API key is missing). @Published public var micDisabled: Bool = false diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift index 9ac588a..8e42a51 100644 --- a/OSGKeyboardShared/Services/PolishingService.swift +++ b/OSGKeyboardShared/Services/PolishingService.swift @@ -54,6 +54,7 @@ public actor PolishingService { let systemPrompt: String? let providerIdOverride: String? let taskKind: ManagedGatewayTaskKind? + let requestPurpose: ManagedGatewayRequestPurpose? let context: PolishContext? } @@ -79,6 +80,7 @@ public actor PolishingService { private let store: any ConfigurationStore private let timeout: TimeInterval + private let analyticsClient: any AnalyticsClient /// Optional injected client (mostly for testing). When nil we build /// one from `store.makeClient()` per call. private let injectedClient: LLMClient? @@ -91,11 +93,13 @@ public actor PolishingService { public init( store: any ConfigurationStore = AppGroupStore(), client: LLMClient? = nil, - timeout: TimeInterval? = nil + timeout: TimeInterval? = nil, + analyticsClient: any AnalyticsClient = NoopAnalyticsClient() ) { self.store = store self.injectedClient = client self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout + self.analyticsClient = analyticsClient } /// Context-aware polish entry point. The optional @@ -110,6 +114,7 @@ public actor PolishingService { systemPrompt: String? = nil, providerIdOverride: String? = nil, taskKind: ManagedGatewayTaskKind? = nil, + requestPurpose: ManagedGatewayRequestPurpose? = nil, context: PolishContext? = nil ) async throws -> String { try await performPolish( @@ -119,6 +124,7 @@ public actor PolishingService { systemPrompt: systemPrompt, providerIdOverride: providerIdOverride, taskKind: taskKind, + requestPurpose: requestPurpose, context: context ) ).text @@ -132,6 +138,7 @@ public actor PolishingService { systemPrompt: String? = nil, providerIdOverride: String? = nil, taskKind: ManagedGatewayTaskKind? = nil, + requestPurpose: ManagedGatewayRequestPurpose? = nil, context: PolishContext? = nil ) async throws -> PolishOutcome { try await performPolish( @@ -141,6 +148,7 @@ public actor PolishingService { systemPrompt: systemPrompt, providerIdOverride: providerIdOverride, taskKind: taskKind, + requestPurpose: requestPurpose, context: context ) ) @@ -152,6 +160,7 @@ public actor PolishingService { let systemPrompt = request.systemPrompt let providerIdOverride = request.providerIdOverride let taskKind = request.taskKind + let requestPurpose = request.requestPurpose let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw PolishError.noTranscript } @@ -185,14 +194,26 @@ public actor PolishingService { } } - let remoteResult = try await polishRemote( - trimmed, - mode: mode, - systemPrompt: systemPrompt, - providerIdOverride: providerIdOverride, - taskKind: taskKind, - context: resolvedContext + let operation = analyticsClient.startAIFeature( + .polish, + executionMode: analyticsExecutionMode ) + let remoteResult: RemotePolishResult + do { + remoteResult = try await polishRemote( + trimmed, + mode: mode, + systemPrompt: systemPrompt, + providerIdOverride: providerIdOverride, + taskKind: taskKind, + requestPurpose: requestPurpose, + context: resolvedContext + ) + operation.succeed() + } catch { + operation.fail(category: Self.analyticsFailureCategory(for: error)) + throw error + } // Translation and custom prompts bypass the polish post-processor. if mode != .polish || (systemPrompt != nil && !(systemPrompt?.isEmpty ?? true)) { @@ -214,6 +235,51 @@ public actor PolishingService { return override } + private var analyticsExecutionMode: AnalyticsExecutionMode { + store.credentialSource == .managed ? .managed : .byok + } + + private static func analyticsFailureCategory( + for error: Error + ) -> AnalyticsFailureCategory { + if error is CancellationError { + return .cancelled + } + if let error = error as? PolishError { + switch error { + case .timeout: + return .timeout + case .noTranscript, .missingAPIKey, .keychainLocked: + return .validation + } + } + if let error = error as? ManagedGatewayError { + switch error { + case .insufficientCredits: + return .insufficientCredits + case .timeout: + return .timeout + case .missingGrant, .scopeNotGranted, .invalidGrant: + return .validation + case .server: + return .provider + } + } + if let error = error as? LLMError { + switch error { + case .cancelled: + return .cancelled + case .transport, .rateLimited: + return .network + case .invalidURL, .noAPIKey, .decoding: + return .validation + case .http: + return .provider + } + } + return .unknown + } + static func managedGatewayTaskKind(for mode: PolishMode) -> ManagedGatewayTaskKind { switch mode { case .polish: @@ -229,6 +295,7 @@ public actor PolishingService { systemPrompt: String? = nil, providerIdOverride: String? = nil, taskKind: ManagedGatewayTaskKind? = nil, + requestPurpose: ManagedGatewayRequestPurpose? = nil, context: PolishContext ) async throws -> RemotePolishResult { let effectiveProviderId = Self.resolvedProviderId( @@ -240,7 +307,8 @@ public actor PolishingService { client = injectedClient } else if store.credentialSource == .managed { client = store.makeClient( - taskKind: taskKind ?? Self.managedGatewayTaskKind(for: mode) + taskKind: taskKind ?? Self.managedGatewayTaskKind(for: mode), + requestPurpose: requestPurpose ) } else { let preset = LLMProvider.provider(id: effectiveProviderId) diff --git a/OSGKeyboardTests/AnalyticsAIOperationTests.swift b/OSGKeyboardTests/AnalyticsAIOperationTests.swift new file mode 100644 index 0000000..45d09c6 --- /dev/null +++ b/OSGKeyboardTests/AnalyticsAIOperationTests.swift @@ -0,0 +1,84 @@ +// AnalyticsAIOperationTests.swift +// OSGKeyboardTests +// +// Ordering, duration, and exactly-once terminal semantics for AI operations. + +import Foundation +@testable import OSGKeyboardShared +import XCTest + +final class AnalyticsAIOperationTests: XCTestCase { + func testStartedIsPersistedBeforeTerminalWithDurationBucket() async throws { + let monotonicClock = AnalyticsTestMonotonicClock(1_000) + let repository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration( + databaseURL: try analyticsTemporaryDatabaseURL() + ), + clock: AnalyticsTestWallClock(), + uuidGenerator: AnalyticsTestUUIDGenerator() + ) + let client = LiveAnalyticsClient( + repository: repository, + context: analyticsTestContext, + monotonicClock: monotonicClock + ) + + let operation = client.startAIFeature(.polish, executionMode: .managed) + monotonicClock.set(4_000_001_000) + operation.succeed() + + _ = await analyticsWaitForPendingEventCount(2, repository: repository) + let events = try await analyticsDecodePendingEvents(repository: repository) + XCTAssertEqual(events.map(\.eventType), [.aiFeatureStarted, .aiFeatureSucceeded]) + XCTAssertEqual(events.map(\.feature), [.polish, .polish]) + XCTAssertEqual(events.map(\.executionMode), [.managed, .managed]) + XCTAssertNil(events[0].durationBucket) + XCTAssertEqual(events[1].durationBucket, .threeToTenSeconds) + XCTAssertNil(events[1].failureCategory) + } + + func testConcurrentTerminalCallsPersistExactlyOneTerminalEvent() async throws { + let monotonicClock = AnalyticsTestMonotonicClock(10) + let repository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration( + databaseURL: try analyticsTemporaryDatabaseURL() + ), + clock: AnalyticsTestWallClock(), + uuidGenerator: AnalyticsTestUUIDGenerator() + ) + let client = LiveAnalyticsClient( + repository: repository, + context: analyticsTestContext, + monotonicClock: monotonicClock + ) + let operation = client.startAIFeature(.aiAssistant, executionMode: .local) + monotonicClock.set(2_000_000_010) + + await withTaskGroup(of: Void.self) { group in + for index in 0..<60 { + group.addTask { + switch index % 3 { + case 0: + operation.succeed() + case 1: + operation.fail(category: .provider) + default: + operation.cancel() + } + } + } + } + + _ = await analyticsWaitForPendingEventCount(2, repository: repository) + let events = try await analyticsDecodePendingEvents(repository: repository) + XCTAssertEqual(events.first?.eventType, .aiFeatureStarted) + XCTAssertEqual(events.count, 2) + let terminalEvents = events.filter { + [.aiFeatureSucceeded, .aiFeatureFailed].contains($0.eventType) + } + XCTAssertEqual(terminalEvents.count, 1) + XCTAssertEqual(terminalEvents.first?.feature, .aiAssistant) + XCTAssertEqual(terminalEvents.first?.executionMode, .local) + XCTAssertEqual(terminalEvents.first?.durationBucket, .oneToThreeSeconds) + } +} diff --git a/OSGKeyboardTests/AnalyticsModelTests.swift b/OSGKeyboardTests/AnalyticsModelTests.swift new file mode 100644 index 0000000..13b02d8 --- /dev/null +++ b/OSGKeyboardTests/AnalyticsModelTests.swift @@ -0,0 +1,165 @@ +// AnalyticsModelTests.swift +// OSGKeyboardTests +// +// Wire-contract and privacy invariants for the allowlisted analytics schema. + +import Foundation +@testable import OSGKeyboardShared +import XCTest + +final class AnalyticsModelTests: XCTestCase { + func testEventEncodingContainsOnlyAllowlistedKeysAndNoFreeTextContainer() throws { + let event = try AnalyticsEvent( + installationId: analyticsTestUUID(1), + clientEventId: analyticsTestUUID(2), + eventType: .aiFeatureFailed, + occurredAt: Date(timeIntervalSince1970: 1_700_000_000.125), + surface: .keyboard, + appVersion: "2.0.0", + osVersion: "26.0", + feature: .polish, + executionMode: .managed, + failureCategory: .network, + durationBucket: .oneToThreeSeconds + ) + + let data = try JSONEncoder().encode(event) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + XCTAssertEqual( + Set(object.keys), + [ + "installationId", + "clientEventId", + "eventType", + "occurredAt", + "surface", + "appVersion", + "osVersion", + "feature", + "executionMode", + "failureCategory", + "durationBucket" + ] + ) + XCTAssertFalse(object.keys.contains("properties")) + XCTAssertFalse(object.keys.contains("text")) + XCTAssertEqual( + Set(Mirror(reflecting: event).children.compactMap(\.label)), + Set(object.keys).union(["acquisitionChannel"]) + ) + } + + func testUnknownFieldsAreRejectedAtEveryWireEnvelope() throws { + let event = try AnalyticsEvent( + installationId: analyticsTestUUID(1), + clientEventId: analyticsTestUUID(2), + eventType: .sessionStarted, + occurredAt: Date(timeIntervalSince1970: 1_700_000_000), + surface: .app, + appVersion: "2.0.0", + osVersion: "26.0" + ) + var eventObject = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(event)) + as? [String: Any] + ) + eventObject["userText"] = "must be rejected" + assertUnknownField( + try JSONSerialization.data(withJSONObject: eventObject), + as: AnalyticsEvent.self, + expected: "userText" + ) + + let request = Data(#"{"events":[],"properties":{"secret":"value"}}"#.utf8) + assertUnknownField( + request, + as: AnalyticsUploadRequest.self, + expected: "properties" + ) + + let response = Data(#"{"accepted":1,"replayed":0,"debug":"secret"}"#.utf8) + assertUnknownField( + response, + as: AnalyticsUploadResponse.self, + expected: "debug" + ) + } + + func testEnvironmentFiltersAndTruncatesVersionsToSafeBound() { + let environment = AnalyticsEnvironment( + appVersion: String(repeating: "a", count: 40) + "/private", + osVersion: "26.0 (Build 23A)/用户" + ) + + XCTAssertEqual(environment.appVersion, String(repeating: "a", count: 32)) + XCTAssertEqual(environment.appVersion.utf8.count, 32) + XCTAssertEqual(environment.osVersion, "26.0Build23A") + + let empty = AnalyticsEnvironment(appVersion: "用户 /", osVersion: "") + XCTAssertEqual(empty.appVersion, "unknown") + XCTAssertEqual(empty.osVersion, "unknown") + } + + func testPurchaseCancelledRequiresCancelledFailureCategory() throws { + XCTAssertNoThrow( + try AnalyticsEvent( + installationId: analyticsTestUUID(1), + clientEventId: analyticsTestUUID(2), + eventType: .purchaseCancelled, + occurredAt: Date(timeIntervalSince1970: 1_700_000_000), + surface: .app, + appVersion: "2.0.0", + osVersion: "26.0", + failureCategory: .cancelled + ) + ) + + XCTAssertThrowsError( + try AnalyticsEvent( + installationId: analyticsTestUUID(1), + clientEventId: analyticsTestUUID(3), + eventType: .purchaseCancelled, + occurredAt: Date(timeIntervalSince1970: 1_700_000_000), + surface: .app, + appVersion: "2.0.0", + osVersion: "26.0", + failureCategory: .network + ) + ) { error in + guard case AnalyticsModelError.invalidDimensions(.purchaseCancelled) = error else { + return XCTFail("Unexpected error: \(error)") + } + } + } + + func testDurationBucketBoundariesAreStable() { + XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 999_999_999), .lessThanOneSecond) + XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 1_000_000_000), .oneToThreeSeconds) + XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 2_999_999_999), .oneToThreeSeconds) + XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 3_000_000_000), .threeToTenSeconds) + XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 10_000_000_000), .tenToThirtySeconds) + XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 30_000_000_000), .thirtySecondsOrMore) + } + + private func assertUnknownField( + _ data: Data, + as type: T.Type, + expected: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertThrowsError( + try JSONDecoder().decode(type, from: data), + file: file, + line: line + ) { error in + guard case AnalyticsModelError.unknownField(let field) = error else { + return XCTFail("Unexpected error: \(error)", file: file, line: line) + } + XCTAssertEqual(field, expected, file: file, line: line) + } + } +} diff --git a/OSGKeyboardTests/AnalyticsRepositoryTests.swift b/OSGKeyboardTests/AnalyticsRepositoryTests.swift new file mode 100644 index 0000000..e24ba00 --- /dev/null +++ b/OSGKeyboardTests/AnalyticsRepositoryTests.swift @@ -0,0 +1,461 @@ +// AnalyticsRepositoryTests.swift +// OSGKeyboardTests +// +// Durable identity, queue policy, retention, and cross-process lease coverage. + +import Foundation +@testable import OSGKeyboardShared +import XCTest + +final class AnalyticsRepositoryTests: XCTestCase { + func testFirstOpenMarkerAndEventAreCreatedExactlyOnceAcrossRestart() async throws { + let url = try analyticsTemporaryDatabaseURL() + let clock = AnalyticsTestWallClock() + let repository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration(databaseURL: url), + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator() + ) + + await repository.prepare( + using: analyticsTestContext, + firstOpenAcquisitionChannel: .referral + ) + await repository.prepare( + using: analyticsTestContext, + firstOpenAcquisitionChannel: .socialContent + ) + + let firstSnapshot = await repository.debugSnapshot() + XCTAssertTrue(firstSnapshot.firstOpenRecorded) + XCTAssertEqual(firstSnapshot.pendingEvents.map(\.eventType), [.firstOpen]) + let decodedEvents = try await analyticsDecodePendingEvents(repository: repository) + let event = try XCTUnwrap(decodedEvents.first) + XCTAssertEqual(event.installationId, analyticsTestUUID(1)) + XCTAssertEqual(event.clientEventId, analyticsTestUUID(2)) + XCTAssertEqual(event.acquisitionChannel, .referral) + + let restarted = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration(databaseURL: url), + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 100) + ) + await restarted.prepare( + using: analyticsTestContext, + firstOpenAcquisitionChannel: .appStoreOrganic + ) + + let restartedSnapshot = await restarted.debugSnapshot() + XCTAssertEqual(restartedSnapshot.installationID, analyticsTestUUID(1)) + XCTAssertEqual(restartedSnapshot.pendingEvents.map(\.eventType), [.firstOpen]) + } + + func testSessionWindowTracksThirtyMinutesOfInactivityPerSurface() async throws { + let clock = AnalyticsTestWallClock() + let repository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration( + databaseURL: try analyticsTemporaryDatabaseURL() + ), + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator() + ) + + await repository.recordSessionIfNeeded(context: analyticsTestContext) + clock.advance(by: 29 * 60) + await repository.recordSessionIfNeeded(context: analyticsTestContext) + clock.advance(by: 29 * 60) + await repository.recordSessionIfNeeded(context: analyticsTestContext) + + var snapshot = await repository.debugSnapshot() + XCTAssertEqual(snapshot.pendingEvents.map(\.eventType), [.sessionStarted]) + + clock.advance(by: 30 * 60) + await repository.recordSessionIfNeeded(context: analyticsTestContext) + snapshot = await repository.debugSnapshot() + XCTAssertEqual( + snapshot.pendingEvents.compactMap(\.eventType), + [.sessionStarted, .sessionStarted] + ) + } + + func testDisableClearsQueuePersistsAcrossRestartAndReenableRotatesIdentity() async throws { + let url = try analyticsTemporaryDatabaseURL() + let clock = AnalyticsTestWallClock() + let repository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration(databaseURL: url), + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator() + ) + await repository.prepare( + using: analyticsTestContext, + firstOpenAcquisitionChannel: .unknown + ) + let originalID = await repository.debugSnapshot().installationID + + await repository.setEnabled(false) + var snapshot = await repository.debugSnapshot() + XCTAssertFalse(snapshot.enabled) + XCTAssertTrue(snapshot.pendingEvents.isEmpty) + + let restarted = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration(databaseURL: url), + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 100) + ) + let remainsDisabled = await restarted.isEnabled() + XCTAssertFalse(remainsDisabled) + await restarted.record( + eventType: .keyboardActivated, + context: analyticsTestContext + ) + let disabledSnapshot = await restarted.debugSnapshot() + XCTAssertTrue(disabledSnapshot.pendingEvents.isEmpty) + + await restarted.setEnabled(true) + await restarted.prepare( + using: analyticsTestContext, + firstOpenAcquisitionChannel: .referral + ) + await restarted.record( + eventType: .keyboardActivated, + context: analyticsTestContext + ) + snapshot = await restarted.debugSnapshot() + XCTAssertTrue(snapshot.enabled) + XCTAssertNotEqual(snapshot.installationID, originalID) + XCTAssertTrue(snapshot.firstOpenRecorded) + XCTAssertEqual(snapshot.pendingEvents.map(\.eventType), [.keyboardActivated]) + } + + func testAccountObservationSwitchAndDeletionRotateWithoutRepeatingFirstOpen() async throws { + let repository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration( + databaseURL: try analyticsTemporaryDatabaseURL() + ), + clock: AnalyticsTestWallClock(), + uuidGenerator: AnalyticsTestUUIDGenerator() + ) + await repository.prepare( + using: analyticsTestContext, + firstOpenAcquisitionChannel: .appStoreOrganic + ) + let installationBeforeAccount = await repository.debugSnapshot().installationID + + let first = await repository.observeAccount(stableIdentifier: "account-a") + guard case .firstAccount = first else { + return XCTFail("Expected firstAccount, got \(first)") + } + let installationAfterFirstAccount = await repository.debugSnapshot().installationID + XCTAssertEqual(installationAfterFirstAccount, installationBeforeAccount) + + let unchanged = await repository.observeAccount(stableIdentifier: "account-a") + guard case .unchanged = unchanged else { + return XCTFail("Expected unchanged, got \(unchanged)") + } + + await repository.record( + eventType: .keyboardActivated, + context: analyticsTestContext + ) + let switched = await repository.observeAccount(stableIdentifier: "account-b") + guard case .switchedAccount = switched else { + return XCTFail("Expected switchedAccount, got \(switched)") + } + let afterSwitch = await repository.debugSnapshot() + XCTAssertNotEqual(afterSwitch.installationID, installationBeforeAccount) + XCTAssertTrue(afterSwitch.pendingEvents.isEmpty) + XCTAssertTrue(afterSwitch.firstOpenRecorded) + + await repository.record( + eventType: .purchaseViewed, + context: analyticsTestContext + ) + let switchedID = afterSwitch.installationID + await repository.handleAccountDeletion() + let afterDeletion = await repository.debugSnapshot() + XCTAssertNotEqual(afterDeletion.installationID, switchedID) + XCTAssertTrue(afterDeletion.pendingEvents.isEmpty) + XCTAssertTrue(afterDeletion.firstOpenRecorded) + + let firstAfterDeletion = await repository.observeAccount(stableIdentifier: "account-b") + guard case .firstAccount = firstAfterDeletion else { + return XCTFail("Expected firstAccount after deletion, got \(firstAfterDeletion)") + } + } + + func testRetryPreservesCanonicalPayloadAndClientEventID() async throws { + let clock = AnalyticsTestWallClock() + let repository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration( + databaseURL: try analyticsTemporaryDatabaseURL() + ), + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator() + ) + await analyticsRecordKeyboardEvents(count: 1, repository: repository) + let snapshot = await repository.debugSnapshot() + let rowID = try XCTUnwrap(snapshot.pendingEvents.first?.rowID) + let storedOriginalPayload = await repository.debugPayloadBytes(rowID: rowID) + let originalPayload = try XCTUnwrap(storedOriginalPayload) + let originalEvent = try JSONDecoder().decode( + AnalyticsEvent.self, + from: originalPayload + ) + let upload = AnalyticsUploadConfiguration( + endpoint: URL(string: "https://analytics.test/events")! + ) + let leasedFirstBatch = await repository.leaseBatch( + ownerID: "owner", + configuration: upload + ) + let firstLease = try XCTUnwrap(leasedFirstBatch) + + await repository.scheduleRetry( + events: firstLease.events, + leaseID: firstLease.leaseID, + delay: 10 + ) + let storedRetriedPayload = await repository.debugPayloadBytes(rowID: rowID) + let retriedPayload = try XCTUnwrap(storedRetriedPayload) + XCTAssertEqual(retriedPayload, originalPayload) + let retrySnapshot = await repository.debugSnapshot() + let retryDiagnostic = try XCTUnwrap(retrySnapshot.pendingEvents.first) + XCTAssertEqual(retryDiagnostic.attemptCount, 1) + XCTAssertEqual(retryDiagnostic.nextAttemptAt, clock.now().addingTimeInterval(10)) + + clock.advance(by: 10) + let leasedSecondBatch = await repository.leaseBatch( + ownerID: "owner", + configuration: upload + ) + let secondLease = try XCTUnwrap(leasedSecondBatch) + XCTAssertEqual(secondLease.events.first?.payload, originalPayload) + let retriedEvent = try JSONDecoder().decode( + AnalyticsEvent.self, + from: try XCTUnwrap(secondLease.events.first?.payload) + ) + XCTAssertEqual(retriedEvent.clientEventId, originalEvent.clientEventId) + } + + func testConcurrentRepositoriesEnforceGlobalLeaseAndRecoverExpiredEventLease() async throws { + let url = try analyticsTemporaryDatabaseURL() + let clock = AnalyticsTestWallClock() + let firstRepository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration(databaseURL: url), + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator() + ) + let secondRepository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration(databaseURL: url), + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 100) + ) + await analyticsRecordKeyboardEvents(count: 1, repository: firstRepository) + let upload = AnalyticsUploadConfiguration( + endpoint: URL(string: "https://analytics.test/events")!, + globalLeaseDuration: 60, + eventLeaseDuration: 60 + ) + + async let first = firstRepository.leaseBatch( + ownerID: "first-owner", + configuration: upload + ) + async let second = secondRepository.leaseBatch( + ownerID: "second-owner", + configuration: upload + ) + let simultaneous = await (first, second) + XCTAssertEqual([simultaneous.0, simultaneous.1].compactMap { $0 }.count, 1) + + clock.advance(by: 61) + let recovered = await secondRepository.leaseBatch( + ownerID: "recovery-owner", + configuration: upload + ) + XCTAssertEqual(recovered?.events.count, 1) + } + + func testCrashAfterServerAcceptanceBeforeDeleteReplaysOriginalBytes() async throws { + let clock = AnalyticsTestWallClock() + let repository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration( + databaseURL: try analyticsTemporaryDatabaseURL() + ), + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator() + ) + await analyticsRecordKeyboardEvents(count: 1, repository: repository) + let configuration = AnalyticsUploadConfiguration( + endpoint: URL(string: "https://analytics.test/events")!, + globalLeaseDuration: 60, + eventLeaseDuration: 60 + ) + let crashedLease = await repository.leaseBatch( + ownerID: "crashed-owner", + configuration: configuration + ) + let acceptedButNotDeleted = try XCTUnwrap(crashedLease) + + clock.advance(by: 61) + let network = AnalyticsQueueNetwork([ + .response(analyticsSuccessResponse(accepted: 0, replayed: 1)) + ]) + let coordinator = AnalyticsUploadCoordinator( + repository: repository, + configuration: configuration, + network: network, + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 500), + random: AnalyticsTestRandomGenerator() + ) + await coordinator.uploadAvailableEvents() + + let requests = await network.requests() + XCTAssertEqual(requests.single?.body, acceptedButNotDeleted.body) + let completedSnapshot = await repository.debugSnapshot() + XCTAssertTrue(completedSnapshot.pendingEvents.isEmpty) + } + + func testBatchHonorsFiftyEventAndDynamicBodyByteLimits() async throws { + let repository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration( + databaseURL: try analyticsTemporaryDatabaseURL() + ), + clock: AnalyticsTestWallClock(), + uuidGenerator: AnalyticsTestUUIDGenerator() + ) + await analyticsRecordKeyboardEvents(count: 55, repository: repository) + let defaultUpload = AnalyticsUploadConfiguration( + endpoint: URL(string: "https://analytics.test/events")!, + maximumBatchCount: 100, + maximumBodyBytes: 100_000 + ) + let countLease = await repository.leaseBatch( + ownerID: "count", + configuration: defaultUpload + ) + let countLimited = try XCTUnwrap(countLease) + XCTAssertEqual(countLimited.events.count, 50) + XCTAssertLessThanOrEqual(countLimited.body.count, 60 * 1_024) + + await repository.releaseEvents( + countLimited.events, + leaseID: countLimited.leaseID + ) + await repository.releaseGlobalLease(ownerID: "count") + + let byteUpload = AnalyticsUploadConfiguration( + endpoint: URL(string: "https://analytics.test/events")!, + maximumBatchCount: 50, + maximumBodyBytes: 1_024 + ) + let byteLease = await repository.leaseBatch( + ownerID: "bytes", + configuration: byteUpload + ) + let byteLimited = try XCTUnwrap(byteLease) + XCTAssertFalse(byteLimited.events.isEmpty) + XCTAssertLessThan(byteLimited.events.count, 50) + XCTAssertLessThanOrEqual(byteLimited.body.count, 1_024) + let snapshot = await repository.debugSnapshot() + let selectedIDs = Set(byteLimited.events.map(\.rowID)) + let nextDiagnostic = try XCTUnwrap( + snapshot.pendingEvents.first { !selectedIDs.contains($0.rowID) } + ) + let storedNextPayload = await repository.debugPayloadBytes( + rowID: nextDiagnostic.rowID + ) + let nextPayload = try XCTUnwrap(storedNextPayload) + XCTAssertGreaterThan(byteLimited.body.count + 1 + nextPayload.count, 1_024) + } + + func testRetentionExpiresAfterThirtyFourDaysAndToleratesClockRollback() async throws { + let retention = 34 * 24 * 60 * 60.0 + let clock = AnalyticsTestWallClock() + let repository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration( + databaseURL: try analyticsTemporaryDatabaseURL(), + eventRetention: retention + ), + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator() + ) + await repository.record( + eventType: .keyboardActivated, + context: analyticsTestContext + ) + + clock.advance(by: retention) + await repository.record( + eventType: .purchaseViewed, + context: analyticsTestContext + ) + let boundarySnapshot = await repository.debugSnapshot() + XCTAssertEqual(boundarySnapshot.pendingEvents.count, 2) + + clock.advance(by: 0.001) + await repository.record( + eventType: .purchaseStarted, + context: analyticsTestContext + ) + var events = try await analyticsDecodePendingEvents(repository: repository) + XCTAssertEqual(Set(events.map(\.eventType)), [.purchaseViewed, .purchaseStarted]) + + let future = clock.now().addingTimeInterval(24 * 60 * 60) + clock.set(future) + await repository.record( + eventType: .referralShared, + context: analyticsTestContext + ) + clock.set(future.addingTimeInterval(-7 * 24 * 60 * 60)) + let upload = AnalyticsUploadConfiguration( + endpoint: URL(string: "https://analytics.test/events")! + ) + let batch = await repository.leaseBatch(ownerID: "rollback", configuration: upload) + events = try batch?.events.map { + try JSONDecoder().decode(AnalyticsEvent.self, from: $0.payload) + } ?? [] + XCTAssertTrue(events.contains(where: { $0.eventType == .referralShared })) + } + + func testCapacityEvictsSessionAndKeyboardBeforeHighValueEvents() async throws { + let repository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration( + databaseURL: try analyticsTemporaryDatabaseURL(), + maximumEventCount: 2 + ), + clock: AnalyticsTestWallClock(), + uuidGenerator: AnalyticsTestUUIDGenerator() + ) + await repository.prepare( + using: analyticsTestContext, + firstOpenAcquisitionChannel: .unknown + ) + await repository.recordSessionIfNeeded(context: analyticsTestContext) + await repository.record( + eventType: .keyboardActivated, + context: analyticsTestContext + ) + var eventTypes = await repository.debugSnapshot().pendingEvents.compactMap(\.eventType) + XCTAssertEqual(Set(eventTypes), [.firstOpen, .keyboardActivated]) + + await repository.record( + eventType: .aiFeatureSucceeded, + context: analyticsTestContext, + dimensions: AnalyticsEventDimensions( + feature: .polish, + executionMode: .managed, + durationBucket: .oneToThreeSeconds + ) + ) + eventTypes = await repository.debugSnapshot().pendingEvents.compactMap(\.eventType) + XCTAssertEqual(Set(eventTypes), [.firstOpen, .aiFeatureSucceeded]) + } +} + +private extension Array { + var single: Element? { + count == 1 ? first : nil + } +} diff --git a/OSGKeyboardTests/AnalyticsTestSupport.swift b/OSGKeyboardTests/AnalyticsTestSupport.swift new file mode 100644 index 0000000..00732c4 --- /dev/null +++ b/OSGKeyboardTests/AnalyticsTestSupport.swift @@ -0,0 +1,267 @@ +// AnalyticsTestSupport.swift +// OSGKeyboardTests +// +// Deterministic analytics clocks, identifiers, transport doubles, and fixtures. + +import Foundation +@testable import OSGKeyboardShared +import XCTest + +let analyticsTestContext = AnalyticsBootstrapContext( + surface: .app, + environment: AnalyticsEnvironment(appVersion: "2.0.0", osVersion: "26.0") +) + +func analyticsTestUUID(_ value: Int) -> UUID { + UUID(uuidString: String(format: "00000000-0000-0000-0000-%012x", value))! +} + +func analyticsTemporaryDatabaseURL() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("OSGKeyboardAnalyticsTests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + return directory.appendingPathComponent("analytics.sqlite3") +} + +final class AnalyticsTestWallClock: AnalyticsWallClock, @unchecked Sendable { + private let lock = NSLock() + private var value: Date + + init(_ value: Date = Date(timeIntervalSince1970: 1_700_000_000)) { + self.value = value + } + + func now() -> Date { + lock.withLock { value } + } + + func set(_ value: Date) { + lock.withLock { self.value = value } + } + + func advance(by interval: TimeInterval) { + lock.withLock { value = value.addingTimeInterval(interval) } + } +} + +final class AnalyticsTestMonotonicClock: AnalyticsMonotonicClock, @unchecked Sendable { + private let lock = NSLock() + private var value: UInt64 + + init(_ value: UInt64 = 0) { + self.value = value + } + + func nowNanoseconds() -> UInt64 { + lock.withLock { value } + } + + func set(_ value: UInt64) { + lock.withLock { self.value = value } + } +} + +final class AnalyticsTestUUIDGenerator: AnalyticsUUIDGenerating, @unchecked Sendable { + private let lock = NSLock() + private var nextValue: Int + + init(startingAt value: Int = 1) { + nextValue = value + } + + func makeUUID() -> UUID { + lock.withLock { + defer { nextValue += 1 } + return analyticsTestUUID(nextValue) + } + } +} + +final class AnalyticsTestRandomGenerator: AnalyticsRandomGenerating, @unchecked Sendable { + enum Value { + case zero + case upperBound + case fixed(UInt64) + } + + private let value: Value + + init(_ value: Value = .zero) { + self.value = value + } + + func next(upperBound: UInt64) -> UInt64 { + switch value { + case .zero: + return 0 + case .upperBound: + return upperBound + case .fixed(let fixed): + return min(fixed, upperBound) + } + } +} + +actor AnalyticsQueueNetwork: AnalyticsNetworking { + enum Outcome: Sendable { + case response(AnalyticsHTTPResponse) + case urlError(URLError.Code) + } + + private var outcomes: [Outcome] + private var capturedRequests: [AnalyticsHTTPRequest] = [] + + init(_ outcomes: [Outcome]) { + self.outcomes = outcomes + } + + func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse { + capturedRequests.append(request) + guard !outcomes.isEmpty else { + return AnalyticsHTTPResponse(statusCode: 500, headers: [:], body: Data()) + } + switch outcomes.removeFirst() { + case .response(let response): + return response + case .urlError(let code): + throw URLError(code) + } + } + + func requests() -> [AnalyticsHTTPRequest] { + capturedRequests + } +} + +actor AnalyticsPoisonNetwork: AnalyticsNetworking { + private let statusCode: Int + private let poisonID: UUID + private var capturedRequests: [AnalyticsHTTPRequest] = [] + + init(statusCode: Int, poisonID: UUID) { + self.statusCode = statusCode + self.poisonID = poisonID + } + + func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse { + capturedRequests.append(request) + let upload = try JSONDecoder().decode(AnalyticsUploadRequest.self, from: request.body) + if upload.events.contains(where: { $0.clientEventId == poisonID }) { + return AnalyticsHTTPResponse(statusCode: statusCode, headers: [:], body: Data()) + } + return analyticsSuccessResponse(accepted: upload.events.count) + } + + func requests() -> [AnalyticsHTTPRequest] { + capturedRequests + } +} + +actor AnalyticsTestBearerProvider: AnalyticsBearerProviding { + private let initialToken: String? + private let refreshedToken: String? + private var refreshInputs: [String?] = [] + + init(initialToken: String?, refreshedToken: String?) { + self.initialToken = initialToken + self.refreshedToken = refreshedToken + } + + func bearerToken() async throws -> String? { + initialToken + } + + func refreshBearerToken( + afterUnauthorizedAccessToken failedToken: String? + ) async throws -> String? { + refreshInputs.append(failedToken) + return refreshedToken + } + + func recordedRefreshInputs() -> [String?] { + refreshInputs + } +} + +func analyticsSuccessResponse( + accepted: Int, + replayed: Int = 0 +) -> AnalyticsHTTPResponse { + AnalyticsHTTPResponse( + statusCode: 200, + headers: [:], + body: Data(#"{"accepted":\#(accepted),"replayed":\#(replayed)}"#.utf8) + ) +} + +func analyticsHTTPResponse( + statusCode: Int, + headers: [String: String] = [:], + body: Data = Data() +) -> AnalyticsHTTPResponse { + AnalyticsHTTPResponse(statusCode: statusCode, headers: headers, body: body) +} + +func analyticsRecordKeyboardEvents( + count: Int, + repository: AnalyticsRepository, + context: AnalyticsBootstrapContext = analyticsTestContext +) async { + for _ in 0.. [Data] { + let snapshot = await repository.debugSnapshot() + var payloads: [Data] = [] + for diagnostic in snapshot.pendingEvents { + let payload = await repository.debugPayloadBytes(rowID: diagnostic.rowID) + payloads.append(try XCTUnwrap(payload)) + } + return payloads +} + +func analyticsDecodePendingEvents( + repository: AnalyticsRepository +) async throws -> [AnalyticsEvent] { + let payloads = try await analyticsPendingPayloads(repository: repository) + return try payloads.map { try JSONDecoder().decode(AnalyticsEvent.self, from: $0) } +} + +func analyticsRequestBody(payloads: [Data]) -> Data { + var body = Data(#"{"events":["#.utf8) + for index in payloads.indices { + if index > 0 { + body.append(UInt8(ascii: ",")) + } + body.append(payloads[index]) + } + body.append(Data("]}".utf8)) + return body +} + +func analyticsWaitForPendingEventCount( + _ count: Int, + repository: AnalyticsRepository, + timeoutIterations: Int = 200 +) async -> AnalyticsRepositoryDebugSnapshot { + for _ in 0..= count { + return snapshot + } + try? await Task.sleep(for: .milliseconds(10)) + } + XCTFail("Timed out waiting for \(count) analytics events") + return await repository.debugSnapshot() +} diff --git a/OSGKeyboardTests/AnalyticsUploadCoordinatorTests.swift b/OSGKeyboardTests/AnalyticsUploadCoordinatorTests.swift new file mode 100644 index 0000000..240a840 --- /dev/null +++ b/OSGKeyboardTests/AnalyticsUploadCoordinatorTests.swift @@ -0,0 +1,308 @@ +// AnalyticsUploadCoordinatorTests.swift +// OSGKeyboardTests +// +// HTTP outcome, authentication, retry, and poison-event isolation coverage. + +import Foundation +@testable import OSGKeyboardShared +import XCTest + +final class AnalyticsUploadCoordinatorTests: XCTestCase { + private let endpoint = URL(string: "https://analytics.test/v1/events")! + + func testExactAcceptedAndReplayedCountDeletesUsingOriginalPayloadBytes() async throws { + let clock = AnalyticsTestWallClock() + let repository = try await makeRepository(clock: clock, eventCount: 2) + let payloads = try await analyticsPendingPayloads(repository: repository) + let expectedBody = analyticsRequestBody(payloads: payloads) + let network = AnalyticsQueueNetwork([ + .response(analyticsSuccessResponse(accepted: 1, replayed: 1)) + ]) + let coordinator = makeCoordinator( + repository: repository, + network: network, + clock: clock + ) + + await coordinator.uploadAvailableEvents() + + let snapshot = await repository.debugSnapshot() + XCTAssertTrue(snapshot.pendingEvents.isEmpty) + let requests = await network.requests() + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(requests.count, 1) + XCTAssertEqual(request.body, expectedBody) + XCTAssertEqual(request.url, endpoint) + XCTAssertEqual(request.headers["Content-Type"], "application/json") + XCTAssertNil(request.headers["Authorization"]) + let topLevel = try XCTUnwrap( + JSONSerialization.jsonObject(with: request.body) as? [String: Any] + ) + XCTAssertEqual(Set(topLevel.keys), ["events"]) + XCTAssertEqual((topLevel["events"] as? [Any])?.count, 2) + } + + func testCountMismatchRetriesWithoutMutatingPayloadOrEventID() async throws { + let clock = AnalyticsTestWallClock() + let repository = try await makeRepository(clock: clock, eventCount: 2) + let originalPayloads = try await analyticsPendingPayloads(repository: repository) + let originalIDs = try originalPayloads.map { + try JSONDecoder().decode(AnalyticsEvent.self, from: $0).clientEventId + } + let network = AnalyticsQueueNetwork([ + .response(analyticsSuccessResponse(accepted: 1, replayed: 0)) + ]) + let coordinator = makeCoordinator( + repository: repository, + network: network, + clock: clock, + random: AnalyticsTestRandomGenerator(.upperBound) + ) + + await coordinator.uploadAvailableEvents() + + let snapshot = await repository.debugSnapshot() + XCTAssertEqual(snapshot.pendingEvents.map(\.attemptCount), [1, 1]) + XCTAssertEqual( + snapshot.pendingEvents.map(\.nextAttemptAt), + [clock.now().addingTimeInterval(1), clock.now().addingTimeInterval(1)] + ) + let retriedPayloads = try await analyticsPendingPayloads(repository: repository) + XCTAssertEqual(retriedPayloads, originalPayloads) + let retriedIDs = try retriedPayloads.map { + try JSONDecoder().decode(AnalyticsEvent.self, from: $0).clientEventId + } + XCTAssertEqual(retriedIDs, originalIDs) + } + + func testNetwork408429And5xxScheduleBoundedBackoff() async throws { + let cases: [(AnalyticsQueueNetwork.Outcome, TimeInterval)] = [ + (.urlError(.notConnectedToInternet), 1), + (.response(analyticsHTTPResponse(statusCode: 408)), 1), + ( + .response( + analyticsHTTPResponse( + statusCode: 429, + headers: ["rEtRy-AfTeR": "120"] + ) + ), + 120 + ), + (.response(analyticsHTTPResponse(statusCode: 503)), 1) + ] + + for (index, testCase) in cases.enumerated() { + let clock = AnalyticsTestWallClock() + let repository = try await makeRepository( + clock: clock, + eventCount: 1, + uuidStart: 100 * (index + 1) + ) + let originalPayloads = try await analyticsPendingPayloads( + repository: repository + ) + let originalPayload = try XCTUnwrap(originalPayloads.first) + let network = AnalyticsQueueNetwork([testCase.0]) + let coordinator = makeCoordinator( + repository: repository, + network: network, + clock: clock, + random: AnalyticsTestRandomGenerator(.upperBound) + ) + + await coordinator.uploadAvailableEvents() + + let snapshot = await repository.debugSnapshot() + let diagnostic = try XCTUnwrap(snapshot.pendingEvents.first) + XCTAssertEqual(diagnostic.attemptCount, 1, "case \(index)") + XCTAssertEqual( + diagnostic.nextAttemptAt, + clock.now().addingTimeInterval(testCase.1), + "case \(index)" + ) + let storedPayload = await repository.debugPayloadBytes(rowID: diagnostic.rowID) + XCTAssertEqual(storedPayload, originalPayload, "case \(index)") + } + } + + func testUnauthorizedRefreshesOnceAndRetriesWithNewAuthorization() async throws { + let clock = AnalyticsTestWallClock() + let repository = try await makeRepository(clock: clock, eventCount: 1) + let network = AnalyticsQueueNetwork([ + .response(analyticsHTTPResponse(statusCode: 401)), + .response(analyticsSuccessResponse(accepted: 1)) + ]) + let bearer = AnalyticsTestBearerProvider( + initialToken: "old-token", + refreshedToken: "new-token" + ) + let coordinator = AnalyticsUploadCoordinator( + repository: repository, + configuration: uploadConfiguration(), + network: network, + bearerProvider: bearer, + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000), + random: AnalyticsTestRandomGenerator() + ) + + await coordinator.uploadAvailableEvents() + + let requests = await network.requests() + XCTAssertEqual(requests.count, 2) + XCTAssertEqual(requests[0].headers["Authorization"], "Bearer old-token") + XCTAssertEqual(requests[1].headers["Authorization"], "Bearer new-token") + XCTAssertEqual(requests[0].body, requests[1].body) + let refreshInputs = await bearer.recordedRefreshInputs() + XCTAssertEqual(refreshInputs.count, 1) + XCTAssertEqual(refreshInputs[0], "old-token") + let snapshot = await repository.debugSnapshot() + XCTAssertTrue(snapshot.pendingEvents.isEmpty) + } + + func testSecondUnauthorizedDoesNotRefreshAgainAndSchedulesLongRetry() async throws { + let clock = AnalyticsTestWallClock() + let repository = try await makeRepository(clock: clock, eventCount: 1) + let network = AnalyticsQueueNetwork([ + .response(analyticsHTTPResponse(statusCode: 401)), + .response(analyticsHTTPResponse(statusCode: 401)) + ]) + let bearer = AnalyticsTestBearerProvider( + initialToken: "old-token", + refreshedToken: "new-token" + ) + let configuration = uploadConfiguration(maximumBackoff: 600) + let coordinator = AnalyticsUploadCoordinator( + repository: repository, + configuration: configuration, + network: network, + bearerProvider: bearer, + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000), + random: AnalyticsTestRandomGenerator(.upperBound) + ) + + await coordinator.uploadAvailableEvents() + + let refreshInputs = await bearer.recordedRefreshInputs() + XCTAssertEqual(refreshInputs.count, 1) + let snapshot = await repository.debugSnapshot() + XCTAssertEqual(snapshot.pendingEvents.first?.attemptCount, 1) + XCTAssertEqual( + snapshot.pendingEvents.first?.nextAttemptAt, + clock.now().addingTimeInterval(600) + ) + } + + func testValidationStatusesRecursivelyIsolateOnlyPoisonEvent() async throws { + for statusCode in [400, 409, 422] { + let clock = AnalyticsTestWallClock() + let repository = try await makeRepository( + clock: clock, + eventCount: 4, + uuidStart: statusCode + ) + let events = try await analyticsDecodePendingEvents(repository: repository) + let poisonID = try XCTUnwrap(events.dropFirst().first?.clientEventId) + let network = AnalyticsPoisonNetwork( + statusCode: statusCode, + poisonID: poisonID + ) + let coordinator = makeCoordinator( + repository: repository, + network: network, + clock: clock + ) + + await coordinator.uploadAvailableEvents() + + let snapshot = await repository.debugSnapshot() + XCTAssertTrue(snapshot.pendingEvents.isEmpty, "HTTP \(statusCode)") + XCTAssertEqual(snapshot.quarantinedEvents.count, 1, "HTTP \(statusCode)") + XCTAssertEqual( + snapshot.quarantinedEvents.first?.reason, + "http\(statusCode)" + ) + let quarantinedRowID = try XCTUnwrap( + snapshot.quarantinedEvents.first?.rowID + ) + let quarantinedPayload = await repository.debugPayloadBytes( + rowID: quarantinedRowID, + quarantined: true + ) + let quarantinedEvent = try JSONDecoder().decode( + AnalyticsEvent.self, + from: try XCTUnwrap(quarantinedPayload) + ) + XCTAssertEqual(quarantinedEvent.clientEventId, poisonID) + let requests = await network.requests() + XCTAssertGreaterThan(requests.count, 1) + } + } + + func testOtherPermanent4xxQuarantinesWholeBatchWithoutRetry() async throws { + let clock = AnalyticsTestWallClock() + let repository = try await makeRepository(clock: clock, eventCount: 3) + let network = AnalyticsQueueNetwork([ + .response(analyticsHTTPResponse(statusCode: 403)) + ]) + let coordinator = makeCoordinator( + repository: repository, + network: network, + clock: clock + ) + + await coordinator.uploadAvailableEvents() + + let snapshot = await repository.debugSnapshot() + XCTAssertTrue(snapshot.pendingEvents.isEmpty) + XCTAssertEqual(snapshot.quarantinedEvents.count, 3) + XCTAssertEqual(Set(snapshot.quarantinedEvents.map(\.reason)), ["http403"]) + let requests = await network.requests() + XCTAssertEqual(requests.count, 1) + } + + private func makeRepository( + clock: AnalyticsTestWallClock, + eventCount: Int, + uuidStart: Int = 1 + ) async throws -> AnalyticsRepository { + let repository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration( + databaseURL: try analyticsTemporaryDatabaseURL() + ), + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: uuidStart) + ) + await analyticsRecordKeyboardEvents( + count: eventCount, + repository: repository + ) + return repository + } + + private func uploadConfiguration( + maximumBackoff: TimeInterval = 600 + ) -> AnalyticsUploadConfiguration { + AnalyticsUploadConfiguration( + endpoint: endpoint, + maximumBackoff: maximumBackoff + ) + } + + private func makeCoordinator( + repository: AnalyticsRepository, + network: some AnalyticsNetworking, + clock: AnalyticsTestWallClock, + random: AnalyticsTestRandomGenerator = AnalyticsTestRandomGenerator() + ) -> AnalyticsUploadCoordinator { + AnalyticsUploadCoordinator( + repository: repository, + configuration: uploadConfiguration(), + network: network, + clock: clock, + uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000), + random: random + ) + } +} diff --git a/OSGKeyboardTests/AppGroupOnboardingStoreTests.swift b/OSGKeyboardTests/AppGroupOnboardingStoreTests.swift index 78db976..1b04726 100644 --- a/OSGKeyboardTests/AppGroupOnboardingStoreTests.swift +++ b/OSGKeyboardTests/AppGroupOnboardingStoreTests.swift @@ -62,6 +62,50 @@ final class AppGroupOnboardingStoreTests: XCTestCase { XCTAssertEqual(store3.onboardingPage, 0) } + func testOnboardingPracticeWindowExpires() { + let now = Date(timeIntervalSince1970: 1_000) + KeyboardSetupBridge.setOnboardingPracticeActive( + true, + duration: 30, + defaults: defaults, + now: now + ) + + XCTAssertTrue( + KeyboardSetupBridge.onboardingPracticeIsActive( + defaults: defaults, + now: now.addingTimeInterval(29) + ) + ) + XCTAssertFalse( + KeyboardSetupBridge.onboardingPracticeIsActive( + defaults: defaults, + now: now.addingTimeInterval(31) + ) + ) + } + + func testDisablingOnboardingPracticeClearsWindow() { + let now = Date(timeIntervalSince1970: 1_000) + KeyboardSetupBridge.setOnboardingPracticeActive( + true, + defaults: defaults, + now: now + ) + KeyboardSetupBridge.setOnboardingPracticeActive( + false, + defaults: defaults, + now: now + ) + + XCTAssertFalse( + KeyboardSetupBridge.onboardingPracticeIsActive( + defaults: defaults, + now: now + ) + ) + } + // MARK: - App context detection round-trip func testDetectedAppContextRoundTrip() throws { diff --git a/OSGKeyboardTests/FlowHomePiPStatusPolicyTests.swift b/OSGKeyboardTests/FlowHomePiPStatusPolicyTests.swift new file mode 100644 index 0000000..783e49b --- /dev/null +++ b/OSGKeyboardTests/FlowHomePiPStatusPolicyTests.swift @@ -0,0 +1,97 @@ +// FlowHomePiPStatusPolicyTests.swift +// OSGKeyboardTests + +@testable import OSGKeyboard +import XCTest + +final class FlowHomePiPStatusPolicyTests: XCTestCase { + + func testPreparingAndRecoveringExposeAttemptProgress() { + XCTAssertEqual( + descriptor(for: .preparing(attempt: 1, total: 3)), + .progress( + localizationKey: "home.flow.preparingProgress", + attempt: 1, + total: 3 + ) + ) + XCTAssertEqual( + descriptor(for: .recovering(attempt: 2, total: 3)), + .progress( + localizationKey: "home.flow.recoveringProgress", + attempt: 2, + total: 3 + ) + ) + } + + func testTakeoverAndFailureUseActionableStatuses() { + XCTAssertEqual( + descriptor(for: .waitingForForeground), + .text(localizationKey: "home.flow.waitingForForeground") + ) + XCTAssertEqual( + descriptor(for: .failed(.systemRejected)), + .text(localizationKey: "home.flow.recoveryFailed") + ) + } + + func testActiveSessionDoesNotClaimReadyWithoutReadyContract() { + XCTAssertEqual( + FlowHomePiPStatusPolicy.descriptor( + lifecycle: .active, + isStarting: false, + isRecording: false, + isProcessing: false, + isActive: true, + isHostReady: false + ), + .text(localizationKey: "home.flow.notReady") + ) + XCTAssertEqual( + FlowHomePiPStatusPolicy.descriptor( + lifecycle: .active, + isStarting: false, + isRecording: false, + isProcessing: false, + isActive: true, + isHostReady: true + ), + .text(localizationKey: "home.flow.label") + ) + } + + func testRetryAppearsOnlyForRecoverableFailureWithPermissions() { + XCTAssertTrue( + FlowHomePiPStatusPolicy.canRetry( + lifecycle: .failed(.timedOut), + needsPermissionSetup: false + ) + ) + XCTAssertFalse( + FlowHomePiPStatusPolicy.canRetry( + lifecycle: .failed(.timedOut), + needsPermissionSetup: true + ) + ) + XCTAssertFalse( + FlowHomePiPStatusPolicy.canRetry( + lifecycle: .recovering(attempt: 1, total: 3), + needsPermissionSetup: false + ) + ) + } + + private func descriptor( + for lifecycle: FlowPiPLifecycleState + ) -> FlowHomePiPStatusDescriptor { + FlowHomePiPStatusPolicy.descriptor( + lifecycle: lifecycle, + isStarting: false, + isRecording: false, + isProcessing: false, + isActive: false, + isHostReady: false + ) + } +} diff --git a/OSGKeyboardTests/FlowPiPRecoveryPolicyTests.swift b/OSGKeyboardTests/FlowPiPRecoveryPolicyTests.swift new file mode 100644 index 0000000..dd0b2d1 --- /dev/null +++ b/OSGKeyboardTests/FlowPiPRecoveryPolicyTests.swift @@ -0,0 +1,149 @@ +// FlowPiPRecoveryPolicyTests.swift +// OSGKeyboardTests + +@testable import OSGKeyboard +import XCTest + +final class FlowPiPRecoveryPolicyTests: XCTestCase { + + // MARK: - Reconciliation + + func testNoSessionIntentNeverStartsRecovery() { + XCTAssertEqual( + FlowPiPRecoveryPolicy.reconciliationDecision( + wantsActiveSession: false, + isAppForeground: true, + isPictureInPictureActive: false + ), + .noSessionIntent + ) + } + + func testActivePiPIsIdempotent() { + XCTAssertEqual( + FlowPiPRecoveryPolicy.reconciliationDecision( + wantsActiveSession: true, + isAppForeground: true, + isPictureInPictureActive: true + ), + .alreadyActive + ) + } + + func testTakeoverWaitsWhileAppIsInBackground() { + XCTAssertEqual( + FlowPiPRecoveryPolicy.reconciliationDecision( + wantsActiveSession: true, + isAppForeground: false, + isPictureInPictureActive: false + ), + .waitForForeground + ) + } + + func testReturningToForegroundStartsRecovery() { + XCTAssertEqual( + FlowPiPRecoveryPolicy.reconciliationDecision( + wantsActiveSession: true, + isAppForeground: true, + isPictureInPictureActive: false + ), + .startRecovery + ) + } + + // MARK: - Bounded retry + + func testRetryPolicyUsesThreeIncreasingBoundedAttempts() { + XCTAssertEqual(FlowPiPRecoveryPolicy.maxAttempts, 3) + XCTAssertEqual( + (1...FlowPiPRecoveryPolicy.maxAttempts).map { + FlowPiPRecoveryPolicy.retryDelay(beforeAttempt: $0) + }, + [0, 0.25, 0.6] + ) + XCTAssertEqual(FlowPiPRecoveryPolicy.totalBudget, 5) + } + + func testRetryTimeoutsNeverExceedRemainingBudget() { + XCTAssertEqual( + FlowPiPRecoveryPolicy.hostTimeout(attempt: 1, remainingBudget: 0.4), + 0.4 + ) + XCTAssertEqual( + FlowPiPRecoveryPolicy.hostTimeout(attempt: 2, remainingBudget: 4), + 0.25 + ) + XCTAssertEqual( + FlowPiPRecoveryPolicy.activeTimeout(remainingBudget: 0.3), + 0.3 + ) + XCTAssertEqual( + FlowPiPRecoveryPolicy.activeTimeout(remainingBudget: 4), + 1.4 + ) + XCTAssertEqual( + FlowPiPRecoveryPolicy.activeTimeout(remainingBudget: 0.01), + 0.01 + ) + } + + // MARK: - Late completion safety + + func testCurrentForegroundOperationCanContinue() { + XCTAssertTrue( + FlowPiPRecoveryPolicy.canContinue( + operation: 7, + currentOperation: 7, + wantsActiveSession: true, + isAppForeground: true, + taskIsCancelled: false + ) + ) + } + + func testOldGenerationCannotOverwriteNewRecovery() { + XCTAssertFalse( + FlowPiPRecoveryPolicy.canContinue( + operation: 6, + currentOperation: 7, + wantsActiveSession: true, + isAppForeground: true, + taskIsCancelled: false + ) + ) + } + + func testExplicitEndPreventsLateRecovery() { + XCTAssertFalse( + FlowPiPRecoveryPolicy.canContinue( + operation: 7, + currentOperation: 8, + wantsActiveSession: false, + isAppForeground: true, + taskIsCancelled: true + ) + ) + } + + func testBackgroundAndCancellationBothStopRecovery() { + XCTAssertFalse( + FlowPiPRecoveryPolicy.canContinue( + operation: 7, + currentOperation: 7, + wantsActiveSession: true, + isAppForeground: false, + taskIsCancelled: false + ) + ) + XCTAssertFalse( + FlowPiPRecoveryPolicy.canContinue( + operation: 7, + currentOperation: 7, + wantsActiveSession: true, + isAppForeground: true, + taskIsCancelled: true + ) + ) + } +} diff --git a/OSGKeyboardTests/FlowSessionBridgeTests.swift b/OSGKeyboardTests/FlowSessionBridgeTests.swift index 1a907b2..d5a440f 100644 --- a/OSGKeyboardTests/FlowSessionBridgeTests.swift +++ b/OSGKeyboardTests/FlowSessionBridgeTests.swift @@ -1342,7 +1342,7 @@ final class FlowSessionBridgeTests: XCTestCase { startDeadlineAt: 1_700_000_008.25, processingDeadlineAt: 1_700_000_045.25 ) - let expected = #"{"action":"startRecording","aiConversationID":"33333333-4444-5555-6666-777777777777","commandSeq":42,"createdAt":1700000000.25,"editSourceText":"draft","fieldContext":{"followingText":"after","isContextAvailable":true,"isEmptyField":false,"isSecureEntry":false,"keyboardType":"default","precedingText":"before","returnKeyType":"send"},"localeId":"en-US","processingDeadlineAt":1700000045.25,"protocolVersion":6,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF","sourceHistoryEntryID":"22222222-3333-4444-5555-666666666666","sourceHistoryEntryRevision":7,"startDeadlineAt":1700000008.25,"utteranceId":"11111111-2222-3333-4444-555555555555","utteranceMode":"editLastInput"}"# + let expected = #"{"action":"startRecording","aiConversationID":"33333333-4444-5555-6666-777777777777","commandSeq":42,"createdAt":1700000000.25,"editSourceText":"draft","fieldContext":{"followingText":"after","isContextAvailable":true,"isEmptyField":false,"isSecureEntry":false,"keyboardType":"default","precedingText":"before","returnKeyType":"send"},"localeId":"en-US","processingDeadlineAt":1700000045.25,"protocolVersion":7,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF","sourceHistoryEntryID":"22222222-3333-4444-5555-666666666666","sourceHistoryEntryRevision":7,"startDeadlineAt":1700000008.25,"utteranceId":"11111111-2222-3333-4444-555555555555","utteranceMode":"editLastInput"}"# XCTAssertEqual(try sortedJSONString(command), expected) } @@ -1378,7 +1378,7 @@ final class FlowSessionBridgeTests: XCTestCase { historyEntryRevision: 9, aiConversationID: conversationID ) - let expected = #"{"aiConversationID":"33333333-4444-5555-6666-777777777777","commandSeq":42,"createdAt":1700000050.5,"errorKind":"asrFailed","fieldFingerprint":"default|send|before|after","historyEntryID":"22222222-3333-4444-5555-666666666666","historyEntryRevision":9,"hostGeneration":"generation-1","protocolVersion":6,"rawText":"raw","revision":8,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF","status":"final","text":"polished","utteranceId":"11111111-2222-3333-4444-555555555555","utteranceMode":"aiQuestion","warning":"fallback"}"# + let expected = #"{"aiConversationID":"33333333-4444-5555-6666-777777777777","commandSeq":42,"createdAt":1700000050.5,"errorKind":"asrFailed","fieldFingerprint":"default|send|before|after","historyEntryID":"22222222-3333-4444-5555-666666666666","historyEntryRevision":9,"hostGeneration":"generation-1","protocolVersion":7,"rawText":"raw","revision":8,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF","status":"final","text":"polished","utteranceId":"11111111-2222-3333-4444-555555555555","utteranceMode":"aiQuestion","warning":"fallback"}"# XCTAssertEqual(try sortedJSONString(result), expected) } diff --git a/README.md b/README.md index dcc0fae..ec607ad 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ iOS / iPadOS 账号中心支持: - **击键不上传**:中文候选学习与英文建议偏好保存在本机,密码框不学习 - **剪贴板可控**:历史默认关闭、最多 15 条、仅本机保存,不会自行发送给 AI - **凭证隔离**:用户 API Key 保存在 Keychain;账号会话令牌只在主 App 私有 Keychain -- **无广告追踪**:不集成广告、分析或追踪 SDK,不出售个人数据 +- **无广告追踪**:不集成第三方分析、广告或追踪 SDK;可退出的第一方产品分析不采集输入内容,不出售个人数据 完整数据类型、保留策略、账号删除与第三方服务说明见[隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/)。 diff --git a/Scripts/device-pip-stress.sh b/Scripts/device-pip-stress.sh index 26559f3..b9760d5 100755 --- a/Scripts/device-pip-stress.sh +++ b/Scripts/device-pip-stress.sh @@ -2,12 +2,13 @@ # Physical-device PiP / host-wake stress using `devicectl --console` logs. # # Usage: -# ./Scripts/device-pip-stress.sh [UDID] [COUNT=50] [SUITES=cold,bgfg,hold] +# ./Scripts/device-pip-stress.sh [UDID] [COUNT=50] [SUITES=cold,bgfg,hold,reclaim] # # Suites: # cold — terminate-existing launch (force-quit / cold start) # bgfg — open Safari (background) then relaunch OSG (foreground restore) # hold — start PiP, wait 20s in session, relaunch to verify still recoverable +# reclaim — foreground another PiP-capable app, then reopen OSG to reclaim set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -15,9 +16,11 @@ cd "$ROOT" UDID="${1:-00008130-001C249C0E52001C}" COUNT="${2:-50}" -SUITES="${3:-cold,bgfg,hold}" +SUITES="${3:-cold,bgfg,hold,reclaim}" BUNDLE="com.osgkeyboard.ios" SAFARI="com.apple.mobilesafari" +TAKEOVER_BUNDLE="${PIP_TAKEOVER_BUNDLE:-$SAFARI}" +TAKEOVER_SETTLE_SECONDS="${PIP_TAKEOVER_SETTLE_SECONDS:-5}" OUT_DIR="${ROOT}/.tmp/device-pip-stress-$(date +%Y%m%d-%H%M%S)" mkdir -p "$OUT_DIR" REPORT="$OUT_DIR/report.jsonl" @@ -48,9 +51,18 @@ console_launch() { "$BUNDLE" >"$outfile" 2>&1 || true } +# Launch without attaching a console. Killing a timed `--console` command also +# terminates the app, so preparation phases must use a detached launch. +plain_launch() { + xcrun devicectl device process launch \ + --device "$UDID" \ + --terminate-existing \ + "$BUNDLE" >/dev/null 2>&1 +} + classify_file() { local f="$1" - local host="unknown" pip="unknown" mic="unknown" + local host="unknown" pip="unknown" mic="unknown" recovery="none" if rg -q "OSGKeyboardApp\.init|MainAppRoot\.onAppear|activateOnForeground" "$f"; then host="success" @@ -93,12 +105,23 @@ classify_file() { fi fi - echo "$host|$pip|$mic" + if rg -q "pip\.unexpectedStop" "$f" \ + && rg -q "startSessionAsync\.ready.*trigger=(foreground|healthCheck|existingSession)" "$f"; then + recovery="reclaimed" + elif rg -q "startSessionAsync\.ready.*trigger=(foreground|healthCheck|existingSession|manualRetry)" "$f"; then + recovery="reconciled" + elif rg -q "failed after bounded recovery|pipState=failed" "$f"; then + recovery="exhausted" + elif rg -q "pipRecovery\.attempt" "$f"; then + recovery="attempted" + fi + + echo "$host|$pip|$mic|$recovery" } run_suite() { local suite="$1" - local i outfile result host pip mic start_ts elapsed flags + local i outfile result host pip mic recovery start_ts elapsed flags echo "==> Suite: $suite × $COUNT" for i in $(seq 1 "$COUNT"); do outfile="$OUT_DIR/${suite}-$i.console.log" @@ -112,7 +135,8 @@ run_suite() { ;; bgfg) # Ensure app running with PiP first - console_launch "$OUT_DIR/${suite}-$i.prep.log" 12 "--terminate-existing" >/dev/null || true + plain_launch + sleep 7 # Background by opening Safari xcrun devicectl device process launch --device "$UDID" "$SAFARI" >/dev/null 2>&1 || true sleep 3 @@ -120,7 +144,8 @@ run_suite() { console_launch "$outfile" 12 "" ;; hold) - console_launch "$OUT_DIR/${suite}-$i.prep.log" 12 "--terminate-existing" >/dev/null || true + plain_launch + sleep 7 # Leave session alive ~20s (PiP should keep host) sleep 20 # Background briefly then resume @@ -128,6 +153,15 @@ run_suite() { sleep 2 console_launch "$outfile" 12 "" ;; + reclaim) + plain_launch + sleep 7 + # The target app must already be configured to enter PiP when launched. + # Safari is only a foreground/background fallback unless media is playing. + xcrun devicectl device process launch --device "$UDID" "$TAKEOVER_BUNDLE" >/dev/null 2>&1 || true + sleep "$TAKEOVER_SETTLE_SECONDS" + console_launch "$outfile" 14 "" + ;; esac elapsed=$(( $(date +%s) - start_ts )) @@ -135,12 +169,14 @@ run_suite() { host="${result%%|*}" rest="${result#*|}" pip="${rest%%|*}" - mic="${rest##*|}" + rest="${rest#*|}" + mic="${rest%%|*}" + recovery="${rest##*|}" - printf '{"suite":"%s","i":%d,"host":"%s","pip":"%s","mic":"%s","elapsed_s":%d}\n' \ - "$suite" "$i" "$host" "$pip" "$mic" "$elapsed" >>"$REPORT" - printf "[%s %3d/%d] host=%-11s pip=%-12s mic=%-12s %2ds\n" \ - "$suite" "$i" "$COUNT" "$host" "$pip" "$mic" "$elapsed" + printf '{"suite":"%s","i":%d,"host":"%s","pip":"%s","mic":"%s","recovery":"%s","elapsed_s":%d}\n' \ + "$suite" "$i" "$host" "$pip" "$mic" "$recovery" "$elapsed" >>"$REPORT" + printf "[%s %3d/%d] host=%-11s pip=%-12s mic=%-12s recovery=%-10s %2ds\n" \ + "$suite" "$i" "$COUNT" "$host" "$pip" "$mic" "$recovery" "$elapsed" done } @@ -148,7 +184,7 @@ run_suite() { IFS=',' read -r -a selected_suites <<<"$SUITES" for selected_suite in "${selected_suites[@]}"; do case "$selected_suite" in - cold|bgfg|hold) run_suite "$selected_suite" ;; + cold|bgfg|hold|reclaim) run_suite "$selected_suite" ;; *) echo "error: unknown suite '$selected_suite'" >&2 exit 1 @@ -165,7 +201,7 @@ by = collections.defaultdict(list) for r in rows: by[r["suite"]].append(r) -selected = [name for name in ("cold", "bgfg", "hold") if by.get(name)] +selected = [name for name in ("cold", "bgfg", "hold", "reclaim") if by.get(name)] lines = [ "Device PiP / mic keep-alive stress summary", f"device=Rocky 15 PM udid={udid} count_per_suite={count}", @@ -178,12 +214,14 @@ for suite in selected: host_ok = sum(1 for r in rs if r["host"] in ("success", "launch_only")) pip_c = collections.Counter(r["pip"] for r in rs) mic_c = collections.Counter(r["mic"] for r in rs) + recovery_c = collections.Counter(r["recovery"] for r in rs) pip_ok = pip_c.get("success", 0) lines += [ f"[{suite}]", f" host_ok={host_ok}/{len(rs)} ({host_ok/n*100:.1f}%)", f" pip_success={pip_ok}/{len(rs)} ({pip_ok/n*100:.1f}%) breakdown={dict(pip_c)}", f" mic={dict(mic_c)}", + f" recovery={dict(recovery_c)}", "", ] lines += [ @@ -192,6 +230,7 @@ lines += [ " - cold uses --terminate-existing (force-quit recovery).", " - bgfg backgrounds via Safari then resumes.", " - hold keeps session ~20s then Safari background + resume.", + " - reclaim launches PIP_TAKEOVER_BUNDLE, then verifies OSG foreground reconciliation.", " - Keyboard-extension mic tap is not automated.", ] text = "\n".join(lines) + "\n" diff --git a/Tests/suite-manifest.json b/Tests/suite-manifest.json index ebf80f5..a34567c 100644 --- a/Tests/suite-manifest.json +++ b/Tests/suite-manifest.json @@ -40,6 +40,17 @@ "OSGKeyboardExtTests/ManagedGatewayTests" ] }, + "analytics": { + "description": "Privacy-safe analytics schema, durable queue, uploader, identity lifecycle, and anonymous extension runtime", + "platform": "ios", + "tests": [ + "OSGKeyboardTests/AnalyticsModelTests", + "OSGKeyboardTests/AnalyticsRepositoryTests", + "OSGKeyboardTests/AnalyticsUploadCoordinatorTests", + "OSGKeyboardTests/AnalyticsAIOperationTests", + "OSGKeyboardExtTests/AnalyticsExtensionPrivacyTests" + ] + }, "sync": { "description": "iCloud KVS merge/sync for settings, local-only clipboard consent, dictionary, speech history, usage stats", "platform": "ios", @@ -123,6 +134,8 @@ "platform": "ios", "tests": [ "OSGKeyboardTests/FlowHandoffPolicyTests", + "OSGKeyboardTests/FlowHomePiPStatusPolicyTests", + "OSGKeyboardTests/FlowPiPRecoveryPolicyTests", "OSGKeyboardTests/FlowSessionBridgeTests", "OSGKeyboardTests/FlowSessionPolicyTests", "OSGKeyboardTests/FlowBudgetAndMergeTests", @@ -207,6 +220,7 @@ "config", "account", "managed_gateway", + "analytics", "sync", "polish", "edit_last_input", @@ -225,6 +239,7 @@ "config", "account", "managed_gateway", + "analytics", "sync", "polish", "edit_last_input", diff --git a/docs/APPSTORE_METADATA.md b/docs/APPSTORE_METADATA.md index 5553fed..4452850 100644 --- a/docs/APPSTORE_METADATA.md +++ b/docs/APPSTORE_METADATA.md @@ -71,7 +71,11 @@ TYPING PRIVACY -• No advertising, analytics, or tracking SDKs. +• Limited first-party product analytics; no third-party analytics, + advertising, tracking SDKs, ATT, or IDFA. +• Product analytics never includes keyboard input, audio, transcripts, + prompts, model output, credentials, or personal identifiers. It can be + disabled in Settings, which deletes queued events. • Local recognition does not upload audio. • User-configured cloud requests go directly to that provider. Managed-credit requests go through OSGKeyboard's managed gateway to the managed provider. @@ -157,6 +161,9 @@ OSGKeyboard is a custom keyboard for iOS/iPadOS 26. Clipboard, then return to the keyboard. Secure fields hide the clipboard entry point. Turning History off preserves saved items; use the separate confirmed clear action to delete them. +10. First-party Product Analytics is enabled by default under Settings → + About → Privacy. Turning it off deletes queued events. It does not collect + keyboard input, audio, transcripts, prompts, model output, or credentials. Privacy policy: https://hkgood.github.io/OSGKeyboard/privacy/ @@ -230,9 +237,41 @@ This covers the pseudonymous OSGKeyboard account identifier and scoped managed-service grant identifiers. Core use remains available without an OSGKeyboard account. +### Identifiers → Device ID + +- Collected: Yes +- Purpose: Analytics +- Linked to the user: Yes +- Used for tracking: No + +This is an app-scoped random installation identifier. It rotates when analytics +is re-enabled, after account deletion, or when a different account signs in. It +is not IDFA and is not used across apps. + +### Usage Data → Product Interaction + +- Collected: Yes +- Purpose: Analytics +- Linked to the user: Yes +- Used for tracking: No + +This covers fixed event names for app and keyboard sessions, purchase-page +interactions, and invitation actions. It contains no free-form properties. + +### Usage Data → Other Usage Data + +- Collected: Yes +- Purpose: Analytics +- Linked to the user: Yes +- Used for tracking: No + +This covers fixed AI feature categories, execution modes, outcome categories, +and coarse duration buckets. It does not include prompts, transcripts, model +output, audio, or keyboard content. + ### Do not select -- Advertising, marketing, analytics, product personalization, or tracking +- Advertising, marketing, product personalization, or tracking - Email address, phone number, physical address, location, contacts, photos, browsing history, or search history - Usage data or diagnostics stored only locally or in the user's private iCloud @@ -249,6 +288,8 @@ standard HTTPS. Re-evaluate this answer if non-exempt cryptography is added. - [ ] Run the release build and test suites on macOS with Xcode 26 - [ ] Replace screenshots with captures from the submitted build - [ ] Verify the privacy answers against the submitted provider features +- [ ] In App Store Connect, add Device ID, Product Interaction, and Other + Usage Data for Analytics; linked to the user, not used for tracking - [ ] Confirm `500tks`, `1500tks`, and `3000tks` are approved, consumable, and mapped to the server credit catalog - [ ] Confirm `ByRockyACoffee` remains an optional consumable tip and unlocks diff --git a/docs/assets/site.css b/docs/assets/site.css index b0ea726..ae3e389 100644 --- a/docs/assets/site.css +++ b/docs/assets/site.css @@ -6,7 +6,7 @@ --surface-strong: #e5eaf0; --ink: #111416; --ink-secondary: #5f676e; - --ink-tertiary: #8b949c; + --ink-tertiary: #626c74; --line: rgba(17, 20, 22, 0.1); --line-strong: rgba(17, 20, 22, 0.18); --accent: #2d8a57; @@ -28,7 +28,7 @@ html[data-theme="dark"] { --surface-strong: #2b3033; --ink: #f2f4f3; --ink-secondary: #aab0ac; - --ink-tertiary: #737a75; + --ink-tertiary: #969e98; --line: rgba(255, 255, 255, 0.09); --line-strong: rgba(255, 255, 255, 0.16); --accent: #6fbd8a; @@ -97,6 +97,13 @@ h3 { text-wrap: balance; } +html[data-lang="zh"] h1, +html[data-lang="zh"] h2, +html[data-lang="zh"] h3 { + overflow-wrap: normal; + word-break: keep-all; +} + .skip-link { position: fixed; z-index: 100; @@ -300,27 +307,6 @@ html[data-theme="dark"] #themeToggle::before { height: 22px; } -.assistant-tabs > .material-symbols-rounded::before { - content: "•••"; - font-size: 0.72rem; - letter-spacing: 0.08em; -} - -.round-control:first-child .material-symbols-rounded::before { - content: "↶"; - font-size: 1.15rem; -} - -.round-control:last-child .material-symbols-rounded::before { - content: "↑"; - font-size: 1.05rem; -} - -.mic-control .material-symbols-rounded::before { - content: "●"; - font-size: 0.72rem; -} - .gesture-grid .material-symbols-rounded, .skill-actions .material-symbols-rounded { display: none; @@ -341,16 +327,6 @@ html[data-theme="dark"] #themeToggle::before { font-size: 1.55rem; } -.ios-symbol .material-symbols-rounded::before { - content: "⌨"; - font-size: 2.25rem; -} - -.ios-symbol .material-symbols-rounded { - width: 46px; - height: 46px; -} - .outline-action .material-symbols-rounded::before { content: "⌘"; font-size: 1rem; @@ -467,6 +443,14 @@ html[data-lang="zh"] .hero h1 { height: 48px; } +html[data-theme="light"] .store-badge img { + filter: invert(1); +} + +html[data-theme="dark"] .store-badge img { + filter: none; +} + .store-badge:hover, .store-badge:focus-visible { opacity: 0.86; @@ -718,103 +702,37 @@ html[data-theme="dark"] .message-bubble { bottom: 24px; } -.keyboard-demo { - display: flex; +.keyboard-capture { + position: relative; + align-self: center; min-width: 0; + aspect-ratio: 1.18; + margin: 0; + overflow: hidden; background: var(--surface-strong); - flex-direction: column; } -.assistant-tabs { - display: grid; - grid-template-columns: 46px repeat(3, auto) 1fr; - gap: 8px; - align-items: center; - padding: 16px 18px; - border-bottom: 1px solid var(--line); +.keyboard-capture img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center bottom; } -.assistant-logo { - display: grid; - width: 36px; - height: 36px; - border-radius: 11px; - background: var(--accent); - color: #fff; - font-size: 0.66rem; - font-weight: 800; - place-items: center; -} - -.assistant-tab { - padding: 6px 9px; +.keyboard-capture figcaption { + position: absolute; + top: 16px; + left: 16px; + padding: 7px 10px; + border: 1px solid rgba(255, 255, 255, 0.22); border-radius: 999px; - color: var(--ink-tertiary); - font-size: 0.72rem; + background: rgba(17, 20, 22, 0.68); + color: #ffffff; + font-size: 0.68rem; font-weight: 650; -} - -.assistant-tab.active { - background: var(--surface); - color: var(--ink); -} - -.assistant-tabs > .material-symbols-rounded { - justify-self: end; - color: var(--ink-tertiary); -} - -.keyboard-keys { - display: grid; - grid-template-columns: repeat(10, 1fr); - gap: 7px; - padding: clamp(42px, 6vw, 80px) 18px 20px; -} - -.keyboard-keys span { - display: grid; - min-width: 0; - height: 42px; - border-bottom: 2px solid var(--line-strong); - color: var(--ink-secondary); - font-size: 0.75rem; - place-items: center; -} - -.keyboard-keys span:nth-child(n + 11) { - transform: translateX(50%); -} - -.assistant-controls { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - margin-top: auto; - padding: 22px 24px 26px; - border-top: 1px solid var(--line); -} - -.round-control, -.mic-control { - display: grid; - width: 44px; - height: 44px; - border-radius: 50%; - background: var(--surface); - color: var(--ink-secondary); - place-items: center; -} - -.round-control:last-child { - justify-self: end; -} - -.mic-control { - width: 58px; - height: 58px; - background: var(--accent); - color: #fff; - box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 26%, transparent); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); } .gesture-grid { @@ -1417,29 +1335,28 @@ html[data-theme="dark"] .privacy-section { .platform-symbol { display: grid; - width: 110px; - height: 150px; + width: 104px; + height: 104px; align-self: start; border: 1px solid var(--line); - border-radius: 28px; + border-radius: 24px; background: var(--surface-soft); color: var(--ink-secondary); place-items: center; } -.platform-symbol .material-symbols-rounded { - font-size: 42px; +.platform-symbol svg { + width: 48px; + height: 48px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2.6; } .mac-symbol { - width: 116px; - height: 96px; - border-radius: 18px; -} - -.option-key { - font-size: 2.4rem; - font-weight: 500; + border-radius: 24px; } .platform-stories h3 { @@ -1795,10 +1712,6 @@ html[data-lang="zh"] .download-panel h2 { border-bottom: 1px solid var(--line); } - .keyboard-demo { - min-height: 430px; - } - .scene-copy { max-width: 690px; } @@ -1926,7 +1839,7 @@ html[data-lang="zh"] .download-panel h2 { font-size: clamp(2rem, 9vw, 2.2rem); } - .hero h1 > span { + html[data-lang="zh"] .hero h1 > span { white-space: nowrap; } @@ -1987,30 +1900,6 @@ html[data-lang="zh"] .download-panel h2 { padding: 18px; } - .keyboard-demo { - min-height: 360px; - } - - .assistant-tabs { - grid-template-columns: 42px repeat(3, auto) 1fr; - gap: 3px; - padding: 13px 10px; - } - - .assistant-tab { - padding: 6px; - } - - .keyboard-keys { - gap: 4px; - padding: 45px 10px 16px; - } - - .keyboard-keys span { - height: 36px; - font-size: 0.65rem; - } - .gesture-grid { grid-template-columns: 1fr; } @@ -2094,14 +1983,18 @@ html[data-lang="zh"] .download-panel h2 { } .platform-symbol { - width: 92px; - height: 122px; - border-radius: 23px; + width: 88px; + height: 88px; + border-radius: 21px; } .mac-symbol { - height: 76px; - border-radius: 16px; + border-radius: 21px; + } + + .platform-symbol svg { + width: 42px; + height: 42px; } .setup-steps { diff --git a/docs/assets/site.js b/docs/assets/site.js index d0cee4e..6348f17 100644 --- a/docs/assets/site.js +++ b/docs/assets/site.js @@ -58,15 +58,20 @@ element.setAttribute("content", element.dataset[`${lang}Content`] || ""); }); + document.querySelectorAll("[data-shot]").forEach((image) => { + const theme = root.dataset.theme || "light"; + image.src = `assets/screenshots/${lang}/${theme}/${image.dataset.shot}`; + }); + document.title = lang === "zh" - ? "OSGKeyboard — 语音、打字与 AI 助手键盘" - : "OSGKeyboard — Voice, typing, and an AI assistant keyboard"; + ? "OSGKeyboard — 语音输入、中英打字与 AI 助手" + : "OSGKeyboard — Voice input, bilingual typing, and an AI assistant"; if (languageButton) { languageButton.textContent = lang === "zh" ? "EN" : "中文"; languageButton.setAttribute( "aria-label", - lang === "zh" ? "Switch to English" : "切换到中文" + lang === "zh" ? "切换到英文" : "Switch to Chinese" ); } @@ -131,6 +136,11 @@ themeColor?.setAttribute("content", resolved === "dark" ? "#0c0e0f" : "#eef2f6"); + document.querySelectorAll("[data-shot]").forEach((image) => { + const lang = root.dataset.lang || "zh"; + image.src = `assets/screenshots/${lang}/${resolved}/${image.dataset.shot}`; + }); + if (persist) localStorage.setItem("osg-site-theme", resolved); } diff --git a/docs/assets/whats-new/osg-credits-en.png b/docs/assets/whats-new/osg-credits-en.png new file mode 100644 index 0000000000000000000000000000000000000000..5754d2d097432fe51b84aec3ed91352b720e49b7 GIT binary patch literal 75502 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 literal 0 HcmV?d00001 diff --git a/docs/assets/whats-new/osg-credits-zh.png b/docs/assets/whats-new/osg-credits-zh.png new file mode 100644 index 0000000000000000000000000000000000000000..f0a916d1869903c8a369045a759214d30d84ea63 GIT binary patch literal 64904 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
  • =~#HL78? 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 literal 0 HcmV?d00001 diff --git a/docs/index.html b/docs/index.html index 0a9bb2d..4c8af6f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,12 +3,12 @@ - OSGKeyboard — 语音、打字与 AI 助手键盘 + OSGKeyboard — 语音输入、中英打字与 AI 助手 @@ -25,13 +25,13 @@ property="og:title" content="OSGKeyboard — 开口即文字" data-zh-content="OSGKeyboard — 开口即文字" - data-en-content="OSGKeyboard — Speak it. It's typed." + data-en-content="OSGKeyboard — Say it. It’s typed." > @@ -56,7 +56,7 @@ "operatingSystem": "iOS 26, iPadOS 26, macOS 15", "applicationCategory": "UtilitiesApplication", "softwareVersion": "2.0.0", - "description": "A source-available voice and typing keyboard with on-device speech recognition by default, Chinese and English typing, an AI assistant, clipboard skills, BYOK providers, and optional managed credits.", + "description": "A source-available voice and typing keyboard with on-device speech recognition by default, Chinese and English typing, an AI assistant, clipboard skills, providers configured with your own API key, and optional managed credits.", "downloadUrl": "https://apps.apple.com/app/osgkeyboard/id6781553267", "url": "https://hkgood.github.io/OSGKeyboard/", "offers": { @@ -71,8 +71,8 @@ "Unified dictation and AI assistant keyboard", "Clipboard history and AI skills", "Voice editing of the last verified insertion", - "Optional Sign in with Apple and managed credits on iOS", - "Bring-your-own-key cloud speech and AI", + "Optional Sign in with Apple and managed credits on iOS and iPadOS", + "Cloud speech and AI with your own API key", "Mac Option-key global dictation with local Qwen3 MLX on Apple Silicon", "Personal dictionary and optional iCloud sync" ], @@ -86,7 +86,7 @@ "name": "Does OSGKeyboard require an account?", "acceptedAnswer": { "@type": "Answer", - "text": "No account is required for local dictation or providers you configure yourself. Sign in with Apple is optional and is used for managed credits, purchases, and referrals." + "text": "You do not need an OSG account for on-device dictation or when using providers you configure yourself. Sign in with Apple is optional and is used for managed credits, purchases, and referrals." } }, { @@ -94,7 +94,7 @@ "name": "Does OSGKeyboard upload recordings?", "acceptedAnswer": { "@type": "Answer", - "text": "Local recognition keeps audio on the device. Audio leaves the device only after you explicitly choose a user-configured cloud engine or the managed-credit cloud path." + "text": "On-device recognition keeps audio on your device. Audio leaves it only when you explicitly choose a cloud speech-recognition provider configured with your own API key or cloud recognition paid for with managed credits." } }, { @@ -102,7 +102,7 @@ "name": "Is OSGKeyboard open source?", "acceptedAnswer": { "@type": "Answer", - "text": "The code is source available for audit and personal, non-commercial local use. It is not an open-source or MIT license." + "text": "The code is source available under a non-open-source license for audit and personal, non-commercial local use; it is not licensed under the MIT License." } } ] @@ -120,7 +120,7 @@ >跳到主要内容
  • -