@siteed/expo-audio-stream 1.9.2 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,24 +8,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
 
11
+ ## [1.11.0] - 2025-01-22
12
+ - feat(audio): add intelligent call interruption handling & compression improvements ([f8f6187](https://github.com/deeeed/expo-audio-stream/pull/78))
13
+
14
+ ## [1.10.0] - 2025-01-14
15
+ - add support for pausing and resuming compressed recordings ([bc3f629](https://github.com/deeeed/expo-audio-stream/commit/bc3f6295d060396325e0f008ff00b3be9c8722cd))
16
+ - optimize notification channel settings ([daa075e](https://github.com/deeeed/expo-audio-stream/commit/daa075e668f8faf0b8d2849e18c37384bdd293b8))
17
+
18
+
11
19
  ## [1.9.2] - 2025-01-12
12
20
  - ios bitrate verification to prevent invalid values ([035a180](https://github.com/deeeed/expo-audio-stream/commit/035a1800833264edcc59724aaa8a2e12d5c78dc2))
13
21
 
22
+
23
+
14
24
  ## [1.9.1] - 2025-01-12
15
25
  - ios potentially missing compressed file info ([88a628c](https://github.com/deeeed/expo-audio-stream/commit/88a628c35f2bfd626a2a5de1eb6950efd814619d))
16
26
 
17
27
 
28
+
29
+
18
30
  ## [1.9.0] - 2025-01-11
19
31
  - feat(web-audio): optimize memory usage and streaming performance for web audio recording (#75) ([7b93e12](https://github.com/deeeed/expo-audio-stream/commit/7b93e12aae4bc0599b06b48ca34a60f65587fc75))
20
32
 
21
33
 
22
34
 
35
+
36
+
23
37
  ## [1.8.0] - 2025-01-10
24
38
  - feat(audio): implement audio compression support ([ff4e060](https://github.com/deeeed/expo-audio-stream/commit/ff4e060fef1061804c1cc0126d4344d2d50daa9a))
25
39
 
26
40
 
27
41
 
28
42
 
43
+
44
+
29
45
  ## [1.7.2] - 2025-01-07
30
46
  - fix(audio-stream): correct WAV header handling in web audio recording ([9ba7de5](https://github.com/deeeed/expo-audio-stream/commit/9ba7de5b96ca4cc937dea261c80d3fda9c99e8f4))
31
47
 
@@ -33,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
33
49
 
34
50
 
35
51
 
52
+
53
+
36
54
  ## [1.7.1] - 2025-01-07
37
55
  - update notification to avoid triggering new alerts (#71) ([32dcfc5](https://github.com/deeeed/expo-audio-stream/commit/32dcfc55daf3236babefc17016f329c177d466fd))
38
56
 
@@ -41,6 +59,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
41
59
 
42
60
 
43
61
 
62
+
63
+
44
64
  ## [1.7.0] - 2025-01-05
45
65
  - feat(playground): enhance app configuration and build setup for production deployment (#58) ([929d443](https://github.com/deeeed/expo-audio-stream/commit/929d443145378b1430d215db5c00b13758420e2b))
46
66
  - chore(expo-audio-stream): release @siteed/expo-audio-stream@1.6.1 ([084e8ad](https://github.com/deeeed/expo-audio-stream/commit/084e8adb91da7874c9e608b55d9c7b2ffd7a8327))
@@ -55,6 +75,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
55
75
 
56
76
 
57
77
 
78
+
79
+
58
80
  ## [1.6.1] - 2024-12-11
59
81
  - chore(expo-audio-stream): remove git commit step from publish script ([4a772ce](https://github.com/deeeed/expo-audio-stream/commit/4a772ce93bb7405d9b8e981f46bdf8941a71ecfe))
60
82
  - chore: more publishing automation ([3693021](https://github.com/deeeed/expo-audio-stream/commit/369302107f9dca9dddd8ae68e6214481a39976ac))
@@ -73,6 +95,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
73
95
 
74
96
 
75
97
 
98
+
99
+
76
100
  ## [1.5.0] - 2024-12-10
77
101
  - UNPUBLISHED because of a bug in the build system
78
102
 
@@ -84,6 +108,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
84
108
 
85
109
 
86
110
 
111
+
112
+
87
113
  ## [1.4.0] - 2024-12-05
88
114
  - chore: remove unusded dependencies ([ad81dd5](https://github.com/deeeed/expo-audio-stream/commit/ad81dd560c93dd1d04995a323a4ae72d4de20f3e))
89
115
 
@@ -95,6 +121,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
95
121
 
96
122
 
97
123
 
124
+
125
+
98
126
  ## [1.3.1] - 2024-12-05
99
127
  - feat(web): implement throttling and optimize event processing (#49) ([da28765](https://github.com/deeeed/expo-audio-stream/commit/da2876524c2c9d6e0a980fde40a0197b929d8a7f))
100
128
 
@@ -106,6 +134,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
106
134
 
107
135
 
108
136
 
137
+
138
+
109
139
  ## [1.3.0] - 2024-11-28
110
140
  ### Added
111
141
  - refactor(permissions): standardize permission status response structure across platforms (#44) ([7c9c800](https://github.com/deeeed/expo-audio-stream/commit/7c9c800d83b7cea3516643371484d5e1f3b99e4c))
@@ -122,6 +152,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
122
152
 
123
153
 
124
154
 
155
+
156
+
125
157
  ## [1.2.5] - 2024-11-12
126
158
  ### Added
127
159
  - docs(license): add MIT license to all packages (6 files changed)
@@ -135,6 +167,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
135
167
 
136
168
 
137
169
 
170
+
171
+
138
172
  ## [1.2.4] - 2024-11-05
139
173
  ### Changed
140
174
  - Android minimum audio interval set to 10ms.
@@ -151,6 +185,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
151
185
 
152
186
 
153
187
 
188
+
189
+
154
190
  ## [1.2.0] - 2024-10-24
155
191
  ### Added
156
192
  - Feature: Keep device awake during recording with `keepAwake` option
@@ -167,6 +203,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
167
203
 
168
204
 
169
205
 
206
+
207
+
170
208
  ## [1.1.17] - 2024-10-21
171
209
  ### Added
172
210
  - Support bluetooth headset on ios
@@ -180,6 +218,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
180
218
 
181
219
 
182
220
 
221
+
222
+
183
223
  ## [1.0.0] - 2024-04-01
184
224
  ### Added
185
225
  - Initial release of @siteed/expo-audio-stream.
@@ -190,7 +230,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
190
230
  - Feature: Audio features extraction during recording.
191
231
  - Feature: Consistent WAV PCM recording format across all platforms.
192
232
 
193
- [unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.9.2...HEAD
233
+ [unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.11.0...HEAD
234
+ [1.11.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.10.0...@siteed/expo-audio-stream@1.11.0
235
+ [1.10.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.9.2...@siteed/expo-audio-stream@1.10.0
194
236
  [1.9.2]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.9.1...@siteed/expo-audio-stream@1.9.2
195
237
  [1.9.1]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.9.0...@siteed/expo-audio-stream@1.9.1
196
238
  [1.9.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.8.0...@siteed/expo-audio-stream@1.9.0
package/README.md CHANGED
@@ -37,6 +37,10 @@
37
37
  - Compression formats: OPUS or AAC
38
38
  - Configurable bitrate for compressed audio
39
39
  - Optimized storage for both high-quality and compressed formats
40
+ - Intelligent interruption handling:
41
+ - Automatic pause/resume during phone calls
42
+ - Configurable automatic resumption
43
+ - Detailed interruption event callbacks
40
44
  - Configurable intervals for audio buffer receipt.
41
45
  - Automated microphone permissions setup in managed Expo projects.
42
46
  - Background audio recording on iOS.
@@ -18,6 +18,7 @@ import androidx.core.app.NotificationCompat
18
18
  import java.lang.ref.WeakReference
19
19
  import java.util.Locale
20
20
  import java.util.concurrent.atomic.AtomicBoolean
21
+ import java.util.Objects
21
22
 
22
23
  class AudioNotificationManager private constructor(context: Context) {
23
24
  private val contextRef = WeakReference(context.applicationContext)
@@ -40,6 +41,7 @@ class AudioNotificationManager private constructor(context: Context) {
40
41
  private var lastPauseTime: Long = 0
41
42
  private var lastWaveformUpdate: Long = 0
42
43
  private val waveformRenderer = WaveformRenderer()
44
+ private var lastNotificationHash: Int? = null
43
45
 
44
46
  companion object {
45
47
  private const val WAVEFORM_UPDATE_INTERVAL = 100L
@@ -66,21 +68,16 @@ class AudioNotificationManager private constructor(context: Context) {
66
68
  val channel = NotificationChannel(
67
69
  recordingConfig.notification.channelId,
68
70
  recordingConfig.notification.channelName,
69
- NotificationManager.IMPORTANCE_LOW // Lower importance means no sound by default
71
+ NotificationManager.IMPORTANCE_LOW
70
72
  ).apply {
71
73
  description = recordingConfig.notification.channelDescription
72
- enableLights(true)
73
- lightColor = Color.parseColor(recordingConfig.notification.lightColor)
74
- enableVibration(true)
75
- setShowBadge(true)
74
+ enableLights(false)
75
+ enableVibration(false)
76
+ setSound(null, null)
77
+ setShowBadge(false)
78
+ vibrationPattern = null
76
79
  }
77
80
 
78
- // Set description, disable lights, vibration, and sound.
79
- channel.enableLights(false);
80
- channel.enableVibration(false);
81
- channel.setSound(null, null);
82
- channel.setShowBadge(false);
83
-
84
81
  notificationManager.createNotificationChannel(channel)
85
82
  }
86
83
  }
@@ -134,6 +131,9 @@ class AudioNotificationManager private constructor(context: Context) {
134
131
  }
135
132
 
136
133
  private fun addNotificationActions(context: Context) {
134
+ // Clear existing actions first
135
+ notificationBuilder.clearActions()
136
+
137
137
  // Create pause action
138
138
  val pauseIntent = Intent(context, RecordingActionReceiver::class.java).apply {
139
139
  action = RecordingActionReceiver.ACTION_PAUSE_RECORDING
@@ -156,7 +156,7 @@ class AudioNotificationManager private constructor(context: Context) {
156
156
  PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
157
157
  )
158
158
 
159
- // Add pause or resume action based on current state
159
+ // Add only one pause/resume action based on current state
160
160
  if (isPaused.get()) {
161
161
  notificationBuilder.addAction(
162
162
  R.drawable.ic_play,
@@ -171,20 +171,23 @@ class AudioNotificationManager private constructor(context: Context) {
171
171
  )
172
172
  }
173
173
 
174
- // Add configured custom actions
174
+ // Add configured custom actions (only if they don't already exist)
175
+ val existingActions = mutableSetOf<String>()
175
176
  recordingConfig.notification.actions.forEach { action ->
176
- val intent = Intent(context, RecordingActionReceiver::class.java).apply {
177
- this.action = action.intentAction
177
+ if (existingActions.add(action.intentAction)) { // Only add if action is unique
178
+ val intent = Intent(context, RecordingActionReceiver::class.java).apply {
179
+ this.action = action.intentAction
180
+ }
181
+ val pendingIntent = PendingIntent.getBroadcast(
182
+ context,
183
+ action.intentAction.hashCode(),
184
+ intent,
185
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
186
+ )
187
+ val actionIconResId = action.icon?.let { getResourceIdByName(it) }
188
+ ?: R.drawable.ic_default_action_icon
189
+ notificationBuilder.addAction(actionIconResId, action.title, pendingIntent)
178
190
  }
179
- val pendingIntent = PendingIntent.getBroadcast(
180
- context,
181
- action.intentAction.hashCode(),
182
- intent,
183
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
184
- )
185
- val actionIconResId = action.icon?.let { getResourceIdByName(it) }
186
- ?: R.drawable.ic_default_action_icon
187
- notificationBuilder.addAction(actionIconResId, action.title, pendingIntent)
188
191
  }
189
192
  }
190
193
 
@@ -232,22 +235,43 @@ class AudioNotificationManager private constructor(context: Context) {
232
235
 
233
236
  try {
234
237
  val currentTime = SystemClock.elapsedRealtime()
238
+
239
+ // Calculate current notification state
240
+ val recordingDuration = if (isPaused.get()) {
241
+ lastPauseTime - recordingStartTime - pausedDuration
242
+ } else {
243
+ System.currentTimeMillis() - recordingStartTime - pausedDuration
244
+ }
245
+
246
+ // Create a hash of the current notification state
247
+ val currentHash = Objects.hash(
248
+ recordingConfig.notification.title,
249
+ recordingConfig.notification.text,
250
+ formatDuration(recordingDuration),
251
+ isPaused.get()
252
+ )
253
+
235
254
  val needsRemoteViewsRefresh = currentTime - lastRemoteViewsUpdate >= remoteViewsRefreshInterval ||
236
255
  consecutiveUpdateFailures >= maxUpdateFailures
237
256
 
257
+ // Only update if content changed or refresh needed
258
+ if (currentHash == lastNotificationHash && !needsRemoteViewsRefresh) {
259
+ // Update waveform only if needed
260
+ if (shouldUpdateWaveform(audioData, currentTime)) {
261
+ updateWaveformOnly(audioData)
262
+ }
263
+ return
264
+ }
265
+
266
+ lastNotificationHash = currentHash
267
+
268
+ // Only recreate RemoteViews periodically or after failures
238
269
  if (needsRemoteViewsRefresh) {
239
- // Only recreate RemoteViews periodically or after failures
240
270
  remoteViews = RemoteViews(context.packageName, R.layout.notification_recording)
241
271
  lastRemoteViewsUpdate = currentTime
242
272
  consecutiveUpdateFailures = 0
243
273
  }
244
274
 
245
- val recordingDuration = if (isPaused.get()) {
246
- lastPauseTime - recordingStartTime - pausedDuration
247
- } else {
248
- System.currentTimeMillis() - recordingStartTime - pausedDuration
249
- }
250
-
251
275
  // Update RemoteViews content
252
276
  remoteViews.apply {
253
277
  setTextViewText(R.id.notification_title, recordingConfig.notification.title)
@@ -280,11 +304,13 @@ class AudioNotificationManager private constructor(context: Context) {
280
304
  addNotificationActions(context)
281
305
  }
282
306
 
283
- // Update the notification without triggering a new alert
307
+ // Update the notification with disabled alerts
284
308
  notificationManager.notify(
285
309
  recordingConfig.notification.notificationId,
286
310
  notificationBuilder
287
311
  .setOnlyAlertOnce(true)
312
+ .setVibrate(null)
313
+ .setDefaults(0)
288
314
  .build()
289
315
  )
290
316
 
@@ -300,6 +326,37 @@ class AudioNotificationManager private constructor(context: Context) {
300
326
  }
301
327
  }
302
328
  }
329
+
330
+ private fun shouldUpdateWaveform(audioData: FloatArray?, currentTime: Long): Boolean {
331
+ return recordingConfig.showWaveformInNotification &&
332
+ audioData != null &&
333
+ audioData.isNotEmpty() &&
334
+ currentTime - lastWaveformUpdate >= WAVEFORM_UPDATE_INTERVAL
335
+ }
336
+
337
+ private fun updateWaveformOnly(audioData: FloatArray?) {
338
+ if (audioData == null) return
339
+
340
+ try {
341
+ val waveformBitmap = waveformRenderer.generateWaveform(audioData, recordingConfig.notification.waveform)
342
+ remoteViews.setImageViewBitmap(R.id.notification_waveform, waveformBitmap)
343
+ lastWaveformUpdate = SystemClock.elapsedRealtime()
344
+
345
+ notificationManager.notify(
346
+ recordingConfig.notification.notificationId,
347
+ notificationBuilder
348
+ .setCustomContentView(remoteViews)
349
+ .setCustomBigContentView(remoteViews)
350
+ .setOnlyAlertOnce(true)
351
+ .setVibrate(null)
352
+ .setDefaults(0)
353
+ .build()
354
+ )
355
+ } catch (e: Exception) {
356
+ Log.e(Constants.TAG, "Error updating waveform", e)
357
+ }
358
+ }
359
+
303
360
  private fun reinitializeNotification() {
304
361
  try {
305
362
  val context = contextRef.get() ?: return
@@ -322,7 +379,6 @@ class AudioNotificationManager private constructor(context: Context) {
322
379
  }
323
380
  }
324
381
 
325
-
326
382
  fun getNotification(): Notification = notificationBuilder.build()
327
383
 
328
384
  fun pauseUpdates() {
@@ -23,6 +23,11 @@ import android.os.PowerManager
23
23
  import android.content.Context
24
24
  import java.nio.ByteBuffer
25
25
  import java.nio.ByteOrder
26
+ import android.media.AudioManager
27
+ import android.media.AudioAttributes
28
+ import android.media.AudioFocusRequest
29
+ import android.telephony.PhoneStateListener
30
+ import android.telephony.TelephonyManager
26
31
 
27
32
  class AudioRecorderManager(
28
33
  private val context: Context,
@@ -63,6 +68,55 @@ class AudioRecorderManager(
63
68
  private var compressedRecorder: MediaRecorder? = null
64
69
  private var compressedFile: File? = null
65
70
 
71
+ private var audioManager: AudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
72
+ private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
73
+ private var audioFocusRequest: Any? = null // Type Any to handle both old and new APIs
74
+ private var phoneStateListener: PhoneStateListener? = null
75
+ private var telephonyManager: TelephonyManager? = null
76
+
77
+ @RequiresApi(Build.VERSION_CODES.O)
78
+ private val audioFocusCallback = object : AudioManager.OnAudioFocusChangeListener {
79
+ override fun onAudioFocusChange(focusChange: Int) {
80
+ when (focusChange) {
81
+ AudioManager.AUDIOFOCUS_LOSS,
82
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
83
+ if (isRecording.get() && !isPaused.get()) {
84
+ mainHandler.post {
85
+ pauseRecording(object : Promise {
86
+ override fun resolve(value: Any?) {
87
+ eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
88
+ "reason" to "audioFocusLoss",
89
+ "isPaused" to true
90
+ ))
91
+ }
92
+ override fun reject(code: String, message: String?, cause: Throwable?) {
93
+ Log.e(Constants.TAG, "Failed to pause recording on audio focus loss")
94
+ }
95
+ })
96
+ }
97
+ }
98
+ }
99
+ AudioManager.AUDIOFOCUS_GAIN -> {
100
+ if (isRecording.get() && isPaused.get()) {
101
+ mainHandler.post {
102
+ resumeRecording(object : Promise {
103
+ override fun resolve(value: Any?) {
104
+ eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
105
+ "reason" to "audioFocusGain",
106
+ "isPaused" to false
107
+ ))
108
+ }
109
+ override fun reject(code: String, message: String?, cause: Throwable?) {
110
+ Log.e(Constants.TAG, "Failed to resume recording on audio focus gain")
111
+ }
112
+ })
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+
66
120
  companion object {
67
121
  @SuppressLint("StaticFieldLeak")
68
122
  @Volatile
@@ -88,6 +142,15 @@ class AudioRecorderManager(
88
142
  @RequiresApi(Build.VERSION_CODES.R)
89
143
  fun startRecording(options: Map<String, Any?>, promise: Promise) {
90
144
  try {
145
+ // Initialize phone state listener
146
+ initializePhoneStateListener()
147
+
148
+ // Request audio focus
149
+ if (!requestAudioFocus()) {
150
+ promise.reject("AUDIO_FOCUS_ERROR", "Failed to obtain audio focus", null)
151
+ return
152
+ }
153
+
91
154
  Log.d(Constants.TAG, "Starting recording with options: $options")
92
155
 
93
156
  // Check permissions
@@ -158,7 +221,8 @@ class AudioRecorderManager(
158
221
  promise.resolve(result)
159
222
 
160
223
  } catch (e: Exception) {
161
- Log.e(Constants.TAG, "Unexpected error in startRecording", e)
224
+ releaseAudioFocus()
225
+ telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
162
226
  promise.reject("UNEXPECTED_ERROR", "Unexpected error: ${e.message}", e)
163
227
  }
164
228
  }
@@ -539,7 +603,11 @@ class AudioRecorderManager(
539
603
  acquireWakeLock()
540
604
  pausedDuration += System.currentTimeMillis() - lastPauseTime
541
605
  isPaused.set(false)
606
+
607
+ // Add these lines to resume both recordings
542
608
  audioRecord?.startRecording()
609
+ compressedRecorder?.resume()
610
+
543
611
  promise.resolve("Recording resumed")
544
612
  } catch (e: Exception) {
545
613
  releaseWakeLock()
@@ -549,7 +617,10 @@ class AudioRecorderManager(
549
617
 
550
618
  fun pauseRecording(promise: Promise) {
551
619
  if (isRecording.get() && !isPaused.get()) {
620
+ // Add these lines to pause both recordings
552
621
  audioRecord?.stop()
622
+ compressedRecorder?.pause()
623
+
553
624
  lastPauseTime = System.currentTimeMillis()
554
625
  isPaused.set(true)
555
626
 
@@ -899,4 +970,145 @@ class AudioRecorderManager(
899
970
  return false
900
971
  }
901
972
  }
973
+
974
+ private fun initializePhoneStateListener() {
975
+ try {
976
+ // Check for READ_PHONE_STATE permission before initializing phone state listener
977
+ if (permissionUtils.checkPhoneStatePermission()) {
978
+ phoneStateListener = object : PhoneStateListener() {
979
+ override fun onCallStateChanged(state: Int, phoneNumber: String?) {
980
+ when (state) {
981
+ TelephonyManager.CALL_STATE_RINGING,
982
+ TelephonyManager.CALL_STATE_OFFHOOK -> {
983
+ if (isRecording.get() && !isPaused.get()) {
984
+ mainHandler.post {
985
+ pauseRecording(object : Promise {
986
+ override fun resolve(value: Any?) {
987
+ eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
988
+ "reason" to "phoneCall",
989
+ "isPaused" to true
990
+ ))
991
+ }
992
+ override fun reject(code: String, message: String?, cause: Throwable?) {
993
+ Log.e(Constants.TAG, "Failed to pause recording on phone call")
994
+ }
995
+ })
996
+ }
997
+ }
998
+ }
999
+ TelephonyManager.CALL_STATE_IDLE -> {
1000
+ if (isRecording.get() && isPaused.get()) {
1001
+ mainHandler.post {
1002
+ resumeRecording(object : Promise {
1003
+ override fun resolve(value: Any?) {
1004
+ eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1005
+ "reason" to "phoneCallEnded",
1006
+ "isPaused" to false
1007
+ ))
1008
+ }
1009
+ override fun reject(code: String, message: String?, cause: Throwable?) {
1010
+ Log.e(Constants.TAG, "Failed to resume recording after phone call")
1011
+ }
1012
+ })
1013
+ }
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+ }
1019
+
1020
+ telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
1021
+ if (telephonyManager != null) {
1022
+ try {
1023
+ telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
1024
+ } catch (e: Exception) {
1025
+ Log.e(Constants.TAG, "Failed to register phone state listener", e)
1026
+ }
1027
+ }
1028
+ } else {
1029
+ Log.w(Constants.TAG, "READ_PHONE_STATE permission not granted, phone call interruption handling disabled")
1030
+ }
1031
+ } catch (e: Exception) {
1032
+ Log.e(Constants.TAG, "Failed to initialize phone state listener", e)
1033
+ }
1034
+ }
1035
+
1036
+ @SuppressLint("NewApi")
1037
+ private fun requestAudioFocus(): Boolean {
1038
+ audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
1039
+ when (focusChange) {
1040
+ AudioManager.AUDIOFOCUS_LOSS,
1041
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
1042
+ if (isRecording.get() && !isPaused.get()) {
1043
+ mainHandler.post {
1044
+ pauseRecording(object : Promise {
1045
+ override fun resolve(value: Any?) {
1046
+ eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1047
+ "reason" to "audioFocusLoss",
1048
+ "isPaused" to true
1049
+ ))
1050
+ }
1051
+ override fun reject(code: String, message: String?, cause: Throwable?) {
1052
+ Log.e(Constants.TAG, "Failed to pause recording on audio focus loss")
1053
+ }
1054
+ })
1055
+ }
1056
+ }
1057
+ }
1058
+ AudioManager.AUDIOFOCUS_GAIN -> {
1059
+ if (isRecording.get() && isPaused.get()) {
1060
+ mainHandler.post {
1061
+ resumeRecording(object : Promise {
1062
+ override fun resolve(value: Any?) {
1063
+ eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1064
+ "reason" to "audioFocusGain",
1065
+ "isPaused" to false
1066
+ ))
1067
+ }
1068
+ override fun reject(code: String, message: String?, cause: Throwable?) {
1069
+ Log.e(Constants.TAG, "Failed to resume recording on audio focus gain")
1070
+ }
1071
+ })
1072
+ }
1073
+ }
1074
+ }
1075
+ }
1076
+ }
1077
+
1078
+ val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1079
+ val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
1080
+ .setAudioAttributes(AudioAttributes.Builder()
1081
+ .setUsage(AudioAttributes.USAGE_MEDIA)
1082
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
1083
+ .build())
1084
+ .setOnAudioFocusChangeListener(audioFocusChangeListener!!)
1085
+ .build()
1086
+ audioFocusRequest = focusRequest
1087
+ audioManager.requestAudioFocus(focusRequest)
1088
+ } else {
1089
+ @Suppress("DEPRECATION")
1090
+ audioManager.requestAudioFocus(
1091
+ audioFocusChangeListener,
1092
+ AudioManager.STREAM_MUSIC,
1093
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
1094
+ )
1095
+ }
1096
+
1097
+ return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
1098
+ }
1099
+
1100
+ private fun releaseAudioFocus() {
1101
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1102
+ (audioFocusRequest as? AudioFocusRequest)?.let { request ->
1103
+ audioManager.abandonAudioFocusRequest(request)
1104
+ }
1105
+ } else {
1106
+ @Suppress("DEPRECATION")
1107
+ audioFocusChangeListener?.let { listener ->
1108
+ audioManager.abandonAudioFocus(listener)
1109
+ }
1110
+ }
1111
+ audioFocusRequest = null
1112
+ audioFocusChangeListener = null
1113
+ }
902
1114
  }
@@ -3,6 +3,7 @@ package net.siteed.audiostream
3
3
  object Constants {
4
4
  const val AUDIO_EVENT_NAME = "AudioData"
5
5
  const val AUDIO_ANALYSIS_EVENT_NAME = "AudioAnalysis"
6
+ const val RECORDING_INTERRUPTED_EVENT_NAME = "onRecordingInterrupted"
6
7
  const val DEFAULT_SAMPLE_RATE = 16000 // Default sample rate for audio recording
7
8
  const val DEFAULT_CHANNEL_CONFIG = 1 // Mono
8
9
  const val DEFAULT_AUDIO_FORMAT = 16 // 16-bit PCM
@@ -20,7 +20,11 @@ class ExpoAudioStreamModule : Module(), EventSender {
20
20
  // The module will be accessible from `requireNativeModule('ExpoAudioStream')` in JavaScript.
21
21
  Name("ExpoAudioStream")
22
22
 
23
- Events(Constants.AUDIO_EVENT_NAME, Constants.AUDIO_ANALYSIS_EVENT_NAME)
23
+ Events(
24
+ Constants.AUDIO_EVENT_NAME,
25
+ Constants.AUDIO_ANALYSIS_EVENT_NAME,
26
+ Constants.RECORDING_INTERRUPTED_EVENT_NAME
27
+ )
24
28
 
25
29
  // Initialize AudioRecorderManager
26
30
  initializeManager()
@@ -48,4 +48,12 @@ class PermissionUtils(private val context: Context) {
48
48
  }
49
49
  return result
50
50
  }
51
+
52
+ fun checkPhoneStatePermission(): Boolean {
53
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
54
+ context.checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED
55
+ } else {
56
+ true
57
+ }
58
+ }
51
59
  }