@siteed/expo-audio-studio 2.4.0 → 2.5.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 (81) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/README.md +25 -0
  3. package/android/src/main/java/net/siteed/audiostream/AudioAnalysisData.kt +22 -0
  4. package/android/src/main/java/net/siteed/audiostream/AudioDeviceManager.kt +1501 -0
  5. package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +10 -5
  6. package/android/src/main/java/net/siteed/audiostream/AudioNotificationsManager.kt +27 -25
  7. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +73 -71
  8. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +581 -255
  9. package/android/src/main/java/net/siteed/audiostream/Constants.kt +17 -1
  10. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +435 -158
  11. package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +65 -0
  12. package/android/src/main/java/net/siteed/audiostream/PermissionUtils.kt +14 -5
  13. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +9 -1
  14. package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  15. package/build/AudioDeviceManager.d.ts +107 -0
  16. package/build/AudioDeviceManager.d.ts.map +1 -0
  17. package/build/AudioDeviceManager.js +493 -0
  18. package/build/AudioDeviceManager.js.map +1 -0
  19. package/build/AudioRecorder.provider.d.ts.map +1 -1
  20. package/build/AudioRecorder.provider.js +3 -0
  21. package/build/AudioRecorder.provider.js.map +1 -1
  22. package/build/ExpoAudioStream.types.d.ts +90 -1
  23. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  24. package/build/ExpoAudioStream.types.js +7 -1
  25. package/build/ExpoAudioStream.types.js.map +1 -1
  26. package/build/ExpoAudioStream.web.d.ts +37 -0
  27. package/build/ExpoAudioStream.web.d.ts.map +1 -1
  28. package/build/ExpoAudioStream.web.js +399 -54
  29. package/build/ExpoAudioStream.web.js.map +1 -1
  30. package/build/ExpoAudioStreamModule.d.ts.map +1 -1
  31. package/build/ExpoAudioStreamModule.js +20 -0
  32. package/build/ExpoAudioStreamModule.js.map +1 -1
  33. package/build/WebRecorder.web.d.ts +63 -10
  34. package/build/WebRecorder.web.d.ts.map +1 -1
  35. package/build/WebRecorder.web.js +277 -68
  36. package/build/WebRecorder.web.js.map +1 -1
  37. package/build/hooks/useAudioDevices.d.ts +14 -0
  38. package/build/hooks/useAudioDevices.d.ts.map +1 -0
  39. package/build/hooks/useAudioDevices.js +151 -0
  40. package/build/hooks/useAudioDevices.js.map +1 -0
  41. package/build/index.d.ts +2 -0
  42. package/build/index.d.ts.map +1 -1
  43. package/build/index.js +4 -0
  44. package/build/index.js.map +1 -1
  45. package/build/useAudioRecorder.d.ts +1 -0
  46. package/build/useAudioRecorder.d.ts.map +1 -1
  47. package/build/useAudioRecorder.js +20 -1
  48. package/build/useAudioRecorder.js.map +1 -1
  49. package/build/utils/BlobFix.d.ts.map +1 -1
  50. package/build/utils/BlobFix.js +2 -2
  51. package/build/utils/BlobFix.js.map +1 -1
  52. package/build/workers/InlineFeaturesExtractor.web.d.ts +1 -1
  53. package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
  54. package/build/workers/InlineFeaturesExtractor.web.js +27 -26
  55. package/build/workers/InlineFeaturesExtractor.web.js.map +1 -1
  56. package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
  57. package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
  58. package/build/workers/inlineAudioWebWorker.web.js +25 -1
  59. package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
  60. package/ios/AudioDeviceManager.swift +654 -0
  61. package/ios/AudioStreamManager.swift +964 -760
  62. package/ios/ExpoAudioStreamModule.swift +174 -19
  63. package/ios/Features.swift +1 -1
  64. package/ios/ISSUE_IOS.md +45 -0
  65. package/ios/Logger.swift +13 -1
  66. package/ios/RecordingSettings.swift +12 -0
  67. package/package.json +2 -2
  68. package/src/AudioAnalysis/AudioAnalysis.types.ts +2 -2
  69. package/src/AudioDeviceManager.ts +571 -0
  70. package/src/AudioRecorder.provider.tsx +3 -0
  71. package/src/ExpoAudioStream.types.ts +97 -1
  72. package/src/ExpoAudioStream.web.ts +513 -63
  73. package/src/ExpoAudioStreamModule.ts +23 -0
  74. package/src/WebRecorder.web.ts +346 -81
  75. package/src/hooks/useAudioDevices.ts +180 -0
  76. package/src/index.ts +6 -0
  77. package/src/types/crc-32.d.ts +6 -6
  78. package/src/useAudioRecorder.tsx +27 -1
  79. package/src/utils/BlobFix.ts +6 -4
  80. package/src/workers/InlineFeaturesExtractor.web.tsx +27 -26
  81. package/src/workers/inlineAudioWebWorker.web.tsx +25 -1
@@ -1,7 +1,10 @@
1
1
  // net/siteed/audiostream/AudioRecorderManager.kt
2
2
  package net.siteed.audiostream
3
3
 
4
+ import android.Manifest
4
5
  import android.annotation.SuppressLint
6
+ import android.content.Context
7
+ import android.media.AudioDeviceInfo
5
8
  import android.media.AudioFormat
6
9
  import android.media.AudioRecord
7
10
  import android.media.MediaRecorder
@@ -9,6 +12,7 @@ import android.os.Build
9
12
  import android.os.Bundle
10
13
  import android.os.Handler
11
14
  import android.os.Looper
15
+ import android.os.PowerManager
12
16
  import android.os.SystemClock
13
17
  import android.util.Log
14
18
  import androidx.annotation.RequiresApi
@@ -19,8 +23,6 @@ import java.io.File
19
23
  import java.io.FileOutputStream
20
24
  import java.io.IOException
21
25
  import java.util.concurrent.atomic.AtomicBoolean
22
- import android.os.PowerManager
23
- import android.content.Context
24
26
  import java.nio.ByteBuffer
25
27
  import java.nio.ByteOrder
26
28
  import android.media.AudioManager
@@ -30,7 +32,7 @@ import android.telephony.PhoneStateListener
30
32
  import android.telephony.TelephonyManager
31
33
  import android.app.ActivityManager
32
34
  import java.util.UUID
33
- import android.media.AudioDeviceInfo
35
+ import net.siteed.audiostream.LogUtils
34
36
 
35
37
  class AudioRecorderManager(
36
38
  private val context: Context,
@@ -38,11 +40,44 @@ class AudioRecorderManager(
38
40
  private val permissionUtils: PermissionUtils,
39
41
  private val audioDataEncoder: AudioDataEncoder,
40
42
  private val eventSender: EventSender,
41
- private val enablePhoneStateHandling: Boolean = true
43
+ private val enablePhoneStateHandling: Boolean = true,
44
+ private val enableBackgroundAudio: Boolean = true
42
45
  ) {
46
+ companion object {
47
+ private const val CLASS_NAME = "AudioRecorderManager"
48
+
49
+ @SuppressLint("StaticFieldLeak")
50
+ @Volatile
51
+ private var instance: AudioRecorderManager? = null
52
+
53
+ fun getInstance(): AudioRecorderManager? = instance
54
+
55
+ fun initialize(
56
+ context: Context,
57
+ filesDir: File,
58
+ permissionUtils: PermissionUtils,
59
+ audioDataEncoder: AudioDataEncoder,
60
+ eventSender: EventSender,
61
+ enablePhoneStateHandling: Boolean = true,
62
+ enableBackgroundAudio: Boolean = true
63
+ ): AudioRecorderManager {
64
+ return instance ?: synchronized(this) {
65
+ instance ?: AudioRecorderManager(
66
+ context, filesDir, permissionUtils, audioDataEncoder, eventSender,
67
+ enablePhoneStateHandling, enableBackgroundAudio
68
+ ).also { instance = it }
69
+ }
70
+ }
71
+
72
+ fun destroy() {
73
+ instance?.cleanup()
74
+ instance = null
75
+ }
76
+ }
77
+
43
78
  private var audioRecord: AudioRecord? = null
44
79
  private var bufferSizeInBytes = 0
45
- private var isRecording = AtomicBoolean(false)
80
+ private var _isRecording = AtomicBoolean(false)
46
81
  private val isPaused = AtomicBoolean(false)
47
82
  private var streamUuid: String? = null
48
83
  private var audioFile: File? = null
@@ -80,7 +115,7 @@ class AudioRecorderManager(
80
115
  get() {
81
116
  if (field == null) {
82
117
  field = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
83
- Log.d(Constants.TAG, "TelephonyManager initialization: ${if (field != null) "successful" else "failed"}")
118
+ LogUtils.d(CLASS_NAME, "TelephonyManager initialization: ${if (field != null) "successful" else "failed"}")
84
119
  }
85
120
  return field
86
121
  }
@@ -89,12 +124,253 @@ class AudioRecorderManager(
89
124
  private val analysisBuffer = ByteArrayOutputStream()
90
125
  private var isFirstAnalysis = true
91
126
 
127
+ // Properties for device disconnection handling
128
+ var isPrepared = false
129
+ private var selectedDeviceId: String? = null
130
+ private var deviceDisconnectionBehavior: String? = null
131
+
132
+ // Add a method to handle device changes
133
+ fun handleDeviceChange() {
134
+ LogUtils.d(CLASS_NAME, "🔄 handleDeviceChange called - isRecording=${_isRecording.get()}, isPaused=${isPaused.get()}")
135
+ if (!_isRecording.get()) {
136
+ LogUtils.d(CLASS_NAME, "🔄 handleDeviceChange: Not recording, no action needed")
137
+ return
138
+ }
139
+
140
+ if (isPaused.get()) {
141
+ LogUtils.d(CLASS_NAME, "🔄 handleDeviceChange: Recording is paused, marking for restart with new device when resumed")
142
+
143
+ // When paused after device disconnection, we need to release the existing AudioRecord
144
+ // so that it can be properly reinitialized when resumed
145
+ synchronized(audioRecordLock) {
146
+ if (audioRecord != null) {
147
+ LogUtils.d(CLASS_NAME, "🔄 Releasing current AudioRecord while paused to allow proper reinitialization")
148
+ audioRecord?.release()
149
+ audioRecord = null
150
+ LogUtils.d(CLASS_NAME, "🔄 AudioRecord released successfully")
151
+ }
152
+ }
153
+
154
+ return
155
+ }
156
+
157
+ LogUtils.d(CLASS_NAME, "🔄 handleDeviceChange: Restarting recording with new device")
158
+
159
+ try {
160
+ // Log current device configuration for debugging
161
+ val deviceInfo = getAudioDeviceInfo()
162
+ LogUtils.d(CLASS_NAME, "🔄 Current device info: ${deviceInfo["id"] ?: "unknown"} (${deviceInfo["type"] ?: "unknown"})")
163
+
164
+ // Make a copy of current recording settings
165
+ val currentSettings = recordingConfig
166
+
167
+ // Pause the current recording
168
+ synchronized(audioRecordLock) {
169
+ if (audioRecord != null && audioRecord!!.state == AudioRecord.STATE_INITIALIZED) {
170
+ LogUtils.d(CLASS_NAME, "🔄 Stopping current AudioRecord")
171
+ audioRecord!!.stop()
172
+ LogUtils.d(CLASS_NAME, "🔄 AudioRecord stopped")
173
+ }
174
+
175
+ if (compressedRecorder != null) {
176
+ LogUtils.d(CLASS_NAME, "🔄 Pausing compressed recorder")
177
+ compressedRecorder!!.pause()
178
+ LogUtils.d(CLASS_NAME, "🔄 Compressed recorder paused")
179
+ }
180
+ }
181
+
182
+ // Release the current audio record resources
183
+ synchronized(audioRecordLock) {
184
+ LogUtils.d(CLASS_NAME, "🔄 Releasing current AudioRecord")
185
+ audioRecord?.release()
186
+ audioRecord = null
187
+ LogUtils.d(CLASS_NAME, "🔄 AudioRecord resources released")
188
+ }
189
+
190
+ // Log available devices
191
+ logAvailableDevices()
192
+
193
+ // Give a small delay for the system to fully complete device transition
194
+ LogUtils.d(CLASS_NAME, "🔄 Waiting for device transition to complete")
195
+ Thread.sleep(200)
196
+
197
+ // Initialize a new audio record with the same settings
198
+ LogUtils.d(CLASS_NAME, "🔄 Reinitializing AudioRecord with new device")
199
+ if (!initializeAudioRecord(object : Promise {
200
+ override fun resolve(value: Any?) {
201
+ LogUtils.d(CLASS_NAME, "🔄 Successfully reinitialized AudioRecord with new device")
202
+ }
203
+ override fun reject(code: String, message: String?, cause: Throwable?) {
204
+ LogUtils.e(CLASS_NAME, "🔄 Failed to reinitialize AudioRecord: $message")
205
+ }
206
+ })) {
207
+ LogUtils.e(CLASS_NAME, "🔄 Failed to reinitialize audio record, stopping recording")
208
+ stopRecording(object : Promise {
209
+ override fun resolve(value: Any?) {
210
+ eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
211
+ "reason" to "deviceSwitchFailed",
212
+ "isPaused" to true
213
+ ))
214
+ }
215
+ override fun reject(code: String, message: String?, cause: Throwable?) {}
216
+ })
217
+ return
218
+ }
219
+
220
+ // Re-verify recording state
221
+ synchronized(audioRecordLock) {
222
+ if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
223
+ LogUtils.e(CLASS_NAME, "🔄 AudioRecord not properly initialized after device change")
224
+ stopRecording(object : Promise {
225
+ override fun resolve(value: Any?) {
226
+ eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
227
+ "reason" to "deviceSwitchFailed",
228
+ "isPaused" to true
229
+ ))
230
+ }
231
+ override fun reject(code: String, message: String?, cause: Throwable?) {}
232
+ })
233
+ return
234
+ }
235
+ }
236
+
237
+ // Restart the audio record
238
+ synchronized(audioRecordLock) {
239
+ LogUtils.d(CLASS_NAME, "🔄 Starting recording with new device")
240
+ audioRecord?.startRecording()
241
+ LogUtils.d(CLASS_NAME, "🔄 AudioRecord started recording")
242
+
243
+ // Resume compressed recorder if it was active
244
+ if (compressedRecorder != null) {
245
+ LogUtils.d(CLASS_NAME, "🔄 Resuming compressed recorder")
246
+ compressedRecorder!!.resume()
247
+ LogUtils.d(CLASS_NAME, "🔄 Compressed recorder resumed")
248
+ }
249
+ }
250
+
251
+ // Get new device info
252
+ val newDeviceInfo = getAudioDeviceInfo()
253
+ LogUtils.d(CLASS_NAME, "🔄 New device info: ${newDeviceInfo["id"] ?: "unknown"} (${newDeviceInfo["type"] ?: "unknown"})")
254
+
255
+ // Notify JavaScript
256
+ LogUtils.d(CLASS_NAME, "🔄 Sending device changed event to JavaScript")
257
+ eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
258
+ "reason" to "deviceChanged",
259
+ "isPaused" to false,
260
+ "deviceInfo" to newDeviceInfo
261
+ ))
262
+ LogUtils.d(CLASS_NAME, "🔄 Device change handling completed successfully")
263
+
264
+ } catch (e: Exception) {
265
+ LogUtils.e(CLASS_NAME, "🔄 Error handling device change: ${e.message}", e)
266
+ // If something went wrong, try to pause recording
267
+ pauseRecording(object : Promise {
268
+ override fun resolve(value: Any?) {
269
+ eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
270
+ "reason" to "deviceSwitchFailed",
271
+ "isPaused" to true,
272
+ "error" to e.message
273
+ ))
274
+ }
275
+ override fun reject(code: String, message: String?, cause: Throwable?) {}
276
+ })
277
+ }
278
+ }
279
+
280
+ // Helper to get info about current audio device
281
+ private fun getAudioDeviceInfo(): Map<String, Any> {
282
+ return try {
283
+ val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
284
+
285
+ // Check if using Bluetooth SCO
286
+ if (audioManager.isBluetoothScoOn) {
287
+ mapOf(
288
+ "id" to (selectedDeviceId ?: "unknown"),
289
+ "type" to "bluetooth",
290
+ "name" to "Bluetooth Headset",
291
+ "isDefault" to false
292
+ )
293
+ }
294
+ // Check if using wired headset
295
+ else if (audioManager.isWiredHeadsetOn) {
296
+ mapOf(
297
+ "id" to (selectedDeviceId ?: "unknown"),
298
+ "type" to "wired",
299
+ "name" to "Wired Headset",
300
+ "isDefault" to false
301
+ )
302
+ }
303
+ // Default to built-in mic
304
+ else {
305
+ mapOf(
306
+ "id" to (selectedDeviceId ?: "unknown"),
307
+ "type" to "builtin_mic",
308
+ "name" to "Built-in Microphone",
309
+ "isDefault" to true
310
+ )
311
+ }
312
+ } catch (e: Exception) {
313
+ LogUtils.e(CLASS_NAME, "Error getting audio device info: ${e.message}", e)
314
+ mapOf(
315
+ "id" to "unknown",
316
+ "type" to "unknown",
317
+ "name" to "Unknown Device",
318
+ "isDefault" to false
319
+ )
320
+ }
321
+ }
322
+
323
+ // Log available audio devices for debugging
324
+ private fun logAvailableDevices() {
325
+ try {
326
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
327
+ val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
328
+ val devices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
329
+
330
+ LogUtils.d(CLASS_NAME, "Available audio devices (${devices.size}):")
331
+ devices.forEachIndexed { index, device ->
332
+ val name = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
333
+ device.productName?.toString() ?: "Unknown"
334
+ } else {
335
+ when (device.type) {
336
+ AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Built-in Microphone"
337
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth Headset"
338
+ AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired Headset"
339
+ AudioDeviceInfo.TYPE_USB_DEVICE -> "USB Audio Device"
340
+ AudioDeviceInfo.TYPE_USB_HEADSET -> "USB Headset"
341
+ else -> "Unknown Device Type (${device.type})"
342
+ }
343
+ }
344
+
345
+ LogUtils.d(CLASS_NAME, "Device $index: $name (ID: ${device.id})")
346
+ }
347
+ } else {
348
+ val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
349
+ LogUtils.d(CLASS_NAME, "Device info on pre-M Android:")
350
+ LogUtils.d(CLASS_NAME, "- Bluetooth SCO: ${audioManager.isBluetoothScoOn}")
351
+ LogUtils.d(CLASS_NAME, "- Wired Headset: ${audioManager.isWiredHeadsetOn}")
352
+ LogUtils.d(CLASS_NAME, "- Selected Device ID: $selectedDeviceId")
353
+ }
354
+ } catch (e: Exception) {
355
+ LogUtils.e(CLASS_NAME, "Error logging available devices: ${e.message}", e)
356
+ }
357
+ }
358
+
359
+ // Get the device disconnection behavior
360
+ fun getDeviceDisconnectionBehavior(): String {
361
+ return deviceDisconnectionBehavior ?: "pause" // Default to pause if not specified
362
+ }
363
+
364
+ // Add isRecording property accessor
365
+ val isRecording: Boolean
366
+ get() = _isRecording.get()
367
+
92
368
  private fun initializePhoneStateListener() {
93
369
  try {
94
- Log.d(Constants.TAG, "Initializing phone state listener...")
370
+ LogUtils.d(CLASS_NAME, "Initializing phone state listener...")
95
371
 
96
372
  if (permissionUtils.checkPhoneStatePermission()) {
97
- Log.d(Constants.TAG, "Phone state permission granted")
373
+ LogUtils.d(CLASS_NAME, "Phone state permission granted")
98
374
 
99
375
  phoneStateListener = object : PhoneStateListener() {
100
376
  override fun onCallStateChanged(state: Int, phoneNumber: String?) {
@@ -104,49 +380,49 @@ class AudioRecorderManager(
104
380
  TelephonyManager.CALL_STATE_IDLE -> "IDLE"
105
381
  else -> "UNKNOWN"
106
382
  }
107
- Log.d(Constants.TAG, "Phone state changed to: $stateStr")
383
+ LogUtils.d(CLASS_NAME, "Phone state changed to: $stateStr")
108
384
 
109
385
  when (state) {
110
386
  TelephonyManager.CALL_STATE_RINGING,
111
387
  TelephonyManager.CALL_STATE_OFFHOOK -> {
112
- if (isRecording.get() && !isPaused.get()) {
113
- Log.d(Constants.TAG, "Pausing recording due to incoming/ongoing call")
388
+ if (_isRecording.get() && !isPaused.get()) {
389
+ LogUtils.d(CLASS_NAME, "Pausing recording due to incoming/ongoing call")
114
390
  mainHandler.post {
115
391
  pauseRecording(object : Promise {
116
392
  override fun resolve(value: Any?) {
117
- Log.d(Constants.TAG, "Successfully paused recording due to call")
393
+ LogUtils.d(CLASS_NAME, "Successfully paused recording due to call")
118
394
  eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
119
395
  "reason" to "phoneCall",
120
396
  "isPaused" to true
121
397
  ))
122
398
  }
123
399
  override fun reject(code: String, message: String?, cause: Throwable?) {
124
- Log.e(Constants.TAG, "Failed to pause recording on phone call", cause)
400
+ LogUtils.e(CLASS_NAME, "Failed to pause recording on phone call", cause)
125
401
  }
126
402
  })
127
403
  }
128
404
  }
129
405
  }
130
406
  TelephonyManager.CALL_STATE_IDLE -> {
131
- if (isRecording.get() && isPaused.get()) {
132
- Log.d(Constants.TAG, "Call ended, handling auto-resume (enabled: ${recordingConfig.autoResumeAfterInterruption})")
407
+ if (_isRecording.get() && isPaused.get()) {
408
+ LogUtils.d(CLASS_NAME, "Call ended, handling auto-resume (enabled: ${recordingConfig.autoResumeAfterInterruption})")
133
409
  if (recordingConfig.autoResumeAfterInterruption) {
134
410
  mainHandler.post {
135
411
  resumeRecording(object : Promise {
136
412
  override fun resolve(value: Any?) {
137
- Log.d(Constants.TAG, "Successfully resumed recording after call")
413
+ LogUtils.d(CLASS_NAME, "Successfully resumed recording after call")
138
414
  eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
139
415
  "reason" to "phoneCallEnded",
140
416
  "isPaused" to false
141
417
  ))
142
418
  }
143
419
  override fun reject(code: String, message: String?, cause: Throwable?) {
144
- Log.e(Constants.TAG, "Failed to resume recording after phone call", cause)
420
+ LogUtils.e(CLASS_NAME, "Failed to resume recording after phone call", cause)
145
421
  }
146
422
  })
147
423
  }
148
424
  } else {
149
- Log.d(Constants.TAG, "Auto-resume disabled, staying paused")
425
+ LogUtils.d(CLASS_NAME, "Auto-resume disabled, staying paused")
150
426
  eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
151
427
  "reason" to "phoneCallEnded",
152
428
  "isPaused" to true
@@ -161,18 +437,18 @@ class AudioRecorderManager(
161
437
  if (telephonyManager != null) {
162
438
  try {
163
439
  telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
164
- Log.d(Constants.TAG, "Successfully registered phone state listener")
440
+ LogUtils.d(CLASS_NAME, "Successfully registered phone state listener")
165
441
  } catch (e: Exception) {
166
- Log.e(Constants.TAG, "Failed to register phone state listener", e)
442
+ LogUtils.e(CLASS_NAME, "Failed to register phone state listener", e)
167
443
  }
168
444
  } else {
169
- Log.e(Constants.TAG, "TelephonyManager is null, cannot register phone state listener")
445
+ LogUtils.e(CLASS_NAME, "TelephonyManager is null, cannot register phone state listener")
170
446
  }
171
447
  } else {
172
- Log.w(Constants.TAG, "READ_PHONE_STATE permission not granted, phone call interruption handling disabled")
448
+ LogUtils.w(CLASS_NAME, "READ_PHONE_STATE permission not granted, phone call interruption handling disabled")
173
449
  }
174
450
  } catch (e: Exception) {
175
- Log.e(Constants.TAG, "Failed to initialize phone state listener", e)
451
+ LogUtils.e(CLASS_NAME, "Failed to initialize phone state listener", e)
176
452
  }
177
453
  }
178
454
 
@@ -182,7 +458,7 @@ class AudioRecorderManager(
182
458
  when (focusChange) {
183
459
  AudioManager.AUDIOFOCUS_LOSS,
184
460
  AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
185
- if (isRecording.get() && !isPaused.get()) {
461
+ if (_isRecording.get() && !isPaused.get()) {
186
462
  mainHandler.post {
187
463
  pauseRecording(object : Promise {
188
464
  override fun resolve(value: Any?) {
@@ -192,14 +468,14 @@ class AudioRecorderManager(
192
468
  ))
193
469
  }
194
470
  override fun reject(code: String, message: String?, cause: Throwable?) {
195
- Log.e(Constants.TAG, "Failed to pause recording on audio focus loss")
471
+ LogUtils.e(CLASS_NAME, "Failed to pause recording on audio focus loss")
196
472
  }
197
473
  })
198
474
  }
199
475
  }
200
476
  }
201
477
  AudioManager.AUDIOFOCUS_GAIN -> {
202
- if (isRecording.get() && isPaused.get() && recordingConfig.autoResumeAfterInterruption) {
478
+ if (_isRecording.get() && isPaused.get() && recordingConfig.autoResumeAfterInterruption) {
203
479
  mainHandler.post {
204
480
  resumeRecording(object : Promise {
205
481
  override fun resolve(value: Any?) {
@@ -209,7 +485,7 @@ class AudioRecorderManager(
209
485
  ))
210
486
  }
211
487
  override fun reject(code: String, message: String?, cause: Throwable?) {
212
- Log.e(Constants.TAG, "Failed to resume recording on audio focus gain")
488
+ LogUtils.e(CLASS_NAME, "Failed to resume recording on audio focus gain")
213
489
  }
214
490
  })
215
491
  }
@@ -219,190 +495,83 @@ class AudioRecorderManager(
219
495
  }
220
496
  }
221
497
 
222
- companion object {
223
- @SuppressLint("StaticFieldLeak")
224
- @Volatile
225
- private var instance: AudioRecorderManager? = null
226
-
227
- fun getInstance(): AudioRecorderManager? = instance
228
-
229
- fun initialize(
230
- context: Context,
231
- filesDir: File,
232
- permissionUtils: PermissionUtils,
233
- audioDataEncoder: AudioDataEncoder,
234
- eventSender: EventSender,
235
- enablePhoneStateHandling: Boolean = true
236
- ): AudioRecorderManager {
237
- return instance ?: synchronized(this) {
238
- instance ?: AudioRecorderManager(
239
- context, filesDir, permissionUtils, audioDataEncoder, eventSender,
240
- enablePhoneStateHandling
241
- ).also { instance = it }
242
- }
243
- }
244
-
245
- fun destroy() {
246
- instance?.cleanup()
247
- instance = null
248
- }
249
- }
250
-
251
- private fun isOngoingCall(): Boolean {
252
- try {
253
- if (!permissionUtils.checkPhoneStatePermission()) {
254
- Log.w(Constants.TAG, "READ_PHONE_STATE permission not granted, cannot check call state")
255
- return false
256
- }
257
-
258
- val tm = telephonyManager
259
- if (tm == null) {
260
- Log.e(Constants.TAG, "TelephonyManager is null")
261
- return false
262
- }
263
-
264
- // Get audio manager state
265
- val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
266
- val audioMode = audioManager.mode
267
- val isMusicActive = audioManager.isMusicActive
268
-
269
- // Get current audio device info
270
- val currentRoute =
271
- audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull()?.type?.let { type ->
272
- when (type) {
273
- AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "SPEAKER"
274
- AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "EARPIECE"
275
- AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BLUETOOTH_SCO"
276
- AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BLUETOOTH_A2DP"
277
- AudioDeviceInfo.TYPE_WIRED_HEADSET -> "WIRED_HEADSET"
278
- AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "WIRED_HEADPHONES"
279
- else -> "OTHER($type)"
280
- }
281
- } ?: "UNKNOWN"
282
-
283
- // Get communication device info
284
- val communicationDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
285
- audioManager.communicationDevice?.type?.let { type ->
286
- when (type) {
287
- AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BLUETOOTH_SCO"
288
- AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "SPEAKER"
289
- AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "EARPIECE"
290
- else -> "OTHER($type)"
291
- }
292
- } ?: "NONE"
293
- } else {
294
- @Suppress("DEPRECATION")
295
- if (audioManager.isBluetoothScoOn) "BLUETOOTH_SCO" else "NONE"
296
- }
297
-
298
- Log.d(Constants.TAG, """
299
- Audio State Check:
300
- - Audio Mode: ${getAudioModeString(audioMode)}
301
- - Music Active: $isMusicActive
302
- - Current Audio Route: $currentRoute
303
- - Communication Device: $communicationDevice
304
- """.trimIndent())
305
-
306
- // Check telephony state
307
- val callState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
308
- tm.callStateForSubscription
309
- } else {
310
- @Suppress("DEPRECATION")
311
- tm.callState
312
- }
313
-
314
- val isVoipCall = audioMode == AudioManager.MODE_IN_COMMUNICATION
315
- val isRegularCall = callState == TelephonyManager.CALL_STATE_OFFHOOK ||
316
- callState == TelephonyManager.CALL_STATE_RINGING
317
-
318
- Log.d(Constants.TAG, """
319
- Call State Check:
320
- - Telephony Call State: ${getCallStateString(callState)}
321
- - VoIP Call Detected: $isVoipCall
322
- - Regular Call Detected: $isRegularCall
323
- """.trimIndent())
324
-
325
- return isVoipCall || isRegularCall
326
-
327
- } catch (e: SecurityException) {
328
- Log.e(Constants.TAG, "SecurityException when checking call state", e)
329
- return false
330
- } catch (e: Exception) {
331
- Log.e(Constants.TAG, "Error checking call state", e)
332
- return false
333
- }
334
- }
335
-
336
- private fun getAudioModeString(mode: Int): String = when (mode) {
337
- AudioManager.MODE_NORMAL -> "MODE_NORMAL"
338
- AudioManager.MODE_RINGTONE -> "MODE_RINGTONE"
339
- AudioManager.MODE_IN_CALL -> "MODE_IN_CALL"
340
- AudioManager.MODE_IN_COMMUNICATION -> "MODE_IN_COMMUNICATION"
341
- else -> "MODE_UNKNOWN($mode)"
342
- }
343
-
344
- private fun getCallStateString(state: Int): String = when (state) {
345
- TelephonyManager.CALL_STATE_IDLE -> "CALL_STATE_IDLE"
346
- TelephonyManager.CALL_STATE_RINGING -> "CALL_STATE_RINGING"
347
- TelephonyManager.CALL_STATE_OFFHOOK -> "CALL_STATE_OFFHOOK"
348
- else -> "CALL_STATE_UNKNOWN($state)"
349
- }
350
-
351
498
  @RequiresApi(Build.VERSION_CODES.R)
352
499
  fun startRecording(options: Map<String, Any?>, promise: Promise) {
353
500
  try {
354
- // Initialize phone state listener only if enabled
355
- if (enablePhoneStateHandling) {
356
- initializePhoneStateListener()
501
+ // Check if already recording
502
+ if (_isRecording.get() && !isPaused.get()) {
503
+ promise.reject("ALREADY_RECORDING", "Recording is already in progress", null)
504
+ return
357
505
  }
358
-
359
- // Request audio focus
506
+
507
+ // Request audio focus - always do this right before starting
360
508
  if (!requestAudioFocus()) {
361
509
  promise.reject("AUDIO_FOCUS_ERROR", "Failed to obtain audio focus", null)
362
510
  return
363
511
  }
364
512
 
365
- Log.d(Constants.TAG, "Starting recording with options: $options")
513
+ // If already prepared, we can skip initialization
514
+ if (!isPrepared) {
515
+ LogUtils.d(CLASS_NAME, "Not prepared, preparing recording first")
516
+
517
+ // Initialize phone state listener only if enabled
518
+ if (enablePhoneStateHandling) {
519
+ initializePhoneStateListener()
520
+ }
366
521
 
367
- // Check permissions
368
- if (!checkPermissions(options, promise)) return
522
+ LogUtils.d(CLASS_NAME, "Starting recording with options: $options")
369
523
 
370
- // Check if already recording
371
- if (isRecording.get() && !isPaused.get()) {
372
- promise.reject("ALREADY_RECORDING", "Recording is already in progress", null)
373
- return
374
- }
524
+ // Check permissions
525
+ if (!checkPermissions(options, promise)) return
375
526
 
376
- // Parse recording configuration
377
- val configResult = RecordingConfig.fromMap(options)
378
- if (configResult.isFailure) {
379
- promise.reject(
380
- "INVALID_CONFIG",
381
- configResult.exceptionOrNull()?.message ?: "Invalid configuration",
382
- configResult.exceptionOrNull()
383
- )
384
- return
385
- }
527
+ // Parse recording configuration
528
+ val configResult = RecordingConfig.fromMap(options)
529
+ if (configResult.isFailure) {
530
+ promise.reject(
531
+ "INVALID_CONFIG",
532
+ configResult.exceptionOrNull()?.message ?: "Invalid configuration",
533
+ configResult.exceptionOrNull()
534
+ )
535
+ return
536
+ }
386
537
 
387
- val (tempRecordingConfig, audioFormatInfo) = configResult.getOrNull()!!
388
-
389
- recordingConfig = tempRecordingConfig
390
-
391
- audioFormat = audioFormatInfo.format
392
- mimeType = audioFormatInfo.mimeType
538
+ val (tempRecordingConfig, audioFormatInfo) = configResult.getOrNull()!!
539
+
540
+ recordingConfig = tempRecordingConfig
541
+
542
+ // Store device-related settings
543
+ selectedDeviceId = recordingConfig.deviceId
544
+ deviceDisconnectionBehavior = recordingConfig.deviceDisconnectionBehavior ?: "pause"
545
+
546
+ audioFormat = audioFormatInfo.format
547
+ mimeType = audioFormatInfo.mimeType
393
548
 
394
- if (!initializeAudioFormat(promise)) return
549
+ if (!initializeAudioFormat(promise)) return
395
550
 
396
- if (!initializeBufferSize(promise)) return
551
+ if (!initializeBufferSize(promise)) return
397
552
 
398
- if (!initializeAudioRecord(promise)) return
553
+ if (!initializeAudioRecord(promise)) return
399
554
 
400
- if (recordingConfig.enableCompressedOutput && !initializeCompressedRecorder(
401
- if (recordingConfig.compressedFormat == "aac") "aac" else "opus",
402
- promise
403
- )) return
555
+ if (recordingConfig.enableCompressedOutput && !initializeCompressedRecorder(
556
+ if (recordingConfig.compressedFormat == "aac") "aac" else "opus",
557
+ promise
558
+ )) return
404
559
 
405
- if (!initializeRecordingResources(audioFormatInfo.fileExtension, promise)) return
560
+ if (!initializeRecordingResources(audioFormatInfo.fileExtension, promise)) return
561
+ } else {
562
+ LogUtils.d(CLASS_NAME, "Using prepared recording state")
563
+
564
+ // Even when prepared, update device settings from the new options
565
+ val configResult = RecordingConfig.fromMap(options)
566
+ if (configResult.isSuccess) {
567
+ val (tempRecordingConfig, _) = configResult.getOrNull()!!
568
+ // Update device-related settings
569
+ selectedDeviceId = tempRecordingConfig.deviceId ?: selectedDeviceId
570
+ deviceDisconnectionBehavior = tempRecordingConfig.deviceDisconnectionBehavior
571
+ ?: deviceDisconnectionBehavior
572
+ ?: "pause"
573
+ }
574
+ }
406
575
 
407
576
  if (!startRecordingProcess(promise)) return
408
577
 
@@ -410,7 +579,7 @@ class AudioRecorderManager(
410
579
  try {
411
580
  compressedRecorder?.start()
412
581
  } catch (e: Exception) {
413
- Log.e(Constants.TAG, "Failed to start compressed recording", e)
582
+ LogUtils.e(CLASS_NAME, "Failed to start compressed recording", e)
414
583
  cleanup()
415
584
  promise.reject("COMPRESSED_START_FAILED", "Failed to start compressed recording", e)
416
585
  return
@@ -441,7 +610,7 @@ class AudioRecorderManager(
441
610
  }
442
611
 
443
612
  private fun isAudioFormatSupported(sampleRate: Int, channels: Int, format: Int): Boolean {
444
- if (!permissionUtils.checkRecordingPermission()) {
613
+ if (!permissionUtils.checkRecordingPermission(enableBackgroundAudio)) {
445
614
  throw SecurityException("Recording permission has not been granted")
446
615
  }
447
616
 
@@ -477,7 +646,7 @@ class AudioRecorderManager(
477
646
  }
478
647
 
479
648
  private fun checkPermissions(options: Map<String, Any?>, promise: Promise): Boolean {
480
- if (!permissionUtils.checkRecordingPermission()) {
649
+ if (!permissionUtils.checkRecordingPermission(enableBackgroundAudio)) {
481
650
  promise.reject(
482
651
  "PERMISSION_DENIED",
483
652
  "Recording permission has not been granted",
@@ -488,7 +657,7 @@ class AudioRecorderManager(
488
657
 
489
658
  // Only check phone state permission if enabled
490
659
  if (enablePhoneStateHandling && !permissionUtils.checkPhoneStatePermission()) {
491
- Log.w(Constants.TAG, "READ_PHONE_STATE permission not granted, phone call interruption handling will be disabled")
660
+ LogUtils.w(CLASS_NAME, "READ_PHONE_STATE permission not granted, phone call interruption handling will be disabled")
492
661
  // Don't reject here, just log warning as this is optional
493
662
  }
494
663
 
@@ -513,7 +682,7 @@ class AudioRecorderManager(
513
682
  audioFormat
514
683
  )
515
684
  ) {
516
- Log.e(Constants.TAG, "Selected audio format not supported, falling back to 16-bit PCM")
685
+ LogUtils.e(CLASS_NAME, "Selected audio format not supported, falling back to 16-bit PCM")
517
686
  audioFormat = AudioFormat.ENCODING_PCM_16BIT
518
687
 
519
688
  if (!isAudioFormatSupported(
@@ -551,7 +720,7 @@ class AudioRecorderManager(
551
720
 
552
721
  when {
553
722
  bufferSizeInBytes == AudioRecord.ERROR -> {
554
- Log.e(Constants.TAG, "Error getting minimum buffer size: ERROR")
723
+ LogUtils.e(CLASS_NAME, "Error getting minimum buffer size: ERROR")
555
724
  promise.reject(
556
725
  "BUFFER_SIZE_ERROR",
557
726
  "Failed to get minimum buffer size: generic error",
@@ -560,7 +729,7 @@ class AudioRecorderManager(
560
729
  return false
561
730
  }
562
731
  bufferSizeInBytes == AudioRecord.ERROR_BAD_VALUE -> {
563
- Log.e(Constants.TAG, "Error getting minimum buffer size: BAD_VALUE")
732
+ LogUtils.e(CLASS_NAME, "Error getting minimum buffer size: BAD_VALUE")
564
733
  promise.reject(
565
734
  "BUFFER_SIZE_ERROR",
566
735
  "Failed to get minimum buffer size: invalid parameters",
@@ -569,7 +738,7 @@ class AudioRecorderManager(
569
738
  return false
570
739
  }
571
740
  bufferSizeInBytes <= 0 -> {
572
- Log.e(Constants.TAG, "Invalid buffer size: $bufferSizeInBytes")
741
+ LogUtils.e(CLASS_NAME, "Invalid buffer size: $bufferSizeInBytes")
573
742
  promise.reject(
574
743
  "BUFFER_SIZE_ERROR",
575
744
  "Failed to get valid buffer size",
@@ -578,12 +747,12 @@ class AudioRecorderManager(
578
747
  return false
579
748
  }
580
749
  else -> {
581
- Log.d(Constants.TAG, "AudioFormat: $audioFormat, BufferSize: $bufferSizeInBytes")
750
+ LogUtils.d(CLASS_NAME, "AudioFormat: $audioFormat, BufferSize: $bufferSizeInBytes")
582
751
  return true
583
752
  }
584
753
  }
585
754
  } catch (e: Exception) {
586
- Log.e(Constants.TAG, "Failed to initialize buffer size", e)
755
+ LogUtils.e(CLASS_NAME, "Failed to initialize buffer size", e)
587
756
  promise.reject(
588
757
  "BUFFER_SIZE_ERROR",
589
758
  "Failed to initialize buffer size: ${e.message}",
@@ -595,7 +764,7 @@ class AudioRecorderManager(
595
764
 
596
765
 
597
766
  private fun initializeAudioRecord(promise: Promise): Boolean {
598
- if (!permissionUtils.checkRecordingPermission()) {
767
+ if (!permissionUtils.checkRecordingPermission(enableBackgroundAudio)) {
599
768
  promise.reject(
600
769
  "PERMISSION_DENIED",
601
770
  "Recording permission has not been granted",
@@ -606,7 +775,7 @@ class AudioRecorderManager(
606
775
 
607
776
  try {
608
777
  if (audioRecord == null || !isPaused.get()) {
609
- Log.d(Constants.TAG, "Initializing AudioRecord with format: $audioFormat, BufferSize: $bufferSizeInBytes")
778
+ LogUtils.d(CLASS_NAME, "Initializing AudioRecord with format: $audioFormat, BufferSize: $bufferSizeInBytes")
610
779
 
611
780
  audioRecord = AudioRecord(
612
781
  MediaRecorder.AudioSource.MIC,
@@ -628,7 +797,7 @@ class AudioRecorderManager(
628
797
  return true
629
798
 
630
799
  } catch (e: SecurityException) {
631
- Log.e(Constants.TAG, "Security exception while initializing AudioRecord", e)
800
+ LogUtils.e(CLASS_NAME, "Security exception while initializing AudioRecord", e)
632
801
  promise.reject(
633
802
  "PERMISSION_DENIED",
634
803
  "Recording permission denied: ${e.message}",
@@ -636,7 +805,7 @@ class AudioRecorderManager(
636
805
  )
637
806
  return false
638
807
  } catch (e: Exception) {
639
- Log.e(Constants.TAG, "Failed to initialize AudioRecord", e)
808
+ LogUtils.e(CLASS_NAME, "Failed to initialize AudioRecord", e)
640
809
  promise.reject(
641
810
  "INITIALIZATION_FAILED",
642
811
  "Failed to initialize the audio recorder: ${e.message}",
@@ -677,7 +846,7 @@ class AudioRecorderManager(
677
846
  return false
678
847
  } catch (e: Exception) {
679
848
  releaseWakeLock()
680
- Log.e(Constants.TAG, "Unexpected error in startRecording", e)
849
+ LogUtils.e(CLASS_NAME, "Unexpected error in startRecording", e)
681
850
  promise.reject("UNEXPECTED_ERROR", "Unexpected error: ${e.message}", e)
682
851
  return false
683
852
  }
@@ -686,7 +855,7 @@ class AudioRecorderManager(
686
855
  private fun startRecordingProcess(promise: Promise): Boolean {
687
856
  try {
688
857
  // Add detailed logging of recording configuration
689
- Log.d(Constants.TAG, """
858
+ LogUtils.d(CLASS_NAME, """
690
859
  Starting audio recording with configuration:
691
860
  - Sample Rate: ${recordingConfig.sampleRate} Hz
692
861
  - Channels: ${recordingConfig.channels}
@@ -710,7 +879,7 @@ class AudioRecorderManager(
710
879
 
711
880
  audioRecord?.startRecording()
712
881
  isPaused.set(false)
713
- isRecording.set(true)
882
+ _isRecording.set(true)
714
883
  isFirstChunk = true
715
884
 
716
885
  if (!isPaused.get()) {
@@ -727,7 +896,7 @@ class AudioRecorderManager(
727
896
  return true
728
897
 
729
898
  } catch (e: Exception) {
730
- Log.e(Constants.TAG, "Failed to start recording", e)
899
+ LogUtils.e(CLASS_NAME, "Failed to start recording", e)
731
900
  cleanup()
732
901
  promise.reject("START_FAILED", "Failed to start recording: ${e.message}", e)
733
902
  return false
@@ -736,8 +905,8 @@ class AudioRecorderManager(
736
905
 
737
906
  fun stopRecording(promise: Promise) {
738
907
  synchronized(audioRecordLock) {
739
- if (!isRecording.get()) {
740
- Log.e(Constants.TAG, "Recording is not active")
908
+ if (!_isRecording.get()) {
909
+ LogUtils.e(CLASS_NAME, "Recording is not active")
741
910
  promise.reject("NOT_RECORDING", "Recording is not active", null)
742
911
  return
743
912
  }
@@ -756,25 +925,26 @@ class AudioRecorderManager(
756
925
  AudioRecordingService.stopService(context)
757
926
  }
758
927
 
759
- isRecording.set(false)
928
+ _isRecording.set(false)
929
+ isPrepared = false // Reset preparation state
760
930
  recordingThread?.join(1000)
761
931
 
762
932
  val audioData = ByteArray(bufferSizeInBytes)
763
933
  val bytesRead = audioRecord?.read(audioData, 0, bufferSizeInBytes) ?: -1
764
- Log.d(Constants.TAG, "Last Read $bytesRead bytes")
934
+ LogUtils.d(CLASS_NAME, "Last Read $bytesRead bytes")
765
935
  if (bytesRead > 0) {
766
936
  emitAudioData(audioData.copyOfRange(0, bytesRead), bytesRead)
767
937
  }
768
938
 
769
- Log.d(Constants.TAG, "Stopping recording state = ${audioRecord?.state}")
939
+ LogUtils.d(CLASS_NAME, "Stopping recording state = ${audioRecord?.state}")
770
940
  if (audioRecord != null && audioRecord!!.state == AudioRecord.STATE_INITIALIZED) {
771
- Log.d(Constants.TAG, "Stopping AudioRecord")
941
+ LogUtils.d(CLASS_NAME, "Stopping AudioRecord")
772
942
  audioRecord!!.stop()
773
943
  }
774
944
 
775
945
  cleanup()
776
946
  } catch (e: IllegalStateException) {
777
- Log.e(Constants.TAG, "Error reading from AudioRecord", e)
947
+ LogUtils.e(CLASS_NAME, "Error reading from AudioRecord", e)
778
948
  } finally {
779
949
  releaseWakeLock()
780
950
  audioRecord?.release()
@@ -785,7 +955,7 @@ class AudioRecorderManager(
785
955
  audioProcessor.resetCumulativeAmplitudeRange()
786
956
 
787
957
  val fileSize = audioFile?.length() ?: 0
788
- Log.d(Constants.TAG, "WAV File validation - Size: $fileSize bytes, Path: ${audioFile?.absolutePath}")
958
+ LogUtils.d(CLASS_NAME, "WAV File validation - Size: $fileSize bytes, Path: ${audioFile?.absolutePath}")
789
959
 
790
960
  val dataFileSize = fileSize - 44 // Subtract header size
791
961
  val byteRate =
@@ -806,7 +976,7 @@ class AudioRecorderManager(
806
976
  // Log compressed file status if enabled
807
977
  if (recordingConfig.enableCompressedOutput) {
808
978
  val compressedSize = compressedFile?.length() ?: 0
809
- Log.d(Constants.TAG, "Compressed File validation - Size: $compressedSize bytes, Path: ${compressedFile?.absolutePath}")
979
+ LogUtils.d(CLASS_NAME, "Compressed File validation - Size: $compressedSize bytes, Path: ${compressedFile?.absolutePath}")
810
980
  }
811
981
 
812
982
  val result = bundleOf(
@@ -830,12 +1000,12 @@ class AudioRecorderManager(
830
1000
  promise.resolve(result)
831
1001
 
832
1002
  // Reset the timing variables
833
- isRecording.set(false)
1003
+ _isRecording.set(false)
834
1004
  isPaused.set(false)
835
1005
  totalRecordedTime = 0
836
1006
  pausedDuration = 0
837
1007
  } catch (e: Exception) {
838
- Log.d(Constants.TAG, "Failed to stop recording", e)
1008
+ LogUtils.e(CLASS_NAME, "Failed to stop recording: ${e.message}")
839
1009
  promise.reject("STOP_FAILED", "Failed to stop recording", e)
840
1010
  } finally {
841
1011
  audioRecord = null
@@ -844,18 +1014,51 @@ class AudioRecorderManager(
844
1014
  }
845
1015
 
846
1016
  fun resumeRecording(promise: Promise) {
1017
+ LogUtils.d(CLASS_NAME, "⏺️ resumeRecording method entered - isPaused=${isPaused.get()}, isRecording=${_isRecording.get()}")
847
1018
  if (!isPaused.get()) {
1019
+ LogUtils.e(CLASS_NAME, "⏺️ Cannot resume recording: not paused")
848
1020
  promise.reject("NOT_PAUSED", "Recording is not paused", null)
849
1021
  return
850
1022
  }
851
1023
 
852
1024
  if (isOngoingCall()) {
1025
+ LogUtils.e(CLASS_NAME, "⏺️ Cannot resume recording: ongoing call detected")
853
1026
  promise.reject("ONGOING_CALL", "Cannot resume recording during an ongoing call", null)
854
1027
  return
855
1028
  }
856
1029
 
857
1030
  try {
1031
+ // Check if audioRecord needs reinitializing
1032
+ var needsReinitialize = false
1033
+ synchronized(audioRecordLock) {
1034
+ LogUtils.d(CLASS_NAME, "⏺️ Checking audioRecord state: ${audioRecord?.state ?: "null"}")
1035
+ if (audioRecord == null || audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
1036
+ LogUtils.d(CLASS_NAME, "⏺️ AudioRecord is null or not properly initialized, will reinitialize")
1037
+ needsReinitialize = true
1038
+ }
1039
+ }
1040
+
1041
+ // Reinitialize audioRecord if needed (like after device disconnection)
1042
+ if (needsReinitialize) {
1043
+ LogUtils.d(CLASS_NAME, "⏺️ Starting reinitialization of AudioRecord for resumption after disconnection")
1044
+ if (!initializeAudioRecord(object : Promise {
1045
+ override fun resolve(value: Any?) {
1046
+ LogUtils.d(CLASS_NAME, "⏺️ Successfully reinitialized AudioRecord for resumption")
1047
+ }
1048
+ override fun reject(code: String, message: String?, cause: Throwable?) {
1049
+ LogUtils.e(CLASS_NAME, "⏺️ Failed to reinitialize AudioRecord: $message")
1050
+ // We'll let the main try-catch handle this error
1051
+ throw IllegalStateException("Failed to reinitialize AudioRecord: $message")
1052
+ }
1053
+ })) {
1054
+ LogUtils.e(CLASS_NAME, "⏺️ Failed to reinitialize AudioRecord")
1055
+ throw IllegalStateException("Failed to reinitialize AudioRecord for resumption")
1056
+ }
1057
+ LogUtils.d(CLASS_NAME, "⏺️ Reinitialization completed successfully")
1058
+ }
1059
+
858
1060
  if (recordingConfig.showNotification) {
1061
+ LogUtils.d(CLASS_NAME, "⏺️ Resuming notification updates")
859
1062
  notificationManager.resumeUpdates()
860
1063
  }
861
1064
 
@@ -863,18 +1066,36 @@ class AudioRecorderManager(
863
1066
  pausedDuration += System.currentTimeMillis() - lastPauseTime
864
1067
  isPaused.set(false)
865
1068
 
866
- audioRecord?.startRecording()
867
- compressedRecorder?.resume()
1069
+ synchronized(audioRecordLock) {
1070
+ // Double-check audioRecord is valid after potential reinitialization
1071
+ LogUtils.d(CLASS_NAME, "⏺️ Final check of audioRecord state: ${audioRecord?.state ?: "null"}")
1072
+ if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
1073
+ LogUtils.e(CLASS_NAME, "⏺️ AudioRecord is not properly initialized")
1074
+ throw IllegalStateException("AudioRecord is not properly initialized")
1075
+ }
1076
+
1077
+ LogUtils.d(CLASS_NAME, "⏺️ Starting AudioRecord recording")
1078
+ audioRecord?.startRecording()
1079
+ LogUtils.d(CLASS_NAME, "⏺️ AudioRecord.startRecording called")
1080
+
1081
+ if (compressedRecorder != null) {
1082
+ LogUtils.d(CLASS_NAME, "⏺️ Resuming compressed recorder")
1083
+ compressedRecorder?.resume()
1084
+ LogUtils.d(CLASS_NAME, "⏺️ Compressed recorder resumed")
1085
+ }
1086
+ }
868
1087
 
1088
+ LogUtils.d(CLASS_NAME, "⏺️ Recording resumed successfully")
869
1089
  promise.resolve("Recording resumed")
870
1090
  } catch (e: Exception) {
1091
+ LogUtils.e(CLASS_NAME, "⏺️ Failed to resume recording: ${e.message}", e)
871
1092
  releaseWakeLock()
872
- promise.reject("RESUME_FAILED", "Failed to resume recording", e)
1093
+ promise.reject("RESUME_FAILED", "Failed to resume recording: ${e.message}", e)
873
1094
  }
874
1095
  }
875
1096
 
876
1097
  fun pauseRecording(promise: Promise) {
877
- if (isRecording.get() && !isPaused.get()) {
1098
+ if (_isRecording.get() && !isPaused.get()) {
878
1099
  audioRecord?.stop()
879
1100
  compressedRecorder?.pause()
880
1101
 
@@ -906,14 +1127,14 @@ class AudioRecorderManager(
906
1127
  } ?: false
907
1128
 
908
1129
  // If service is running but we think we're not recording, clean up
909
- if (isServiceRunning && !isRecording.get()) {
910
- Log.d(Constants.TAG, "Detected orphaned recording service, cleaning up...")
1130
+ if (isServiceRunning && !_isRecording.get()) {
1131
+ LogUtils.d(CLASS_NAME, "Detected orphaned recording service, cleaning up...")
911
1132
  cleanup()
912
1133
  AudioRecordingService.stopService(context)
913
1134
  }
914
1135
 
915
- if (!isRecording.get()) {
916
- Log.d(Constants.TAG, "Not recording --- skip status with default values")
1136
+ if (!_isRecording.get()) {
1137
+ LogUtils.d(CLASS_NAME, "Not recording --- skip status with default values")
917
1138
  return bundleOf(
918
1139
  "isRecording" to false,
919
1140
  "isPaused" to false,
@@ -945,7 +1166,7 @@ class AudioRecorderManager(
945
1166
 
946
1167
  return bundleOf(
947
1168
  "durationMs" to duration,
948
- "isRecording" to isRecording.get(),
1169
+ "isRecording" to _isRecording.get(),
949
1170
  "isPaused" to isPaused.get(),
950
1171
  "mimeType" to mimeType,
951
1172
  "size" to totalDataSize,
@@ -967,9 +1188,9 @@ class AudioRecorderManager(
967
1188
  acquire()
968
1189
  }
969
1190
  wasWakeLockEnabled = true
970
- Log.d(Constants.TAG, "Wake lock acquired")
1191
+ LogUtils.d(CLASS_NAME, "Wake lock acquired")
971
1192
  } catch (e: Exception) {
972
- Log.e(Constants.TAG, "Failed to acquire wake lock", e)
1193
+ LogUtils.e(CLASS_NAME, "Failed to acquire wake lock", e)
973
1194
  }
974
1195
  }
975
1196
  }
@@ -980,13 +1201,45 @@ class AudioRecorderManager(
980
1201
  wakeLock?.let {
981
1202
  if (it.isHeld) {
982
1203
  it.release()
983
- Log.d(Constants.TAG, "Wake lock released")
1204
+ LogUtils.d(CLASS_NAME, "Wake lock released")
984
1205
  }
985
1206
  wakeLock = null
986
1207
  wasWakeLockEnabled = false
987
1208
  }
988
1209
  } catch (e: Exception) {
989
- Log.e(Constants.TAG, "Failed to release wake lock", e)
1210
+ LogUtils.e(CLASS_NAME, "Failed to release wake lock", e)
1211
+ }
1212
+ }
1213
+
1214
+ /**
1215
+ * Checks if there is an ongoing call that would interfere with recording
1216
+ */
1217
+ private fun isOngoingCall(): Boolean {
1218
+ try {
1219
+ if (telephonyManager == null) return false
1220
+
1221
+ // Get phone call state directly from telephonyManager instead of
1222
+ // relying on audio manager state which could be misleading after device disconnection
1223
+ val callState = telephonyManager?.callState
1224
+
1225
+ LogUtils.d(CLASS_NAME, "Call state check: callState=${callState}, " +
1226
+ "audioManager.mode=${audioManager.mode}, " +
1227
+ "audioManager.isBluetoothScoOn=${audioManager.isBluetoothScoOn}")
1228
+
1229
+ // Trust phone state more than audio manager state
1230
+ if (callState == TelephonyManager.CALL_STATE_RINGING ||
1231
+ callState == TelephonyManager.CALL_STATE_OFFHOOK) {
1232
+ return true
1233
+ }
1234
+
1235
+ // Only check audio manager mode as secondary indicator
1236
+ return audioManager.mode == AudioManager.MODE_IN_CALL ||
1237
+ audioManager.mode == AudioManager.MODE_IN_COMMUNICATION
1238
+
1239
+ // Remove audioManager.isBluetoothScoOn check as it can be erroneously true after disconnection
1240
+ } catch (e: Exception) {
1241
+ LogUtils.e(CLASS_NAME, "Error checking call state: ${e.message}")
1242
+ return false
990
1243
  }
991
1244
  }
992
1245
 
@@ -1003,11 +1256,11 @@ class AudioRecorderManager(
1003
1256
 
1004
1257
  private fun recordingProcess() {
1005
1258
  try {
1006
- Log.i(Constants.TAG, "Starting recording process...")
1259
+ LogUtils.i(CLASS_NAME, "Starting recording process...")
1007
1260
  FileOutputStream(audioFile, true).use { fos ->
1008
1261
  // Write audio data directly to the file
1009
1262
  val audioData = ByteArray(bufferSizeInBytes)
1010
- Log.d(Constants.TAG, "Entering recording loop")
1263
+ LogUtils.d(CLASS_NAME, "Entering recording loop")
1011
1264
 
1012
1265
  // Buffer to accumulate data
1013
1266
  val accumulatedAudioData = ByteArrayOutputStream()
@@ -1031,7 +1284,7 @@ class AudioRecorderManager(
1031
1284
  var shouldProcessAnalysis = false
1032
1285
 
1033
1286
  // Debug log for intervals
1034
- Log.d(Constants.TAG, """
1287
+ LogUtils.d(CLASS_NAME, """
1035
1288
  Recording process started with intervals:
1036
1289
  - Data emission interval: ${recordingConfig.interval}ms
1037
1290
  - Analysis interval: ${recordingConfig.intervalAnalysis}ms
@@ -1039,7 +1292,7 @@ class AudioRecorderManager(
1039
1292
  """.trimIndent())
1040
1293
 
1041
1294
  // Recording loop
1042
- while (isRecording.get() && !Thread.currentThread().isInterrupted) {
1295
+ while (_isRecording.get() && !Thread.currentThread().isInterrupted) {
1043
1296
  if (isPaused.get()) {
1044
1297
  Thread.sleep(100) // Add small delay when paused
1045
1298
  continue
@@ -1053,12 +1306,12 @@ class AudioRecorderManager(
1053
1306
  val bytesRead = synchronized(audioRecordLock) {
1054
1307
  audioRecord?.let {
1055
1308
  if (it.state != AudioRecord.STATE_INITIALIZED) {
1056
- Log.e(Constants.TAG, "AudioRecord not initialized")
1309
+ LogUtils.e(CLASS_NAME, "AudioRecord not initialized")
1057
1310
  return@let -1
1058
1311
  }
1059
1312
  it.read(audioData, 0, bufferSizeInBytes).also { bytes ->
1060
1313
  if (bytes < 0) {
1061
- Log.e(Constants.TAG, "AudioRecord read error: $bytes")
1314
+ LogUtils.e(CLASS_NAME, "AudioRecord read error: $bytes")
1062
1315
  }
1063
1316
  }
1064
1317
  } ?: -1 // Handle null case
@@ -1083,7 +1336,7 @@ class AudioRecorderManager(
1083
1336
  // Handle analysis emission separately
1084
1337
  if (shouldProcessAnalysis) {
1085
1338
  val analysisDataSize = accumulatedAnalysisData.size()
1086
- Log.d(Constants.TAG, """
1339
+ LogUtils.d(CLASS_NAME, """
1087
1340
  Processing analysis data:
1088
1341
  - Time since last: ${currentTime - lastEmissionTimeAnalysis}ms
1089
1342
  - Configured interval: ${recordingConfig.intervalAnalysis}ms
@@ -1100,7 +1353,7 @@ class AudioRecorderManager(
1100
1353
  recordingConfig
1101
1354
  )
1102
1355
 
1103
- Log.d(Constants.TAG, """
1356
+ LogUtils.d(CLASS_NAME, """
1104
1357
  Analysis data details:
1105
1358
  - Raw data size: ${accumulatedAnalysisData.size()} bytes
1106
1359
  """.trimIndent())
@@ -1112,7 +1365,7 @@ class AudioRecorderManager(
1112
1365
  analysisData.toBundle()
1113
1366
  )
1114
1367
  } catch (e: Exception) {
1115
- Log.e(Constants.TAG, "Failed to send audio analysis event", e)
1368
+ LogUtils.e(CLASS_NAME, "Failed to send audio analysis event", e)
1116
1369
  }
1117
1370
  }
1118
1371
 
@@ -1135,7 +1388,7 @@ class AudioRecorderManager(
1135
1388
  if (!isPaused.get()) {
1136
1389
  releaseWakeLock()
1137
1390
  }
1138
- Log.e(Constants.TAG, "Error in recording process", e)
1391
+ LogUtils.e(CLASS_NAME, "Error in recording process", e)
1139
1392
  }
1140
1393
  }
1141
1394
 
@@ -1164,7 +1417,7 @@ class AudioRecorderManager(
1164
1417
  audioDataEncoder.encodeToBase64(buffer)
1165
1418
  }
1166
1419
  } catch (e: Exception) {
1167
- Log.e(Constants.TAG, "Failed to read compressed data", e)
1420
+ LogUtils.e(CLASS_NAME, "Failed to read compressed data", e)
1168
1421
  null
1169
1422
  }
1170
1423
  } else null
@@ -1196,7 +1449,7 @@ class AudioRecorderManager(
1196
1449
  )
1197
1450
  )
1198
1451
  } catch (e: Exception) {
1199
- Log.e(Constants.TAG, "Failed to send event", e)
1452
+ LogUtils.e(CLASS_NAME, "Failed to send event", e)
1200
1453
  }
1201
1454
  }
1202
1455
 
@@ -1266,14 +1519,15 @@ class AudioRecorderManager(
1266
1519
  fun cleanup() {
1267
1520
  synchronized(audioRecordLock) {
1268
1521
  try {
1269
- if (isRecording.get()) {
1522
+ if (_isRecording.get()) {
1270
1523
  audioRecord?.stop()
1271
1524
  compressedRecorder?.stop()
1272
1525
  compressedRecorder?.release()
1273
1526
  }
1274
1527
 
1275
- isRecording.set(false)
1528
+ _isRecording.set(false)
1276
1529
  isPaused.set(false)
1530
+ isPrepared = false // Reset prepared state
1277
1531
 
1278
1532
  if (recordingConfig.showNotification) {
1279
1533
  notificationManager.stopUpdates()
@@ -1302,7 +1556,7 @@ class AudioRecorderManager(
1302
1556
  "isPaused" to false
1303
1557
  ))
1304
1558
  } catch (e: Exception) {
1305
- Log.e(Constants.TAG, "Error during cleanup", e)
1559
+ LogUtils.e(CLASS_NAME, "Error during cleanup", e)
1306
1560
  }
1307
1561
  }
1308
1562
  }
@@ -1336,7 +1590,7 @@ class AudioRecorderManager(
1336
1590
  }
1337
1591
  return true
1338
1592
  } catch (e: Exception) {
1339
- Log.e(Constants.TAG, "Failed to initialize compressed recorder", e)
1593
+ LogUtils.e(CLASS_NAME, "Failed to initialize compressed recorder", e)
1340
1594
  promise.reject("COMPRESSED_INIT_FAILED", "Failed to initialize compressed recorder", e)
1341
1595
  return false
1342
1596
  }
@@ -1348,7 +1602,7 @@ class AudioRecorderManager(
1348
1602
  when (focusChange) {
1349
1603
  AudioManager.AUDIOFOCUS_LOSS,
1350
1604
  AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
1351
- if (isRecording.get() && !isPaused.get()) {
1605
+ if (_isRecording.get() && !isPaused.get()) {
1352
1606
  mainHandler.post {
1353
1607
  pauseRecording(object : Promise {
1354
1608
  override fun resolve(value: Any?) {
@@ -1359,14 +1613,14 @@ class AudioRecorderManager(
1359
1613
  ))
1360
1614
  }
1361
1615
  override fun reject(code: String, message: String?, cause: Throwable?) {
1362
- Log.e(Constants.TAG, "Failed to pause recording on audio focus loss")
1616
+ LogUtils.e(CLASS_NAME, "Failed to pause recording on audio focus loss")
1363
1617
  }
1364
1618
  })
1365
1619
  }
1366
1620
  }
1367
1621
  }
1368
1622
  AudioManager.AUDIOFOCUS_GAIN -> {
1369
- if (isRecording.get() && isPaused.get() && recordingConfig.autoResumeAfterInterruption) {
1623
+ if (_isRecording.get() && isPaused.get() && recordingConfig.autoResumeAfterInterruption) {
1370
1624
  mainHandler.post {
1371
1625
  resumeRecording(object : Promise {
1372
1626
  override fun resolve(value: Any?) {
@@ -1376,7 +1630,7 @@ class AudioRecorderManager(
1376
1630
  ))
1377
1631
  }
1378
1632
  override fun reject(code: String, message: String?, cause: Throwable?) {
1379
- Log.e(Constants.TAG, "Failed to resume recording on audio focus gain")
1633
+ LogUtils.e(CLASS_NAME, "Failed to resume recording on audio focus gain")
1380
1634
  }
1381
1635
  })
1382
1636
  }
@@ -1444,4 +1698,76 @@ class AudioRecorderManager(
1444
1698
  fun getKeepAwakeStatus(): Boolean {
1445
1699
  return recordingConfig?.keepAwake ?: true
1446
1700
  }
1701
+
1702
+ /**
1703
+ * Prepares audio recording with all initial setup but without starting.
1704
+ * This reuses the existing validation and setup functions for compatibility.
1705
+ */
1706
+ fun prepareRecording(options: Map<String, Any?>): Boolean {
1707
+ if (_isRecording.get()) {
1708
+ LogUtils.d(CLASS_NAME, "Cannot prepare recording - already recording")
1709
+ return false
1710
+ }
1711
+
1712
+ if (isPrepared) {
1713
+ LogUtils.d(CLASS_NAME, "Already prepared")
1714
+ return true
1715
+ }
1716
+
1717
+ try {
1718
+ // Initialize phone state listener only if enabled
1719
+ if (enablePhoneStateHandling) {
1720
+ initializePhoneStateListener()
1721
+ }
1722
+
1723
+ // Check permissions - create a dummy promise to avoid rejections
1724
+ val dummyPromise = object : Promise {
1725
+ override fun resolve(value: Any?) {}
1726
+ override fun reject(code: String, message: String?, cause: Throwable?) {
1727
+ LogUtils.e(CLASS_NAME, "Preparation error: $code - $message", cause)
1728
+ }
1729
+ }
1730
+
1731
+ if (!checkPermissions(options, dummyPromise)) return false
1732
+
1733
+ // Parse recording configuration - reuse existing code
1734
+ val configResult = RecordingConfig.fromMap(options)
1735
+ if (configResult.isFailure) {
1736
+ LogUtils.e(CLASS_NAME, "Invalid configuration: ${configResult.exceptionOrNull()?.message}")
1737
+ return false
1738
+ }
1739
+
1740
+ val (tempRecordingConfig, audioFormatInfo) = configResult.getOrNull()!!
1741
+ recordingConfig = tempRecordingConfig
1742
+
1743
+ // Store device-related settings
1744
+ selectedDeviceId = recordingConfig.deviceId
1745
+ deviceDisconnectionBehavior = recordingConfig.deviceDisconnectionBehavior ?: "pause"
1746
+
1747
+ audioFormat = audioFormatInfo.format
1748
+ mimeType = audioFormatInfo.mimeType
1749
+
1750
+ // Use all the existing validation functions with our dummy promise
1751
+ if (!initializeAudioFormat(dummyPromise)) return false
1752
+ if (!initializeBufferSize(dummyPromise)) return false
1753
+ if (!initializeAudioRecord(dummyPromise)) return false
1754
+
1755
+ if (recordingConfig.enableCompressedOutput && !initializeCompressedRecorder(
1756
+ if (recordingConfig.compressedFormat == "aac") "aac" else "opus",
1757
+ dummyPromise
1758
+ )) return false
1759
+
1760
+ if (!initializeRecordingResources(audioFormatInfo.fileExtension, dummyPromise)) return false
1761
+
1762
+ // Everything is ready, mark as prepared
1763
+ isPrepared = true
1764
+ LogUtils.d(CLASS_NAME, "Recording prepared successfully")
1765
+ return true
1766
+ } catch (e: Exception) {
1767
+ LogUtils.e(CLASS_NAME, "Error during preparation: ${e.message}", e)
1768
+ cleanup()
1769
+ isPrepared = false
1770
+ return false
1771
+ }
1772
+ }
1447
1773
  }