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