@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.
- package/CHANGELOG.md +12 -0
- package/CONTRIBUTING.md +92 -0
- package/LICENSE +22 -0
- package/README.md +109 -0
- package/SECURITY.md +67 -0
- package/android/build.gradle +35 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/qafka/attestation/QafkaAttestationModule.kt +92 -0
- package/android/src/main/java/com/qafka/attestation/QafkaAttestationPackage.kt +22 -0
- package/android/src/main/java/com/qafka/audio/QafkaAudioModule.kt +290 -0
- package/android/src/main/java/com/qafka/clipboard/QafkaClipboardModule.kt +28 -0
- package/android/src/main/java/com/qafka/storage/QafkaStorageModule.kt +80 -0
- package/app.plugin.js +1 -0
- package/dist/QafkaSDK.d.ts +174 -0
- package/dist/QafkaSDK.js +461 -0
- package/dist/cards/bindings/resolveFieldName.d.ts +25 -0
- package/dist/cards/bindings/resolveFieldName.js +82 -0
- package/dist/cards/cta/CardContext.d.ts +16 -0
- package/dist/cards/cta/CardContext.js +58 -0
- package/dist/cards/cta/dispatcher.d.ts +7 -0
- package/dist/cards/cta/dispatcher.js +90 -0
- package/dist/cards/cta/types.d.ts +66 -0
- package/dist/cards/cta/types.js +2 -0
- package/dist/cards/index.d.ts +20 -0
- package/dist/cards/index.js +34 -0
- package/dist/cards/primitives/QButton.d.ts +10 -0
- package/dist/cards/primitives/QButton.js +115 -0
- package/dist/cards/primitives/QDivider.d.ts +7 -0
- package/dist/cards/primitives/QDivider.js +17 -0
- package/dist/cards/primitives/QIcon.d.ts +13 -0
- package/dist/cards/primitives/QIcon.js +26 -0
- package/dist/cards/primitives/QImage.d.ts +9 -0
- package/dist/cards/primitives/QImage.js +22 -0
- package/dist/cards/primitives/QText.d.ts +9 -0
- package/dist/cards/primitives/QText.js +30 -0
- package/dist/cards/primitives/QView.d.ts +8 -0
- package/dist/cards/primitives/QView.js +19 -0
- package/dist/cards/renderer/CardRenderer.d.ts +19 -0
- package/dist/cards/renderer/CardRenderer.js +64 -0
- package/dist/cards/renderer/renderNode.d.ts +13 -0
- package/dist/cards/renderer/renderNode.js +42 -0
- package/dist/cards/types.d.ts +110 -0
- package/dist/cards/types.js +6 -0
- package/dist/components/ActionResultBadge.d.ts +12 -0
- package/dist/components/ActionResultBadge.js +58 -0
- package/dist/components/ChatPage.d.ts +44 -0
- package/dist/components/ChatPage.js +84 -0
- package/dist/components/DataChip.d.ts +8 -0
- package/dist/components/DataChip.js +80 -0
- package/dist/components/DataChipList.d.ts +13 -0
- package/dist/components/DataChipList.js +21 -0
- package/dist/components/FloatingButton.d.ts +11 -0
- package/dist/components/FloatingButton.js +162 -0
- package/dist/components/InputArea.d.ts +57 -0
- package/dist/components/InputArea.js +142 -0
- package/dist/components/MarkdownText.d.ts +15 -0
- package/dist/components/MarkdownText.js +283 -0
- package/dist/components/MessageBubble.d.ts +134 -0
- package/dist/components/MessageBubble.js +384 -0
- package/dist/components/NavigationSuggestion.d.ts +11 -0
- package/dist/components/NavigationSuggestion.js +109 -0
- package/dist/components/Qafka.d.ts +39 -0
- package/dist/components/Qafka.handlers.d.ts +21 -0
- package/dist/components/Qafka.handlers.js +54 -0
- package/dist/components/Qafka.js +493 -0
- package/dist/components/Qafka.styles.d.ts +19 -0
- package/dist/components/Qafka.styles.js +101 -0
- package/dist/components/Qafka.types.d.ts +744 -0
- package/dist/components/Qafka.types.js +2 -0
- package/dist/components/Qafka.utils.d.ts +7 -0
- package/dist/components/Qafka.utils.js +34 -0
- package/dist/components/QafkaProvider.d.ts +12 -0
- package/dist/components/QafkaProvider.js +87 -0
- package/dist/components/QuickReplies.d.ts +14 -0
- package/dist/components/QuickReplies.js +48 -0
- package/dist/components/StepProgressIndicator.d.ts +12 -0
- package/dist/components/StepProgressIndicator.js +48 -0
- package/dist/components/SuggestionButton.d.ts +42 -0
- package/dist/components/SuggestionButton.js +67 -0
- package/dist/components/ToolStatusPill.d.ts +20 -0
- package/dist/components/ToolStatusPill.js +43 -0
- package/dist/components/TypingIndicator.d.ts +28 -0
- package/dist/components/TypingIndicator.js +109 -0
- package/dist/components/VoicePage.d.ts +48 -0
- package/dist/components/VoicePage.js +683 -0
- package/dist/components/defaults/DefaultCard.d.ts +14 -0
- package/dist/components/defaults/DefaultCard.js +156 -0
- package/dist/components/defaults/DefaultDetail.d.ts +14 -0
- package/dist/components/defaults/DefaultDetail.js +138 -0
- package/dist/components/defaults/DefaultList.d.ts +12 -0
- package/dist/components/defaults/DefaultList.js +98 -0
- package/dist/components/defaults/DefaultTable.d.ts +14 -0
- package/dist/components/defaults/DefaultTable.js +204 -0
- package/dist/components/defaults/index.d.ts +14 -0
- package/dist/components/defaults/index.js +25 -0
- package/dist/components/index.d.ts +22 -0
- package/dist/components/index.js +36 -0
- package/dist/constants.d.ts +10 -0
- package/dist/constants.js +13 -0
- package/dist/hooks/useChatMessages.d.ts +72 -0
- package/dist/hooks/useChatMessages.js +505 -0
- package/dist/hooks/useContextManager.d.ts +12 -0
- package/dist/hooks/useContextManager.js +46 -0
- package/dist/hooks/useProjectTheme.d.ts +19 -0
- package/dist/hooks/useProjectTheme.js +163 -0
- package/dist/hooks/useSDK.d.ts +31 -0
- package/dist/hooks/useSDK.js +103 -0
- package/dist/hooks/useVoiceChat.d.ts +110 -0
- package/dist/hooks/useVoiceChat.js +436 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +59 -0
- package/dist/native/QafkaAttestation.d.ts +23 -0
- package/dist/native/QafkaAttestation.js +70 -0
- package/dist/native/QafkaAudio.d.ts +14 -0
- package/dist/native/QafkaAudio.js +31 -0
- package/dist/native/QafkaClipboard.d.ts +11 -0
- package/dist/native/QafkaClipboard.js +14 -0
- package/dist/native/QafkaStorage.d.ts +15 -0
- package/dist/native/QafkaStorage.js +12 -0
- package/dist/resolve-project-config.d.ts +35 -0
- package/dist/resolve-project-config.js +41 -0
- package/dist/runtime-config-loader.d.ts +37 -0
- package/dist/runtime-config-loader.js +53 -0
- package/dist/services/AttestationManager.d.ts +38 -0
- package/dist/services/AttestationManager.js +296 -0
- package/dist/services/BackendService.d.ts +156 -0
- package/dist/services/BackendService.js +755 -0
- package/dist/services/ConversationManager.d.ts +43 -0
- package/dist/services/ConversationManager.js +96 -0
- package/dist/services/NavigationHandler.d.ts +29 -0
- package/dist/services/NavigationHandler.js +70 -0
- package/dist/services/RealtimeService.d.ts +83 -0
- package/dist/services/RealtimeService.js +203 -0
- package/dist/services/storage.d.ts +11 -0
- package/dist/services/storage.js +15 -0
- package/dist/services/storageCore.d.ts +17 -0
- package/dist/services/storageCore.js +46 -0
- package/dist/themes/dark.d.ts +5 -0
- package/dist/themes/dark.js +129 -0
- package/dist/themes/index.d.ts +12 -0
- package/dist/themes/index.js +33 -0
- package/dist/themes/light.d.ts +5 -0
- package/dist/themes/light.js +129 -0
- package/dist/themes/types.d.ts +155 -0
- package/dist/themes/types.js +5 -0
- package/dist/types/chat.d.ts +126 -0
- package/dist/types/chat.js +5 -0
- package/dist/types/components.d.ts +56 -0
- package/dist/types/components.js +16 -0
- package/dist/types/external-navigation.d.ts +19 -0
- package/dist/types/external-navigation.js +8 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/types/index.js +25 -0
- package/dist/types/navigation.d.ts +86 -0
- package/dist/types/navigation.js +5 -0
- package/dist/types/sdk.d.ts +36 -0
- package/dist/types/sdk.js +5 -0
- package/dist/utils/deepMerge.d.ts +46 -0
- package/dist/utils/deepMerge.js +70 -0
- package/dist/utils/fontUtils.d.ts +8 -0
- package/dist/utils/fontUtils.js +16 -0
- package/dist/validate-end-user.d.ts +18 -0
- package/dist/validate-end-user.js +74 -0
- package/expo-plugin/withQafkaAttestation.js +57 -0
- package/ios/QafkaAttestation.m +25 -0
- package/ios/QafkaAttestation.swift +128 -0
- package/ios/QafkaAudio.m +23 -0
- package/ios/QafkaAudio.swift +519 -0
- package/ios/QafkaClipboard.m +10 -0
- package/ios/QafkaClipboard.swift +21 -0
- package/ios/QafkaReactImports.h +2 -0
- package/ios/QafkaStorage.m +26 -0
- package/ios/QafkaStorage.swift +118 -0
- package/package.json +82 -0
- package/qafka.config.d.ts +9 -0
- package/qafka.config.js +9 -0
- package/react-native-qafka.podspec +28 -0
- 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,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,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
|
+
}
|