From 13cd7b30f954549956ca9e7d5f7baf6de0017cc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 07:38:26 +0000 Subject: [PATCH] feat(flow): implement ABCD session policy and host return whitelist - Scheme A: on-demand session start, inactivity-based expiry, handoff auto-recording - Scheme B: cold-start overlay with swipe guidance and return alert - Scheme C+D: HostAppURLRegistry (20 apps) and sourceApplication capture - Settings: skip app switch toggle and inactivity duration picker - Add LSApplicationQueriesSchemes for canOpenURL checks Co-authored-by: Rocky --- CHANGELOG.md | 4 + OSGKeyboard/Info.plist | 24 +++ OSGKeyboard/OSGKeyboardApp.swift | 2 + OSGKeyboard/Services/AppURLHandler.swift | 29 +++ OSGKeyboard/Services/FlowSessionManager.swift | 113 +++++++---- OSGKeyboard/Services/HostReturnService.swift | 46 +++++ OSGKeyboard/Views/FlowColdStartOverlay.swift | 111 +++++++++++ OSGKeyboard/Views/HomeView.swift | 3 - OSGKeyboard/Views/MainAppRoot.swift | 41 ++-- OSGKeyboard/Views/MainTabView.swift | 1 - OSGKeyboard/Views/SettingsView.swift | 64 ++++++ OSGKeyboard/en.lproj/Localizable.strings | 45 +++++ OSGKeyboard/zh-Hans.lproj/Localizable.strings | 45 +++++ .../Services/KeyboardFlowCoordinator.swift | 22 +-- .../Models/AppGroupConfiguration.swift | 21 +- .../Models/FlowInactivityDuration.swift | 46 +++++ OSGKeyboardShared/Models/ProviderConfig.swift | 21 ++ .../Services/FlowSessionBridge.swift | 46 ++++- .../Services/FlowSessionKeys.swift | 6 +- .../Services/FlowSessionPolicy.swift | 39 ++++ .../Services/HostAppURLRegistry.swift | 182 ++++++++++++++++++ .../AppGroupConfigurationTests.swift | 6 + OSGKeyboardTests/FlowSessionPolicyTests.swift | 46 +++++ .../HostAppURLRegistryTests.swift | 28 +++ project.yml | 22 +++ 25 files changed, 936 insertions(+), 77 deletions(-) create mode 100644 OSGKeyboard/Services/AppURLHandler.swift create mode 100644 OSGKeyboard/Services/HostReturnService.swift create mode 100644 OSGKeyboard/Views/FlowColdStartOverlay.swift create mode 100644 OSGKeyboardShared/Models/FlowInactivityDuration.swift create mode 100644 OSGKeyboardShared/Services/FlowSessionPolicy.swift create mode 100644 OSGKeyboardShared/Services/HostAppURLRegistry.swift create mode 100644 OSGKeyboardTests/FlowSessionPolicyTests.swift create mode 100644 OSGKeyboardTests/HostAppURLRegistryTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e29179..c41d914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Flow Live Activity**: Dynamic Island shows the OSGKeyboard brand mark during an active voice session (ActivityKit widget extension). / **Flow 灵动岛 Live Activity**:语音会话期间在灵动岛显示 OSGKeyboard 品牌标识(ActivityKit 小组件扩展)。 - **Xiaomi MiMo cloud provider**: preset for the cloud engine with `mimo-v2.5` polish via `api.xiaomimimo.com` (on-device ASR, same pipeline as other online providers). / **小米 MiMo 云端引擎**:云端引擎新增预设,经 `api.xiaomimimo.com` 使用 `mimo-v2.5` 润色(端侧 ASR,与其他在线服务相同管线)。 +- **Flow session policy (A)**: on-demand session start from the keyboard; inactivity-based expiry (10m–24h, default 12h) reset after each utterance; handoff auto-starts recording. / **Flow 会话策略(A)**:键盘按需启动会话;无活动超时(10 分钟–24 小时,默认 12 小时)每句结束后重置;交接完成后自动开始录音。 +- **Skip app switch (B+C)**: settings toggle (default on) plus cold-start overlay with swipe-back guidance and optional “Return to [App]” alert. / **跳过应用切换(B+C)**:设置开关(默认开)+ 冷启动极简页(右滑引导 + 可选「返回 [App]」弹窗)。 +- **Host return whitelist (C+D)**: `HostAppURLRegistry` with 20 high-frequency host apps; `sourceApplication` capture on `startflow`. / **宿主回跳白名单(C+D)**:`HostAppURLRegistry` 覆盖 20 个高频宿主 App;`startflow` 时记录 `sourceApplication`。 ### Changed +- **Keyboard language label**: `PrimaryLanguage` set to `mis` so Settings no longer shows a misleading “English” subtitle under OSGKeyboard. / **键盘语言标签**:`PrimaryLanguage` 设为 `mis`,系统设置中 OSGKeyboard 下不再显示误导性的「英文」副标题。 - **Flow ASR pipelining**: shorter first chunk (2.5s) and 5s follow-ups so short utterances start on-device recognition while still recording; session-level ASR warmup and format cache reuse; live partials mirrored to the keyboard transcript line. / **Flow ASR 流水线**:首块 2.5 秒、后续 5 秒,短句录音期间即开始端侧识别;会话级 ASR 预热与格式缓存复用;实时 partial 同步到键盘转写行。 ## [0.4.0] - 2026-07-05 diff --git a/OSGKeyboard/Info.plist b/OSGKeyboard/Info.plist index c16f9f1..40b99ff 100644 --- a/OSGKeyboard/Info.plist +++ b/OSGKeyboard/Info.plist @@ -34,6 +34,30 @@ + LSApplicationQueriesSchemes + + weixin + mqq + wxwork + dingtalk + lark + tg + whatsapp + line + fb-messenger + slack + msteams + discord + notion + bear + obsidian + drafts5 + googlegmail + ms-outlook + googlechrome + sinaweibo + xhsdiscover + CFBundleVersion $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption diff --git a/OSGKeyboard/OSGKeyboardApp.swift b/OSGKeyboard/OSGKeyboardApp.swift index ee83ca1..c8521bc 100644 --- a/OSGKeyboard/OSGKeyboardApp.swift +++ b/OSGKeyboard/OSGKeyboardApp.swift @@ -6,6 +6,8 @@ import OSGKeyboardShared @main struct OSGKeyboardApp: App { + @UIApplicationDelegateAdaptor(AppURLHandler.self) private var appURLHandler + init() { MaterialIconsFont.registerIfNeeded() if AppGroup.isAvailable { diff --git a/OSGKeyboard/Services/AppURLHandler.swift b/OSGKeyboard/Services/AppURLHandler.swift new file mode 100644 index 0000000..7848f2d --- /dev/null +++ b/OSGKeyboard/Services/AppURLHandler.swift @@ -0,0 +1,29 @@ +// AppURLHandler.swift +// OSGKeyboard · Main App +// +// Captures `sourceApplication` from UIKit open-URL options (scheme D). + +import UIKit +import OSGKeyboardShared + +extension Notification.Name { + static let osgKeyboardOpenURL = Notification.Name("osgkeyboard.openURL") +} + +final class AppURLHandler: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + if let source = options[.sourceApplication] as? String { + FlowSessionBridge.setPendingHostBundleId(source) + } + NotificationCenter.default.post( + name: .osgKeyboardOpenURL, + object: nil, + userInfo: ["url": url] + ) + return true + } +} diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index cb52b7a..c2862e9 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -19,6 +19,8 @@ final class FlowSessionManager: ObservableObject { @Published private(set) var sessionExpiresAt: Date? /// Non-nil when continuous capture failed or permissions are missing. @Published private(set) var sessionWarning: String? + /// Cold-start handoff overlay state (scheme B). + @Published var coldStartContext: FlowColdStartContext? private let capture = FlowContinuousCapture() private let store = AppGroupStore() @@ -61,6 +63,8 @@ final class FlowSessionManager: ObservableObject { /// True while the host app scene is `.active` — drives foreground renewal. private var isAppForeground = false private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid + /// True while handling a keyboard-initiated `startflow` cold start. + private var isColdStartHandoff = false init() { Task { @MainActor [weak self] in @@ -71,42 +75,65 @@ final class FlowSessionManager: ObservableObject { // MARK: - Public /// Starts a Flow session: permissions → continuous capture → App Group active. - func startSession(duration: TimeInterval = FlowSessionKeys.defaultSessionDuration) { + func startSession(duration: TimeInterval? = nil, coldStart: Bool = false) { guard AppGroup.isAvailable else { debug("cannot start flow session: App Group unavailable") return } + if coldStart { + isColdStartHandoff = true + } + if isActive { extendSession(duration: duration) + if coldStart { + Task { @MainActor [weak self] in + self?.handleColdStartAfterSessionReady() + } + } return } startTask?.cancel() startTask = Task { @MainActor [weak self] in await self?.startSessionAsync(duration: duration) + self?.handleColdStartAfterSessionReady() } } - /// Called on launch / foreground when onboarding is complete. - func autoStartIfNeeded() { + /// Restores an in-flight session after relaunch; does not auto-start a new one. + func restoreSessionIfNeeded() { guard AppGroup.isAvailable else { return } guard !isActive, !isStarting else { return } - guard AppPermissions.flowRequirementsMet else { - sessionWarning = permissionWarningMessage() - return - } - let markedActive = AppGroup.defaults.bool(forKey: FlowSessionKeys.flowSessionActive) if markedActive, FlowSessionBridge.remainingSessionDuration() != nil { Task { await bootstrapFromStorageIfNeeded() } + } + } + + /// One-shot warmup after onboarding when permissions are already granted. + func warmupAfterOnboardingIfNeeded() { + guard AppGroup.isAvailable else { return } + guard !isActive, !isStarting else { return } + guard AppPermissions.flowRequirementsMet else { + sessionWarning = permissionWarningMessage() return } - startSession() } + func dismissColdStartOverlay() { + coldStartContext = nil + isColdStartHandoff = false + } + + func returnToPendingHostFromColdStart() { + _ = HostReturnService.openPendingHostIfPossible() + dismissColdStartOverlay() + } + /// Reattach capture when the host app was killed but the session has not expired. func bootstrapFromStorageIfNeeded() async { guard AppGroup.isAvailable, !isActive else { return } @@ -165,6 +192,7 @@ final class FlowSessionManager: ObservableObject { guard isActive else { return } debug("Flow session ended") + dismissColdStartOverlay() startTask?.cancel() startTask = nil pollingTask?.cancel() @@ -205,18 +233,16 @@ final class FlowSessionManager: ObservableObject { lastFinal = "" } - func extendSession(duration: TimeInterval = FlowSessionKeys.defaultSessionDuration) { - FlowSessionBridge.extendSession(by: duration) - sessionExpiresAt = Date().addingTimeInterval(duration) - scheduleExpiry(after: duration) + func extendSession(duration: TimeInterval? = nil) { + let resolved = duration ?? FlowSessionPolicy.sessionDuration() + FlowSessionBridge.extendSession(by: resolved) + sessionExpiresAt = Date().addingTimeInterval(resolved) + scheduleExpiry(after: resolved) } /// Called from `OSGKeyboardApp` when `scenePhase` changes. func setAppForeground(_ foreground: Bool) { isAppForeground = foreground - if foreground, isActive { - renewSessionIfNeededWhileForeground() - } } /// Full scene lifecycle — keeps Flow + ASR alive across app switches. @@ -294,25 +320,29 @@ final class FlowSessionManager: ObservableObject { } } - /// Extend the session before it expires while the host app stays in foreground. - private func renewSessionIfNeededWhileForeground() { - guard isActive, isAppForeground else { return } - guard let remaining = FlowSessionBridge.remainingSessionDuration() else { return } - let threshold = FlowSessionKeys.defaultSessionDuration * 0.25 - guard remaining < threshold else { return } - extendSession() - debug("Flow session renewed in foreground (\(Int(threshold))s threshold)") + /// Extend expiry after utterance completion based on the inactivity policy. + private func touchSessionActivity() { + guard isActive else { return } + FlowSessionBridge.touchLastActivity() + if let expires = FlowSessionBridge.sessionExpiresAt() { + sessionExpiresAt = Date(timeIntervalSince1970: expires) + let remaining = expires - Date().timeIntervalSince1970 + if remaining > 0 { + scheduleExpiry(after: remaining) + } + } } // MARK: - Session start - private func startSessionAsync(duration: TimeInterval) async { + private func startSessionAsync(duration: TimeInterval?) async { isStarting = true sessionWarning = nil defer { isStarting = false } guard AppPermissions.flowRequirementsMet else { sessionWarning = permissionWarningMessage() + isColdStartHandoff = false return } @@ -321,29 +351,46 @@ final class FlowSessionManager: ObservableObject { } catch { let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription sessionWarning = message + isColdStartHandoff = false debug("continuous capture failed: \(message)") return } - FlowSessionBridge.markSessionActive(duration: duration) + let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration() + FlowSessionBridge.markSessionActive(duration: resolvedDuration) FlowSessionDarwin.postSessionChanged() isActive = true ScreenWakeLock.acquire() - sessionExpiresAt = Date().addingTimeInterval(duration) + sessionExpiresAt = Date().addingTimeInterval(resolvedDuration) startHeartbeat() startPolling() startLevelPublishing() - scheduleExpiry(after: duration) + scheduleExpiry(after: resolvedDuration) - // v0.2.0: iOS `SpeechAnalyzer` needs no warm-up; just refresh - // the cached ASR service in case the user flipped engines - // while the session was idle. bindSessionASRIfNeeded() scheduleASRWarmup() FlowLiveActivityController.startSession() - debug("Flow session started (\(Int(duration))s), continuous capture running") + debug("Flow session started (\(Int(resolvedDuration))s inactivity window), continuous capture running") + } + + @MainActor + private func handleColdStartAfterSessionReady() { + guard isColdStartHandoff, isActive else { return } + + let hostEntry = HostReturnService.pendingHostEntry() + let skipSwitch = FlowSessionPolicy.skipAppSwitch() + + if skipSwitch, hostEntry != nil, HostReturnService.openPendingHostIfPossible() { + dismissColdStartOverlay() + return + } + + coldStartContext = FlowColdStartContext( + hostEntry: hostEntry, + showReturnAlert: hostEntry != nil + ) } private func bindSessionASRIfNeeded(force: Bool = false) { @@ -575,6 +622,7 @@ final class FlowSessionManager: ObservableObject { isUtteranceProcessing = false FlowSessionBridge.setRecordingState(.idle) FlowLiveActivityController.update(phase: .idle) + touchSessionActivity() } let asrWait = asrWaitTimeout() @@ -740,7 +788,6 @@ final class FlowSessionManager: ObservableObject { heartbeatTask = Task { @MainActor [weak self] in while !Task.isCancelled { FlowSessionBridge.writeHeartbeat() - self?.renewSessionIfNeededWhileForeground() try? await Task.sleep(nanoseconds: 1_000_000_000) guard self?.isActive == true else { break } } diff --git a/OSGKeyboard/Services/HostReturnService.swift b/OSGKeyboard/Services/HostReturnService.swift new file mode 100644 index 0000000..29da01c --- /dev/null +++ b/OSGKeyboard/Services/HostReturnService.swift @@ -0,0 +1,46 @@ +// HostReturnService.swift +// OSGKeyboard · Main App +// +// Opens a whitelisted host-app URL after a cold-start Flow handoff. + +import UIKit +import OSGKeyboardShared + +enum HostReturnService { + /// Attempts to return to the pending host app. Clears the pending bundle id on success. + @MainActor + static func openPendingHostIfPossible() -> Bool { + let bundleId = FlowSessionBridge.pendingHostBundleId() + guard let entry = HostAppURLRegistry.lookup(bundleId: bundleId), + let url = entry.returnURL else { + return false + } + guard UIApplication.shared.canOpenURL(url) else { + return false + } + UIApplication.shared.open(url, options: [:]) { success in + if success { + FlowSessionBridge.clearPendingHostBundleId() + } + } + return true + } + + @MainActor + static func openHost(entry: HostAppEntry) -> Bool { + guard let url = entry.returnURL, UIApplication.shared.canOpenURL(url) else { + return false + } + UIApplication.shared.open(url, options: [:]) { success in + if success { + FlowSessionBridge.clearPendingHostBundleId() + } + } + return true + } + + @MainActor + static func pendingHostEntry() -> HostAppEntry? { + HostAppURLRegistry.lookup(bundleId: FlowSessionBridge.pendingHostBundleId()) + } +} diff --git a/OSGKeyboard/Views/FlowColdStartOverlay.swift b/OSGKeyboard/Views/FlowColdStartOverlay.swift new file mode 100644 index 0000000..1e9ea77 --- /dev/null +++ b/OSGKeyboard/Views/FlowColdStartOverlay.swift @@ -0,0 +1,111 @@ +// FlowColdStartOverlay.swift +// OSGKeyboard · Main App +// +// Minimal cold-start handoff UI: swipe-back guidance and optional return alert. + +import SwiftUI +import OSGKeyboardShared + +struct FlowColdStartContext: Equatable { + let hostEntry: HostAppEntry? + var showReturnAlert: Bool +} + +struct FlowColdStartOverlay: View { + @Environment(\.themePalette) private var palette: ThemePalette + + let context: FlowColdStartContext + let onReturnToHost: () -> Void + let onDismiss: () -> Void + + @State private var showAlert: Bool + + init( + context: FlowColdStartContext, + onReturnToHost: @escaping () -> Void, + onDismiss: @escaping () -> Void + ) { + self.context = context + self.onReturnToHost = onReturnToHost + self.onDismiss = onDismiss + _showAlert = State(initialValue: context.showReturnAlert) + } + + var body: some View { + ZStack { + palette.background.opacity(0.96) + .ignoresSafeArea() + + VStack(spacing: Spacing.xl) { + Image(systemName: "waveform.circle.fill") + .font(.system(size: 56)) + .foregroundStyle(palette.accent) + .accessibilityHidden(true) + + Text("flow.coldStart.title") + .font(TypeStyle.title3) + .foregroundStyle(palette.textPrimary) + .multilineTextAlignment(.center) + + Text(swipeHintKey) + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, Spacing.lg) + + swipeHintAnimation + .padding(.top, Spacing.md) + + Button(action: onDismiss) { + Text("flow.coldStart.dismiss") + .font(TypeStyle.body.weight(.semibold)) + .foregroundStyle(palette.accent) + .frame(maxWidth: .infinity) + .padding(.vertical, Spacing.md) + } + .buttonStyle(.plain) + .padding(.horizontal, Spacing.xl) + .padding(.top, Spacing.lg) + } + .padding(Spacing.xl) + } + .alert(alertTitle, isPresented: $showAlert) { + if context.hostEntry != nil { + Button(returnButtonTitle, action: onReturnToHost) + } + Button("flow.coldStart.dismiss", role: .cancel, action: onDismiss) + } message: { + Text("flow.coldStart.alert.message") + } + } + + private var swipeHintKey: LocalizedStringKey { + context.hostEntry == nil + ? "flow.coldStart.swipeHint" + : "flow.coldStart.swipeHint.withSystemBack" + } + + private var alertTitle: String { + AppL10n.string("flow.coldStart.alert.title") + } + + private var returnButtonTitle: String { + guard let entry = context.hostEntry else { + return AppL10n.string("flow.coldStart.return.generic") + } + let appName = AppL10n.string(entry.displayNameKey) + return AppL10n.format("flow.coldStart.return.named", appName) + } + + private var swipeHintAnimation: some View { + VStack(spacing: Spacing.sm) { + Image(systemName: "chevron.up") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(palette.textTertiary.opacity(0.5)) + .frame(width: 120, height: 5) + } + .accessibilityLabel(AppL10n.string("flow.coldStart.swipeAccessibility")) + } +} diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index bf7cc48..52e673f 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -106,9 +106,6 @@ struct HomeView: View { private func refreshPermissionStatuses() { micStatus = AppPermissions.micStatus speechStatus = AppPermissions.speechStatus - if AppPermissions.flowRequirementsMet { - flowManager.autoStartIfNeeded() - } } private func handlePermissionGuidanceAction() { diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift index 2a701f7..74d4761 100644 --- a/OSGKeyboard/Views/MainAppRoot.swift +++ b/OSGKeyboard/Views/MainAppRoot.swift @@ -24,31 +24,44 @@ struct MainAppRoot: View { } .environment(\.locale, config.uiLanguage.swiftUILocale) .environmentObject(flowManager) + .overlay { + if let context = flowManager.coldStartContext { + FlowColdStartOverlay( + context: context, + onReturnToHost: { flowManager.returnToPendingHostFromColdStart() }, + onDismiss: { flowManager.dismissColdStartOverlay() } + ) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.2), value: flowManager.coldStartContext != nil) .onAppear { flowManager.setAppForeground(scenePhase == .active) + flowManager.restoreSessionIfNeeded() } - .onOpenURL { url in - guard url.scheme == "osgkeyboard" else { return } - switch url.host { - case "startflow": - flowManager.startSession() - default: - break - } + .onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in + guard let url = notification.userInfo?["url"] as? URL else { return } + handleIncomingURL(url) } .onChange(of: config.hasCompletedOnboarding) { _, done in if done { - flowManager.autoStartIfNeeded() + flowManager.warmupAfterOnboardingIfNeeded() } } .onChange(of: scenePhase) { _, phase in flowManager.handleScenePhase(phase) guard phase == .active, config.hasCompletedOnboarding else { return } - if flowManager.isActive { - flowManager.extendSession() - } else { - flowManager.autoStartIfNeeded() - } + flowManager.restoreSessionIfNeeded() + } + } + + private func handleIncomingURL(_ url: URL) { + guard url.scheme == "osgkeyboard" else { return } + switch url.host { + case "startflow": + flowManager.startSession(coldStart: true) + default: + break } } } diff --git a/OSGKeyboard/Views/MainTabView.swift b/OSGKeyboard/Views/MainTabView.swift index d4abdb8..9fdfe3e 100644 --- a/OSGKeyboard/Views/MainTabView.swift +++ b/OSGKeyboard/Views/MainTabView.swift @@ -48,6 +48,5 @@ struct MainTabView: View { // Keep home card/input/tab layout fixed when system keyboard appears. // Let the keyboard overlay the content instead of pushing it. .ignoresSafeArea(.keyboard, edges: .bottom) - .onAppear { flowManager.autoStartIfNeeded() } } } diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 6c7cca4..ae10183 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -40,6 +40,7 @@ struct SettingsView: View { ScrollView { VStack(spacing: Spacing.md) { languageAndPolishSection + flowSessionSection engineSection // v0.2.1: hide provider/api card when the // local engine is active regardless of the @@ -101,6 +102,44 @@ struct SettingsView: View { } } + // MARK: - Flow session + + private var flowSessionSection: some View { + VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { + sectionHeader("settings.flow.title") + VStack(spacing: 0) { + Toggle(isOn: $config.flowSkipAppSwitch) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text("settings.flow.skipAppSwitch.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Text("settings.flow.skipAppSwitch.subtitle") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + } + } + .tint(palette.accent) + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + + Divider().background(palette.divider) + + FlowInactivityPickerRow( + selection: Binding( + get: { config.flowInactivityDuration }, + set: { config.flowInactivityDuration = $0 } + ) + ) + } + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + } + // MARK: - Engine private var engineSection: some View { @@ -434,6 +473,31 @@ private struct AppLanguagePickerRow: View { } } +// MARK: - Flow inactivity picker row + +private struct FlowInactivityPickerRow: View { + @Binding var selection: FlowInactivityDuration + + private var options: [(id: String, label: String)] { + FlowInactivityDuration.allCases.map { duration in + (duration.rawValue, AppL10n.string(duration.labelKey)) + } + } + + var body: some View { + PickerRow( + title: AppL10n.string("settings.flow.inactivity.title"), + options: options, + selection: Binding( + get: { selection.rawValue }, + set: { newValue in + selection = FlowInactivityDuration(rawValue: newValue) ?? .default + } + ) + ) + } +} + // MARK: - Handedness picker row private struct HandednessPickerRow: View { diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 59b646b..2ff56f1 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -360,3 +360,48 @@ /* v0.3.0: Polish intensity */ "settings.polishIntensity.title" = "Polish intensity"; + +/* Flow session policy */ +"settings.flow.title" = "Voice session"; +"settings.flow.skipAppSwitch.title" = "Skip app switch"; +"settings.flow.skipAppSwitch.subtitle" = "After a cold start, try to return to the app you came from."; +"settings.flow.inactivity.title" = "End session after inactivity"; +"settings.flow.inactivity.10m" = "10 minutes"; +"settings.flow.inactivity.30m" = "30 minutes"; +"settings.flow.inactivity.3h" = "3 hours"; +"settings.flow.inactivity.12h" = "12 hours"; +"settings.flow.inactivity.24h" = "24 hours"; + +/* Cold-start handoff (scheme B) */ +"flow.coldStart.title" = "Voice is ready"; +"flow.coldStart.swipeHint" = "Swipe up from the bottom edge to return to your previous app."; +"flow.coldStart.swipeHint.withSystemBack" = "Tap ‹ in the top-left or swipe up from the bottom to return."; +"flow.coldStart.dismiss" = "Got it"; +"flow.coldStart.swipeAccessibility" = "Swipe up from the bottom to return"; +"flow.coldStart.alert.title" = "Voice is ready"; +"flow.coldStart.alert.message" = "You can return to continue typing, or swipe up from the bottom."; +"flow.coldStart.return.named" = "Return to %@"; +"flow.coldStart.return.generic" = "Return to app"; + +/* Host app display names (scheme C whitelist) */ +"hostApp.wechat" = "WeChat"; +"hostApp.qq" = "QQ"; +"hostApp.wecom" = "WeCom"; +"hostApp.dingtalk" = "DingTalk"; +"hostApp.lark" = "Lark"; +"hostApp.telegram" = "Telegram"; +"hostApp.whatsapp" = "WhatsApp"; +"hostApp.line" = "LINE"; +"hostApp.messenger" = "Messenger"; +"hostApp.slack" = "Slack"; +"hostApp.teams" = "Microsoft Teams"; +"hostApp.discord" = "Discord"; +"hostApp.notion" = "Notion"; +"hostApp.bear" = "Bear"; +"hostApp.obsidian" = "Obsidian"; +"hostApp.drafts" = "Drafts"; +"hostApp.gmail" = "Gmail"; +"hostApp.outlook" = "Outlook"; +"hostApp.chrome" = "Chrome"; +"hostApp.weibo" = "Weibo"; +"hostApp.xiaohongshu" = "RED"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 1d67e36..2868061 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -359,3 +359,48 @@ /* v0.3.0: 润色档位 */ "settings.polishIntensity.title" = "润色档位"; + +/* Flow 会话策略 */ +"settings.flow.title" = "语音会话"; +"settings.flow.skipAppSwitch.title" = "跳过应用切换"; +"settings.flow.skipAppSwitch.subtitle" = "冷启动完成后,尝试自动返回来源 App。"; +"settings.flow.inactivity.title" = "无活动后结束会话"; +"settings.flow.inactivity.10m" = "10 分钟"; +"settings.flow.inactivity.30m" = "30 分钟"; +"settings.flow.inactivity.3h" = "3 小时"; +"settings.flow.inactivity.12h" = "12 小时"; +"settings.flow.inactivity.24h" = "24 小时"; + +/* 冷启动兜底(方案 B) */ +"flow.coldStart.title" = "语音已就绪"; +"flow.coldStart.swipeHint" = "从屏幕底部边缘向上滑动,返回上一个 App。"; +"flow.coldStart.swipeHint.withSystemBack" = "点左上角 ‹ 或从底部向上滑动返回。"; +"flow.coldStart.dismiss" = "知道了"; +"flow.coldStart.swipeAccessibility" = "从底部向上滑动返回"; +"flow.coldStart.alert.title" = "语音已就绪"; +"flow.coldStart.alert.message" = "可返回继续输入,或从底部向上滑动返回。"; +"flow.coldStart.return.named" = "返回%@"; +"flow.coldStart.return.generic" = "返回 App"; + +/* 宿主 App 显示名(方案 C 白名单) */ +"hostApp.wechat" = "微信"; +"hostApp.qq" = "QQ"; +"hostApp.wecom" = "企业微信"; +"hostApp.dingtalk" = "钉钉"; +"hostApp.lark" = "飞书"; +"hostApp.telegram" = "Telegram"; +"hostApp.whatsapp" = "WhatsApp"; +"hostApp.line" = "LINE"; +"hostApp.messenger" = "Messenger"; +"hostApp.slack" = "Slack"; +"hostApp.teams" = "Microsoft Teams"; +"hostApp.discord" = "Discord"; +"hostApp.notion" = "Notion"; +"hostApp.bear" = "Bear"; +"hostApp.obsidian" = "Obsidian"; +"hostApp.drafts" = "Drafts"; +"hostApp.gmail" = "Gmail"; +"hostApp.outlook" = "Outlook"; +"hostApp.chrome" = "Chrome"; +"hostApp.weibo" = "微博"; +"hostApp.xiaohongshu" = "小红书"; diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift index 6ea4fe8..833a568 100644 --- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift +++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift @@ -36,8 +36,6 @@ final class KeyboardFlowCoordinator { private var wasFlowSessionActive = false private var flowSessionMonitorTask: Task? private var isAwaitingFlowResult = false - private var lastFlowAutoStartAttempt: TimeInterval = 0 - private static let flowAutoStartCooldown: TimeInterval = 20 init( state: KeyboardState, @@ -95,10 +93,6 @@ final class KeyboardFlowCoordinator { } } wasFlowSessionActive = active - - if !active { - maybeAutoStartFlowSession() - } } func toggleRecording() { @@ -224,18 +218,6 @@ final class KeyboardFlowCoordinator { } } - private func maybeAutoStartFlowSession() { - guard !FlowSessionBridge.isSessionActive() else { return } - guard !isPendingFlowStart, !isFlowRecording, !isAwaitingFlowResult else { return } - guard hasFullAccess(), AppGroup.isAvailable else { return } - guard case .idle = state.phase else { return } - - let now = Date().timeIntervalSince1970 - guard now - lastFlowAutoStartAttempt >= Self.flowAutoStartCooldown else { return } - lastFlowAutoStartAttempt = now - beginFlowStart() - } - private func showFlowSessionExpiredHint() { let message = ExtL10n.string("keyboard.flow.sessionExpired") state.phase = .error(.flowSessionExpired, message: message) @@ -332,9 +314,9 @@ final class KeyboardFlowCoordinator { flowStartDeadline = 0 stopFlowWatchdog() state.lastTranscript = "" - state.phase = .idle refreshSessionState() - debug("completeFlowStartHandoff") + startFlowRecording() + debug("completeFlowStartHandoff → auto startFlowRecording") } private func startFlowLevelWatchdog() { diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift index 1c99efa..b3a01fe 100644 --- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift +++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift @@ -30,6 +30,10 @@ public struct AppGroupConfiguration: Sendable, Equatable { public static let detectedAppContext = "config.detectedAppContext" public static let detectedAppContextAt = "config.detectedAppContextAt" public static let personalDictionary = "config.personalDictionary.v1" + /// When true, the host app auto-returns to the source app after a cold-start handoff. + public static let flowSkipAppSwitch = "config.flowSkipAppSwitch" + /// Raw `FlowInactivityDuration` value; session expires after this idle window. + public static let flowInactivityDuration = "config.flowInactivityDuration" } // MARK: - Stored fields @@ -49,6 +53,10 @@ public struct AppGroupConfiguration: Sendable, Equatable { public var cursorDragNavigationEnabled: Bool public var polishIntensity: PolishIntensity public var personalDictionary: PersonalDictionary + /// Auto-return to the host app after `startflow` cold start (default on). + public var flowSkipAppSwitch: Bool + /// Idle timeout before the Flow session ends; resets on each utterance. + public var flowInactivityDuration: FlowInactivityDuration // MARK: - Derived @@ -145,7 +153,16 @@ public struct AppGroupConfiguration: Sendable, Equatable { return defaults.bool(forKey: Keys.cursorDragNavigationEnabled) }(), polishIntensity: resolvePolishIntensity(from: defaults), - personalDictionary: decodePersonalDictionary(from: defaults) + personalDictionary: decodePersonalDictionary(from: defaults), + flowSkipAppSwitch: { + if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil { + return true + } + return defaults.bool(forKey: Keys.flowSkipAppSwitch) + }(), + flowInactivityDuration: FlowInactivityDuration.fromStored( + defaults.string(forKey: Keys.flowInactivityDuration) + ) ) let preset = LLMProvider.provider(id: config.providerId) @@ -193,6 +210,8 @@ public struct AppGroupConfiguration: Sendable, Equatable { defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference) defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled) defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity) + defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch) + defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration) Self.encodePersonalDictionary(personalDictionary, to: defaults) } diff --git a/OSGKeyboardShared/Models/FlowInactivityDuration.swift b/OSGKeyboardShared/Models/FlowInactivityDuration.swift new file mode 100644 index 0000000..51b6a89 --- /dev/null +++ b/OSGKeyboardShared/Models/FlowInactivityDuration.swift @@ -0,0 +1,46 @@ +// FlowInactivityDuration.swift +// OSGKeyboard · Shared +// +// User-selectable Flow session inactivity timeout. The timer resets after +// each completed utterance (and on session start). + +import Foundation + +public enum FlowInactivityDuration: String, CaseIterable, Identifiable, Sendable, Codable { + case tenMinutes = "10m" + case thirtyMinutes = "30m" + case threeHours = "3h" + case twelveHours = "12h" + case twentyFourHours = "24h" + + public var id: String { rawValue } + + public static let `default`: FlowInactivityDuration = .twelveHours + + public var timeInterval: TimeInterval { + switch self { + case .tenMinutes: return 10 * 60 + case .thirtyMinutes: return 30 * 60 + case .threeHours: return 3 * 60 * 60 + case .twelveHours: return 12 * 60 * 60 + case .twentyFourHours: return 24 * 60 * 60 + } + } + + public var labelKey: String { + switch self { + case .tenMinutes: return "settings.flow.inactivity.10m" + case .thirtyMinutes: return "settings.flow.inactivity.30m" + case .threeHours: return "settings.flow.inactivity.3h" + case .twelveHours: return "settings.flow.inactivity.12h" + case .twentyFourHours: return "settings.flow.inactivity.24h" + } + } + + public static func fromStored(_ raw: String?) -> FlowInactivityDuration { + guard let raw, let value = FlowInactivityDuration(rawValue: raw) else { + return .default + } + return value + } +} diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index 18d9763..dde83f6 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -171,6 +171,25 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { } } + /// When enabled, the host app tries to return to the source app after a cold-start handoff. + @Published public var flowSkipAppSwitch: Bool { + didSet { + guard !isApplyingConfiguration, flowSkipAppSwitch != configuration.flowSkipAppSwitch else { return } + configuration.flowSkipAppSwitch = flowSkipAppSwitch + persistConfiguration() + } + } + + /// Idle window before an active Flow session expires; resets on each utterance. + @Published public var flowInactivityDuration: FlowInactivityDuration { + didSet { + guard !isApplyingConfiguration, + flowInactivityDuration != configuration.flowInactivityDuration else { return } + configuration.flowInactivityDuration = flowInactivityDuration + persistConfiguration() + } + } + public var isConfigured: Bool { // Local engine uses on-device ASR + built-in DeepSeek polish and // does not need a user API key. Cloud needs base URL, key, and model. @@ -218,6 +237,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { handednessPreference = configuration.handednessPreference cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled polishIntensity = configuration.polishIntensity + flowSkipAppSwitch = configuration.flowSkipAppSwitch + flowInactivityDuration = configuration.flowInactivityDuration isApplyingConfiguration = false } diff --git a/OSGKeyboardShared/Services/FlowSessionBridge.swift b/OSGKeyboardShared/Services/FlowSessionBridge.swift index 500c95c..65fb653 100644 --- a/OSGKeyboardShared/Services/FlowSessionBridge.swift +++ b/OSGKeyboardShared/Services/FlowSessionBridge.swift @@ -39,13 +39,16 @@ public enum FlowSessionBridge { // MARK: - Session lifecycle (host app) public static func markSessionActive( - duration: TimeInterval = FlowSessionKeys.defaultSessionDuration, + duration: TimeInterval? = nil, defaults: UserDefaults? = nil ) { let store = resolvedDefaults(defaults) - let expires = Date().timeIntervalSince1970 + duration + let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store) + let now = Date().timeIntervalSince1970 + let expires = now + resolvedDuration store.set(true, forKey: FlowSessionKeys.flowSessionActive) store.set(expires, forKey: FlowSessionKeys.flowSessionExpires) + store.set(now, forKey: FlowSessionKeys.lastActivityAt) writeHeartbeat(defaults: store) setRecordingState(.idle, defaults: store) clearTranscription(defaults: store) @@ -69,16 +72,49 @@ public enum FlowSessionBridge { } public static func extendSession( - by duration: TimeInterval = FlowSessionKeys.defaultSessionDuration, + by duration: TimeInterval? = nil, defaults: UserDefaults? = nil ) { let store = resolvedDefaults(defaults) - let expires = Date().timeIntervalSince1970 + duration + let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store) + let expires = Date().timeIntervalSince1970 + resolvedDuration store.set(true, forKey: FlowSessionKeys.flowSessionActive) store.set(expires, forKey: FlowSessionKeys.flowSessionExpires) flush(store) } + /// Resets the inactivity timer after utterance completion or explicit activity. + public static func touchLastActivity(defaults: UserDefaults? = nil) { + let store = resolvedDefaults(defaults) + let now = Date().timeIntervalSince1970 + let duration = FlowSessionPolicy.sessionDuration(defaults: store) + store.set(now, forKey: FlowSessionKeys.lastActivityAt) + store.set(now + duration, forKey: FlowSessionKeys.flowSessionExpires) + store.set(true, forKey: FlowSessionKeys.flowSessionActive) + flush(store) + } + + // MARK: - Host return (scheme D) + + public static func setPendingHostBundleId(_ bundleId: String?, defaults: UserDefaults? = nil) { + let store = resolvedDefaults(defaults) + if let bundleId, !bundleId.isEmpty { + store.set(bundleId, forKey: FlowSessionKeys.pendingHostBundleId) + } else { + store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId) + } + flush(store) + } + + public static func pendingHostBundleId(defaults: UserDefaults? = nil) -> String? { + let store = resolvedDefaults(defaults) + return store.string(forKey: FlowSessionKeys.pendingHostBundleId) + } + + public static func clearPendingHostBundleId(defaults: UserDefaults? = nil) { + setPendingHostBundleId(nil, defaults: defaults) + } + // MARK: - Session validity (keyboard) /// True when the session contract is still valid (not expired). @@ -280,6 +316,8 @@ public enum FlowSessionBridge { store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage) clearTranscription(defaults: store) store.removeObject(forKey: FlowSessionKeys.audioLevels) + store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId) + store.removeObject(forKey: FlowSessionKeys.lastActivityAt) flush(store) } diff --git a/OSGKeyboardShared/Services/FlowSessionKeys.swift b/OSGKeyboardShared/Services/FlowSessionKeys.swift index cd98e4a..b5a8856 100644 --- a/OSGKeyboardShared/Services/FlowSessionKeys.swift +++ b/OSGKeyboardShared/Services/FlowSessionKeys.swift @@ -21,11 +21,15 @@ public enum FlowSessionKeys { /// Structured kind paired with `transcriptionError` for keyboard UI. public static let transcriptionErrorKind = "flow.transcriptionErrorKind" public static let audioLevels = "flow.audioLevels" + /// Bundle id of the app that opened `osgkeyboard://startflow` (scheme D). + public static let pendingHostBundleId = "flow.pendingHostBundleId" + /// Wall-clock timestamp of the last utterance completion or session start. + public static let lastActivityAt = "flow.lastActivityAt" /// Heartbeat older than this while the host is foreground → likely killed. public static let heartbeatStaleInterval: TimeInterval = 3 - /// Default Flow session length when started from the keyboard. + /// Legacy fixed session length — prefer `FlowSessionPolicy.sessionDuration()`. public static let defaultSessionDuration: TimeInterval = 480 /// Maximum duration for a single keyboard utterance (3.5 minutes). diff --git a/OSGKeyboardShared/Services/FlowSessionPolicy.swift b/OSGKeyboardShared/Services/FlowSessionPolicy.swift new file mode 100644 index 0000000..19f24fb --- /dev/null +++ b/OSGKeyboardShared/Services/FlowSessionPolicy.swift @@ -0,0 +1,39 @@ +// FlowSessionPolicy.swift +// OSGKeyboard · Shared +// +// Reads Flow session behaviour preferences from the App Group. + +import Foundation + +public enum FlowSessionPolicy { + public static func skipAppSwitch(defaults: UserDefaults? = nil) -> Bool { + let store = resolvedDefaults(defaults) + if store.object(forKey: AppGroupConfiguration.Keys.flowSkipAppSwitch) == nil { + return true + } + return store.bool(forKey: AppGroupConfiguration.Keys.flowSkipAppSwitch) + } + + public static func inactivityDuration(defaults: UserDefaults? = nil) -> FlowInactivityDuration { + let store = resolvedDefaults(defaults) + return FlowInactivityDuration.fromStored( + store.string(forKey: AppGroupConfiguration.Keys.flowInactivityDuration) + ) + } + + public static func sessionDuration(defaults: UserDefaults? = nil) -> TimeInterval { + inactivityDuration(defaults: defaults).timeInterval + } + + private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults { + if let defaults { return defaults } + guard let available = AppGroup.defaultsIfAvailable else { + #if DEBUG + fatalError("App Group unavailable — inject UserDefaults in tests.") + #else + fatalError("App Group unavailable.") + #endif + } + return available + } +} diff --git a/OSGKeyboardShared/Services/HostAppURLRegistry.swift b/OSGKeyboardShared/Services/HostAppURLRegistry.swift new file mode 100644 index 0000000..cbeda1d --- /dev/null +++ b/OSGKeyboardShared/Services/HostAppURLRegistry.swift @@ -0,0 +1,182 @@ +// HostAppURLRegistry.swift +// OSGKeyboard · Shared +// +// Public URL-scheme whitelist for returning to a known host app after a +// cold-start Flow session handoff. One bundle id maps to one preferred URL. + +import Foundation + +public struct HostAppEntry: Sendable, Equatable { + public let bundleId: String + public let displayNameKey: String + public let returnURLString: String + public let tier: Int + + public var returnURL: URL? { + URL(string: returnURLString) + } + + public init(bundleId: String, displayNameKey: String, returnURLString: String, tier: Int) { + self.bundleId = bundleId + self.displayNameKey = displayNameKey + self.returnURLString = returnURLString + self.tier = tier + } +} + +public enum HostAppURLRegistry { + /// Curated whitelist for high-frequency host apps (IM, work, notes). + public static let entries: [HostAppEntry] = [ + // Tier 1 — China IM / work + HostAppEntry( + bundleId: "com.tencent.xin", + displayNameKey: "hostApp.wechat", + returnURLString: "weixin://", + tier: 1 + ), + HostAppEntry( + bundleId: "com.tencent.mqq", + displayNameKey: "hostApp.qq", + returnURLString: "mqq://", + tier: 1 + ), + HostAppEntry( + bundleId: "com.tencent.wework", + displayNameKey: "hostApp.wecom", + returnURLString: "wxwork://", + tier: 1 + ), + HostAppEntry( + bundleId: "com.laiwang.DingTalk", + displayNameKey: "hostApp.dingtalk", + returnURLString: "dingtalk://", + tier: 1 + ), + HostAppEntry( + bundleId: "com.bytedance.ee.lark", + displayNameKey: "hostApp.lark", + returnURLString: "lark://", + tier: 1 + ), + // Tier 2 — global IM / collaboration + HostAppEntry( + bundleId: "ph.telegra.Telegraph", + displayNameKey: "hostApp.telegram", + returnURLString: "tg://", + tier: 2 + ), + HostAppEntry( + bundleId: "net.whatsapp.WhatsApp", + displayNameKey: "hostApp.whatsapp", + returnURLString: "whatsapp://", + tier: 2 + ), + HostAppEntry( + bundleId: "jp.naver.line", + displayNameKey: "hostApp.line", + returnURLString: "line://", + tier: 2 + ), + HostAppEntry( + bundleId: "com.facebook.Messenger", + displayNameKey: "hostApp.messenger", + returnURLString: "fb-messenger://", + tier: 2 + ), + HostAppEntry( + bundleId: "com.tinyspeck.chatlyio", + displayNameKey: "hostApp.slack", + returnURLString: "slack://", + tier: 2 + ), + HostAppEntry( + bundleId: "com.microsoft.skype.teams", + displayNameKey: "hostApp.teams", + returnURLString: "msteams://", + tier: 2 + ), + HostAppEntry( + bundleId: "com.hammerandchisel.discord", + displayNameKey: "hostApp.discord", + returnURLString: "discord://", + tier: 2 + ), + // Tier 3 — notes / mail / browser + HostAppEntry( + bundleId: "notion.id", + displayNameKey: "hostApp.notion", + returnURLString: "notion://", + tier: 3 + ), + HostAppEntry( + bundleId: "net.shinyfrog.bear", + displayNameKey: "hostApp.bear", + returnURLString: "bear://", + tier: 3 + ), + HostAppEntry( + bundleId: "md.obsidian", + displayNameKey: "hostApp.obsidian", + returnURLString: "obsidian://", + tier: 3 + ), + HostAppEntry( + bundleId: "com.agiletortoise.Drafts5", + displayNameKey: "hostApp.drafts", + returnURLString: "drafts5://", + tier: 3 + ), + HostAppEntry( + bundleId: "com.google.Gmail", + displayNameKey: "hostApp.gmail", + returnURLString: "googlegmail://", + tier: 3 + ), + HostAppEntry( + bundleId: "com.microsoft.Office.Outlook", + displayNameKey: "hostApp.outlook", + returnURLString: "ms-outlook://", + tier: 3 + ), + HostAppEntry( + bundleId: "com.google.chrome.ios", + displayNameKey: "hostApp.chrome", + returnURLString: "googlechrome://", + tier: 3 + ), + // Tier 4 — China social + HostAppEntry( + bundleId: "com.sina.weibo", + displayNameKey: "hostApp.weibo", + returnURLString: "sinaweibo://", + tier: 4 + ), + HostAppEntry( + bundleId: "com.xingin.discover", + displayNameKey: "hostApp.xiaohongshu", + returnURLString: "xhsdiscover://", + tier: 4 + ) + ] + + private static let byBundleId: [String: HostAppEntry] = { + Dictionary(uniqueKeysWithValues: entries.map { ($0.bundleId, $0) }) + }() + + public static func lookup(bundleId: String?) -> HostAppEntry? { + guard let bundleId, !bundleId.isEmpty else { return nil } + return byBundleId[bundleId] + } + + /// URL schemes declared in `LSApplicationQueriesSchemes` for `canOpenURL`. + public static var querySchemes: [String] { + Array( + Set( + entries.compactMap { entry -> String? in + guard let url = entry.returnURL, let scheme = url.scheme else { return nil } + return scheme + } + ) + ).sorted() + } +} diff --git a/OSGKeyboardTests/AppGroupConfigurationTests.swift b/OSGKeyboardTests/AppGroupConfigurationTests.swift index 6324abb..f34221b 100644 --- a/OSGKeyboardTests/AppGroupConfigurationTests.swift +++ b/OSGKeyboardTests/AppGroupConfigurationTests.swift @@ -30,6 +30,8 @@ final class AppGroupConfigurationTests: XCTestCase { XCTAssertTrue(config.cursorDragNavigationEnabled) XCTAssertEqual(config.polishIntensity, .default) XCTAssertTrue(config.personalDictionary.entries.isEmpty) + XCTAssertTrue(config.flowSkipAppSwitch) + XCTAssertEqual(config.flowInactivityDuration, .twelveHours) } func testSaveAndLoadRoundTrip() { @@ -49,6 +51,8 @@ final class AppGroupConfigurationTests: XCTestCase { config.handednessPreference = .right config.cursorDragNavigationEnabled = false config.polishIntensity = .light + config.flowSkipAppSwitch = false + config.flowInactivityDuration = .thirtyMinutes config.save(to: defaults) let loaded = AppGroupConfiguration.load(fromAvailable: defaults) @@ -66,6 +70,8 @@ final class AppGroupConfigurationTests: XCTestCase { XCTAssertEqual(loaded.handednessPreference, .right) XCTAssertFalse(loaded.cursorDragNavigationEnabled) XCTAssertEqual(loaded.polishIntensity, .light) + XCTAssertFalse(loaded.flowSkipAppSwitch) + XCTAssertEqual(loaded.flowInactivityDuration, .thirtyMinutes) } func testTranslationEnabledDerivedFromTargetLocale() { diff --git a/OSGKeyboardTests/FlowSessionPolicyTests.swift b/OSGKeyboardTests/FlowSessionPolicyTests.swift new file mode 100644 index 0000000..8c17907 --- /dev/null +++ b/OSGKeyboardTests/FlowSessionPolicyTests.swift @@ -0,0 +1,46 @@ +// FlowSessionPolicyTests.swift +// OSGKeyboardTests + +import XCTest +@testable import OSGKeyboardShared + +final class FlowSessionPolicyTests: XCTestCase { + private func makeDefaults() -> UserDefaults { + let suite = "group.com.osgkeyboard.shared.tests.policy.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } + + func testSkipAppSwitchDefaultsToTrue() { + let defaults = makeDefaults() + XCTAssertTrue(FlowSessionPolicy.skipAppSwitch(defaults: defaults)) + } + + func testInactivityDurationDefaultsToTwelveHours() { + let defaults = makeDefaults() + XCTAssertEqual(FlowSessionPolicy.inactivityDuration(defaults: defaults), .twelveHours) + XCTAssertEqual(FlowSessionPolicy.sessionDuration(defaults: defaults), 12 * 60 * 60) + } + + func testTouchLastActivityExtendsExpiry() { + let defaults = makeDefaults() + defaults.set(FlowInactivityDuration.tenMinutes.rawValue, forKey: AppGroupConfiguration.Keys.flowInactivityDuration) + FlowSessionBridge.markSessionActive(defaults: defaults) + + 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) + } + + func testPendingHostBundleIdRoundTrip() { + let defaults = makeDefaults() + FlowSessionBridge.setPendingHostBundleId("com.tencent.xin", defaults: defaults) + XCTAssertEqual(FlowSessionBridge.pendingHostBundleId(defaults: defaults), "com.tencent.xin") + FlowSessionBridge.clearPendingHostBundleId(defaults: defaults) + XCTAssertNil(FlowSessionBridge.pendingHostBundleId(defaults: defaults)) + } +} diff --git a/OSGKeyboardTests/HostAppURLRegistryTests.swift b/OSGKeyboardTests/HostAppURLRegistryTests.swift new file mode 100644 index 0000000..afe6b3c --- /dev/null +++ b/OSGKeyboardTests/HostAppURLRegistryTests.swift @@ -0,0 +1,28 @@ +// HostAppURLRegistryTests.swift +// OSGKeyboardTests + +import XCTest +@testable import OSGKeyboardShared + +final class HostAppURLRegistryTests: XCTestCase { + func testWeChatLookup() { + let entry = HostAppURLRegistry.lookup(bundleId: "com.tencent.xin") + XCTAssertEqual(entry?.returnURLString, "weixin://") + XCTAssertEqual(entry?.displayNameKey, "hostApp.wechat") + } + + func testUnknownBundleReturnsNil() { + XCTAssertNil(HostAppURLRegistry.lookup(bundleId: "com.apple.MobileSMS")) + XCTAssertNil(HostAppURLRegistry.lookup(bundleId: nil)) + XCTAssertNil(HostAppURLRegistry.lookup(bundleId: "")) + } + + func testQuerySchemesIncludesWeChat() { + XCTAssertTrue(HostAppURLRegistry.querySchemes.contains("weixin")) + } + + func testEntriesHaveUniqueBundleIds() { + let ids = HostAppURLRegistry.entries.map(\.bundleId) + XCTAssertEqual(Set(ids).count, ids.count) + } +} diff --git a/project.yml b/project.yml index c34d4f0..0eca770 100644 --- a/project.yml +++ b/project.yml @@ -118,6 +118,28 @@ targets: - CFBundleURLName: com.osgkeyboard.ios.settings CFBundleURLSchemes: - osgkeyboard + LSApplicationQueriesSchemes: + - weixin + - mqq + - wxwork + - dingtalk + - lark + - tg + - whatsapp + - line + - fb-messenger + - slack + - msteams + - discord + - notion + - bear + - obsidian + - drafts5 + - googlegmail + - ms-outlook + - googlechrome + - sinaweibo + - xhsdiscover # All network calls are HTTPS (NSAppTransportSecurity above); # nothing in the app uses non-exempt encryption. Declaring # `ITSAppUsesNonExemptEncryption: false` lets us skip the