diff --git a/CHANGELOG.md b/CHANGELOG.md index 481e5f0..5e29179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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,与其他在线服务相同管线)。 ### Changed diff --git a/OSGKeyboard/Info.plist b/OSGKeyboard/Info.plist index 39fe503..c16f9f1 100644 --- a/OSGKeyboard/Info.plist +++ b/OSGKeyboard/Info.plist @@ -58,6 +58,8 @@ audio + NSSupportsLiveActivities + UILaunchScreen UIColorName diff --git a/OSGKeyboard/Services/FlowLiveActivityController.swift b/OSGKeyboard/Services/FlowLiveActivityController.swift new file mode 100644 index 0000000..0fbaf44 --- /dev/null +++ b/OSGKeyboard/Services/FlowLiveActivityController.swift @@ -0,0 +1,76 @@ +// FlowLiveActivityController.swift +// OSGKeyboard · Main App +// +// Starts and updates the Flow Live Activity so the Dynamic Island shows the +// OSGKeyboard brand mark while a voice session is active. + +import ActivityKit +import Foundation +import OSGKeyboardShared + +@MainActor +enum FlowLiveActivityController { + private static var currentActivity: Activity? + + /// Begin showing OSGKeyboard in the Dynamic Island for an active Flow session. + static func startSession() { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + debug("Live Activity disabled in Settings") + return + } + + endStaleActivities() + + guard currentActivity == nil else { + update(phase: .idle) + return + } + + let state = FlowActivityAttributes.ContentState(phase: .idle) + let content = ActivityContent(state: state, staleDate: nil) + + do { + currentActivity = try Activity.request( + attributes: FlowActivityAttributes(), + content: content, + pushType: nil + ) + debug("Live Activity started") + } catch { + debug("Live Activity start failed: \(error.localizedDescription)") + } + } + + static func update(phase: FlowActivityAttributes.ContentState.Phase) { + guard let activity = currentActivity else { return } + let state = FlowActivityAttributes.ContentState(phase: phase) + let content = ActivityContent(state: state, staleDate: nil) + Task { + await activity.update(content) + } + } + + /// Dismiss the island presentation when the Flow session ends. + static func endSession() { + guard let activity = currentActivity else { + endStaleActivities() + return + } + + currentActivity = nil + Task { + await activity.end(nil, dismissalPolicy: .immediate) + debug("Live Activity ended") + } + } + + /// Host relaunch can leave orphan activities; clear them before starting anew. + private static func endStaleActivities() { + for activity in Activity.activities { + Task { + await activity.end(nil, dismissalPolicy: .immediate) + } + } + currentActivity = nil + } +} diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index dd14afe..cb52b7a 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -156,6 +156,7 @@ final class FlowSessionManager: ObservableObject { // (e.g. switching from cloud to local) is honoured. bindSessionASRIfNeeded() scheduleASRWarmup() + FlowLiveActivityController.startSession() debug("Flow session restored (\(Int(remaining))s remaining)") } @@ -196,6 +197,7 @@ final class FlowSessionManager: ObservableObject { sessionASRWarmedLocaleID = nil FlowSessionBridge.markSessionInactive() FlowSessionDarwin.postSessionChanged() + FlowLiveActivityController.endSession() isActive = false sessionExpiresAt = nil sessionWarning = nil @@ -339,6 +341,7 @@ final class FlowSessionManager: ObservableObject { // while the session was idle. bindSessionASRIfNeeded() scheduleASRWarmup() + FlowLiveActivityController.startSession() debug("Flow session started (\(Int(duration))s), continuous capture running") } @@ -437,6 +440,7 @@ final class FlowSessionManager: ObservableObject { isUtteranceRecording = true utteranceRecordingStartedAt = Date() + FlowLiveActivityController.update(phase: .recording) FlowDiagnostics.log( "beginUtterance engine=\(store.engineMode) " + "asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s" @@ -489,6 +493,7 @@ final class FlowSessionManager: ObservableObject { FlowSessionBridge.setRecordingState(.processing) isUtteranceRecording = false isUtteranceProcessing = true + FlowLiveActivityController.update(phase: .processing) // Do NOT cancel `asrTask` or `asr` — the preview pipeline relies on // the consumer staying alive until `.final` lands (see @@ -517,6 +522,7 @@ final class FlowSessionManager: ObservableObject { chunkWarnings = [] FlowSessionBridge.storeTranscriptionPartial("") FlowSessionBridge.setRecordingState(.idle) + FlowLiveActivityController.update(phase: .idle) debug("utterance aborted") } @@ -540,6 +546,7 @@ final class FlowSessionManager: ObservableObject { FlowSessionBridge.storeTranscriptionPartial("") FlowSessionBridge.storeTranscriptionError(message, kind: kind) FlowSessionBridge.setRecordingState(.idle) + FlowLiveActivityController.update(phase: .idle) debug("utterance failed: \(message)") } @@ -558,6 +565,7 @@ final class FlowSessionManager: ObservableObject { FlowSessionBridge.storeTranscriptionPartial("") FlowSessionBridge.storeTranscriptionError(message, kind: kind) FlowSessionBridge.setRecordingState(.idle) + FlowLiveActivityController.update(phase: .idle) debug("utterance processing failed: \(message)") } @@ -566,6 +574,7 @@ final class FlowSessionManager: ObservableObject { defer { isUtteranceProcessing = false FlowSessionBridge.setRecordingState(.idle) + FlowLiveActivityController.update(phase: .idle) } let asrWait = asrWaitTimeout() diff --git a/OSGKeyboardExt/Info.plist b/OSGKeyboardExt/Info.plist index 8494b88..03a1fda 100644 --- a/OSGKeyboardExt/Info.plist +++ b/OSGKeyboardExt/Info.plist @@ -34,7 +34,7 @@ PrefersRightToLeft PrimaryLanguage - en-US + mis RequestsOpenAccess diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 64871a6..d3f8ed7 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -54,6 +54,8 @@ public final class KeyboardViewController: UIInputViewController { public override func viewDidLoad() { super.viewDidLoad() + // Voice-first keyboard — hide the misleading "English" subtitle in Settings. + primaryLanguage = "mis" OSGLog.keyboardExt.info("viewDidLoad — extension booted") CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded() setNeedsUpdateOfScreenEdgesDeferringSystemGestures() diff --git a/OSGKeyboardLiveActivity/FlowActivityAttributes.swift b/OSGKeyboardLiveActivity/FlowActivityAttributes.swift new file mode 100644 index 0000000..7ea949c --- /dev/null +++ b/OSGKeyboardLiveActivity/FlowActivityAttributes.swift @@ -0,0 +1,22 @@ +// FlowActivityAttributes.swift +// OSGKeyboard · Live Activity +// +// Shared ActivityKit model compiled into the widget extension and the +// main app so `FlowLiveActivityController` can start/update sessions. + +import ActivityKit +import Foundation + +/// Live Activity shown in the Dynamic Island while a Flow session is active. +struct FlowActivityAttributes: ActivityAttributes { + /// Dynamic content updated as the user records and processes speech. + struct ContentState: Codable, Hashable, Sendable { + var phase: Phase + + enum Phase: String, Codable, Hashable, Sendable { + case idle + case recording + case processing + } + } +} diff --git a/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift b/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift new file mode 100644 index 0000000..8c93646 --- /dev/null +++ b/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift @@ -0,0 +1,146 @@ +// FlowLiveActivityWidget.swift +// OSGKeyboard · Live Activity +// +// Dynamic Island compact leading shows the OSGKeyboard mark instead of the +// generic system microphone glyph that appears without a Live Activity. + +import ActivityKit +import SwiftUI +import WidgetKit + +struct FlowLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: FlowActivityAttributes.self) { context in + FlowLiveActivityLockScreenView(phase: context.state.phase) + .activityBackgroundTint(Color.black.opacity(0.82)) + .activitySystemActionForegroundColor(.white) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + FlowLiveActivityBrandMark(size: 28) + } + DynamicIslandExpandedRegion(.trailing) { + FlowLiveActivityPhaseLabel(phase: context.state.phase) + } + DynamicIslandExpandedRegion(.center) { + Text("OSGKeyboard") + .font(.headline) + } + DynamicIslandExpandedRegion(.bottom) { + FlowLiveActivityPhaseCaption(phase: context.state.phase) + .font(.caption) + .foregroundStyle(.secondary) + } + } compactLeading: { + FlowLiveActivityBrandMark(size: 22) + } compactTrailing: { + FlowLiveActivityTrailingGlyph(phase: context.state.phase) + } minimal: { + FlowLiveActivityBrandMark(size: 18) + } + .keylineTint(Color(red: 0.35, green: 0.55, blue: 1.0)) + } + } +} + +// MARK: - Views + +private struct FlowLiveActivityLockScreenView: View { + let phase: FlowActivityAttributes.ContentState.Phase + + var body: some View { + HStack(spacing: 12) { + FlowLiveActivityBrandMark(size: 32) + VStack(alignment: .leading, spacing: 4) { + Text("OSGKeyboard") + .font(.headline) + FlowLiveActivityPhaseCaption(phase: phase) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + FlowLiveActivityTrailingGlyph(phase: phase) + } + .padding(.horizontal, 4) + } +} + +/// Branded mark used in compactLeading so users see OSGKeyboard, not the system mic icon. +private struct FlowLiveActivityBrandMark: View { + let size: CGFloat + + var body: some View { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [ + Color(red: 0.35, green: 0.55, blue: 1.0), + Color(red: 0.22, green: 0.38, blue: 0.92) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + Image(systemName: "keyboard") + .font(.system(size: size * 0.48, weight: .semibold)) + .foregroundStyle(.white) + } + .frame(width: size, height: size) + .accessibilityLabel("OSGKeyboard") + } +} + +private struct FlowLiveActivityTrailingGlyph: View { + let phase: FlowActivityAttributes.ContentState.Phase + + var body: some View { + switch phase { + case .recording: + Image(systemName: "waveform") + .foregroundStyle(.red) + .symbolEffect(.variableColor.iterative, options: .repeating) + case .processing: + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + case .idle: + Image(systemName: "mic.fill") + .foregroundStyle(.secondary) + } + } +} + +private struct FlowLiveActivityPhaseLabel: View { + let phase: FlowActivityAttributes.ContentState.Phase + + var body: some View { + switch phase { + case .recording: + Text("REC") + .font(.caption.monospacedDigit().weight(.bold)) + .foregroundStyle(.red) + case .processing: + Text("…") + .font(.title3.weight(.semibold)) + case .idle: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } +} + +private struct FlowLiveActivityPhaseCaption: View { + let phase: FlowActivityAttributes.ContentState.Phase + + var body: some View { + switch phase { + case .idle: + Text("Voice session active") + case .recording: + Text("Listening…") + case .processing: + Text("Transcribing…") + } + } +} diff --git a/OSGKeyboardLiveActivity/Info.plist b/OSGKeyboardLiveActivity/Info.plist new file mode 100644 index 0000000..e6a000c --- /dev/null +++ b/OSGKeyboardLiveActivity/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OSGKeyboardLiveActivity + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + NSSupportsLiveActivities + + + diff --git a/OSGKeyboardLiveActivity/OSGKeyboardLiveActivityBundle.swift b/OSGKeyboardLiveActivity/OSGKeyboardLiveActivityBundle.swift new file mode 100644 index 0000000..ade0cdb --- /dev/null +++ b/OSGKeyboardLiveActivity/OSGKeyboardLiveActivityBundle.swift @@ -0,0 +1,12 @@ +// OSGKeyboardLiveActivityBundle.swift +// OSGKeyboard · Live Activity + +import SwiftUI +import WidgetKit + +@main +struct OSGKeyboardLiveActivityBundle: WidgetBundle { + var body: some Widget { + FlowLiveActivityWidget() + } +} diff --git a/project.yml b/project.yml index 289d29a..c34d4f0 100644 --- a/project.yml +++ b/project.yml @@ -64,6 +64,8 @@ targets: # crashes when both are passed. iOS 26 uses Icon Composer only. - "Assets.xcassets/AppIcon.appiconset" - "Resources/CustomLanguageModel/**" + # Shared with the Live Activity widget so the host app can request sessions. + - path: OSGKeyboardLiveActivity/FlowActivityAttributes.swift entitlements: path: OSGKeyboard/OSGKeyboard.entitlements properties: @@ -108,6 +110,7 @@ targets: NSSpeechRecognitionUsageDescription: "OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription." UIBackgroundModes: - audio + NSSupportsLiveActivities: true NSAppTransportSecurity: NSAllowsArbitraryLoads: false ITSAppUsesNonExemptEncryption: false @@ -139,7 +142,10 @@ targets: embed: true - target: OSGKeyboardExt # Keyboard Extension is a plugin of the main App; embed it. + - target: OSGKeyboardLiveActivity + embed: true - sdk: Speech.framework + - sdk: ActivityKit.framework # ========================================================= # Keyboard Extension @@ -187,7 +193,7 @@ targets: NSExtensionAttributes: IsASCIICapable: false PrefersRightToLeft: false - PrimaryLanguage: "en-US" + PrimaryLanguage: "mis" RequestsOpenAccess: true NSExtensionPointIdentifier: com.apple.keyboard-service NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).KeyboardViewController @@ -205,6 +211,37 @@ targets: - target: OSGKeyboardShared embed: false + # ========================================================= + # Live Activity Widget Extension (Dynamic Island) + # ========================================================= + OSGKeyboardLiveActivity: + type: app-extension + platform: iOS + sources: + - path: OSGKeyboardLiveActivity + info: + path: OSGKeyboardLiveActivity/Info.plist + properties: + CFBundleDisplayName: OSGKeyboardLiveActivity + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + NSSupportsLiveActivities: true + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.liveactivity + TARGETED_DEVICE_FAMILY: "1" + SUPPORTS_MACCATALYST: NO + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: X329MZU23S + dependencies: + - sdk: WidgetKit.framework + - sdk: SwiftUI.framework + - sdk: ActivityKit.framework + # ========================================================= # Shared Framework (主 App 与扩展共用) # ========================================================= @@ -284,6 +321,7 @@ schemes: targets: OSGKeyboard: all OSGKeyboardExt: all + OSGKeyboardLiveActivity: all run: config: Debug test: