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