refactor(flow): remove Live Activity and keep-alive mode picker

Keep voice sessions on silent low-profile PiP only, and fix custom polish style editor presentation via sheet(item:).
This commit is contained in:
Rocky
2026-08-07 11:32:31 +08:00
parent d237318bf3
commit 41b8db87b5
44 changed files with 276 additions and 1997 deletions
@@ -34,22 +34,9 @@ final class AppGroupConfigurationTests: XCTestCase {
XCTAssertEqual(config.polishIntensity, .light)
XCTAssertTrue(config.personalDictionary.entries.isEmpty)
XCTAssertTrue(config.flowSkipAppSwitch)
XCTAssertEqual(config.flowKeepAliveMode, .pictureInPicture)
XCTAssertEqual(config.flowInactivityDuration, .fiveMinutes)
}
func testLoadPreservesStoredLiveActivityKeepAliveMode() {
let defaults = makeDefaults()
defaults.set(
FlowKeepAliveMode.liveActivity.rawValue,
forKey: AppGroupConfiguration.Keys.flowKeepAliveMode
)
let config = AppGroupConfiguration.load(fromAvailable: defaults)
XCTAssertEqual(config.flowKeepAliveMode, .liveActivity)
}
func testSaveAndLoadRoundTrip() {
let defaults = makeDefaults()
var config = AppGroupConfiguration.load(fromAvailable: defaults)
@@ -73,7 +60,6 @@ final class AppGroupConfigurationTests: XCTestCase {
config.keyboardHapticIntensity = .strong
config.polishIntensity = .heavy
config.flowSkipAppSwitch = false
config.flowKeepAliveMode = .pictureInPicture
// Use a non-default value so the round-trip actually proves persistence.
config.flowInactivityDuration = .threeHours
config.save(to: defaults)
@@ -98,7 +84,6 @@ final class AppGroupConfigurationTests: XCTestCase {
XCTAssertEqual(loaded.keyboardHapticIntensity, .strong)
XCTAssertEqual(loaded.polishIntensity, .heavy)
XCTAssertFalse(loaded.flowSkipAppSwitch)
XCTAssertEqual(loaded.flowKeepAliveMode, .pictureInPicture)
XCTAssertEqual(loaded.flowInactivityDuration, .threeHours)
}
@@ -294,7 +294,7 @@ final class FlowHandoffPolicyTests: XCTestCase {
defaults.removePersistentDomain(forName: suite)
let sessionId = UUID()
FlowSessionBridge.markSessionActive(duration: 1_800, sessionId: sessionId, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId, defaults: defaults)
FlowSessionBridge.writeReadySnapshot(
FlowReadySnapshot(
sessionId: sessionId,
@@ -303,7 +303,7 @@ final class FlowHandoffPolicyTests: XCTestCase {
engineMode: "local",
localeId: "zh-Hans",
busyUtteranceId: UUID(),
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(defaults: defaults),
sessionExpiresAt: nil,
hostGeneration: FlowSessionBridge.currentHostGeneration(defaults: defaults)
),
defaults: defaults
+23 -35
View File
@@ -15,7 +15,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testSessionActiveSurvivesStaleHeartbeatWhileNotExpired() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(duration: 60, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
@@ -28,7 +28,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testHostStaleWhenHeartbeatVeryOld() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
@@ -39,7 +39,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testClearIfHostStaleRemovesZombieSession() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setRecordingState(.stopped, defaults: defaults)
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
@@ -59,18 +59,13 @@ final class FlowSessionBridgeTests: XCTestCase {
XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults))
}
func testSessionInactiveWhenExpired() {
func testPersistentSessionIgnoresLegacyExpiry() {
let defaults = makeDefaults()
// Expiry only applies on the Live Activity keep-alive path (PiP is persistent).
defaults.set(
FlowKeepAliveMode.liveActivity.rawValue,
forKey: AppGroupConfiguration.Keys.flowKeepAliveMode
)
FlowSessionBridge.markSessionActive(duration: 1, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let expired = Date().timeIntervalSince1970 - 5
defaults.set(expired, forKey: FlowSessionKeys.flowSessionExpires)
XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isHostReachable(defaults: defaults))
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
}
func testRecordingStateRoundTrip() {
@@ -104,7 +99,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testClearFlowStateRemovesSessionKeys() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.storeTranscriptionResult("x", defaults: defaults)
FlowSessionBridge.clearFlowState(defaults: defaults)
@@ -113,18 +108,11 @@ final class FlowSessionBridgeTests: XCTestCase {
XCTAssertEqual(FlowSessionBridge.recordingState(defaults: defaults), .idle)
}
func testRemainingSessionDurationNilWhenExpired() {
func testPersistentActivationClearsLegacyExpiry() {
let defaults = makeDefaults()
defaults.set(
FlowKeepAliveMode.liveActivity.rawValue,
forKey: AppGroupConfiguration.Keys.flowKeepAliveMode
)
FlowSessionBridge.markSessionActive(duration: 1, defaults: defaults)
XCTAssertNotNil(FlowSessionBridge.remainingSessionDuration(defaults: defaults))
let expired = Date().timeIntervalSince1970 - 5
defaults.set(expired, forKey: FlowSessionKeys.flowSessionExpires)
XCTAssertNil(FlowSessionBridge.remainingSessionDuration(defaults: defaults))
defaults.set(Date().timeIntervalSince1970 + 60, forKey: FlowSessionKeys.flowSessionExpires)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
XCTAssertNil(defaults.object(forKey: FlowSessionKeys.flowSessionExpires))
}
func testConsumeTranscriptionErrorIncludesKind() {
@@ -156,7 +144,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testHostReadyRequiresExplicitContract() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(duration: 60, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
@@ -166,7 +154,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testHostReadyFalseWhenHeartbeatStale() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setHostReady(true, defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
@@ -177,7 +165,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testHeartbeatRefreshKeepsHostReadyPublished() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setHostReady(true, defaults: defaults)
FlowSessionBridge.writeHeartbeat(defaults: defaults)
@@ -187,7 +175,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testClearFlowStateClearsHostReady() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setHostReady(true, defaults: defaults)
FlowSessionBridge.clearFlowState(defaults: defaults)
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.flowHostReady))
@@ -305,7 +293,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testNotReadySnapshotDoesNotRefreshHeartbeat() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
@@ -328,7 +316,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testBusySnapshotStillRefreshesHeartbeat() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let staleHeartbeat = Date().timeIntervalSince1970 - 10
defaults.set(staleHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
@@ -359,7 +347,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testNotReadyStartingSnapshotIsRetainedWithoutRevivingHeartbeat() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
@@ -387,7 +375,7 @@ final class FlowSessionBridgeTests: XCTestCase {
let now = Date().timeIntervalSince1970
FlowSessionBridge.rotateHostGeneration(defaults: defaults)
let liveGeneration = FlowSessionBridge.currentHostGeneration(defaults: defaults)
FlowSessionBridge.markSessionActive(duration: 60, sessionId: sessionId, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId, defaults: defaults)
FlowSessionBridge.writeReadySnapshot(
FlowReadySnapshot(
sessionId: sessionId,
@@ -412,7 +400,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testClearFlowStateOnHostLaunchPreservesPendingHost() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setHostReady(true, defaults: defaults)
FlowSessionBridge.setPendingHostBundleId("com.example.host", defaults: defaults)
@@ -478,7 +466,7 @@ final class FlowSessionBridgeTests: XCTestCase {
let defaults = makeDefaults()
let sessionId = UUID()
let now = Date().timeIntervalSince1970
FlowSessionBridge.markSessionActive(duration: 60, sessionId: sessionId, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId, defaults: defaults)
let snapshot = FlowReadySnapshot(
sessionId: sessionId,
ready: true,
@@ -501,7 +489,7 @@ final class FlowSessionBridgeTests: XCTestCase {
let defaults = makeDefaults()
let sessionId = UUID()
let now = Date().timeIntervalSince1970
FlowSessionBridge.markSessionActive(duration: 60, sessionId: sessionId, defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId, defaults: defaults)
let skewed = FlowReadySnapshot(
sessionId: sessionId,
ready: true,
+6 -26
View File
@@ -29,40 +29,20 @@ final class FlowSessionPolicyTests: XCTestCase {
XCTAssertEqual(FlowInactivityDuration.tenMinutes.timeInterval, 10 * 60)
}
func testKeepAliveModeDefaultsToPictureInPicture() {
let defaults = makeDefaults()
XCTAssertEqual(FlowSessionPolicy.keepAliveMode(defaults: defaults), .pictureInPicture)
XCTAssertFalse(FlowSessionPolicy.usesInactivityExpiry(defaults: defaults))
}
func testPiPSessionHasNoInactivityExpiry() {
let defaults = makeDefaults()
defaults.set(FlowKeepAliveMode.pictureInPicture.rawValue,
forKey: AppGroupConfiguration.Keys.flowKeepAliveMode)
FlowSessionBridge.markSessionActive(sessionId: UUID(), defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(sessionId: UUID(), defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertNil(FlowSessionBridge.sessionExpiresAt(defaults: defaults))
FlowSessionBridge.touchLastActivity(defaults: defaults)
XCTAssertNil(FlowSessionBridge.sessionExpiresAt(defaults: defaults))
XCTAssertNil(defaults.object(forKey: FlowSessionKeys.flowSessionExpires))
}
func testTouchLastActivityExtendsExpiry() {
func testLegacyExpiryDoesNotInvalidatePersistentSession() {
let defaults = makeDefaults()
defaults.set(
FlowKeepAliveMode.liveActivity.rawValue,
forKey: AppGroupConfiguration.Keys.flowKeepAliveMode
)
defaults.set(FlowInactivityDuration.tenMinutes.rawValue, forKey: AppGroupConfiguration.Keys.flowInactivityDuration)
FlowSessionBridge.markSessionActive(defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
defaults.set(Date().timeIntervalSince1970 - 30, forKey: FlowSessionKeys.flowSessionExpires)
let staleExpiry = Date().timeIntervalSince1970 + 30
defaults.set(staleExpiry, forKey: FlowSessionKeys.flowSessionExpires)
FlowSessionBridge.touchLastActivity(defaults: defaults)
let refreshed = FlowSessionBridge.sessionExpiresAt(defaults: defaults) ?? 0
XCTAssertGreaterThan(refreshed, staleExpiry)
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
}
func testPendingHostBundleIdRoundTrip() {
+25 -2
View File
@@ -64,7 +64,6 @@ final class SettingsCloudSyncTests: XCTestCase {
activePolishStyleId: SyncedField(value: "builtin.light", updatedAt: stampA, deviceID: deviceA),
llmThinkingEnabled: SyncedField(value: false, updatedAt: stampA, deviceID: deviceA),
flowSkipAppSwitch: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
flowKeepAliveMode: SyncedField(value: .liveActivity, updatedAt: stampA, deviceID: deviceA),
flowInactivityDuration: SyncedField(value: .twelveHours, updatedAt: stampA, deviceID: deviceA)
)
let remote = SyncedAppSettingsV2(
@@ -87,7 +86,6 @@ final class SettingsCloudSyncTests: XCTestCase {
activePolishStyleId: SyncedField(value: "builtin.formal", updatedAt: stampB, deviceID: deviceB),
llmThinkingEnabled: SyncedField(value: true, updatedAt: stampB, deviceID: deviceB),
flowSkipAppSwitch: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
flowKeepAliveMode: SyncedField(value: .pictureInPicture, updatedAt: stampB, deviceID: deviceB),
flowInactivityDuration: SyncedField(value: .threeHours, updatedAt: stampB, deviceID: deviceB)
)
@@ -100,6 +98,31 @@ final class SettingsCloudSyncTests: XCTestCase {
XCTAssertEqual(merged.polishIntensity.value, .heavy)
}
func testLegacyKeepAliveFieldDecodesButIsNotReencoded() throws {
let payload = SyncedAppSettingsV2.seeded(
from: AppGroupConfiguration.load(fromAvailable: defaults),
deviceID: deviceA,
updatedAt: Date(timeIntervalSince1970: 100)
)
let encoder = JSONEncoder()
var object = try XCTUnwrap(
JSONSerialization.jsonObject(with: encoder.encode(payload)) as? [String: Any]
)
object["flowKeepAliveMode"] = [
"value": "liveActivity",
"updatedAt": 0,
"deviceID": "legacy-device",
]
let legacyData = try JSONSerialization.data(withJSONObject: object)
let decoded = try JSONDecoder().decode(SyncedAppSettingsV2.self, from: legacyData)
let reencodedObject = try XCTUnwrap(
JSONSerialization.jsonObject(with: encoder.encode(decoded)) as? [String: Any]
)
XCTAssertNil(reencodedObject["flowKeepAliveMode"])
}
func testLegacyV1PullDoesNotClearKeychain() async throws {
try Keychain.setAPIKey("sk-local-openai", for: "openai", useICloudSync: false)
store.setSettingsICloudSyncEnabled(true)