feat: iCloud settings sync and cold-start return redesign
- Add iCloud key-value settings sync (engine/language/polish/Flow prefs); API keys stay on-device. New "Sync settings via iCloud" toggle. - Redesign cold-start handoff: bottom-bar left-to-right swipe guidance, auto-dismiss on app switch, tap-anywhere to close, retained return link. - Harden keyboard->app handoff with host-disconnected hint. - Include prior Unreleased ASR fixes (route-change crash, fallback warning, multi-utterance recognition, local ASR diagnostics). Release 0.5.0 (build 18).
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
// FakeUbiquitousKeyValueStore.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// In-memory KVS fake for hermetic iCloud sync tests.
|
||||
|
||||
import Foundation
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class FakeUbiquitousKeyValueStore: UbiquitousKeyValueStoreing, @unchecked Sendable {
|
||||
private var storage: [String: Any] = [:]
|
||||
|
||||
func data(forKey key: String) -> Data? {
|
||||
storage[key] as? Data
|
||||
}
|
||||
|
||||
func set(_ value: Data?, forKey key: String) {
|
||||
if let value {
|
||||
storage[key] = value
|
||||
} else {
|
||||
storage.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func object(forKey key: String) -> Any? {
|
||||
storage[key]
|
||||
}
|
||||
|
||||
func set(_ value: Any?, forKey key: String) {
|
||||
if let value {
|
||||
storage[key] = value
|
||||
} else {
|
||||
storage.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func synchronize() -> Bool { true }
|
||||
}
|
||||
@@ -23,6 +23,40 @@ final class FlowSessionBridgeTests: XCTestCase {
|
||||
defaults.set(staleHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
|
||||
XCTAssertFalse(FlowSessionBridge.isHostReachable(defaults: defaults))
|
||||
XCTAssertFalse(FlowSessionBridge.isHostStale(defaults: defaults))
|
||||
}
|
||||
|
||||
func testHostStaleWhenHeartbeatVeryOld() {
|
||||
let defaults = makeDefaults()
|
||||
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
|
||||
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
|
||||
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
|
||||
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
|
||||
XCTAssertFalse(FlowSessionBridge.isHostReachable(defaults: defaults))
|
||||
XCTAssertTrue(FlowSessionBridge.isHostStale(defaults: defaults))
|
||||
}
|
||||
|
||||
func testClearIfHostStaleRemovesZombieSession() {
|
||||
let defaults = makeDefaults()
|
||||
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
|
||||
FlowSessionBridge.setRecordingState(.stopped, defaults: defaults)
|
||||
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
|
||||
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
|
||||
XCTAssertTrue(FlowSessionBridge.clearIfHostStale(defaults: defaults))
|
||||
XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults))
|
||||
XCTAssertEqual(FlowSessionBridge.recordingState(defaults: defaults), .idle)
|
||||
}
|
||||
|
||||
func testHostStaleWhenSessionActiveButNoHeartbeat() {
|
||||
let defaults = makeDefaults()
|
||||
defaults.set(true, forKey: FlowSessionKeys.flowSessionActive)
|
||||
defaults.set(Date().timeIntervalSince1970 + 3_600, forKey: FlowSessionKeys.flowSessionExpires)
|
||||
|
||||
XCTAssertTrue(FlowSessionBridge.isHostStale(defaults: defaults))
|
||||
XCTAssertTrue(FlowSessionBridge.clearIfHostStale(defaults: defaults))
|
||||
XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults))
|
||||
}
|
||||
|
||||
func testSessionInactiveWhenExpired() {
|
||||
|
||||
@@ -326,6 +326,32 @@ final class IntelligentPolishTests: XCTestCase {
|
||||
XCTAssertEqual(output, "this is a broken sentence")
|
||||
}
|
||||
|
||||
func testCleanRawASRFallbackRemovesChineseInteriorSpaces() {
|
||||
let input = " 你 是不是 已经 解决了 这个 问题 ? "
|
||||
let output = TranscriptPostProcessor.cleanRawASRFallback(input)
|
||||
XCTAssertEqual(output, "你是不是已经解决了这个问题?")
|
||||
}
|
||||
|
||||
func testCleanRawASRFallbackPreservesEnglishAndMixedSpaces() {
|
||||
let input = "iOS 版本 uses Swift UI"
|
||||
let output = TranscriptPostProcessor.cleanRawASRFallback(input)
|
||||
XCTAssertEqual(output, "iOS 版本 uses Swift UI")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testFlowFallbackDeliveryCleansTextAndCarriesWeakNetworkWarning() {
|
||||
let delivery = FlowSessionManager.makeFallbackDelivery(
|
||||
rawText: " 你 是不是 已经 解决了 这个 问题 ? ",
|
||||
error: LLMError.transport("offline"),
|
||||
engineMode: "cloud",
|
||||
chunkWarning: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(delivery.text, "你是不是已经解决了这个问题?")
|
||||
XCTAssertFalse(delivery.text.contains("未润色"))
|
||||
XCTAssertEqual(delivery.polishWarning, SharedL10n.string("flow.warning.polishDegraded"))
|
||||
}
|
||||
|
||||
func testHasStructureSignalDetectsChineseEnumeration() {
|
||||
XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "首先测试其次上线"))
|
||||
XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "第一点修复"))
|
||||
|
||||
@@ -8,23 +8,7 @@ import XCTest
|
||||
|
||||
// MARK: - Fake KVS
|
||||
|
||||
private final class FakeUbiquitousKeyValueStore: UbiquitousKeyValueStoreing, @unchecked Sendable {
|
||||
private var storage: [String: Data] = [:]
|
||||
|
||||
func data(forKey key: String) -> Data? {
|
||||
storage[key]
|
||||
}
|
||||
|
||||
func set(_ value: Data?, forKey key: String) {
|
||||
if let value {
|
||||
storage[key] = value
|
||||
} else {
|
||||
storage.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func synchronize() -> Bool { true }
|
||||
}
|
||||
// See FakeUbiquitousKeyValueStore.swift
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@@ -170,6 +154,7 @@ final class PersonalDictionaryCloudSyncTests: XCTestCase {
|
||||
try await sync.enableSync()
|
||||
|
||||
XCTAssertTrue(store.personalDictionaryICloudSyncEnabled)
|
||||
XCTAssertEqual(kvs.object(forKey: ICloudSyncPreferences.dictionaryEnabledKey) as? Bool, true)
|
||||
XCTAssertEqual(Set(store.personalDictionary.entries.map(\.term)), Set(["LocalTerm", "RemoteTerm"]))
|
||||
XCTAssertNotNil(sync.loadRemote())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// SettingsCloudSyncTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Hermetic tests for iCloud KVS settings sync + preference toggles.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
final class SettingsCloudSyncTests: XCTestCase {
|
||||
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
private var store: AppGroupStore!
|
||||
private var kvs: FakeUbiquitousKeyValueStore!
|
||||
private var settingsSync: SettingsCloudSync!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "group.com.osgkeyboard.shared.tests.settings.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
store = AppGroupStore(defaults: defaults)
|
||||
kvs = FakeUbiquitousKeyValueStore()
|
||||
settingsSync = SettingsCloudSync(kvs: kvs) { [unowned self] in store }
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testMergePrefersNewerUpdatedAt() {
|
||||
let older = SyncedAppSettings(
|
||||
updatedAt: Date(timeIntervalSince1970: 100),
|
||||
providerId: "openai",
|
||||
baseURL: "https://old.example",
|
||||
model: "gpt-old",
|
||||
modeId: "polish",
|
||||
localeId: "auto",
|
||||
engineMode: "cloud",
|
||||
hasAcknowledgedCloudSharing: false,
|
||||
uiLanguage: .english,
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
handednessPreference: .left,
|
||||
cursorDragNavigationEnabled: true,
|
||||
polishIntensity: .medium,
|
||||
flowSkipAppSwitch: true,
|
||||
flowInactivityDuration: .twelveHours
|
||||
)
|
||||
let newer = SyncedAppSettings(
|
||||
updatedAt: Date(timeIntervalSince1970: 200),
|
||||
providerId: "openai",
|
||||
baseURL: "https://new.example",
|
||||
model: "gpt-new",
|
||||
modeId: "polish",
|
||||
localeId: "zh-Hans",
|
||||
engineMode: "local",
|
||||
hasAcknowledgedCloudSharing: true,
|
||||
uiLanguage: .chinese,
|
||||
translationTargetLocaleId: "en",
|
||||
handednessPreference: .right,
|
||||
cursorDragNavigationEnabled: false,
|
||||
polishIntensity: .light,
|
||||
flowSkipAppSwitch: false,
|
||||
flowInactivityDuration: .threeHours
|
||||
)
|
||||
|
||||
let merged = SyncedAppSettings.merge(local: older, remote: newer)
|
||||
XCTAssertEqual(merged.model, "gpt-new")
|
||||
XCTAssertEqual(merged.localeId, "zh-Hans")
|
||||
XCTAssertEqual(merged.engineMode, "local")
|
||||
}
|
||||
|
||||
func testEnableSyncUploadsMergedSettingsAndToggle() async throws {
|
||||
store.setModeId("polish")
|
||||
store.setLocaleId("zh-Hans")
|
||||
|
||||
let remote = SyncedAppSettings(
|
||||
updatedAt: Date().addingTimeInterval(3600),
|
||||
providerId: "openai",
|
||||
baseURL: "https://remote.example",
|
||||
model: "remote-model",
|
||||
modeId: "polish",
|
||||
localeId: "en",
|
||||
engineMode: "cloud",
|
||||
hasAcknowledgedCloudSharing: true,
|
||||
uiLanguage: .english,
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
handednessPreference: .right,
|
||||
cursorDragNavigationEnabled: true,
|
||||
polishIntensity: .medium,
|
||||
flowSkipAppSwitch: true,
|
||||
flowInactivityDuration: .twelveHours
|
||||
)
|
||||
try settingsSync.push(remote)
|
||||
|
||||
try await settingsSync.enableSync()
|
||||
|
||||
XCTAssertTrue(store.settingsICloudSyncEnabled)
|
||||
XCTAssertEqual(kvs.object(forKey: ICloudSyncPreferences.settingsEnabledKey) as? Bool, true)
|
||||
XCTAssertEqual(settingsSync.loadRemote()?.localeId, "en")
|
||||
}
|
||||
|
||||
func testPullAndMergeAppliesRemoteSettingsToAppGroup() async throws {
|
||||
store.setSettingsICloudSyncEnabled(true)
|
||||
store.setLocaleId("auto")
|
||||
|
||||
let remote = SyncedAppSettings(
|
||||
updatedAt: Date(timeIntervalSince1970: 900),
|
||||
providerId: "openai",
|
||||
baseURL: "https://remote.example",
|
||||
model: "remote-model",
|
||||
modeId: "polish",
|
||||
localeId: "ja",
|
||||
engineMode: "cloud",
|
||||
hasAcknowledgedCloudSharing: false,
|
||||
uiLanguage: .english,
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
handednessPreference: .left,
|
||||
cursorDragNavigationEnabled: true,
|
||||
polishIntensity: .medium,
|
||||
flowSkipAppSwitch: true,
|
||||
flowInactivityDuration: .twelveHours
|
||||
)
|
||||
try settingsSync.push(remote)
|
||||
|
||||
await settingsSync.pullAndMerge(store: store)
|
||||
|
||||
XCTAssertEqual(store.localeId, "ja")
|
||||
XCTAssertEqual(store.settingsCloudUpdatedAt?.timeIntervalSince1970, 900, accuracy: 1)
|
||||
}
|
||||
|
||||
func testPushLocalIfEnabledSkipsWhenDisabled() async throws {
|
||||
store.setSettingsICloudSyncEnabled(false)
|
||||
store.setLocaleId("ko")
|
||||
|
||||
try await settingsSync.pushLocalIfEnabled()
|
||||
|
||||
XCTAssertNil(settingsSync.loadRemote())
|
||||
}
|
||||
|
||||
func testICloudSyncPreferencesMigrateLegacyToggles() {
|
||||
store.setSettingsICloudSyncEnabled(false)
|
||||
store.setPersonalDictionaryICloudSyncEnabled(true)
|
||||
|
||||
ICloudSyncPreferences.migrateLegacyTogglesIfNeeded(kvs: kvs, store: store)
|
||||
|
||||
XCTAssertEqual(kvs.object(forKey: ICloudSyncPreferences.settingsEnabledKey) as? Bool, false)
|
||||
XCTAssertEqual(kvs.object(forKey: ICloudSyncPreferences.dictionaryEnabledKey) as? Bool, true)
|
||||
}
|
||||
|
||||
func testAppGroupConfigurationDefaultsSettingsICloudSyncToOn() {
|
||||
let config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
XCTAssertTrue(config.settingsICloudSyncEnabled)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user