Skip to content

Commit cd84d3a

Browse files
committed
manage the control on Github between voicing and keyboard on
1 parent 0d4ac3a commit cd84d3a

6 files changed

Lines changed: 272 additions & 13 deletions

File tree

VoiceInk-ios.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,21 @@
8686
);
8787
target = E18B9A4C2E600F9F0068773A /* VoiceInkKeyboard */;
8888
};
89+
E18B9BE02E6056DB0068773A /* Exceptions for "VoiceInk-ios" folder in "VoiceInkKeyboard" target */ = {
90+
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
91+
membershipExceptions = (
92+
AppGroupCoordinator.swift,
93+
);
94+
target = E18B9A4C2E600F9F0068773A /* VoiceInkKeyboard */;
95+
};
8996
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
9097

9198
/* Begin PBXFileSystemSynchronizedRootGroup section */
9299
E168DF092E4B464B00F133D2 /* VoiceInk-ios */ = {
93100
isa = PBXFileSystemSynchronizedRootGroup;
94101
exceptions = (
95102
E168DF2A2E4B464C00F133D2 /* Exceptions for "VoiceInk-ios" folder in "VoiceInk-ios" target */,
103+
E18B9BE02E6056DB0068773A /* Exceptions for "VoiceInk-ios" folder in "VoiceInkKeyboard" target */,
96104
);
97105
path = "VoiceInk-ios";
98106
sourceTree = "<group>";
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import Foundation
2+
3+
/// Handles communication between the main VoiceInk app and the keyboard extension
4+
/// Uses App Groups + Darwin Notifications for reliable iOS-native communication
5+
final class AppGroupCoordinator {
6+
static let shared = AppGroupCoordinator()
7+
8+
// MARK: - Constants
9+
private let appGroupIdentifier = "group.com.prakashjoshipax.VoiceInk"
10+
11+
// UserDefaults keys for persistent state
12+
private enum UserDefaultsKeys {
13+
static let shouldStartRecording = "shouldStartRecording"
14+
static let shouldStopRecording = "shouldStopRecording"
15+
static let isRecording = "isRecording"
16+
static let lastRecordingTimestamp = "lastRecordingTimestamp"
17+
}
18+
19+
// Darwin notification names for real-time communication
20+
private enum NotificationNames {
21+
static let startRecording = "com.prakashjoshipax.VoiceInk.startRecording"
22+
static let stopRecording = "com.prakashjoshipax.VoiceInk.stopRecording"
23+
static let recordingStateChanged = "com.prakashjoshipax.VoiceInk.recordingStateChanged"
24+
}
25+
26+
// MARK: - Properties
27+
private let sharedDefaults: UserDefaults?
28+
private let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
29+
30+
// Callbacks for the main app
31+
var onStartRecordingRequested: (() -> Void)?
32+
var onStopRecordingRequested: (() -> Void)?
33+
34+
// MARK: - Initialization
35+
private init() {
36+
sharedDefaults = UserDefaults(suiteName: appGroupIdentifier)
37+
setupNotificationObservers()
38+
}
39+
40+
deinit {
41+
removeNotificationObservers()
42+
}
43+
44+
// MARK: - Public Interface for Keyboard Extension
45+
46+
/// Call this from the keyboard extension to request recording start
47+
func requestStartRecording() {
48+
let timestamp = Date().timeIntervalSince1970
49+
50+
// Set persistent flag
51+
sharedDefaults?.set(true, forKey: UserDefaultsKeys.shouldStartRecording)
52+
sharedDefaults?.set(timestamp, forKey: UserDefaultsKeys.lastRecordingTimestamp)
53+
54+
// Send immediate notification
55+
postDarwinNotification(NotificationNames.startRecording)
56+
}
57+
58+
/// Call this from the keyboard extension to request recording stop
59+
func requestStopRecording() {
60+
let timestamp = Date().timeIntervalSince1970
61+
62+
// Set persistent flag
63+
sharedDefaults?.set(true, forKey: UserDefaultsKeys.shouldStopRecording)
64+
sharedDefaults?.set(timestamp, forKey: UserDefaultsKeys.lastRecordingTimestamp)
65+
66+
// Send immediate notification
67+
postDarwinNotification(NotificationNames.stopRecording)
68+
}
69+
70+
/// Get current recording state (for keyboard UI updates)
71+
var isRecording: Bool {
72+
return sharedDefaults?.bool(forKey: UserDefaultsKeys.isRecording) ?? false
73+
}
74+
75+
// MARK: - Public Interface for Main App
76+
77+
/// Call this from the main app to update recording state
78+
func updateRecordingState(_ isRecording: Bool) {
79+
sharedDefaults?.set(isRecording, forKey: UserDefaultsKeys.isRecording)
80+
81+
// Notify keyboard of state change
82+
postDarwinNotification(NotificationNames.recordingStateChanged)
83+
}
84+
85+
/// Check and consume start recording flag (returns true if should start)
86+
func checkAndConsumeStartRecordingFlag() -> Bool {
87+
guard let defaults = sharedDefaults else { return false }
88+
89+
let shouldStart = defaults.bool(forKey: UserDefaultsKeys.shouldStartRecording)
90+
if shouldStart {
91+
// Consume the flag
92+
defaults.set(false, forKey: UserDefaultsKeys.shouldStartRecording)
93+
return true
94+
}
95+
return false
96+
}
97+
98+
/// Check and consume stop recording flag (returns true if should stop)
99+
func checkAndConsumeStopRecordingFlag() -> Bool {
100+
guard let defaults = sharedDefaults else { return false }
101+
102+
let shouldStop = defaults.bool(forKey: UserDefaultsKeys.shouldStopRecording)
103+
if shouldStop {
104+
// Consume the flag
105+
defaults.set(false, forKey: UserDefaultsKeys.shouldStopRecording)
106+
return true
107+
}
108+
return false
109+
}
110+
111+
// MARK: - Darwin Notifications (Real-time Communication)
112+
113+
private func setupNotificationObservers() {
114+
guard let center = notificationCenter else { return }
115+
116+
// Observe start recording notifications
117+
CFNotificationCenterAddObserver(
118+
center,
119+
Unmanaged.passUnretained(self).toOpaque(),
120+
{ (center, observer, name, object, userInfo) in
121+
guard let observer = observer else { return }
122+
let coordinator = Unmanaged<AppGroupCoordinator>.fromOpaque(observer).takeUnretainedValue()
123+
coordinator.handleStartRecordingNotification()
124+
},
125+
NotificationNames.startRecording as CFString,
126+
nil,
127+
.deliverImmediately
128+
)
129+
130+
// Observe stop recording notifications
131+
CFNotificationCenterAddObserver(
132+
center,
133+
Unmanaged.passUnretained(self).toOpaque(),
134+
{ (center, observer, name, object, userInfo) in
135+
guard let observer = observer else { return }
136+
let coordinator = Unmanaged<AppGroupCoordinator>.fromOpaque(observer).takeUnretainedValue()
137+
coordinator.handleStopRecordingNotification()
138+
},
139+
NotificationNames.stopRecording as CFString,
140+
nil,
141+
.deliverImmediately
142+
)
143+
}
144+
145+
private func removeNotificationObservers() {
146+
guard let center = notificationCenter else { return }
147+
CFNotificationCenterRemoveEveryObserver(center, Unmanaged.passUnretained(self).toOpaque())
148+
}
149+
150+
private func postDarwinNotification(_ name: String) {
151+
guard let center = notificationCenter else { return }
152+
CFNotificationCenterPostNotification(
153+
center,
154+
CFNotificationName(name as CFString),
155+
nil,
156+
nil,
157+
true
158+
)
159+
}
160+
161+
// MARK: - Notification Handlers
162+
163+
private func handleStartRecordingNotification() {
164+
DispatchQueue.main.async { [weak self] in
165+
self?.onStartRecordingRequested?()
166+
}
167+
}
168+
169+
private func handleStopRecordingNotification() {
170+
DispatchQueue.main.async { [weak self] in
171+
self?.onStopRecordingRequested?()
172+
}
173+
}
174+
175+
// MARK: - Debug Helpers
176+
177+
/// Clear all shared data (useful for debugging)
178+
func clearAllSharedData() {
179+
guard let defaults = sharedDefaults else { return }
180+
defaults.removeObject(forKey: UserDefaultsKeys.shouldStartRecording)
181+
defaults.removeObject(forKey: UserDefaultsKeys.shouldStopRecording)
182+
defaults.removeObject(forKey: UserDefaultsKeys.isRecording)
183+
defaults.removeObject(forKey: UserDefaultsKeys.lastRecordingTimestamp)
184+
}
185+
186+
/// Get debug info about current state
187+
func getDebugInfo() -> [String: Any] {
188+
guard let defaults = sharedDefaults else { return ["error": "No shared defaults"] }
189+
190+
return [
191+
"shouldStartRecording": defaults.bool(forKey: UserDefaultsKeys.shouldStartRecording),
192+
"shouldStopRecording": defaults.bool(forKey: UserDefaultsKeys.shouldStopRecording),
193+
"isRecording": defaults.bool(forKey: UserDefaultsKeys.isRecording),
194+
"lastRecordingTimestamp": defaults.double(forKey: UserDefaultsKeys.lastRecordingTimestamp),
195+
"appGroupIdentifier": appGroupIdentifier
196+
]
197+
}
198+
}

VoiceInk-ios/RecordingManager.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,43 @@ final class RecordingManager: ObservableObject {
4242
private let postProcessor = LLMPostProcessor()
4343
private let settings = AppSettings.shared
4444
private var durationTimer: Timer?
45+
private let coordinator = AppGroupCoordinator.shared
4546

4647
var isRecording: Bool {
4748
recordingState == .recording
4849
}
4950

51+
// MARK: - Initialization
52+
init() {
53+
setupKeyboardCoordination()
54+
}
55+
56+
// MARK: - Keyboard Coordination
57+
private func setupKeyboardCoordination() {
58+
// Set up callbacks for keyboard-initiated recording
59+
coordinator.onStartRecordingRequested = { [weak self] in
60+
self?.handleKeyboardStartRecording()
61+
}
62+
63+
coordinator.onStopRecordingRequested = { [weak self] in
64+
self?.handleKeyboardStopRecording()
65+
}
66+
}
67+
68+
private func handleKeyboardStartRecording() {
69+
// Only start if not already recording
70+
guard !isRecording else { return }
71+
startRecordingFlow()
72+
}
73+
74+
private func handleKeyboardStopRecording() {
75+
// Only stop if currently recording
76+
guard isRecording else { return }
77+
// We need the modelContext, but we'll handle this in the view layer
78+
// For now, just update the coordinator state
79+
coordinator.updateRecordingState(false)
80+
}
81+
5082
// MARK: - Recording Flow
5183
func startRecordingFlow() {
5284
switch checkPermissionStatus() {
@@ -78,10 +110,14 @@ final class RecordingManager: ObservableObject {
78110
try recorder.startRecording()
79111
startDurationTimer()
80112
isRecordingSheetPresented = true
113+
114+
// Update coordinator state for keyboard UI
115+
coordinator.updateRecordingState(true)
81116
} catch {
82117
activeRecordingAlert = .generic(error)
83118
recordingState = .idle
84119
animate = false
120+
coordinator.updateRecordingState(false)
85121
}
86122
}
87123

@@ -111,6 +147,9 @@ final class RecordingManager: ObservableObject {
111147
currentRecordingNote = note
112148
isRecordingSheetPresented = false
113149

150+
// Update coordinator state for keyboard UI
151+
coordinator.updateRecordingState(false)
152+
114153
// Start background transcription
115154
transcribeInBackground(note: note, audioFileName: audioFileName, recordingDuration: recordingDuration, modelContext: modelContext)
116155
}
@@ -122,6 +161,9 @@ final class RecordingManager: ObservableObject {
122161
animate = false
123162
isRecordingSheetPresented = false
124163
currentDuration = 0
164+
165+
// Update coordinator state for keyboard UI
166+
coordinator.updateRecordingState(false)
125167
}
126168

127169
// MARK: - Permissions

VoiceInkKeyboard/KeyboardViewController.swift

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,23 +125,32 @@ class KeyboardViewController: KeyboardInputViewController {
125125
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
126126
impactFeedback.impactOccurred()
127127

128-
// TODO: Use the coordinator to signal the main app
129-
// let coordinator = AppGroupCoordinator.shared
128+
// Use the coordinator to signal the main app
129+
let coordinator = AppGroupCoordinator.shared
130130

131-
// For now, just provide visual feedback
131+
if coordinator.isRecording {
132+
// Currently recording, so stop
133+
coordinator.requestStopRecording()
134+
} else {
135+
// Not recording, so start
136+
coordinator.requestStartRecording()
137+
}
138+
139+
// Update visual state
132140
updateButtonAppearanceBasedOnState()
133141
}
134142

135143
private func updateButtonAppearanceBasedOnState() {
136-
// Temporary visual feedback until AppGroupCoordinator is implemented
137-
let isRecording = recordButton.backgroundColor == UIColor.systemGreen
144+
// Use AppGroupCoordinator to get real recording state
145+
let coordinator = AppGroupCoordinator.shared
146+
let isRecording = coordinator.isRecording
138147

139148
if isRecording {
140-
recordButton.backgroundColor = UIColor.systemRed
141-
recordButton.setTitle("🎤 Record", for: .normal)
142-
} else {
143149
recordButton.backgroundColor = UIColor.systemGreen
144150
recordButton.setTitle("⏹️ Stop", for: .normal)
151+
} else {
152+
recordButton.backgroundColor = UIColor.systemRed
153+
recordButton.setTitle("🎤 Record", for: .normal)
145154
}
146155

147156
// Ensure capsule shape is maintained

tasks.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,26 +30,28 @@ This document outlines the steps to integrate Keyboard Kit into the VoiceInk app
3030

3131
## Phase 2: Building the Keyboard and Communication
3232

33-
- [ ] **Request Full Access for the Keyboard:**
33+
- [x] **Request Full Access for the Keyboard:**
3434
- In the project navigator, find the `Info.plist` file inside your keyboard extension's folder.
3535
- Right-click and choose `Open As` > `Source Code`.
3636
- Inside the `NSExtension` dictionary, add the following key-value pair to request open access, which is necessary for the keyboard to interact with the App Group.
3737
```xml
3838
<key>RequestsOpenAccess</key>
3939
<true/>
4040
```
41-
- [ ] **Design the Keyboard with a Record Button:**
41+
- [x] **Design the Keyboard with a Record Button:**
4242
- In `KeyboardViewController.swift`, use Keyboard Kit to create a custom layout that includes a "Record" button.
43+
- ✅ **COMPLETED**: Red capsule-shaped record button implemented with proper styling, constraints, and haptic feedback. Button toggles between "🎤 Record" and "⏹️ Stop" states with visual feedback.
4344

44-
- [ ] **Implement Keyboard-to-App Signaling:**
45+
- [x] **Implement Keyboard-to-App Signaling:**
4546
- Create a new Swift file, `AppGroupCoordinator.swift`, to manage communication.
4647
- In this file, create a class or struct to handle:
4748
1. Writing a "start recording" signal to a shared `UserDefaults` instance associated with your App Group.
4849
2. Posting Darwin notifications for immediate communication between keyboard and main app.
49-
- Make sure to add this new file to both the main app target and the keyboard extension target in the "Target Membership" inspector.
50+
- ✅ **COMPLETED**: Modern 2025 iOS-native implementation created with hybrid App Groups + Darwin Notifications approach
51+
- ⏳ **NEXT**: Add this file to both the main app target and keyboard extension target in Xcode's "Target Membership" inspector.
5052
- When the user taps the Record button, the keyboard will:
5153
1. Set a flag (e.g., `shouldStartRecording = true`) in the shared `UserDefaults`.
52-
2. Post a Darwin notification (e.g., `com.yourcompany.voiceink.startRecording`) to immediately notify the main app.
54+
2. Post a Darwin notification (e.g., `com.prakashjoshipax.VoiceInk.startRecording`) to immediately notify the main app.
5355

5456
## Phase 3: Implementing Recording in the Main App
5557

0 commit comments

Comments
 (0)