@siteed/expo-audio-studio 2.4.1 → 2.6.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 (85) 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 +576 -252
  9. package/android/src/main/java/net/siteed/audiostream/Constants.kt +17 -1
  10. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +419 -155
  11. package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +65 -0
  12. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +9 -1
  13. package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  14. package/build/AudioDeviceManager.d.ts +107 -0
  15. package/build/AudioDeviceManager.d.ts.map +1 -0
  16. package/build/AudioDeviceManager.js +493 -0
  17. package/build/AudioDeviceManager.js.map +1 -0
  18. package/build/AudioRecorder.provider.d.ts.map +1 -1
  19. package/build/AudioRecorder.provider.js +3 -0
  20. package/build/AudioRecorder.provider.js.map +1 -1
  21. package/build/ExpoAudioStream.types.d.ts +104 -1
  22. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  23. package/build/ExpoAudioStream.types.js +7 -1
  24. package/build/ExpoAudioStream.types.js.map +1 -1
  25. package/build/ExpoAudioStream.web.d.ts +37 -0
  26. package/build/ExpoAudioStream.web.d.ts.map +1 -1
  27. package/build/ExpoAudioStream.web.js +478 -62
  28. package/build/ExpoAudioStream.web.js.map +1 -1
  29. package/build/ExpoAudioStreamModule.d.ts.map +1 -1
  30. package/build/ExpoAudioStreamModule.js +20 -0
  31. package/build/ExpoAudioStreamModule.js.map +1 -1
  32. package/build/WebRecorder.web.d.ts +74 -11
  33. package/build/WebRecorder.web.d.ts.map +1 -1
  34. package/build/WebRecorder.web.js +390 -74
  35. package/build/WebRecorder.web.js.map +1 -1
  36. package/build/hooks/useAudioDevices.d.ts +14 -0
  37. package/build/hooks/useAudioDevices.d.ts.map +1 -0
  38. package/build/hooks/useAudioDevices.js +151 -0
  39. package/build/hooks/useAudioDevices.js.map +1 -0
  40. package/build/index.d.ts +2 -0
  41. package/build/index.d.ts.map +1 -1
  42. package/build/index.js +4 -0
  43. package/build/index.js.map +1 -1
  44. package/build/useAudioRecorder.d.ts +1 -0
  45. package/build/useAudioRecorder.d.ts.map +1 -1
  46. package/build/useAudioRecorder.js +20 -1
  47. package/build/useAudioRecorder.js.map +1 -1
  48. package/build/utils/BlobFix.d.ts.map +1 -1
  49. package/build/utils/BlobFix.js +2 -2
  50. package/build/utils/BlobFix.js.map +1 -1
  51. package/build/utils/writeWavHeader.d.ts +3 -18
  52. package/build/utils/writeWavHeader.d.ts.map +1 -1
  53. package/build/utils/writeWavHeader.js +19 -26
  54. package/build/utils/writeWavHeader.js.map +1 -1
  55. package/build/workers/InlineFeaturesExtractor.web.d.ts +1 -1
  56. package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
  57. package/build/workers/InlineFeaturesExtractor.web.js +27 -26
  58. package/build/workers/InlineFeaturesExtractor.web.js.map +1 -1
  59. package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
  60. package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
  61. package/build/workers/inlineAudioWebWorker.web.js +25 -1
  62. package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
  63. package/ios/AudioDeviceManager.swift +654 -0
  64. package/ios/AudioStreamManager.swift +964 -760
  65. package/ios/ExpoAudioStreamModule.swift +174 -19
  66. package/ios/Features.swift +1 -1
  67. package/ios/ISSUE_IOS.md +45 -0
  68. package/ios/Logger.swift +13 -1
  69. package/ios/RecordingSettings.swift +12 -0
  70. package/package.json +2 -2
  71. package/src/AudioAnalysis/AudioAnalysis.types.ts +2 -2
  72. package/src/AudioDeviceManager.ts +571 -0
  73. package/src/AudioRecorder.provider.tsx +3 -0
  74. package/src/ExpoAudioStream.types.ts +113 -1
  75. package/src/ExpoAudioStream.web.ts +609 -69
  76. package/src/ExpoAudioStreamModule.ts +23 -0
  77. package/src/WebRecorder.web.ts +482 -92
  78. package/src/hooks/useAudioDevices.ts +180 -0
  79. package/src/index.ts +6 -0
  80. package/src/types/crc-32.d.ts +6 -6
  81. package/src/useAudioRecorder.tsx +27 -1
  82. package/src/utils/BlobFix.ts +6 -4
  83. package/src/utils/writeWavHeader.ts +26 -25
  84. package/src/workers/InlineFeaturesExtractor.web.tsx +27 -26
  85. 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,
@@ -41,9 +43,41 @@ class AudioRecorderManager(
41
43
  private val enablePhoneStateHandling: Boolean = true,
42
44
  private val enableBackgroundAudio: Boolean = true
43
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
+
44
78
  private var audioRecord: AudioRecord? = null
45
79
  private var bufferSizeInBytes = 0
46
- private var isRecording = AtomicBoolean(false)
80
+ private var _isRecording = AtomicBoolean(false)
47
81
  private val isPaused = AtomicBoolean(false)
48
82
  private var streamUuid: String? = null
49
83
  private var audioFile: File? = null
@@ -81,7 +115,7 @@ class AudioRecorderManager(
81
115
  get() {
82
116
  if (field == null) {
83
117
  field = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
84
- 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"}")
85
119
  }
86
120
  return field
87
121
  }
@@ -90,12 +124,253 @@ class AudioRecorderManager(
90
124
  private val analysisBuffer = ByteArrayOutputStream()
91
125
  private var isFirstAnalysis = true
92
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
+
93
368
  private fun initializePhoneStateListener() {
94
369
  try {
95
- Log.d(Constants.TAG, "Initializing phone state listener...")
370
+ LogUtils.d(CLASS_NAME, "Initializing phone state listener...")
96
371
 
97
372
  if (permissionUtils.checkPhoneStatePermission()) {
98
- Log.d(Constants.TAG, "Phone state permission granted")
373
+ LogUtils.d(CLASS_NAME, "Phone state permission granted")
99
374
 
100
375
  phoneStateListener = object : PhoneStateListener() {
101
376
  override fun onCallStateChanged(state: Int, phoneNumber: String?) {
@@ -105,49 +380,49 @@ class AudioRecorderManager(
105
380
  TelephonyManager.CALL_STATE_IDLE -> "IDLE"
106
381
  else -> "UNKNOWN"
107
382
  }
108
- Log.d(Constants.TAG, "Phone state changed to: $stateStr")
383
+ LogUtils.d(CLASS_NAME, "Phone state changed to: $stateStr")
109
384
 
110
385
  when (state) {
111
386
  TelephonyManager.CALL_STATE_RINGING,
112
387
  TelephonyManager.CALL_STATE_OFFHOOK -> {
113
- if (isRecording.get() && !isPaused.get()) {
114
- 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")
115
390
  mainHandler.post {
116
391
  pauseRecording(object : Promise {
117
392
  override fun resolve(value: Any?) {
118
- Log.d(Constants.TAG, "Successfully paused recording due to call")
393
+ LogUtils.d(CLASS_NAME, "Successfully paused recording due to call")
119
394
  eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
120
395
  "reason" to "phoneCall",
121
396
  "isPaused" to true
122
397
  ))
123
398
  }
124
399
  override fun reject(code: String, message: String?, cause: Throwable?) {
125
- 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)
126
401
  }
127
402
  })
128
403
  }
129
404
  }
130
405
  }
131
406
  TelephonyManager.CALL_STATE_IDLE -> {
132
- if (isRecording.get() && isPaused.get()) {
133
- 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})")
134
409
  if (recordingConfig.autoResumeAfterInterruption) {
135
410
  mainHandler.post {
136
411
  resumeRecording(object : Promise {
137
412
  override fun resolve(value: Any?) {
138
- Log.d(Constants.TAG, "Successfully resumed recording after call")
413
+ LogUtils.d(CLASS_NAME, "Successfully resumed recording after call")
139
414
  eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
140
415
  "reason" to "phoneCallEnded",
141
416
  "isPaused" to false
142
417
  ))
143
418
  }
144
419
  override fun reject(code: String, message: String?, cause: Throwable?) {
145
- 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)
146
421
  }
147
422
  })
148
423
  }
149
424
  } else {
150
- Log.d(Constants.TAG, "Auto-resume disabled, staying paused")
425
+ LogUtils.d(CLASS_NAME, "Auto-resume disabled, staying paused")
151
426
  eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
152
427
  "reason" to "phoneCallEnded",
153
428
  "isPaused" to true
@@ -162,18 +437,18 @@ class AudioRecorderManager(
162
437
  if (telephonyManager != null) {
163
438
  try {
164
439
  telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
165
- Log.d(Constants.TAG, "Successfully registered phone state listener")
440
+ LogUtils.d(CLASS_NAME, "Successfully registered phone state listener")
166
441
  } catch (e: Exception) {
167
- Log.e(Constants.TAG, "Failed to register phone state listener", e)
442
+ LogUtils.e(CLASS_NAME, "Failed to register phone state listener", e)
168
443
  }
169
444
  } else {
170
- 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")
171
446
  }
172
447
  } else {
173
- 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")
174
449
  }
175
450
  } catch (e: Exception) {
176
- Log.e(Constants.TAG, "Failed to initialize phone state listener", e)
451
+ LogUtils.e(CLASS_NAME, "Failed to initialize phone state listener", e)
177
452
  }
178
453
  }
179
454
 
@@ -183,7 +458,7 @@ class AudioRecorderManager(
183
458
  when (focusChange) {
184
459
  AudioManager.AUDIOFOCUS_LOSS,
185
460
  AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
186
- if (isRecording.get() && !isPaused.get()) {
461
+ if (_isRecording.get() && !isPaused.get()) {
187
462
  mainHandler.post {
188
463
  pauseRecording(object : Promise {
189
464
  override fun resolve(value: Any?) {
@@ -193,14 +468,14 @@ class AudioRecorderManager(
193
468
  ))
194
469
  }
195
470
  override fun reject(code: String, message: String?, cause: Throwable?) {
196
- 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")
197
472
  }
198
473
  })
199
474
  }
200
475
  }
201
476
  }
202
477
  AudioManager.AUDIOFOCUS_GAIN -> {
203
- if (isRecording.get() && isPaused.get() && recordingConfig.autoResumeAfterInterruption) {
478
+ if (_isRecording.get() && isPaused.get() && recordingConfig.autoResumeAfterInterruption) {
204
479
  mainHandler.post {
205
480
  resumeRecording(object : Promise {
206
481
  override fun resolve(value: Any?) {
@@ -210,7 +485,7 @@ class AudioRecorderManager(
210
485
  ))
211
486
  }
212
487
  override fun reject(code: String, message: String?, cause: Throwable?) {
213
- 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")
214
489
  }
215
490
  })
216
491
  }
@@ -220,191 +495,83 @@ class AudioRecorderManager(
220
495
  }
221
496
  }
222
497
 
223
- companion object {
224
- @SuppressLint("StaticFieldLeak")
225
- @Volatile
226
- private var instance: AudioRecorderManager? = null
227
-
228
- fun getInstance(): AudioRecorderManager? = instance
229
-
230
- fun initialize(
231
- context: Context,
232
- filesDir: File,
233
- permissionUtils: PermissionUtils,
234
- audioDataEncoder: AudioDataEncoder,
235
- eventSender: EventSender,
236
- enablePhoneStateHandling: Boolean = true,
237
- enableBackgroundAudio: Boolean = true
238
- ): AudioRecorderManager {
239
- return instance ?: synchronized(this) {
240
- instance ?: AudioRecorderManager(
241
- context, filesDir, permissionUtils, audioDataEncoder, eventSender,
242
- enablePhoneStateHandling, enableBackgroundAudio
243
- ).also { instance = it }
244
- }
245
- }
246
-
247
- fun destroy() {
248
- instance?.cleanup()
249
- instance = null
250
- }
251
- }
252
-
253
- private fun isOngoingCall(): Boolean {
254
- try {
255
- if (!permissionUtils.checkPhoneStatePermission()) {
256
- Log.w(Constants.TAG, "READ_PHONE_STATE permission not granted, cannot check call state")
257
- return false
258
- }
259
-
260
- val tm = telephonyManager
261
- if (tm == null) {
262
- Log.e(Constants.TAG, "TelephonyManager is null")
263
- return false
264
- }
265
-
266
- // Get audio manager state
267
- val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
268
- val audioMode = audioManager.mode
269
- val isMusicActive = audioManager.isMusicActive
270
-
271
- // Get current audio device info
272
- val currentRoute =
273
- audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull()?.type?.let { type ->
274
- when (type) {
275
- AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "SPEAKER"
276
- AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "EARPIECE"
277
- AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BLUETOOTH_SCO"
278
- AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BLUETOOTH_A2DP"
279
- AudioDeviceInfo.TYPE_WIRED_HEADSET -> "WIRED_HEADSET"
280
- AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "WIRED_HEADPHONES"
281
- else -> "OTHER($type)"
282
- }
283
- } ?: "UNKNOWN"
284
-
285
- // Get communication device info
286
- val communicationDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
287
- audioManager.communicationDevice?.type?.let { type ->
288
- when (type) {
289
- AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BLUETOOTH_SCO"
290
- AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "SPEAKER"
291
- AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "EARPIECE"
292
- else -> "OTHER($type)"
293
- }
294
- } ?: "NONE"
295
- } else {
296
- @Suppress("DEPRECATION")
297
- if (audioManager.isBluetoothScoOn) "BLUETOOTH_SCO" else "NONE"
298
- }
299
-
300
- Log.d(Constants.TAG, """
301
- Audio State Check:
302
- - Audio Mode: ${getAudioModeString(audioMode)}
303
- - Music Active: $isMusicActive
304
- - Current Audio Route: $currentRoute
305
- - Communication Device: $communicationDevice
306
- """.trimIndent())
307
-
308
- // Check telephony state
309
- val callState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
310
- tm.callStateForSubscription
311
- } else {
312
- @Suppress("DEPRECATION")
313
- tm.callState
314
- }
315
-
316
- val isVoipCall = audioMode == AudioManager.MODE_IN_COMMUNICATION
317
- val isRegularCall = callState == TelephonyManager.CALL_STATE_OFFHOOK ||
318
- callState == TelephonyManager.CALL_STATE_RINGING
319
-
320
- Log.d(Constants.TAG, """
321
- Call State Check:
322
- - Telephony Call State: ${getCallStateString(callState)}
323
- - VoIP Call Detected: $isVoipCall
324
- - Regular Call Detected: $isRegularCall
325
- """.trimIndent())
326
-
327
- return isVoipCall || isRegularCall
328
-
329
- } catch (e: SecurityException) {
330
- Log.e(Constants.TAG, "SecurityException when checking call state", e)
331
- return false
332
- } catch (e: Exception) {
333
- Log.e(Constants.TAG, "Error checking call state", e)
334
- return false
335
- }
336
- }
337
-
338
- private fun getAudioModeString(mode: Int): String = when (mode) {
339
- AudioManager.MODE_NORMAL -> "MODE_NORMAL"
340
- AudioManager.MODE_RINGTONE -> "MODE_RINGTONE"
341
- AudioManager.MODE_IN_CALL -> "MODE_IN_CALL"
342
- AudioManager.MODE_IN_COMMUNICATION -> "MODE_IN_COMMUNICATION"
343
- else -> "MODE_UNKNOWN($mode)"
344
- }
345
-
346
- private fun getCallStateString(state: Int): String = when (state) {
347
- TelephonyManager.CALL_STATE_IDLE -> "CALL_STATE_IDLE"
348
- TelephonyManager.CALL_STATE_RINGING -> "CALL_STATE_RINGING"
349
- TelephonyManager.CALL_STATE_OFFHOOK -> "CALL_STATE_OFFHOOK"
350
- else -> "CALL_STATE_UNKNOWN($state)"
351
- }
352
-
353
498
  @RequiresApi(Build.VERSION_CODES.R)
354
499
  fun startRecording(options: Map<String, Any?>, promise: Promise) {
355
500
  try {
356
- // Initialize phone state listener only if enabled
357
- if (enablePhoneStateHandling) {
358
- 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
359
505
  }
360
-
361
- // Request audio focus
506
+
507
+ // Request audio focus - always do this right before starting
362
508
  if (!requestAudioFocus()) {
363
509
  promise.reject("AUDIO_FOCUS_ERROR", "Failed to obtain audio focus", null)
364
510
  return
365
511
  }
366
512
 
367
- 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
+ }
368
521
 
369
- // Check permissions
370
- if (!checkPermissions(options, promise)) return
522
+ LogUtils.d(CLASS_NAME, "Starting recording with options: $options")
371
523
 
372
- // Check if already recording
373
- if (isRecording.get() && !isPaused.get()) {
374
- promise.reject("ALREADY_RECORDING", "Recording is already in progress", null)
375
- return
376
- }
524
+ // Check permissions
525
+ if (!checkPermissions(options, promise)) return
377
526
 
378
- // Parse recording configuration
379
- val configResult = RecordingConfig.fromMap(options)
380
- if (configResult.isFailure) {
381
- promise.reject(
382
- "INVALID_CONFIG",
383
- configResult.exceptionOrNull()?.message ?: "Invalid configuration",
384
- configResult.exceptionOrNull()
385
- )
386
- return
387
- }
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
+ }
388
537
 
389
- val (tempRecordingConfig, audioFormatInfo) = configResult.getOrNull()!!
390
-
391
- recordingConfig = tempRecordingConfig
392
-
393
- audioFormat = audioFormatInfo.format
394
- 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
395
548
 
396
- if (!initializeAudioFormat(promise)) return
549
+ if (!initializeAudioFormat(promise)) return
397
550
 
398
- if (!initializeBufferSize(promise)) return
551
+ if (!initializeBufferSize(promise)) return
399
552
 
400
- if (!initializeAudioRecord(promise)) return
553
+ if (!initializeAudioRecord(promise)) return
401
554
 
402
- if (recordingConfig.enableCompressedOutput && !initializeCompressedRecorder(
403
- if (recordingConfig.compressedFormat == "aac") "aac" else "opus",
404
- promise
405
- )) return
555
+ if (recordingConfig.enableCompressedOutput && !initializeCompressedRecorder(
556
+ if (recordingConfig.compressedFormat == "aac") "aac" else "opus",
557
+ promise
558
+ )) return
406
559
 
407
- 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
+ }
408
575
 
409
576
  if (!startRecordingProcess(promise)) return
410
577
 
@@ -412,7 +579,7 @@ class AudioRecorderManager(
412
579
  try {
413
580
  compressedRecorder?.start()
414
581
  } catch (e: Exception) {
415
- Log.e(Constants.TAG, "Failed to start compressed recording", e)
582
+ LogUtils.e(CLASS_NAME, "Failed to start compressed recording", e)
416
583
  cleanup()
417
584
  promise.reject("COMPRESSED_START_FAILED", "Failed to start compressed recording", e)
418
585
  return
@@ -490,7 +657,7 @@ class AudioRecorderManager(
490
657
 
491
658
  // Only check phone state permission if enabled
492
659
  if (enablePhoneStateHandling && !permissionUtils.checkPhoneStatePermission()) {
493
- 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")
494
661
  // Don't reject here, just log warning as this is optional
495
662
  }
496
663
 
@@ -515,7 +682,7 @@ class AudioRecorderManager(
515
682
  audioFormat
516
683
  )
517
684
  ) {
518
- 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")
519
686
  audioFormat = AudioFormat.ENCODING_PCM_16BIT
520
687
 
521
688
  if (!isAudioFormatSupported(
@@ -553,7 +720,7 @@ class AudioRecorderManager(
553
720
 
554
721
  when {
555
722
  bufferSizeInBytes == AudioRecord.ERROR -> {
556
- Log.e(Constants.TAG, "Error getting minimum buffer size: ERROR")
723
+ LogUtils.e(CLASS_NAME, "Error getting minimum buffer size: ERROR")
557
724
  promise.reject(
558
725
  "BUFFER_SIZE_ERROR",
559
726
  "Failed to get minimum buffer size: generic error",
@@ -562,7 +729,7 @@ class AudioRecorderManager(
562
729
  return false
563
730
  }
564
731
  bufferSizeInBytes == AudioRecord.ERROR_BAD_VALUE -> {
565
- Log.e(Constants.TAG, "Error getting minimum buffer size: BAD_VALUE")
732
+ LogUtils.e(CLASS_NAME, "Error getting minimum buffer size: BAD_VALUE")
566
733
  promise.reject(
567
734
  "BUFFER_SIZE_ERROR",
568
735
  "Failed to get minimum buffer size: invalid parameters",
@@ -571,7 +738,7 @@ class AudioRecorderManager(
571
738
  return false
572
739
  }
573
740
  bufferSizeInBytes <= 0 -> {
574
- Log.e(Constants.TAG, "Invalid buffer size: $bufferSizeInBytes")
741
+ LogUtils.e(CLASS_NAME, "Invalid buffer size: $bufferSizeInBytes")
575
742
  promise.reject(
576
743
  "BUFFER_SIZE_ERROR",
577
744
  "Failed to get valid buffer size",
@@ -580,12 +747,12 @@ class AudioRecorderManager(
580
747
  return false
581
748
  }
582
749
  else -> {
583
- Log.d(Constants.TAG, "AudioFormat: $audioFormat, BufferSize: $bufferSizeInBytes")
750
+ LogUtils.d(CLASS_NAME, "AudioFormat: $audioFormat, BufferSize: $bufferSizeInBytes")
584
751
  return true
585
752
  }
586
753
  }
587
754
  } catch (e: Exception) {
588
- Log.e(Constants.TAG, "Failed to initialize buffer size", e)
755
+ LogUtils.e(CLASS_NAME, "Failed to initialize buffer size", e)
589
756
  promise.reject(
590
757
  "BUFFER_SIZE_ERROR",
591
758
  "Failed to initialize buffer size: ${e.message}",
@@ -608,7 +775,7 @@ class AudioRecorderManager(
608
775
 
609
776
  try {
610
777
  if (audioRecord == null || !isPaused.get()) {
611
- Log.d(Constants.TAG, "Initializing AudioRecord with format: $audioFormat, BufferSize: $bufferSizeInBytes")
778
+ LogUtils.d(CLASS_NAME, "Initializing AudioRecord with format: $audioFormat, BufferSize: $bufferSizeInBytes")
612
779
 
613
780
  audioRecord = AudioRecord(
614
781
  MediaRecorder.AudioSource.MIC,
@@ -630,7 +797,7 @@ class AudioRecorderManager(
630
797
  return true
631
798
 
632
799
  } catch (e: SecurityException) {
633
- Log.e(Constants.TAG, "Security exception while initializing AudioRecord", e)
800
+ LogUtils.e(CLASS_NAME, "Security exception while initializing AudioRecord", e)
634
801
  promise.reject(
635
802
  "PERMISSION_DENIED",
636
803
  "Recording permission denied: ${e.message}",
@@ -638,7 +805,7 @@ class AudioRecorderManager(
638
805
  )
639
806
  return false
640
807
  } catch (e: Exception) {
641
- Log.e(Constants.TAG, "Failed to initialize AudioRecord", e)
808
+ LogUtils.e(CLASS_NAME, "Failed to initialize AudioRecord", e)
642
809
  promise.reject(
643
810
  "INITIALIZATION_FAILED",
644
811
  "Failed to initialize the audio recorder: ${e.message}",
@@ -679,7 +846,7 @@ class AudioRecorderManager(
679
846
  return false
680
847
  } catch (e: Exception) {
681
848
  releaseWakeLock()
682
- Log.e(Constants.TAG, "Unexpected error in startRecording", e)
849
+ LogUtils.e(CLASS_NAME, "Unexpected error in startRecording", e)
683
850
  promise.reject("UNEXPECTED_ERROR", "Unexpected error: ${e.message}", e)
684
851
  return false
685
852
  }
@@ -688,7 +855,7 @@ class AudioRecorderManager(
688
855
  private fun startRecordingProcess(promise: Promise): Boolean {
689
856
  try {
690
857
  // Add detailed logging of recording configuration
691
- Log.d(Constants.TAG, """
858
+ LogUtils.d(CLASS_NAME, """
692
859
  Starting audio recording with configuration:
693
860
  - Sample Rate: ${recordingConfig.sampleRate} Hz
694
861
  - Channels: ${recordingConfig.channels}
@@ -712,7 +879,7 @@ class AudioRecorderManager(
712
879
 
713
880
  audioRecord?.startRecording()
714
881
  isPaused.set(false)
715
- isRecording.set(true)
882
+ _isRecording.set(true)
716
883
  isFirstChunk = true
717
884
 
718
885
  if (!isPaused.get()) {
@@ -729,7 +896,7 @@ class AudioRecorderManager(
729
896
  return true
730
897
 
731
898
  } catch (e: Exception) {
732
- Log.e(Constants.TAG, "Failed to start recording", e)
899
+ LogUtils.e(CLASS_NAME, "Failed to start recording", e)
733
900
  cleanup()
734
901
  promise.reject("START_FAILED", "Failed to start recording: ${e.message}", e)
735
902
  return false
@@ -738,8 +905,8 @@ class AudioRecorderManager(
738
905
 
739
906
  fun stopRecording(promise: Promise) {
740
907
  synchronized(audioRecordLock) {
741
- if (!isRecording.get()) {
742
- Log.e(Constants.TAG, "Recording is not active")
908
+ if (!_isRecording.get()) {
909
+ LogUtils.e(CLASS_NAME, "Recording is not active")
743
910
  promise.reject("NOT_RECORDING", "Recording is not active", null)
744
911
  return
745
912
  }
@@ -758,25 +925,26 @@ class AudioRecorderManager(
758
925
  AudioRecordingService.stopService(context)
759
926
  }
760
927
 
761
- isRecording.set(false)
928
+ _isRecording.set(false)
929
+ isPrepared = false // Reset preparation state
762
930
  recordingThread?.join(1000)
763
931
 
764
932
  val audioData = ByteArray(bufferSizeInBytes)
765
933
  val bytesRead = audioRecord?.read(audioData, 0, bufferSizeInBytes) ?: -1
766
- Log.d(Constants.TAG, "Last Read $bytesRead bytes")
934
+ LogUtils.d(CLASS_NAME, "Last Read $bytesRead bytes")
767
935
  if (bytesRead > 0) {
768
936
  emitAudioData(audioData.copyOfRange(0, bytesRead), bytesRead)
769
937
  }
770
938
 
771
- Log.d(Constants.TAG, "Stopping recording state = ${audioRecord?.state}")
939
+ LogUtils.d(CLASS_NAME, "Stopping recording state = ${audioRecord?.state}")
772
940
  if (audioRecord != null && audioRecord!!.state == AudioRecord.STATE_INITIALIZED) {
773
- Log.d(Constants.TAG, "Stopping AudioRecord")
941
+ LogUtils.d(CLASS_NAME, "Stopping AudioRecord")
774
942
  audioRecord!!.stop()
775
943
  }
776
944
 
777
945
  cleanup()
778
946
  } catch (e: IllegalStateException) {
779
- Log.e(Constants.TAG, "Error reading from AudioRecord", e)
947
+ LogUtils.e(CLASS_NAME, "Error reading from AudioRecord", e)
780
948
  } finally {
781
949
  releaseWakeLock()
782
950
  audioRecord?.release()
@@ -787,7 +955,7 @@ class AudioRecorderManager(
787
955
  audioProcessor.resetCumulativeAmplitudeRange()
788
956
 
789
957
  val fileSize = audioFile?.length() ?: 0
790
- 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}")
791
959
 
792
960
  val dataFileSize = fileSize - 44 // Subtract header size
793
961
  val byteRate =
@@ -808,7 +976,7 @@ class AudioRecorderManager(
808
976
  // Log compressed file status if enabled
809
977
  if (recordingConfig.enableCompressedOutput) {
810
978
  val compressedSize = compressedFile?.length() ?: 0
811
- 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}")
812
980
  }
813
981
 
814
982
  val result = bundleOf(
@@ -832,12 +1000,12 @@ class AudioRecorderManager(
832
1000
  promise.resolve(result)
833
1001
 
834
1002
  // Reset the timing variables
835
- isRecording.set(false)
1003
+ _isRecording.set(false)
836
1004
  isPaused.set(false)
837
1005
  totalRecordedTime = 0
838
1006
  pausedDuration = 0
839
1007
  } catch (e: Exception) {
840
- Log.d(Constants.TAG, "Failed to stop recording", e)
1008
+ LogUtils.e(CLASS_NAME, "Failed to stop recording: ${e.message}")
841
1009
  promise.reject("STOP_FAILED", "Failed to stop recording", e)
842
1010
  } finally {
843
1011
  audioRecord = null
@@ -846,18 +1014,51 @@ class AudioRecorderManager(
846
1014
  }
847
1015
 
848
1016
  fun resumeRecording(promise: Promise) {
1017
+ LogUtils.d(CLASS_NAME, "⏺️ resumeRecording method entered - isPaused=${isPaused.get()}, isRecording=${_isRecording.get()}")
849
1018
  if (!isPaused.get()) {
1019
+ LogUtils.e(CLASS_NAME, "⏺️ Cannot resume recording: not paused")
850
1020
  promise.reject("NOT_PAUSED", "Recording is not paused", null)
851
1021
  return
852
1022
  }
853
1023
 
854
1024
  if (isOngoingCall()) {
1025
+ LogUtils.e(CLASS_NAME, "⏺️ Cannot resume recording: ongoing call detected")
855
1026
  promise.reject("ONGOING_CALL", "Cannot resume recording during an ongoing call", null)
856
1027
  return
857
1028
  }
858
1029
 
859
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
+
860
1060
  if (recordingConfig.showNotification) {
1061
+ LogUtils.d(CLASS_NAME, "⏺️ Resuming notification updates")
861
1062
  notificationManager.resumeUpdates()
862
1063
  }
863
1064
 
@@ -865,18 +1066,36 @@ class AudioRecorderManager(
865
1066
  pausedDuration += System.currentTimeMillis() - lastPauseTime
866
1067
  isPaused.set(false)
867
1068
 
868
- audioRecord?.startRecording()
869
- 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
+ }
870
1087
 
1088
+ LogUtils.d(CLASS_NAME, "⏺️ Recording resumed successfully")
871
1089
  promise.resolve("Recording resumed")
872
1090
  } catch (e: Exception) {
1091
+ LogUtils.e(CLASS_NAME, "⏺️ Failed to resume recording: ${e.message}", e)
873
1092
  releaseWakeLock()
874
- promise.reject("RESUME_FAILED", "Failed to resume recording", e)
1093
+ promise.reject("RESUME_FAILED", "Failed to resume recording: ${e.message}", e)
875
1094
  }
876
1095
  }
877
1096
 
878
1097
  fun pauseRecording(promise: Promise) {
879
- if (isRecording.get() && !isPaused.get()) {
1098
+ if (_isRecording.get() && !isPaused.get()) {
880
1099
  audioRecord?.stop()
881
1100
  compressedRecorder?.pause()
882
1101
 
@@ -908,14 +1127,14 @@ class AudioRecorderManager(
908
1127
  } ?: false
909
1128
 
910
1129
  // If service is running but we think we're not recording, clean up
911
- if (isServiceRunning && !isRecording.get()) {
912
- 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...")
913
1132
  cleanup()
914
1133
  AudioRecordingService.stopService(context)
915
1134
  }
916
1135
 
917
- if (!isRecording.get()) {
918
- 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")
919
1138
  return bundleOf(
920
1139
  "isRecording" to false,
921
1140
  "isPaused" to false,
@@ -947,7 +1166,7 @@ class AudioRecorderManager(
947
1166
 
948
1167
  return bundleOf(
949
1168
  "durationMs" to duration,
950
- "isRecording" to isRecording.get(),
1169
+ "isRecording" to _isRecording.get(),
951
1170
  "isPaused" to isPaused.get(),
952
1171
  "mimeType" to mimeType,
953
1172
  "size" to totalDataSize,
@@ -969,9 +1188,9 @@ class AudioRecorderManager(
969
1188
  acquire()
970
1189
  }
971
1190
  wasWakeLockEnabled = true
972
- Log.d(Constants.TAG, "Wake lock acquired")
1191
+ LogUtils.d(CLASS_NAME, "Wake lock acquired")
973
1192
  } catch (e: Exception) {
974
- Log.e(Constants.TAG, "Failed to acquire wake lock", e)
1193
+ LogUtils.e(CLASS_NAME, "Failed to acquire wake lock", e)
975
1194
  }
976
1195
  }
977
1196
  }
@@ -982,13 +1201,45 @@ class AudioRecorderManager(
982
1201
  wakeLock?.let {
983
1202
  if (it.isHeld) {
984
1203
  it.release()
985
- Log.d(Constants.TAG, "Wake lock released")
1204
+ LogUtils.d(CLASS_NAME, "Wake lock released")
986
1205
  }
987
1206
  wakeLock = null
988
1207
  wasWakeLockEnabled = false
989
1208
  }
990
1209
  } catch (e: Exception) {
991
- 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
992
1243
  }
993
1244
  }
994
1245
 
@@ -1005,11 +1256,11 @@ class AudioRecorderManager(
1005
1256
 
1006
1257
  private fun recordingProcess() {
1007
1258
  try {
1008
- Log.i(Constants.TAG, "Starting recording process...")
1259
+ LogUtils.i(CLASS_NAME, "Starting recording process...")
1009
1260
  FileOutputStream(audioFile, true).use { fos ->
1010
1261
  // Write audio data directly to the file
1011
1262
  val audioData = ByteArray(bufferSizeInBytes)
1012
- Log.d(Constants.TAG, "Entering recording loop")
1263
+ LogUtils.d(CLASS_NAME, "Entering recording loop")
1013
1264
 
1014
1265
  // Buffer to accumulate data
1015
1266
  val accumulatedAudioData = ByteArrayOutputStream()
@@ -1033,7 +1284,7 @@ class AudioRecorderManager(
1033
1284
  var shouldProcessAnalysis = false
1034
1285
 
1035
1286
  // Debug log for intervals
1036
- Log.d(Constants.TAG, """
1287
+ LogUtils.d(CLASS_NAME, """
1037
1288
  Recording process started with intervals:
1038
1289
  - Data emission interval: ${recordingConfig.interval}ms
1039
1290
  - Analysis interval: ${recordingConfig.intervalAnalysis}ms
@@ -1041,7 +1292,7 @@ class AudioRecorderManager(
1041
1292
  """.trimIndent())
1042
1293
 
1043
1294
  // Recording loop
1044
- while (isRecording.get() && !Thread.currentThread().isInterrupted) {
1295
+ while (_isRecording.get() && !Thread.currentThread().isInterrupted) {
1045
1296
  if (isPaused.get()) {
1046
1297
  Thread.sleep(100) // Add small delay when paused
1047
1298
  continue
@@ -1055,12 +1306,12 @@ class AudioRecorderManager(
1055
1306
  val bytesRead = synchronized(audioRecordLock) {
1056
1307
  audioRecord?.let {
1057
1308
  if (it.state != AudioRecord.STATE_INITIALIZED) {
1058
- Log.e(Constants.TAG, "AudioRecord not initialized")
1309
+ LogUtils.e(CLASS_NAME, "AudioRecord not initialized")
1059
1310
  return@let -1
1060
1311
  }
1061
1312
  it.read(audioData, 0, bufferSizeInBytes).also { bytes ->
1062
1313
  if (bytes < 0) {
1063
- Log.e(Constants.TAG, "AudioRecord read error: $bytes")
1314
+ LogUtils.e(CLASS_NAME, "AudioRecord read error: $bytes")
1064
1315
  }
1065
1316
  }
1066
1317
  } ?: -1 // Handle null case
@@ -1085,7 +1336,7 @@ class AudioRecorderManager(
1085
1336
  // Handle analysis emission separately
1086
1337
  if (shouldProcessAnalysis) {
1087
1338
  val analysisDataSize = accumulatedAnalysisData.size()
1088
- Log.d(Constants.TAG, """
1339
+ LogUtils.d(CLASS_NAME, """
1089
1340
  Processing analysis data:
1090
1341
  - Time since last: ${currentTime - lastEmissionTimeAnalysis}ms
1091
1342
  - Configured interval: ${recordingConfig.intervalAnalysis}ms
@@ -1102,7 +1353,7 @@ class AudioRecorderManager(
1102
1353
  recordingConfig
1103
1354
  )
1104
1355
 
1105
- Log.d(Constants.TAG, """
1356
+ LogUtils.d(CLASS_NAME, """
1106
1357
  Analysis data details:
1107
1358
  - Raw data size: ${accumulatedAnalysisData.size()} bytes
1108
1359
  """.trimIndent())
@@ -1114,7 +1365,7 @@ class AudioRecorderManager(
1114
1365
  analysisData.toBundle()
1115
1366
  )
1116
1367
  } catch (e: Exception) {
1117
- Log.e(Constants.TAG, "Failed to send audio analysis event", e)
1368
+ LogUtils.e(CLASS_NAME, "Failed to send audio analysis event", e)
1118
1369
  }
1119
1370
  }
1120
1371
 
@@ -1137,7 +1388,7 @@ class AudioRecorderManager(
1137
1388
  if (!isPaused.get()) {
1138
1389
  releaseWakeLock()
1139
1390
  }
1140
- Log.e(Constants.TAG, "Error in recording process", e)
1391
+ LogUtils.e(CLASS_NAME, "Error in recording process", e)
1141
1392
  }
1142
1393
  }
1143
1394
 
@@ -1166,7 +1417,7 @@ class AudioRecorderManager(
1166
1417
  audioDataEncoder.encodeToBase64(buffer)
1167
1418
  }
1168
1419
  } catch (e: Exception) {
1169
- Log.e(Constants.TAG, "Failed to read compressed data", e)
1420
+ LogUtils.e(CLASS_NAME, "Failed to read compressed data", e)
1170
1421
  null
1171
1422
  }
1172
1423
  } else null
@@ -1198,7 +1449,7 @@ class AudioRecorderManager(
1198
1449
  )
1199
1450
  )
1200
1451
  } catch (e: Exception) {
1201
- Log.e(Constants.TAG, "Failed to send event", e)
1452
+ LogUtils.e(CLASS_NAME, "Failed to send event", e)
1202
1453
  }
1203
1454
  }
1204
1455
 
@@ -1268,14 +1519,15 @@ class AudioRecorderManager(
1268
1519
  fun cleanup() {
1269
1520
  synchronized(audioRecordLock) {
1270
1521
  try {
1271
- if (isRecording.get()) {
1522
+ if (_isRecording.get()) {
1272
1523
  audioRecord?.stop()
1273
1524
  compressedRecorder?.stop()
1274
1525
  compressedRecorder?.release()
1275
1526
  }
1276
1527
 
1277
- isRecording.set(false)
1528
+ _isRecording.set(false)
1278
1529
  isPaused.set(false)
1530
+ isPrepared = false // Reset prepared state
1279
1531
 
1280
1532
  if (recordingConfig.showNotification) {
1281
1533
  notificationManager.stopUpdates()
@@ -1304,7 +1556,7 @@ class AudioRecorderManager(
1304
1556
  "isPaused" to false
1305
1557
  ))
1306
1558
  } catch (e: Exception) {
1307
- Log.e(Constants.TAG, "Error during cleanup", e)
1559
+ LogUtils.e(CLASS_NAME, "Error during cleanup", e)
1308
1560
  }
1309
1561
  }
1310
1562
  }
@@ -1338,7 +1590,7 @@ class AudioRecorderManager(
1338
1590
  }
1339
1591
  return true
1340
1592
  } catch (e: Exception) {
1341
- Log.e(Constants.TAG, "Failed to initialize compressed recorder", e)
1593
+ LogUtils.e(CLASS_NAME, "Failed to initialize compressed recorder", e)
1342
1594
  promise.reject("COMPRESSED_INIT_FAILED", "Failed to initialize compressed recorder", e)
1343
1595
  return false
1344
1596
  }
@@ -1350,7 +1602,7 @@ class AudioRecorderManager(
1350
1602
  when (focusChange) {
1351
1603
  AudioManager.AUDIOFOCUS_LOSS,
1352
1604
  AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
1353
- if (isRecording.get() && !isPaused.get()) {
1605
+ if (_isRecording.get() && !isPaused.get()) {
1354
1606
  mainHandler.post {
1355
1607
  pauseRecording(object : Promise {
1356
1608
  override fun resolve(value: Any?) {
@@ -1361,14 +1613,14 @@ class AudioRecorderManager(
1361
1613
  ))
1362
1614
  }
1363
1615
  override fun reject(code: String, message: String?, cause: Throwable?) {
1364
- 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")
1365
1617
  }
1366
1618
  })
1367
1619
  }
1368
1620
  }
1369
1621
  }
1370
1622
  AudioManager.AUDIOFOCUS_GAIN -> {
1371
- if (isRecording.get() && isPaused.get() && recordingConfig.autoResumeAfterInterruption) {
1623
+ if (_isRecording.get() && isPaused.get() && recordingConfig.autoResumeAfterInterruption) {
1372
1624
  mainHandler.post {
1373
1625
  resumeRecording(object : Promise {
1374
1626
  override fun resolve(value: Any?) {
@@ -1378,7 +1630,7 @@ class AudioRecorderManager(
1378
1630
  ))
1379
1631
  }
1380
1632
  override fun reject(code: String, message: String?, cause: Throwable?) {
1381
- 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")
1382
1634
  }
1383
1635
  })
1384
1636
  }
@@ -1446,4 +1698,76 @@ class AudioRecorderManager(
1446
1698
  fun getKeepAwakeStatus(): Boolean {
1447
1699
  return recordingConfig?.keepAwake ?: true
1448
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
+ }
1449
1773
  }