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.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" = "返回";
|
||||
|
||||
Reference in New Issue
Block a user