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: