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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user