feat(app): add resilient Flow recovery and privacy-safe analytics
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled

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.
This commit is contained in:
Rocky
2026-08-20 20:22:20 +08:00
parent ee7de5c934
commit 10bc457c72
69 changed files with 7891 additions and 598 deletions
@@ -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"
+38 -1
View File
@@ -5,7 +5,44 @@
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeDeviceID</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
</array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeProductInteraction</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
</array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherUsageData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
@@ -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)"
)
}
}
@@ -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) {
@@ -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
@@ -88,6 +88,7 @@ final class KeyboardTextInserter {
)
let inserted = separator + trimmed
insertText(inserted)
KeyboardSetupBridge.markVoiceInsertion()
state.noteUserDidInputText()
recordLastInsertion(
inserted,
+9 -1
View File
@@ -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)
+1
View File
@@ -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";
@@ -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" = "返回";