@qafka/react-native 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/CONTRIBUTING.md +92 -0
  3. package/LICENSE +22 -0
  4. package/README.md +109 -0
  5. package/SECURITY.md +67 -0
  6. package/android/build.gradle +35 -0
  7. package/android/src/main/AndroidManifest.xml +2 -0
  8. package/android/src/main/java/com/qafka/attestation/QafkaAttestationModule.kt +92 -0
  9. package/android/src/main/java/com/qafka/attestation/QafkaAttestationPackage.kt +22 -0
  10. package/android/src/main/java/com/qafka/audio/QafkaAudioModule.kt +290 -0
  11. package/android/src/main/java/com/qafka/clipboard/QafkaClipboardModule.kt +28 -0
  12. package/android/src/main/java/com/qafka/storage/QafkaStorageModule.kt +80 -0
  13. package/app.plugin.js +1 -0
  14. package/dist/QafkaSDK.d.ts +174 -0
  15. package/dist/QafkaSDK.js +461 -0
  16. package/dist/cards/bindings/resolveFieldName.d.ts +25 -0
  17. package/dist/cards/bindings/resolveFieldName.js +82 -0
  18. package/dist/cards/cta/CardContext.d.ts +16 -0
  19. package/dist/cards/cta/CardContext.js +58 -0
  20. package/dist/cards/cta/dispatcher.d.ts +7 -0
  21. package/dist/cards/cta/dispatcher.js +90 -0
  22. package/dist/cards/cta/types.d.ts +66 -0
  23. package/dist/cards/cta/types.js +2 -0
  24. package/dist/cards/index.d.ts +20 -0
  25. package/dist/cards/index.js +34 -0
  26. package/dist/cards/primitives/QButton.d.ts +10 -0
  27. package/dist/cards/primitives/QButton.js +115 -0
  28. package/dist/cards/primitives/QDivider.d.ts +7 -0
  29. package/dist/cards/primitives/QDivider.js +17 -0
  30. package/dist/cards/primitives/QIcon.d.ts +13 -0
  31. package/dist/cards/primitives/QIcon.js +26 -0
  32. package/dist/cards/primitives/QImage.d.ts +9 -0
  33. package/dist/cards/primitives/QImage.js +22 -0
  34. package/dist/cards/primitives/QText.d.ts +9 -0
  35. package/dist/cards/primitives/QText.js +30 -0
  36. package/dist/cards/primitives/QView.d.ts +8 -0
  37. package/dist/cards/primitives/QView.js +19 -0
  38. package/dist/cards/renderer/CardRenderer.d.ts +19 -0
  39. package/dist/cards/renderer/CardRenderer.js +64 -0
  40. package/dist/cards/renderer/renderNode.d.ts +13 -0
  41. package/dist/cards/renderer/renderNode.js +42 -0
  42. package/dist/cards/types.d.ts +110 -0
  43. package/dist/cards/types.js +6 -0
  44. package/dist/components/ActionResultBadge.d.ts +12 -0
  45. package/dist/components/ActionResultBadge.js +58 -0
  46. package/dist/components/ChatPage.d.ts +44 -0
  47. package/dist/components/ChatPage.js +84 -0
  48. package/dist/components/DataChip.d.ts +8 -0
  49. package/dist/components/DataChip.js +80 -0
  50. package/dist/components/DataChipList.d.ts +13 -0
  51. package/dist/components/DataChipList.js +21 -0
  52. package/dist/components/FloatingButton.d.ts +11 -0
  53. package/dist/components/FloatingButton.js +162 -0
  54. package/dist/components/InputArea.d.ts +57 -0
  55. package/dist/components/InputArea.js +142 -0
  56. package/dist/components/MarkdownText.d.ts +15 -0
  57. package/dist/components/MarkdownText.js +283 -0
  58. package/dist/components/MessageBubble.d.ts +134 -0
  59. package/dist/components/MessageBubble.js +384 -0
  60. package/dist/components/NavigationSuggestion.d.ts +11 -0
  61. package/dist/components/NavigationSuggestion.js +109 -0
  62. package/dist/components/Qafka.d.ts +39 -0
  63. package/dist/components/Qafka.handlers.d.ts +21 -0
  64. package/dist/components/Qafka.handlers.js +54 -0
  65. package/dist/components/Qafka.js +493 -0
  66. package/dist/components/Qafka.styles.d.ts +19 -0
  67. package/dist/components/Qafka.styles.js +101 -0
  68. package/dist/components/Qafka.types.d.ts +744 -0
  69. package/dist/components/Qafka.types.js +2 -0
  70. package/dist/components/Qafka.utils.d.ts +7 -0
  71. package/dist/components/Qafka.utils.js +34 -0
  72. package/dist/components/QafkaProvider.d.ts +12 -0
  73. package/dist/components/QafkaProvider.js +87 -0
  74. package/dist/components/QuickReplies.d.ts +14 -0
  75. package/dist/components/QuickReplies.js +48 -0
  76. package/dist/components/StepProgressIndicator.d.ts +12 -0
  77. package/dist/components/StepProgressIndicator.js +48 -0
  78. package/dist/components/SuggestionButton.d.ts +42 -0
  79. package/dist/components/SuggestionButton.js +67 -0
  80. package/dist/components/ToolStatusPill.d.ts +20 -0
  81. package/dist/components/ToolStatusPill.js +43 -0
  82. package/dist/components/TypingIndicator.d.ts +28 -0
  83. package/dist/components/TypingIndicator.js +109 -0
  84. package/dist/components/VoicePage.d.ts +48 -0
  85. package/dist/components/VoicePage.js +683 -0
  86. package/dist/components/defaults/DefaultCard.d.ts +14 -0
  87. package/dist/components/defaults/DefaultCard.js +156 -0
  88. package/dist/components/defaults/DefaultDetail.d.ts +14 -0
  89. package/dist/components/defaults/DefaultDetail.js +138 -0
  90. package/dist/components/defaults/DefaultList.d.ts +12 -0
  91. package/dist/components/defaults/DefaultList.js +98 -0
  92. package/dist/components/defaults/DefaultTable.d.ts +14 -0
  93. package/dist/components/defaults/DefaultTable.js +204 -0
  94. package/dist/components/defaults/index.d.ts +14 -0
  95. package/dist/components/defaults/index.js +25 -0
  96. package/dist/components/index.d.ts +22 -0
  97. package/dist/components/index.js +36 -0
  98. package/dist/constants.d.ts +10 -0
  99. package/dist/constants.js +13 -0
  100. package/dist/hooks/useChatMessages.d.ts +72 -0
  101. package/dist/hooks/useChatMessages.js +505 -0
  102. package/dist/hooks/useContextManager.d.ts +12 -0
  103. package/dist/hooks/useContextManager.js +46 -0
  104. package/dist/hooks/useProjectTheme.d.ts +19 -0
  105. package/dist/hooks/useProjectTheme.js +163 -0
  106. package/dist/hooks/useSDK.d.ts +31 -0
  107. package/dist/hooks/useSDK.js +103 -0
  108. package/dist/hooks/useVoiceChat.d.ts +110 -0
  109. package/dist/hooks/useVoiceChat.js +436 -0
  110. package/dist/index.d.ts +13 -0
  111. package/dist/index.js +59 -0
  112. package/dist/native/QafkaAttestation.d.ts +23 -0
  113. package/dist/native/QafkaAttestation.js +70 -0
  114. package/dist/native/QafkaAudio.d.ts +14 -0
  115. package/dist/native/QafkaAudio.js +31 -0
  116. package/dist/native/QafkaClipboard.d.ts +11 -0
  117. package/dist/native/QafkaClipboard.js +14 -0
  118. package/dist/native/QafkaStorage.d.ts +15 -0
  119. package/dist/native/QafkaStorage.js +12 -0
  120. package/dist/resolve-project-config.d.ts +35 -0
  121. package/dist/resolve-project-config.js +41 -0
  122. package/dist/runtime-config-loader.d.ts +37 -0
  123. package/dist/runtime-config-loader.js +53 -0
  124. package/dist/services/AttestationManager.d.ts +38 -0
  125. package/dist/services/AttestationManager.js +296 -0
  126. package/dist/services/BackendService.d.ts +156 -0
  127. package/dist/services/BackendService.js +755 -0
  128. package/dist/services/ConversationManager.d.ts +43 -0
  129. package/dist/services/ConversationManager.js +96 -0
  130. package/dist/services/NavigationHandler.d.ts +29 -0
  131. package/dist/services/NavigationHandler.js +70 -0
  132. package/dist/services/RealtimeService.d.ts +83 -0
  133. package/dist/services/RealtimeService.js +203 -0
  134. package/dist/services/storage.d.ts +11 -0
  135. package/dist/services/storage.js +15 -0
  136. package/dist/services/storageCore.d.ts +17 -0
  137. package/dist/services/storageCore.js +46 -0
  138. package/dist/themes/dark.d.ts +5 -0
  139. package/dist/themes/dark.js +129 -0
  140. package/dist/themes/index.d.ts +12 -0
  141. package/dist/themes/index.js +33 -0
  142. package/dist/themes/light.d.ts +5 -0
  143. package/dist/themes/light.js +129 -0
  144. package/dist/themes/types.d.ts +155 -0
  145. package/dist/themes/types.js +5 -0
  146. package/dist/types/chat.d.ts +126 -0
  147. package/dist/types/chat.js +5 -0
  148. package/dist/types/components.d.ts +56 -0
  149. package/dist/types/components.js +16 -0
  150. package/dist/types/external-navigation.d.ts +19 -0
  151. package/dist/types/external-navigation.js +8 -0
  152. package/dist/types/index.d.ts +9 -0
  153. package/dist/types/index.js +25 -0
  154. package/dist/types/navigation.d.ts +86 -0
  155. package/dist/types/navigation.js +5 -0
  156. package/dist/types/sdk.d.ts +36 -0
  157. package/dist/types/sdk.js +5 -0
  158. package/dist/utils/deepMerge.d.ts +46 -0
  159. package/dist/utils/deepMerge.js +70 -0
  160. package/dist/utils/fontUtils.d.ts +8 -0
  161. package/dist/utils/fontUtils.js +16 -0
  162. package/dist/validate-end-user.d.ts +18 -0
  163. package/dist/validate-end-user.js +74 -0
  164. package/expo-plugin/withQafkaAttestation.js +57 -0
  165. package/ios/QafkaAttestation.m +25 -0
  166. package/ios/QafkaAttestation.swift +128 -0
  167. package/ios/QafkaAudio.m +23 -0
  168. package/ios/QafkaAudio.swift +519 -0
  169. package/ios/QafkaClipboard.m +10 -0
  170. package/ios/QafkaClipboard.swift +21 -0
  171. package/ios/QafkaReactImports.h +2 -0
  172. package/ios/QafkaStorage.m +26 -0
  173. package/ios/QafkaStorage.swift +118 -0
  174. package/package.json +82 -0
  175. package/qafka.config.d.ts +9 -0
  176. package/qafka.config.js +9 -0
  177. package/react-native-qafka.podspec +28 -0
  178. package/react-native.config.js +14 -0
@@ -0,0 +1,519 @@
1
+ import Foundation
2
+ import AVFoundation
3
+ import React
4
+
5
+ #if DEBUG
6
+ private func qafkaLog(_ message: String) { NSLog("%@", message) }
7
+ #else
8
+ private func qafkaLog(_ message: String) {}
9
+ #endif
10
+
11
+ @objc(QafkaAudio)
12
+ class QafkaAudio: RCTEventEmitter {
13
+
14
+ private var audioEngine: AVAudioEngine?
15
+ private var playerNode: AVAudioPlayerNode?
16
+ private var isCapturing = false
17
+ private var hasListeners = false
18
+ private var configChangeObserver: NSObjectProtocol?
19
+ private var playChunkCount = 0
20
+
21
+ // MARK: - RCTEventEmitter overrides
22
+
23
+ override static func requiresMainQueueSetup() -> Bool {
24
+ return false
25
+ }
26
+
27
+ override func supportedEvents() -> [String]! {
28
+ return ["onAudioData", "onAmplitude"]
29
+ }
30
+
31
+ override func startObserving() {
32
+ hasListeners = true
33
+ }
34
+
35
+ override func stopObserving() {
36
+ hasListeners = false
37
+ }
38
+
39
+ // MARK: - Route helpers
40
+
41
+ /// Re-assert the correct output route after VPIO state transitions.
42
+ ///
43
+ /// VPIO (Apple voice-processing IO) can silently park the output on the
44
+ /// receiver (the earpiece next to your face) instead of the loudspeaker —
45
+ /// the documented workaround is the `.none` → `.speaker` toggle. But that
46
+ /// toggle is destructive when an external accessory is connected: it yanks
47
+ /// audio off AirPods / wired headphones / CarPlay / USB and forces the
48
+ /// iPhone speaker, which is the opposite of what the user wants. Only
49
+ /// apply the speaker override when no accessory is in the current route.
50
+ private func reassertOutputRoute() {
51
+ let session = AVAudioSession.sharedInstance()
52
+ let hasExternalOutput = session.currentRoute.outputs.contains { output in
53
+ switch output.portType {
54
+ case .headphones,
55
+ .headsetMic,
56
+ .bluetoothA2DP,
57
+ .bluetoothHFP,
58
+ .bluetoothLE,
59
+ .airPlay,
60
+ .lineOut,
61
+ .usbAudio,
62
+ .carAudio:
63
+ return true
64
+ default:
65
+ return false
66
+ }
67
+ }
68
+ if hasExternalOutput {
69
+ try? session.overrideOutputAudioPort(.none)
70
+ let outputs = session.currentRoute.outputs.map { "\($0.portType.rawValue)" }.joined(separator: ",")
71
+ qafkaLog("[QafkaAudio] external output present — keeping accessory route outputs=[\(outputs)]")
72
+ } else {
73
+ try? session.overrideOutputAudioPort(.none)
74
+ try? session.overrideOutputAudioPort(.speaker)
75
+ }
76
+ }
77
+
78
+ // MARK: - Capture
79
+
80
+ @objc func startCapture(
81
+ _ resolve: @escaping RCTPromiseResolveBlock,
82
+ rejecter reject: @escaping RCTPromiseRejectBlock
83
+ ) {
84
+ if isCapturing {
85
+ resolve(true)
86
+ return
87
+ }
88
+
89
+ AVAudioSession.sharedInstance().requestRecordPermission { [weak self] granted in
90
+ guard let self = self else { return }
91
+
92
+ if !granted {
93
+ reject("PERMISSION_DENIED", "Microphone permission denied", nil)
94
+ return
95
+ }
96
+
97
+ DispatchQueue.global(qos: .userInitiated).async {
98
+ self.doStartCapture(resolve, reject: reject)
99
+ }
100
+ }
101
+ }
102
+
103
+ private func doStartCapture(
104
+ _ resolve: @escaping RCTPromiseResolveBlock,
105
+ reject: @escaping RCTPromiseRejectBlock
106
+ ) {
107
+ qafkaLog("[QafkaAudio] === doStartCapture begin ===")
108
+
109
+ // Defensive startup cleanup — recover from any leaked VPIO/audio-unit state
110
+ // left by a prior crashed session. Apple Forums 721535 confirms the combo
111
+ // of (deactivate) + (force category) + (output-port toggle) is needed to
112
+ // break out of a stuck "silent after VPIO" state without a device reboot.
113
+ do {
114
+ let session = AVAudioSession.sharedInstance()
115
+
116
+ // 1. Deactivate any lingering session so the new category takes cleanly.
117
+ try? session.setActive(false, options: .notifyOthersOnDeactivation)
118
+
119
+ // 2. Force-set category even if it matches current — this causes iOS to
120
+ // rebuild the audio graph and release stale audio-unit references.
121
+ try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth])
122
+
123
+ // 3. Activate.
124
+ try session.setActive(true)
125
+
126
+ // 4. Output-port toggle — verified fix for "VPIO leaves output silent"
127
+ // bug. Speaker is only re-asserted when no accessory (AirPods,
128
+ // headphones, CarPlay) is connected; otherwise the accessory route
129
+ // is preserved.
130
+ reassertOutputRoute()
131
+
132
+ let route = session.currentRoute
133
+ let outputs = route.outputs.map { "\($0.portType.rawValue)" }.joined(separator: ",")
134
+ qafkaLog("[QafkaAudio] AudioSession configured: mode=voiceChat sr=\(session.sampleRate) outputs=[\(outputs)] outVol=\(session.outputVolume)")
135
+ } catch {
136
+ qafkaLog("[QafkaAudio] ❌ AudioSession failed: \(error.localizedDescription)")
137
+ reject("AUDIO_SESSION_ERROR", "Failed to configure audio session: \(error.localizedDescription)", error)
138
+ return
139
+ }
140
+
141
+ let engine = AVAudioEngine()
142
+ let inputNode = engine.inputNode
143
+
144
+ // VPIO — Apple hardware AEC + noise suppression + AGC.
145
+ // Same API FaceTime/WhatsApp use. Clean teardown is critical — see
146
+ // stopCapture: VPIO MUST be disabled AFTER engine.stop(), not before,
147
+ // otherwise setVoiceProcessingEnabled throws (engine-running error) and
148
+ // the audio unit stays hot across app restarts.
149
+ var vpioEnabled = false
150
+ do {
151
+ try inputNode.setVoiceProcessingEnabled(true)
152
+ vpioEnabled = true
153
+ qafkaLog("[QafkaAudio] ✅ VPIO enabled")
154
+ } catch let vpioError as NSError {
155
+ qafkaLog("[QafkaAudio] ⚠️ VPIO failed: \(vpioError.localizedDescription) — continuing without VPIO")
156
+ }
157
+
158
+ // Read formats AFTER enabling VPIO — sample rate may change.
159
+ let inputFormat = inputNode.outputFormat(forBus: 0)
160
+ qafkaLog("[QafkaAudio] inputFormat: sr=\(inputFormat.sampleRate) ch=\(inputFormat.channelCount) vpio=\(vpioEnabled)")
161
+
162
+ // Player setup — mixer format aligns with VPIO output after it's enabled.
163
+ let player = AVAudioPlayerNode()
164
+ engine.attach(player)
165
+ let mixer = engine.mainMixerNode
166
+ let mixerFormat = mixer.outputFormat(forBus: 0)
167
+ engine.connect(player, to: mixer, format: mixerFormat)
168
+
169
+ // Target format for capture: 16kHz mono Int16.
170
+ guard let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: true) else {
171
+ reject("FORMAT_ERROR", "Failed to create target audio format", nil)
172
+ return
173
+ }
174
+
175
+ guard let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
176
+ reject("CONVERTER_ERROR", "Failed to create audio converter", nil)
177
+ return
178
+ }
179
+
180
+ let bufferSize: AVAudioFrameCount = 4096
181
+
182
+ inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: inputFormat) { [weak self] (buffer, _) in
183
+ guard let self = self, self.hasListeners else { return }
184
+
185
+ let frameCapacity: AVAudioFrameCount = AVAudioFrameCount(
186
+ Double(buffer.frameLength) * (16000.0 / inputFormat.sampleRate)
187
+ )
188
+
189
+ guard let convertedBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: frameCapacity) else {
190
+ return
191
+ }
192
+
193
+ var error: NSError?
194
+ let status = converter.convert(to: convertedBuffer, error: &error) { inNumPackets, outStatus in
195
+ outStatus.pointee = .haveData
196
+ return buffer
197
+ }
198
+
199
+ guard status != .error, error == nil else { return }
200
+ guard let channelData = convertedBuffer.int16ChannelData?[0] else { return }
201
+
202
+ let audioData = Data(
203
+ bytes: channelData,
204
+ count: Int(convertedBuffer.frameLength) * MemoryLayout<Int16>.size
205
+ )
206
+
207
+ let sampleCount = Int(convertedBuffer.frameLength)
208
+ if sampleCount > 0 {
209
+ let samples = channelData
210
+ var sumSquares: Float = 0
211
+ for i in 0..<sampleCount {
212
+ let sample = Float(samples[i]) / Float(Int16.max)
213
+ sumSquares += sample * sample
214
+ }
215
+ let rms = sqrt(sumSquares / Float(sampleCount))
216
+ let level = min(rms * 3.0, 1.0)
217
+ self.sendEvent(withName: "onAmplitude", body: ["level": level, "source": "mic"])
218
+ }
219
+
220
+ let base64String = audioData.base64EncodedString()
221
+ self.sendEvent(withName: "onAudioData", body: ["audio": base64String])
222
+ }
223
+
224
+ engine.prepare()
225
+
226
+ // VPIO may trigger AVAudioEngineConfigurationChange which stops the engine.
227
+ // Auto-restart so capture continues. Apple's documented behavior.
228
+ if let existing = configChangeObserver {
229
+ NotificationCenter.default.removeObserver(existing)
230
+ }
231
+ configChangeObserver = NotificationCenter.default.addObserver(
232
+ forName: .AVAudioEngineConfigurationChange,
233
+ object: engine,
234
+ queue: .main
235
+ ) { [weak self, weak engine] _ in
236
+ guard let self = self, let engine = engine else { return }
237
+ qafkaLog("[QafkaAudio] 🔔 AVAudioEngineConfigurationChange — isRunning=\(engine.isRunning) isCapturing=\(self.isCapturing)")
238
+ guard self.isCapturing, let player = self.playerNode else { return }
239
+
240
+ // Config change on iOS rebuilds the audio graph and can change the mixer
241
+ // output format (hardware sample rate). The previous connection between
242
+ // player and mixer becomes invalid — scheduleBuffer still "succeeds" but
243
+ // no audio reaches the speaker. Must reconnect with current formats.
244
+ if engine.isRunning {
245
+ engine.stop()
246
+ }
247
+
248
+ engine.disconnectNodeInput(engine.mainMixerNode)
249
+ let currentMixerFormat = engine.mainMixerNode.outputFormat(forBus: 0)
250
+ engine.connect(player, to: engine.mainMixerNode, format: currentMixerFormat)
251
+ qafkaLog("[QafkaAudio] reconnected player → mixer (sr=\(currentMixerFormat.sampleRate) ch=\(currentMixerFormat.channelCount))")
252
+
253
+ do {
254
+ try engine.start()
255
+ player.play()
256
+
257
+ // Route re-assert — config change can also silently bump route off
258
+ // speaker. Preserves accessory routes (AirPods/headphones) when
259
+ // present.
260
+ self.reassertOutputRoute()
261
+
262
+ qafkaLog("[QafkaAudio] 🔄 Engine rebuilt + restarted after config change")
263
+ } catch let err as NSError {
264
+ qafkaLog("[QafkaAudio] ❌ Engine rebuild failed: \(err.localizedDescription)")
265
+ }
266
+ }
267
+
268
+ do {
269
+ try engine.start()
270
+ } catch let startError as NSError {
271
+ qafkaLog("[QafkaAudio] ❌ engine.start THREW: \(startError.localizedDescription)")
272
+ reject("ENGINE_ERROR", "engine.start: \(startError.localizedDescription)", startError)
273
+ return
274
+ }
275
+
276
+ player.play()
277
+
278
+ // Post-engine-start output-port toggle (Apple Forums 721535).
279
+ // VPIO can silently shift the output route to the receiver or to null
280
+ // during engine-start; this call reasserts the speaker route. When an
281
+ // external accessory (AirPods/headphones/CarPlay) is connected the
282
+ // accessory route is preserved — speaker is only forced for internal
283
+ // routes.
284
+ reassertOutputRoute()
285
+ let postStartOutputs = AVAudioSession.sharedInstance().currentRoute.outputs
286
+ .map { "\($0.portType.rawValue)" }.joined(separator: ",")
287
+ qafkaLog("[QafkaAudio] post-start route — outputs=[\(postStartOutputs)]")
288
+
289
+ qafkaLog("[QafkaAudio] ✅ engine started, player playing. vpio=\(vpioEnabled)")
290
+
291
+ self.audioEngine = engine
292
+ self.playerNode = player
293
+ self.isCapturing = true
294
+
295
+ resolve(true)
296
+ }
297
+
298
+ // MARK: - Stop Capture
299
+
300
+ @objc func stopCapture(
301
+ _ resolve: @escaping RCTPromiseResolveBlock,
302
+ rejecter reject: @escaping RCTPromiseRejectBlock
303
+ ) {
304
+ guard let engine = audioEngine else {
305
+ qafkaLog("[QafkaAudio] stopCapture: no engine, nothing to clean up")
306
+ resolve(true)
307
+ return
308
+ }
309
+
310
+ qafkaLog("[QafkaAudio] === stopCapture begin === isCapturing=\(isCapturing) engineRunning=\(engine.isRunning)")
311
+
312
+ // Remove observer BEFORE any teardown so auto-restart can't fire during it.
313
+ if let observer = configChangeObserver {
314
+ NotificationCenter.default.removeObserver(observer)
315
+ configChangeObserver = nil
316
+ }
317
+
318
+ // Apple-recommended VPIO teardown order. Critical: disable VPIO AFTER
319
+ // engine.stop(), not before. setVoiceProcessingEnabled(_:) throws when
320
+ // the engine is running — an earlier version of this code had VPIO
321
+ // disable BEFORE engine.stop with try?, which silently swallowed the
322
+ // error and never actually disabled VPIO. That leaked the audio unit
323
+ // across sessions and required a device reboot to recover.
324
+
325
+ // 1. Remove the input tap while the engine is still running.
326
+ if isCapturing {
327
+ engine.inputNode.removeTap(onBus: 0)
328
+ }
329
+
330
+ // 2. Stop + reset playback node. reset() purges any scheduled buffers that
331
+ // haven't played yet. Without this, iOS can hold references to those
332
+ // buffer's audio-unit nodes across the session boundary.
333
+ playerNode?.stop()
334
+ playerNode?.reset()
335
+
336
+ // 3. Stop the engine FIRST — VPIO can only be disabled on a stopped engine.
337
+ engine.stop()
338
+
339
+ // 4. Now disable VPIO. With the engine stopped this call succeeds and
340
+ // releases the voice-processing audio unit back to the system.
341
+ do {
342
+ try engine.inputNode.setVoiceProcessingEnabled(false)
343
+ qafkaLog("[QafkaAudio] VPIO disabled")
344
+ } catch let err as NSError {
345
+ qafkaLog("[QafkaAudio] ⚠️ VPIO disable failed: \(err.localizedDescription)")
346
+ }
347
+
348
+ // 5. Break the engine graph and detach nodes. Without disconnectNodeInput,
349
+ // the mixer keeps a strong reference to the player even after detach —
350
+ // iOS doesn't fully release the audio unit until the graph is broken.
351
+ engine.disconnectNodeInput(engine.mainMixerNode)
352
+ if let player = playerNode {
353
+ engine.detach(player)
354
+ }
355
+ engine.reset()
356
+
357
+ // 6. Deactivate the audio session and notify other apps we're done.
358
+ do {
359
+ try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
360
+ qafkaLog("[QafkaAudio] AudioSession deactivated")
361
+ } catch let err as NSError {
362
+ qafkaLog("[QafkaAudio] ⚠️ AudioSession deactivate failed: \(err.localizedDescription)")
363
+ }
364
+
365
+ self.audioEngine = nil
366
+ self.playerNode = nil
367
+ self.isCapturing = false
368
+ self.playChunkCount = 0
369
+
370
+ qafkaLog("[QafkaAudio] === stopCapture done ===")
371
+ resolve(true)
372
+ }
373
+
374
+ // Last-resort cleanup if the JS side never calls stopCapture (e.g. app is
375
+ // killed mid-session). Best-effort — not all cleanup is guaranteed here.
376
+ deinit {
377
+ if let engine = audioEngine {
378
+ qafkaLog("[QafkaAudio] deinit: forcing teardown of live engine")
379
+ if let observer = configChangeObserver {
380
+ NotificationCenter.default.removeObserver(observer)
381
+ }
382
+ if isCapturing {
383
+ engine.inputNode.removeTap(onBus: 0)
384
+ }
385
+ playerNode?.stop()
386
+ playerNode?.reset()
387
+ engine.stop()
388
+ // VPIO disable AFTER engine.stop — same order as stopCapture.
389
+ try? engine.inputNode.setVoiceProcessingEnabled(false)
390
+ engine.disconnectNodeInput(engine.mainMixerNode)
391
+ if let player = playerNode {
392
+ engine.detach(player)
393
+ }
394
+ engine.reset()
395
+ try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
396
+ }
397
+ }
398
+
399
+ // MARK: - Playback
400
+
401
+ @objc func playAudioChunk(
402
+ _ base64Data: String,
403
+ resolver resolve: @escaping RCTPromiseResolveBlock,
404
+ rejecter reject: @escaping RCTPromiseRejectBlock
405
+ ) {
406
+ playChunkCount += 1
407
+ let isLoggedChunk = playChunkCount <= 3 || playChunkCount % 50 == 0
408
+
409
+ guard let player = playerNode, let engine = audioEngine else {
410
+ qafkaLog("[QafkaAudio] ❌ playAudioChunk #\(playChunkCount): engine not running")
411
+ reject("NOT_RUNNING", "Audio engine is not running. Call startCapture first.", nil)
412
+ return
413
+ }
414
+
415
+ // Engine may have stopped due to AVAudioEngineConfigurationChange — restart
416
+ if !engine.isRunning {
417
+ do {
418
+ try engine.start()
419
+ player.play()
420
+ qafkaLog("[QafkaAudio] 🔄 Engine restarted from playAudioChunk")
421
+ } catch let err as NSError {
422
+ qafkaLog("[QafkaAudio] ❌ playAudioChunk engine restart failed: \(err.localizedDescription)")
423
+ reject("NOT_RUNNING", "Audio engine restart failed: \(err.localizedDescription)", err)
424
+ return
425
+ }
426
+ }
427
+
428
+ guard let audioData = Data(base64Encoded: base64Data) else {
429
+ qafkaLog("[QafkaAudio] ❌ playAudioChunk #\(playChunkCount): invalid base64")
430
+ reject("INVALID_DATA", "Invalid base64 audio data", nil)
431
+ return
432
+ }
433
+
434
+ // Incoming audio: 24kHz mono Int16.
435
+ guard let srcFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 24000, channels: 1, interleaved: true) else {
436
+ reject("FORMAT_ERROR", "Failed to create source format", nil)
437
+ return
438
+ }
439
+
440
+ let frameCount = UInt32(audioData.count) / UInt32(MemoryLayout<Int16>.size)
441
+
442
+ guard let srcBuffer = AVAudioPCMBuffer(pcmFormat: srcFormat, frameCapacity: frameCount) else {
443
+ reject("BUFFER_ERROR", "Failed to create source buffer", nil)
444
+ return
445
+ }
446
+ srcBuffer.frameLength = frameCount
447
+
448
+ guard let srcChannelData = srcBuffer.int16ChannelData?[0] else {
449
+ reject("BUFFER_ERROR", "Failed to access source buffer channel data", nil)
450
+ return
451
+ }
452
+ audioData.withUnsafeBytes { rawPtr in
453
+ if let baseAddress = rawPtr.baseAddress {
454
+ memcpy(srcChannelData, baseAddress, audioData.count)
455
+ }
456
+ }
457
+
458
+ if hasListeners && frameCount > 0 {
459
+ let samples = srcChannelData
460
+ var sumSquares: Float = 0
461
+ for i in 0..<Int(frameCount) {
462
+ let sample = Float(samples[i]) / Float(Int16.max)
463
+ sumSquares += sample * sample
464
+ }
465
+ let rms = sqrt(sumSquares / Float(frameCount))
466
+ let level = min(rms * 3.0, 1.0)
467
+ sendEvent(withName: "onAmplitude", body: ["level": level, "source": "speaker"])
468
+ }
469
+
470
+ let mixerFormat = engine.mainMixerNode.outputFormat(forBus: 0)
471
+ guard let playbackConverter = AVAudioConverter(from: srcFormat, to: mixerFormat) else {
472
+ reject("CONVERT_ERROR", "Failed to convert audio to playback format", nil)
473
+ return
474
+ }
475
+
476
+ let outputFrameCount = AVAudioFrameCount(
477
+ Double(frameCount) * (mixerFormat.sampleRate / 24000.0)
478
+ )
479
+ guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: mixerFormat, frameCapacity: outputFrameCount) else {
480
+ reject("CONVERT_ERROR", "Failed to convert audio to playback format", nil)
481
+ return
482
+ }
483
+
484
+ var convError: NSError?
485
+ playbackConverter.convert(to: outputBuffer, error: &convError) { _, outStatus in
486
+ outStatus.pointee = .haveData
487
+ return srcBuffer
488
+ }
489
+
490
+ if let convError = convError {
491
+ reject("CONVERT_ERROR", "Audio conversion failed: \(convError.localizedDescription)", convError)
492
+ return
493
+ }
494
+
495
+ player.scheduleBuffer(outputBuffer, completionHandler: nil)
496
+ if isLoggedChunk {
497
+ qafkaLog("[QafkaAudio] 🔊 playAudioChunk #\(playChunkCount): scheduled \(outputBuffer.frameLength)f @ \(mixerFormat.sampleRate)Hz playerPlaying=\(player.isPlaying) engineRunning=\(engine.isRunning)")
498
+ }
499
+
500
+ resolve(true)
501
+ }
502
+
503
+ // MARK: - Stop Playback
504
+
505
+ @objc func stopPlayback(
506
+ _ resolve: @escaping RCTPromiseResolveBlock,
507
+ rejecter reject: @escaping RCTPromiseRejectBlock
508
+ ) {
509
+ guard let player = playerNode else {
510
+ resolve(true)
511
+ return
512
+ }
513
+
514
+ player.stop()
515
+ player.play() // Reset and ready for new chunks
516
+
517
+ resolve(true)
518
+ }
519
+ }
@@ -0,0 +1,10 @@
1
+ #import <React/RCTBridgeModule.h>
2
+
3
+ @interface RCT_EXTERN_MODULE(QafkaClipboard, NSObject)
4
+
5
+ RCT_EXTERN_METHOD(setString:
6
+ (NSString *)value
7
+ resolver:(RCTPromiseResolveBlock)resolve
8
+ rejecter:(RCTPromiseRejectBlock)reject)
9
+
10
+ @end
@@ -0,0 +1,21 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ @objc(QafkaClipboard)
5
+ class QafkaClipboard: NSObject {
6
+
7
+ @objc static func requiresMainQueueSetup() -> Bool {
8
+ return false
9
+ }
10
+
11
+ @objc func setString(
12
+ _ value: String,
13
+ resolver resolve: @escaping RCTPromiseResolveBlock,
14
+ rejecter reject: @escaping RCTPromiseRejectBlock
15
+ ) {
16
+ DispatchQueue.main.async {
17
+ UIPasteboard.general.string = value
18
+ resolve(NSNull())
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,2 @@
1
+ #import <React/RCTBridgeModule.h>
2
+ #import <React/RCTEventEmitter.h>
@@ -0,0 +1,26 @@
1
+ #import <React/RCTBridgeModule.h>
2
+
3
+ @interface RCT_EXTERN_MODULE(QafkaStorage, NSObject)
4
+
5
+ RCT_EXTERN_METHOD(getItem:
6
+ (NSString *)key
7
+ resolver:(RCTPromiseResolveBlock)resolve
8
+ rejecter:(RCTPromiseRejectBlock)reject)
9
+
10
+ RCT_EXTERN_METHOD(setItem:
11
+ (NSString *)key
12
+ value:(NSString *)value
13
+ resolver:(RCTPromiseResolveBlock)resolve
14
+ rejecter:(RCTPromiseRejectBlock)reject)
15
+
16
+ RCT_EXTERN_METHOD(removeItem:
17
+ (NSString *)key
18
+ resolver:(RCTPromiseResolveBlock)resolve
19
+ rejecter:(RCTPromiseRejectBlock)reject)
20
+
21
+ RCT_EXTERN_METHOD(multiRemove:
22
+ (NSArray<NSString *> *)keys
23
+ resolver:(RCTPromiseResolveBlock)resolve
24
+ rejecter:(RCTPromiseRejectBlock)reject)
25
+
26
+ @end
@@ -0,0 +1,118 @@
1
+ import Foundation
2
+ import Security
3
+
4
+ @objc(QafkaStorage)
5
+ class QafkaStorage: NSObject {
6
+
7
+ private static let service = "app.qafka.sdk"
8
+
9
+ @objc static func requiresMainQueueSetup() -> Bool {
10
+ return false
11
+ }
12
+
13
+ // MARK: - Helpers
14
+
15
+ private func baseQuery(forKey key: String) -> [String: Any] {
16
+ return [
17
+ kSecClass as String: kSecClassGenericPassword,
18
+ kSecAttrService as String: QafkaStorage.service,
19
+ kSecAttrAccount as String: key,
20
+ kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
21
+ ]
22
+ }
23
+
24
+ // MARK: - React methods
25
+
26
+ @objc func getItem(
27
+ _ key: String,
28
+ resolver resolve: @escaping RCTPromiseResolveBlock,
29
+ rejecter reject: @escaping RCTPromiseRejectBlock
30
+ ) {
31
+ var query = baseQuery(forKey: key)
32
+ query[kSecReturnData as String] = kCFBooleanTrue
33
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
34
+
35
+ var item: CFTypeRef?
36
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
37
+
38
+ switch status {
39
+ case errSecSuccess:
40
+ if let data = item as? Data, let str = String(data: data, encoding: .utf8) {
41
+ resolve(str)
42
+ } else {
43
+ resolve(NSNull())
44
+ }
45
+ case errSecItemNotFound:
46
+ resolve(NSNull())
47
+ default:
48
+ reject("KEYCHAIN_READ_FAILED", "Keychain read failed (OSStatus \(status))", nil)
49
+ }
50
+ }
51
+
52
+ @objc func setItem(
53
+ _ key: String,
54
+ value: String,
55
+ resolver resolve: @escaping RCTPromiseResolveBlock,
56
+ rejecter reject: @escaping RCTPromiseRejectBlock
57
+ ) {
58
+ guard let data = value.data(using: .utf8) else {
59
+ reject("INVALID_INPUT", "Value is not valid UTF-8", nil)
60
+ return
61
+ }
62
+
63
+ let query = baseQuery(forKey: key)
64
+ let updateAttrs: [String: Any] = [
65
+ kSecValueData as String: data,
66
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
67
+ ]
68
+
69
+ let updateStatus = SecItemUpdate(query as CFDictionary, updateAttrs as CFDictionary)
70
+ if updateStatus == errSecSuccess {
71
+ resolve(NSNull())
72
+ return
73
+ }
74
+ if updateStatus != errSecItemNotFound {
75
+ reject("KEYCHAIN_UPDATE_FAILED", "Keychain update failed (OSStatus \(updateStatus))", nil)
76
+ return
77
+ }
78
+
79
+ var addQuery = query
80
+ addQuery[kSecValueData as String] = data
81
+ addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
82
+
83
+ let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
84
+ if addStatus == errSecSuccess {
85
+ resolve(NSNull())
86
+ } else {
87
+ reject("KEYCHAIN_ADD_FAILED", "Keychain add failed (OSStatus \(addStatus))", nil)
88
+ }
89
+ }
90
+
91
+ @objc func removeItem(
92
+ _ key: String,
93
+ resolver resolve: @escaping RCTPromiseResolveBlock,
94
+ rejecter reject: @escaping RCTPromiseRejectBlock
95
+ ) {
96
+ let status = SecItemDelete(baseQuery(forKey: key) as CFDictionary)
97
+ if status == errSecSuccess || status == errSecItemNotFound {
98
+ resolve(NSNull())
99
+ } else {
100
+ reject("KEYCHAIN_DELETE_FAILED", "Keychain delete failed (OSStatus \(status))", nil)
101
+ }
102
+ }
103
+
104
+ @objc func multiRemove(
105
+ _ keys: [String],
106
+ resolver resolve: @escaping RCTPromiseResolveBlock,
107
+ rejecter reject: @escaping RCTPromiseRejectBlock
108
+ ) {
109
+ for key in keys {
110
+ let status = SecItemDelete(baseQuery(forKey: key) as CFDictionary)
111
+ if status != errSecSuccess && status != errSecItemNotFound {
112
+ reject("KEYCHAIN_DELETE_FAILED", "Keychain delete failed for key \(key) (OSStatus \(status))", nil)
113
+ return
114
+ }
115
+ }
116
+ resolve(NSNull())
117
+ }
118
+ }