feat(flow): Live Activity Dynamic Island branding and mis PrimaryLanguage

- Add OSGKeyboardLiveActivity widget extension (ActivityKit) with branded
  compactLeading mark in the Dynamic Island during Flow sessions
- Wire FlowLiveActivityController into FlowSessionManager lifecycle
- Set keyboard PrimaryLanguage to mis in project.yml, Info.plist, and at runtime
- Enable NSSupportsLiveActivities on main app and widget extension targets

Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-07-06 05:53:24 +00:00
parent 537a68552a
commit 6489340b2e
11 changed files with 341 additions and 2 deletions
+1
View File
@@ -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
+2
View File
@@ -58,6 +58,8 @@
<array>
<string>audio</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
@@ -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<FlowActivityAttributes>?
/// 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<FlowActivityAttributes>.activities {
Task {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
currentActivity = nil
}
}
@@ -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()
+1 -1
View File
@@ -34,7 +34,7 @@
<key>PrefersRightToLeft</key>
<false/>
<key>PrimaryLanguage</key>
<string>en-US</string>
<string>mis</string>
<key>RequestsOpenAccess</key>
<true/>
</dict>
@@ -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()
@@ -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
}
}
}
@@ -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…")
}
}
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>OSGKeyboardLiveActivity</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,12 @@
// OSGKeyboardLiveActivityBundle.swift
// OSGKeyboard · Live Activity
import SwiftUI
import WidgetKit
@main
struct OSGKeyboardLiveActivityBundle: WidgetBundle {
var body: some Widget {
FlowLiveActivityWidget()
}
}
+39 -1
View File
@@ -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: