nosnia-audio-recorder 0.3.0 → 0.3.2
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.
|
@@ -72,39 +72,109 @@ class NosniaAudioRecorderModule(reactContext: ReactApplicationContext) :
|
|
|
72
72
|
|
|
73
73
|
override fun startRecording(options: ReadableMap, promise: Promise) {
|
|
74
74
|
try {
|
|
75
|
+
// Prevent concurrent recordings
|
|
75
76
|
if (isRecording) {
|
|
76
77
|
promise.reject("ALREADY_RECORDING", "Recording is already in progress")
|
|
77
78
|
return
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
val
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// Validate and get filename
|
|
82
|
+
val filename = options.getString("filename")?.takeIf { it.isNotBlank() } ?: generateFilename()
|
|
83
|
+
|
|
84
|
+
// Validate and sanitize audio parameters with bounds checking
|
|
85
|
+
val bitrate = options.getInt("bitrate").let {
|
|
86
|
+
when {
|
|
87
|
+
it <= 0 -> 128000 // Default
|
|
88
|
+
it > 320000 -> 320000 // Max reasonable bitrate
|
|
89
|
+
else -> it
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
val channels = options.getInt("channels").let {
|
|
94
|
+
when {
|
|
95
|
+
it <= 0 -> 1 // Default to mono
|
|
96
|
+
it > 2 -> 2 // Max 2 channels (stereo)
|
|
97
|
+
else -> it
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
val sampleRate = options.getInt("sampleRate").let {
|
|
102
|
+
when {
|
|
103
|
+
it <= 0 -> 44100 // Default
|
|
104
|
+
it < 8000 -> 8000 // Min reasonable sample rate
|
|
105
|
+
it > 48000 -> 48000 // Max reasonable sample rate
|
|
106
|
+
else -> it
|
|
107
|
+
}
|
|
108
|
+
}
|
|
84
109
|
|
|
110
|
+
// Get and validate recording directory
|
|
85
111
|
val recordingDir = getRecordingDirectory()
|
|
86
112
|
if (!recordingDir.exists()) {
|
|
87
|
-
recordingDir.mkdirs()
|
|
113
|
+
if (!recordingDir.mkdirs()) {
|
|
114
|
+
promise.reject("DIRECTORY_ERROR", "Failed to create recording directory")
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Validate directory is writable
|
|
120
|
+
if (!recordingDir.canWrite()) {
|
|
121
|
+
promise.reject("DIRECTORY_ERROR", "Recording directory is not writable")
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Create file path
|
|
126
|
+
val recordingFile = File(recordingDir, filename)
|
|
127
|
+
currentFilePath = recordingFile.absolutePath
|
|
128
|
+
|
|
129
|
+
if (currentFilePath == null || currentFilePath?.isEmpty() == true) {
|
|
130
|
+
promise.reject("PATH_ERROR", "Invalid file path")
|
|
131
|
+
return
|
|
88
132
|
}
|
|
89
133
|
|
|
90
|
-
|
|
134
|
+
// Clean up any existing file
|
|
135
|
+
try {
|
|
136
|
+
if (recordingFile.exists()) {
|
|
137
|
+
recordingFile.delete()
|
|
138
|
+
}
|
|
139
|
+
} catch (e: Exception) {
|
|
140
|
+
// Continue even if cleanup fails
|
|
141
|
+
}
|
|
91
142
|
|
|
143
|
+
// Create and configure media recorder
|
|
92
144
|
mediaRecorder = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
93
145
|
MediaRecorder(reactApplicationContext)
|
|
94
146
|
} else {
|
|
95
147
|
@Suppress("DEPRECATION")
|
|
96
148
|
MediaRecorder()
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
if (mediaRecorder == null) {
|
|
152
|
+
promise.reject("INIT_RECORDER_ERROR", "Failed to create MediaRecorder instance")
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
mediaRecorder?.apply {
|
|
158
|
+
setAudioSource(MediaRecorder.AudioSource.MIC)
|
|
159
|
+
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
|
160
|
+
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
|
161
|
+
setAudioEncodingBitRate(bitrate)
|
|
162
|
+
setAudioChannels(channels)
|
|
163
|
+
setAudioSamplingRate(sampleRate)
|
|
164
|
+
setOutputFile(currentFilePath)
|
|
165
|
+
|
|
166
|
+
// Prepare recorder
|
|
167
|
+
prepare()
|
|
168
|
+
|
|
169
|
+
// Start recording
|
|
170
|
+
start()
|
|
171
|
+
}
|
|
172
|
+
} catch (e: Exception) {
|
|
173
|
+
mediaRecorder?.release()
|
|
174
|
+
mediaRecorder = null
|
|
175
|
+
currentFilePath = null
|
|
176
|
+
promise.reject("START_RECORDING_ERROR", e.message ?: "Failed to start recording", e)
|
|
177
|
+
return
|
|
108
178
|
}
|
|
109
179
|
|
|
110
180
|
isRecording = true
|
|
@@ -115,75 +185,122 @@ class NosniaAudioRecorderModule(reactContext: ReactApplicationContext) :
|
|
|
115
185
|
mediaRecorder?.release()
|
|
116
186
|
mediaRecorder = null
|
|
117
187
|
isRecording = false
|
|
118
|
-
promise.reject("START_RECORDING_ERROR", e.message, e)
|
|
188
|
+
promise.reject("START_RECORDING_ERROR", e.message ?: "Unknown error", e)
|
|
119
189
|
}
|
|
120
190
|
}
|
|
121
191
|
|
|
122
192
|
override fun stopRecording(promise: Promise) {
|
|
123
193
|
try {
|
|
124
|
-
if (!isRecording
|
|
194
|
+
if (!isRecording || mediaRecorder == null) {
|
|
125
195
|
promise.reject("NOT_RECORDING", "No recording in progress")
|
|
126
196
|
return
|
|
127
197
|
}
|
|
128
198
|
|
|
129
199
|
stopProgressUpdates()
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
mediaRecorder?.apply {
|
|
203
|
+
if (isRecording) {
|
|
204
|
+
stop()
|
|
205
|
+
}
|
|
206
|
+
release()
|
|
207
|
+
}
|
|
208
|
+
} catch (e: Exception) {
|
|
209
|
+
// Try to release even if stop fails
|
|
210
|
+
try {
|
|
211
|
+
mediaRecorder?.release()
|
|
212
|
+
} catch (releaseError: Exception) {
|
|
213
|
+
// Ignore
|
|
214
|
+
}
|
|
133
215
|
}
|
|
216
|
+
|
|
134
217
|
mediaRecorder = null
|
|
135
218
|
isRecording = false
|
|
136
219
|
isPaused = false
|
|
137
220
|
|
|
221
|
+
// Validate file exists before returning
|
|
138
222
|
val filePath = currentFilePath ?: ""
|
|
223
|
+
if (filePath.isNotEmpty()) {
|
|
224
|
+
try {
|
|
225
|
+
if (!File(filePath).exists()) {
|
|
226
|
+
promise.reject("FILE_NOT_FOUND", "Recording file was not created")
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
} catch (e: Exception) {
|
|
230
|
+
// Continue anyway, file might exist
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
139
234
|
currentFilePath = null
|
|
140
235
|
promise.resolve(filePath)
|
|
141
236
|
} catch (e: Exception) {
|
|
142
237
|
stopProgressUpdates()
|
|
143
|
-
|
|
238
|
+
try {
|
|
239
|
+
mediaRecorder?.release()
|
|
240
|
+
} catch (releaseError: Exception) {
|
|
241
|
+
// Ignore
|
|
242
|
+
}
|
|
144
243
|
mediaRecorder = null
|
|
145
244
|
isRecording = false
|
|
146
|
-
promise.reject("STOP_RECORDING_ERROR", e.message, e)
|
|
245
|
+
promise.reject("STOP_RECORDING_ERROR", e.message ?: "Failed to stop recording", e)
|
|
147
246
|
}
|
|
148
247
|
}
|
|
149
248
|
|
|
150
249
|
override fun pauseRecording(promise: Promise) {
|
|
151
250
|
try {
|
|
152
|
-
if (!isRecording) {
|
|
251
|
+
if (!isRecording || mediaRecorder == null) {
|
|
153
252
|
promise.reject("NOT_RECORDING", "No recording in progress")
|
|
154
253
|
return
|
|
155
254
|
}
|
|
156
255
|
|
|
256
|
+
if (isPaused) {
|
|
257
|
+
promise.reject("ALREADY_PAUSED", "Recording is already paused")
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
157
261
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
262
|
+
try {
|
|
263
|
+
mediaRecorder?.pause()
|
|
264
|
+
isPaused = true
|
|
265
|
+
pauseStartTime = System.currentTimeMillis()
|
|
266
|
+
promise.resolve(null)
|
|
267
|
+
} catch (e: Exception) {
|
|
268
|
+
promise.reject("PAUSE_ERROR", e.message ?: "Failed to pause recording", e)
|
|
269
|
+
}
|
|
162
270
|
} else {
|
|
163
|
-
promise.reject("
|
|
271
|
+
promise.reject("PAUSE_NOT_SUPPORTED", "Pause is not supported on this Android version")
|
|
164
272
|
}
|
|
165
273
|
} catch (e: Exception) {
|
|
166
|
-
promise.reject("PAUSE_ERROR", e.message, e)
|
|
274
|
+
promise.reject("PAUSE_ERROR", e.message ?: "Unknown error during pause", e)
|
|
167
275
|
}
|
|
168
276
|
}
|
|
169
277
|
|
|
170
278
|
override fun resumeRecording(promise: Promise) {
|
|
171
279
|
try {
|
|
172
|
-
if (!isRecording ||
|
|
280
|
+
if (!isRecording || mediaRecorder == null) {
|
|
281
|
+
promise.reject("NOT_RECORDING", "No recording in progress")
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!isPaused) {
|
|
173
286
|
promise.reject("NOT_PAUSED", "Recording is not paused")
|
|
174
287
|
return
|
|
175
288
|
}
|
|
176
289
|
|
|
177
290
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
291
|
+
try {
|
|
292
|
+
mediaRecorder?.resume()
|
|
293
|
+
isPaused = false
|
|
294
|
+
pausedDuration += System.currentTimeMillis() - pauseStartTime
|
|
295
|
+
promise.resolve(null)
|
|
296
|
+
} catch (e: Exception) {
|
|
297
|
+
promise.reject("RESUME_ERROR", e.message ?: "Failed to resume recording", e)
|
|
298
|
+
}
|
|
182
299
|
} else {
|
|
183
|
-
promise.reject("
|
|
300
|
+
promise.reject("RESUME_NOT_SUPPORTED", "Resume is not supported on this Android version")
|
|
184
301
|
}
|
|
185
302
|
} catch (e: Exception) {
|
|
186
|
-
promise.reject("RESUME_ERROR", e.message, e)
|
|
303
|
+
promise.reject("RESUME_ERROR", e.message ?: "Unknown error during resume", e)
|
|
187
304
|
}
|
|
188
305
|
}
|
|
189
306
|
|
|
@@ -191,46 +308,83 @@ class NosniaAudioRecorderModule(reactContext: ReactApplicationContext) :
|
|
|
191
308
|
try {
|
|
192
309
|
stopProgressUpdates()
|
|
193
310
|
|
|
194
|
-
|
|
311
|
+
// Safely stop recorder
|
|
312
|
+
try {
|
|
313
|
+
mediaRecorder?.apply {
|
|
314
|
+
if (isRecording || isPaused) {
|
|
315
|
+
stop()
|
|
316
|
+
}
|
|
317
|
+
release()
|
|
318
|
+
}
|
|
319
|
+
} catch (e: Exception) {
|
|
320
|
+
// Try to release even if stop fails
|
|
195
321
|
try {
|
|
196
|
-
|
|
197
|
-
} catch (
|
|
198
|
-
//
|
|
322
|
+
mediaRecorder?.release()
|
|
323
|
+
} catch (releaseError: Exception) {
|
|
324
|
+
// Ignore
|
|
199
325
|
}
|
|
200
|
-
release()
|
|
201
326
|
}
|
|
327
|
+
|
|
202
328
|
mediaRecorder = null
|
|
203
329
|
|
|
330
|
+
// Safely delete recording file
|
|
204
331
|
currentFilePath?.let { filePath ->
|
|
205
|
-
|
|
332
|
+
try {
|
|
333
|
+
val file = File(filePath)
|
|
334
|
+
if (file.exists()) {
|
|
335
|
+
file.delete()
|
|
336
|
+
}
|
|
337
|
+
} catch (e: Exception) {
|
|
338
|
+
// Log but don't fail - file cleanup is not critical
|
|
339
|
+
}
|
|
206
340
|
}
|
|
341
|
+
|
|
207
342
|
currentFilePath = null
|
|
208
343
|
isRecording = false
|
|
209
344
|
isPaused = false
|
|
345
|
+
pausedDuration = 0
|
|
346
|
+
pauseStartTime = 0
|
|
210
347
|
|
|
211
348
|
promise.resolve(null)
|
|
212
349
|
} catch (e: Exception) {
|
|
213
|
-
|
|
350
|
+
mediaRecorder = null
|
|
351
|
+
isRecording = false
|
|
352
|
+
promise.reject("CANCEL_ERROR", e.message ?: "Failed to cancel recording", e)
|
|
214
353
|
}
|
|
215
354
|
}
|
|
216
355
|
|
|
217
356
|
override fun getRecorderStatus(promise: Promise) {
|
|
218
357
|
try {
|
|
219
358
|
val currentTime = System.currentTimeMillis()
|
|
220
|
-
|
|
359
|
+
|
|
360
|
+
// Safely calculate duration
|
|
361
|
+
val duration = if (isRecording && mediaRecorder != null) {
|
|
221
362
|
if (isPaused) {
|
|
222
|
-
|
|
363
|
+
// Paused: duration = pause time - start time - previous paused duration
|
|
364
|
+
val pausedDur = pauseStartTime - startTime - pausedDuration
|
|
365
|
+
if (pausedDur < 0) 0L else pausedDur
|
|
223
366
|
} else {
|
|
224
|
-
|
|
367
|
+
// Recording: duration = current time - start time - previous paused duration
|
|
368
|
+
val recordingDur = currentTime - startTime - pausedDuration
|
|
369
|
+
if (recordingDur < 0) 0L else recordingDur
|
|
225
370
|
}
|
|
226
371
|
} else {
|
|
227
|
-
|
|
372
|
+
0L
|
|
228
373
|
}
|
|
229
374
|
|
|
230
375
|
val status = WritableNativeMap().apply {
|
|
231
376
|
putBoolean("isRecording", isRecording)
|
|
232
377
|
putDouble("duration", duration.toDouble())
|
|
233
|
-
if (currentFilePath
|
|
378
|
+
if (!currentFilePath.isNullOrEmpty()) {
|
|
379
|
+
putString("currentFilePath", currentFilePath)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
promise.resolve(status)
|
|
384
|
+
} catch (e: Exception) {
|
|
385
|
+
promise.reject("STATUS_ERROR", e.message ?: "Failed to get recorder status", e)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
234
388
|
putString("currentFilePath", currentFilePath)
|
|
235
389
|
}
|
|
236
390
|
}
|
|
@@ -97,60 +97,165 @@ RCT_EXPORT_MODULE(NosniaAudioRecorder)
|
|
|
97
97
|
resolve:(RCTPromiseResolveBlock)resolve
|
|
98
98
|
reject:(RCTPromiseRejectBlock)reject {
|
|
99
99
|
@try {
|
|
100
|
-
|
|
100
|
+
// Safety check: options must not be nil
|
|
101
|
+
if (!options) {
|
|
102
|
+
reject(@"INVALID_OPTIONS", @"Options cannot be nil", nil);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Prevent concurrent recordings
|
|
107
|
+
if (_isRecording || _audioRecorder) {
|
|
101
108
|
reject(@"ALREADY_RECORDING", @"Recording is already in progress", nil);
|
|
102
109
|
return;
|
|
103
110
|
}
|
|
104
111
|
|
|
112
|
+
// Validate and generate filename
|
|
105
113
|
NSString *filename = options[@"filename"];
|
|
106
|
-
if (!filename) {
|
|
114
|
+
if (!filename || ![filename isKindOfClass:[NSString class]] || filename.length == 0) {
|
|
107
115
|
filename = [self generateFilename];
|
|
108
116
|
}
|
|
109
117
|
|
|
118
|
+
// Get and validate recording directory
|
|
110
119
|
NSString *recordingDir = [self getRecordingDirectory];
|
|
120
|
+
if (!recordingDir) {
|
|
121
|
+
reject(@"DIRECTORY_ERROR", @"Failed to get recording directory", nil);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
111
125
|
NSString *filePath = [recordingDir stringByAppendingPathComponent:filename];
|
|
126
|
+
if (!filePath) {
|
|
127
|
+
reject(@"PATH_ERROR", @"Invalid file path", nil);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Clean up existing file to prevent conflicts
|
|
132
|
+
NSFileManager *fileManager = [NSFileManager defaultManager];
|
|
133
|
+
[fileManager removeItemAtPath:filePath error:nil];
|
|
134
|
+
|
|
112
135
|
_recordingURL = [NSURL fileURLWithPath:filePath];
|
|
136
|
+
if (!_recordingURL) {
|
|
137
|
+
reject(@"URL_ERROR", @"Failed to create file URL", nil);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Validate and sanitize audio parameters
|
|
142
|
+
NSNumber *bitrate = options[@"bitrate"];
|
|
143
|
+
if (!bitrate || ![bitrate isKindOfClass:[NSNumber class]] || [bitrate integerValue] <= 0) {
|
|
144
|
+
bitrate = @(128000);
|
|
145
|
+
}
|
|
113
146
|
|
|
114
|
-
NSNumber *
|
|
115
|
-
|
|
116
|
-
|
|
147
|
+
NSNumber *channels = options[@"channels"];
|
|
148
|
+
if (!channels || ![channels isKindOfClass:[NSNumber class]]) {
|
|
149
|
+
channels = @(1);
|
|
150
|
+
} else {
|
|
151
|
+
NSInteger chCount = [channels integerValue];
|
|
152
|
+
if (chCount <= 0 || chCount > 2) channels = @(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
NSNumber *sampleRate = options[@"sampleRate"];
|
|
156
|
+
if (!sampleRate || ![sampleRate isKindOfClass:[NSNumber class]] || [sampleRate integerValue] <= 0) {
|
|
157
|
+
sampleRate = @(44100);
|
|
158
|
+
}
|
|
117
159
|
|
|
160
|
+
// Get audio session
|
|
118
161
|
AVAudioSession *audioSession = [AVAudioSession sharedInstance];
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
error:&categoryError];
|
|
122
|
-
if (categoryError) {
|
|
123
|
-
reject(@"AUDIO_SESSION_ERROR", categoryError.description, categoryError);
|
|
162
|
+
if (!audioSession) {
|
|
163
|
+
reject(@"AUDIO_SESSION_ERROR", @"Failed to get audio session", nil);
|
|
124
164
|
return;
|
|
125
165
|
}
|
|
126
166
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
167
|
+
// Set audio category with device compatibility options
|
|
168
|
+
NSError *categoryError = nil;
|
|
169
|
+
BOOL categorySuccess = [audioSession setCategory:AVAudioSessionCategoryRecord
|
|
170
|
+
withOptions:AVAudioSessionCategoryOptionDefaultToSpeaker
|
|
171
|
+
error:&categoryError];
|
|
172
|
+
if (!categorySuccess || categoryError) {
|
|
173
|
+
NSString *msg = categoryError ? categoryError.description : @"Failed to set audio category";
|
|
174
|
+
reject(@"AUDIO_SESSION_ERROR", msg, categoryError);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
134
177
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
reject(@"INIT_RECORDER_ERROR", recorderError.description, recorderError);
|
|
178
|
+
// Activate audio session
|
|
179
|
+
NSError *activateError = nil;
|
|
180
|
+
BOOL activateSuccess = [audioSession setActive:YES error:&activateError];
|
|
181
|
+
if (!activateSuccess || activateError) {
|
|
182
|
+
NSString *msg = activateError ? activateError.description : @"Failed to activate audio session";
|
|
183
|
+
reject(@"AUDIO_SESSION_ERROR", msg, activateError);
|
|
142
184
|
return;
|
|
143
185
|
}
|
|
144
186
|
|
|
145
|
-
|
|
146
|
-
if (!
|
|
147
|
-
reject(@"
|
|
187
|
+
// Validate recording settings dictionary
|
|
188
|
+
if (!bitrate || !channels || !sampleRate) {
|
|
189
|
+
reject(@"INVALID_SETTINGS", @"Invalid recording settings", nil);
|
|
148
190
|
return;
|
|
149
191
|
}
|
|
150
192
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
193
|
+
// Create recording settings dictionary with safety checks
|
|
194
|
+
@try {
|
|
195
|
+
NSDictionary *recordingSettings = @{
|
|
196
|
+
AVFormatIDKey: @(kAudioFormatMPEG4AAC),
|
|
197
|
+
AVSampleRateKey: sampleRate,
|
|
198
|
+
AVNumberOfChannelsKey: channels,
|
|
199
|
+
AVEncoderBitRateKey: bitrate,
|
|
200
|
+
AVEncoderAudioQualityKey: @(AVAudioQualityMedium)
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Validate settings dictionary created successfully
|
|
204
|
+
if (!recordingSettings || recordingSettings.count == 0) {
|
|
205
|
+
reject(@"SETTINGS_ERROR", @"Failed to create recording settings", nil);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Validate all required keys are present
|
|
210
|
+
if (!recordingSettings[AVFormatIDKey] || !recordingSettings[AVSampleRateKey] ||
|
|
211
|
+
!recordingSettings[AVNumberOfChannelsKey] || !recordingSettings[AVEncoderBitRateKey]) {
|
|
212
|
+
reject(@"SETTINGS_ERROR", @"Incomplete recording settings", nil);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Initialize recorder with error handling
|
|
217
|
+
NSError *recorderError = nil;
|
|
218
|
+
_audioRecorder = [[AVAudioRecorder alloc] initWithURL:_recordingURL
|
|
219
|
+
settings:recordingSettings
|
|
220
|
+
error:&recorderError];
|
|
221
|
+
|
|
222
|
+
if (recorderError) {
|
|
223
|
+
NSString *msg = recorderError.description ?: @"Unknown recorder initialization error";
|
|
224
|
+
reject(@"INIT_RECORDER_ERROR", msg, recorderError);
|
|
225
|
+
_audioRecorder = nil;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Validate recorder was created successfully
|
|
230
|
+
if (!_audioRecorder) {
|
|
231
|
+
reject(@"INIT_RECORDER_ERROR", @"AVAudioRecorder initialization returned nil", nil);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Set delegate and enable metering
|
|
236
|
+
_audioRecorder.delegate = self;
|
|
237
|
+
[_audioRecorder setMeteringEnabled:YES];
|
|
238
|
+
|
|
239
|
+
// Attempt to start recording
|
|
240
|
+
if (![_audioRecorder record]) {
|
|
241
|
+
NSString *errorMsg = @"Failed to start recording";
|
|
242
|
+
NSError *recErr = _audioRecorder.error;
|
|
243
|
+
if (recErr) {
|
|
244
|
+
errorMsg = [NSString stringWithFormat:@"%s (Error: %@)", errorMsg.UTF8String, recErr.description];
|
|
245
|
+
}
|
|
246
|
+
reject(@"START_RECORDING_ERROR", errorMsg, nil);
|
|
247
|
+
_audioRecorder = nil;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_isRecording = YES;
|
|
252
|
+
[self startProgressTimer];
|
|
253
|
+
resolve(nil);
|
|
254
|
+
} @catch (NSException *innerException) {
|
|
255
|
+
reject(@"SETTINGS_ERROR", [NSString stringWithFormat:@"Exception creating settings: %@", innerException.reason], nil);
|
|
256
|
+
_audioRecorder = nil;
|
|
257
|
+
_isRecording = NO;
|
|
258
|
+
}
|
|
154
259
|
} @catch (NSException *exception) {
|
|
155
260
|
reject(@"START_RECORDING_ERROR", exception.reason, nil);
|
|
156
261
|
}
|
|
@@ -164,14 +269,42 @@ RCT_EXPORT_MODULE(NosniaAudioRecorder)
|
|
|
164
269
|
return;
|
|
165
270
|
}
|
|
166
271
|
|
|
272
|
+
// Stop timer and recorder
|
|
167
273
|
[self stopProgressTimer];
|
|
168
|
-
|
|
169
|
-
|
|
274
|
+
|
|
275
|
+
// Safely stop the recorder
|
|
276
|
+
if (_audioRecorder && [_audioRecorder isRecording]) {
|
|
277
|
+
[_audioRecorder stop];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Get file path before cleaning up recorder
|
|
281
|
+
NSString *filePath = nil;
|
|
282
|
+
if (_recordingURL) {
|
|
283
|
+
filePath = [_recordingURL path];
|
|
284
|
+
if (!filePath || filePath.length == 0) {
|
|
285
|
+
filePath = @"";
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Clean up recorder
|
|
290
|
+
_audioRecorder.delegate = nil;
|
|
170
291
|
_audioRecorder = nil;
|
|
171
292
|
_isRecording = NO;
|
|
293
|
+
_recordingURL = nil;
|
|
294
|
+
|
|
295
|
+
// Validate file exists before returning
|
|
296
|
+
if (filePath && filePath.length > 0) {
|
|
297
|
+
NSFileManager *fm = [NSFileManager defaultManager];
|
|
298
|
+
if ([fm fileExistsAtPath:filePath]) {
|
|
299
|
+
resolve(filePath);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
172
303
|
|
|
173
|
-
resolve(filePath);
|
|
304
|
+
resolve(filePath ?: @"");
|
|
174
305
|
} @catch (NSException *exception) {
|
|
306
|
+
_audioRecorder = nil;
|
|
307
|
+
_isRecording = NO;
|
|
175
308
|
reject(@"STOP_RECORDING_ERROR", exception.reason, nil);
|
|
176
309
|
}
|
|
177
310
|
}
|
|
@@ -184,7 +317,17 @@ RCT_EXPORT_MODULE(NosniaAudioRecorder)
|
|
|
184
317
|
return;
|
|
185
318
|
}
|
|
186
319
|
|
|
187
|
-
|
|
320
|
+
// Check if recorder supports pause
|
|
321
|
+
if (![_audioRecorder respondsToSelector:@selector(pause)]) {
|
|
322
|
+
reject(@"PAUSE_NOT_SUPPORTED", @"Pause is not supported", nil);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Safely pause
|
|
327
|
+
if ([_audioRecorder isRecording]) {
|
|
328
|
+
[_audioRecorder pause];
|
|
329
|
+
}
|
|
330
|
+
|
|
188
331
|
resolve(nil);
|
|
189
332
|
} @catch (NSException *exception) {
|
|
190
333
|
reject(@"PAUSE_ERROR", exception.reason, nil);
|
|
@@ -194,12 +337,29 @@ RCT_EXPORT_MODULE(NosniaAudioRecorder)
|
|
|
194
337
|
- (void)resumeRecording:(RCTPromiseResolveBlock)resolve
|
|
195
338
|
reject:(RCTPromiseRejectBlock)reject {
|
|
196
339
|
@try {
|
|
197
|
-
if (!_isRecording || !_audioRecorder
|
|
340
|
+
if (!_isRecording || !_audioRecorder) {
|
|
341
|
+
reject(@"NOT_RECORDING", @"No recording in progress", nil);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Check if recorder is currently recording (not paused)
|
|
346
|
+
if ([_audioRecorder isRecording]) {
|
|
198
347
|
reject(@"NOT_PAUSED", @"Recording is not paused", nil);
|
|
199
348
|
return;
|
|
200
349
|
}
|
|
201
350
|
|
|
202
|
-
|
|
351
|
+
// Check if recorder supports record method
|
|
352
|
+
if (![_audioRecorder respondsToSelector:@selector(record)]) {
|
|
353
|
+
reject(@"RECORD_NOT_SUPPORTED", @"Record is not supported", nil);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Resume recording
|
|
358
|
+
if (![_audioRecorder record]) {
|
|
359
|
+
reject(@"RESUME_ERROR", @"Failed to resume recording", nil);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
203
363
|
resolve(nil);
|
|
204
364
|
} @catch (NSException *exception) {
|
|
205
365
|
reject(@"RESUME_ERROR", exception.reason, nil);
|
|
@@ -209,21 +369,41 @@ RCT_EXPORT_MODULE(NosniaAudioRecorder)
|
|
|
209
369
|
- (void)cancelRecording:(RCTPromiseResolveBlock)resolve
|
|
210
370
|
reject:(RCTPromiseRejectBlock)reject {
|
|
211
371
|
@try {
|
|
372
|
+
// Stop progress timer first
|
|
212
373
|
[self stopProgressTimer];
|
|
213
374
|
|
|
375
|
+
// Safely stop recorder
|
|
214
376
|
if (_audioRecorder) {
|
|
215
|
-
[_audioRecorder
|
|
377
|
+
if ([_audioRecorder isRecording] || [_audioRecorder isPaused]) {
|
|
378
|
+
[_audioRecorder stop];
|
|
379
|
+
}
|
|
380
|
+
_audioRecorder.delegate = nil;
|
|
216
381
|
_audioRecorder = nil;
|
|
217
382
|
}
|
|
218
383
|
|
|
384
|
+
// Clean up recording file
|
|
219
385
|
if (_recordingURL) {
|
|
220
386
|
NSFileManager *fileManager = [NSFileManager defaultManager];
|
|
221
|
-
[
|
|
387
|
+
NSString *filePath = [_recordingURL path];
|
|
388
|
+
if (filePath && [fileManager fileExistsAtPath:filePath]) {
|
|
389
|
+
NSError *deleteError = nil;
|
|
390
|
+
[fileManager removeItemAtURL:_recordingURL error:&deleteError];
|
|
391
|
+
if (deleteError) {
|
|
392
|
+
// Log but don't fail - file cleanup is not critical
|
|
393
|
+
}
|
|
394
|
+
}
|
|
222
395
|
_recordingURL = nil;
|
|
223
396
|
}
|
|
224
397
|
|
|
225
398
|
_isRecording = NO;
|
|
226
399
|
resolve(nil);
|
|
400
|
+
} @catch (NSException *exception) {
|
|
401
|
+
_audioRecorder = nil;
|
|
402
|
+
_isRecording = NO;
|
|
403
|
+
reject(@"CANCEL_ERROR", exception.reason, nil);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
resolve(nil);
|
|
227
407
|
} @catch (NSException *exception) {
|
|
228
408
|
reject(@"CANCEL_ERROR", exception.reason, nil);
|
|
229
409
|
}
|
|
@@ -234,10 +414,23 @@ RCT_EXPORT_MODULE(NosniaAudioRecorder)
|
|
|
234
414
|
@try {
|
|
235
415
|
NSMutableDictionary *status = [NSMutableDictionary dictionary];
|
|
236
416
|
status[@"isRecording"] = @(_isRecording);
|
|
237
|
-
|
|
417
|
+
|
|
418
|
+
// Safely get duration
|
|
419
|
+
double duration = 0.0;
|
|
420
|
+
if (_audioRecorder) {
|
|
421
|
+
duration = _audioRecorder.currentTime;
|
|
422
|
+
if (duration < 0) duration = 0.0;
|
|
423
|
+
}
|
|
424
|
+
status[@"duration"] = @(duration * 1000);
|
|
425
|
+
|
|
426
|
+
// Safely get file path
|
|
238
427
|
if (_recordingURL) {
|
|
239
|
-
|
|
428
|
+
NSString *filePath = [_recordingURL path];
|
|
429
|
+
if (filePath) {
|
|
430
|
+
status[@"currentFilePath"] = filePath;
|
|
431
|
+
}
|
|
240
432
|
}
|
|
433
|
+
|
|
241
434
|
resolve(status);
|
|
242
435
|
} @catch (NSException *exception) {
|
|
243
436
|
reject(@"STATUS_ERROR", exception.reason, nil);
|
|
@@ -272,14 +465,38 @@ RCT_EXPORT_MODULE(NosniaAudioRecorder)
|
|
|
272
465
|
|
|
273
466
|
- (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder
|
|
274
467
|
successfully:(BOOL)flag {
|
|
275
|
-
|
|
276
|
-
|
|
468
|
+
@try {
|
|
469
|
+
if (!flag) {
|
|
470
|
+
// Recording failed - log but don't crash
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
_isRecording = NO;
|
|
474
|
+
[self stopProgressTimer];
|
|
475
|
+
|
|
476
|
+
// Clean up recorder reference
|
|
477
|
+
if (_audioRecorder == recorder) {
|
|
478
|
+
_audioRecorder = nil;
|
|
479
|
+
}
|
|
480
|
+
} @catch (NSException *exception) {
|
|
481
|
+
_audioRecorder = nil;
|
|
482
|
+
_isRecording = NO;
|
|
483
|
+
}
|
|
277
484
|
}
|
|
278
485
|
|
|
279
486
|
- (void)audioRecorderEncodeErrorDidOccur:(AVAudioRecorder *)recorder
|
|
280
487
|
error:(NSError *)error {
|
|
281
|
-
|
|
282
|
-
|
|
488
|
+
@try {
|
|
489
|
+
_isRecording = NO;
|
|
490
|
+
[self stopProgressTimer];
|
|
491
|
+
|
|
492
|
+
// Clean up recorder reference
|
|
493
|
+
if (_audioRecorder == recorder) {
|
|
494
|
+
_audioRecorder = nil;
|
|
495
|
+
}
|
|
496
|
+
} @catch (NSException *exception) {
|
|
497
|
+
_audioRecorder = nil;
|
|
498
|
+
_isRecording = NO;
|
|
499
|
+
}
|
|
283
500
|
}
|
|
284
501
|
|
|
285
502
|
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
|
package/package.json
CHANGED