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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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…")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user