feat(flow): add Picture in Picture keep-alive mode

- Add FlowKeepAliveMode (Dynamic Island vs PiP) with iCloud sync
- Settings: keep-alive picker; inactivity timeout only for Live Activity
- PiP: waveform sample-buffer controller, mic released between utterances
- PiP sessions have no idle expiry; user closing PiP ends the session
- FlowSessionManager branches hostReady, session start, and utterance paths
- UIBackgroundModes picture-in-picture; bilingual strings and tests

Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-07-26 10:34:52 +00:00
parent e6f99d2744
commit 70ba3a4359
18 changed files with 801 additions and 36 deletions
@@ -312,11 +312,22 @@ public enum FlowSessionBridge {
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store)
if FlowSessionPolicy.usesInactivityExpiry(defaults: store) {
markSessionActiveWithExpiry(duration: duration, sessionId: sessionId, defaults: store)
} else {
markSessionActivePersistent(sessionId: sessionId, defaults: store)
}
}
/// PiP keep-alive: session stays valid until explicit teardown (no idle expiry).
public static func markSessionActivePersistent(
sessionId: UUID? = nil,
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
let now = Date().timeIntervalSince1970
let expires = now + resolvedDuration
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
writeHeartbeat(defaults: store)
clearTranscription(defaults: store)
@@ -331,7 +342,7 @@ public enum FlowSessionBridge {
heartbeatAt: now,
engineMode: AppGroupConfiguration.load(fromAvailable: store).engineMode,
localeId: AppGroupConfiguration.load(fromAvailable: store).localeId,
sessionExpiresAt: expires,
sessionExpiresAt: nil,
hostGeneration: store.string(forKey: FlowSessionKeys.hostGeneration)
)
if let data = encode(snapshot) {
@@ -343,6 +354,42 @@ public enum FlowSessionBridge {
flush(store)
}
private static func markSessionActiveWithExpiry(
duration: TimeInterval? = nil,
sessionId: UUID? = nil,
defaults: UserDefaults
) {
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: defaults)
let now = Date().timeIntervalSince1970
let expires = now + resolvedDuration
defaults.set(true, forKey: FlowSessionKeys.flowSessionActive)
defaults.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
defaults.set(now, forKey: FlowSessionKeys.lastActivityAt)
writeHeartbeat(defaults: defaults)
clearTranscription(defaults: defaults)
defaults.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
defaults.removeObject(forKey: FlowSessionKeys.flowResultPayload)
defaults.removeObject(forKey: FlowSessionKeys.flowAckPayload)
if let sessionId {
let snapshot = FlowReadySnapshot(
sessionId: sessionId,
ready: false,
reason: .starting,
heartbeatAt: now,
engineMode: AppGroupConfiguration.load(fromAvailable: defaults).engineMode,
localeId: AppGroupConfiguration.load(fromAvailable: defaults).localeId,
sessionExpiresAt: expires,
hostGeneration: defaults.string(forKey: FlowSessionKeys.hostGeneration)
)
if let data = encode(snapshot) {
defaults.set(data, forKey: FlowSessionKeys.flowReadyPayload)
}
} else {
defaults.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
}
flush(defaults)
}
public static func markSessionInactive(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
store.set(false, forKey: FlowSessionKeys.flowSessionActive)
@@ -372,6 +419,7 @@ public enum FlowSessionBridge {
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
guard FlowSessionPolicy.usesInactivityExpiry(defaults: store) else { return }
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store)
let expires = Date().timeIntervalSince1970 + resolvedDuration
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
@@ -382,6 +430,7 @@ public enum FlowSessionBridge {
/// Resets the inactivity timer after utterance completion or explicit activity.
public static func touchLastActivity(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
guard FlowSessionPolicy.usesInactivityExpiry(defaults: store) else { return }
let now = Date().timeIntervalSince1970
let duration = FlowSessionPolicy.sessionDuration(defaults: store)
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
@@ -419,6 +468,10 @@ public enum FlowSessionBridge {
let store = resolvedDefaults(defaults)
guard store.bool(forKey: FlowSessionKeys.flowSessionActive) else { return false }
if !FlowSessionPolicy.usesInactivityExpiry(defaults: store) {
return true
}
let expires = store.double(forKey: FlowSessionKeys.flowSessionExpires)
return expires > Date().timeIntervalSince1970
}
@@ -25,6 +25,18 @@ public enum FlowSessionPolicy {
inactivityDuration(defaults: defaults).timeInterval
}
public static func keepAliveMode(defaults: UserDefaults? = nil) -> FlowKeepAliveMode {
let store = resolvedDefaults(defaults)
return FlowKeepAliveMode.fromStored(
store.string(forKey: AppGroupConfiguration.Keys.flowKeepAliveMode)
)
}
/// PiP sessions have no inactivity expiry; only the Live Activity path times out.
public static func usesInactivityExpiry(defaults: UserDefaults? = nil) -> Bool {
keepAliveMode(defaults: defaults) == .liveActivity
}
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
if let defaults { return defaults }
guard let available = AppGroup.defaultsIfAvailable else {