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
@@ -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()
}
}