react-native-audio-api 0.13.5 → 0.13.6

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 (58) hide show
  1. package/android/src/main/cpp/audioapi/android/OnLoad.cpp +5 -1
  2. package/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp +26 -17
  3. package/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.cpp +41 -0
  4. package/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.h +23 -0
  5. package/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt +1 -0
  6. package/android/src/main/java/com/swmansion/audioapi/system/CentralizedForegroundService.kt +136 -30
  7. package/android/src/main/java/com/swmansion/audioapi/system/ForegroundServiceManager.kt +47 -12
  8. package/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt +23 -0
  9. package/android/src/main/java/com/swmansion/audioapi/system/NativeRecorderControl.kt +39 -0
  10. package/android/src/main/java/com/swmansion/audioapi/system/RecorderState.kt +17 -0
  11. package/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt +48 -20
  12. package/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt +221 -227
  13. package/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt +122 -9
  14. package/android/src/main/java/com/swmansion/audioapi/system/notification/state/RecordingNotificationState.kt +17 -11
  15. package/common/cpp/audioapi/AudioAPIModuleInstaller.h +44 -0
  16. package/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp +18 -11
  17. package/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h +1 -0
  18. package/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp +2 -0
  19. package/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp +117 -0
  20. package/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.h +85 -0
  21. package/common/cpp/audioapi/core/inputs/AudioRecorder.cpp +8 -0
  22. package/common/cpp/audioapi/core/inputs/AudioRecorder.h +3 -1
  23. package/common/cpp/audioapi/core/inputs/RecorderState.h +13 -0
  24. package/common/cpp/audioapi/core/utils/AudioRecorderCallback.cpp +4 -4
  25. package/common/cpp/audioapi/core/utils/AudioRecorderCallback.h +2 -4
  26. package/common/cpp/audioapi/events/AudioEvent.h +1 -0
  27. package/ios/audioapi/ios/AudioAPIModule.mm +30 -23
  28. package/ios/audioapi/ios/core/IOSAudioRecorder.mm +9 -18
  29. package/ios/audioapi/ios/system/SystemNotificationManager.mm +4 -1
  30. package/lib/commonjs/core/AudioRecorder.js +12 -4
  31. package/lib/commonjs/core/AudioRecorder.js.map +1 -1
  32. package/lib/commonjs/mock/index.js +11 -1
  33. package/lib/commonjs/mock/index.js.map +1 -1
  34. package/lib/commonjs/plugin/withAudioAPI.js +4 -2
  35. package/lib/commonjs/plugin/withAudioAPI.js.map +1 -1
  36. package/lib/module/core/AudioRecorder.js +12 -4
  37. package/lib/module/core/AudioRecorder.js.map +1 -1
  38. package/lib/module/mock/index.js +11 -1
  39. package/lib/module/mock/index.js.map +1 -1
  40. package/lib/module/plugin/withAudioAPI.js +4 -2
  41. package/lib/module/plugin/withAudioAPI.js.map +1 -1
  42. package/lib/typescript/core/AudioRecorder.d.ts +2 -0
  43. package/lib/typescript/core/AudioRecorder.d.ts.map +1 -1
  44. package/lib/typescript/mock/index.d.ts +3 -0
  45. package/lib/typescript/mock/index.d.ts.map +1 -1
  46. package/lib/typescript/plugin/withAudioAPI.d.ts +1 -0
  47. package/lib/typescript/plugin/withAudioAPI.d.ts.map +1 -1
  48. package/lib/typescript/system/notification/types.d.ts +32 -2
  49. package/lib/typescript/system/notification/types.d.ts.map +1 -1
  50. package/package.json +1 -1
  51. package/src/AudioAPIModule/globals.d.ts +5 -0
  52. package/src/core/AudioRecorder.ts +17 -4
  53. package/src/mock/index.ts +14 -1
  54. package/src/plugin/withAudioAPI.ts +4 -2
  55. package/src/system/notification/types.ts +32 -2
  56. package/android/src/main/res/layout/btn_round_ripple.xml +0 -9
  57. package/android/src/main/res/layout/notification_collapsed.xml +0 -45
  58. package/android/src/main/res/layout/notification_expanded.xml +0 -44
@@ -1,9 +1,13 @@
1
1
  #include <audioapi/android/AudioAPIModule.h>
2
+ #include <audioapi/android/system/NativeRecorderControl.h>
2
3
 
3
4
  #include <fbjni/fbjni.h>
4
5
 
5
6
  using namespace audioapi;
6
7
 
7
8
  JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
8
- return facebook::jni::initialize(vm, [] { AudioAPIModule::registerNatives(); });
9
+ return facebook::jni::initialize(vm, [] {
10
+ AudioAPIModule::registerNatives();
11
+ NativeRecorderControl::registerNatives();
12
+ });
9
13
  }
@@ -224,27 +224,26 @@ AudioRecorder::StopResult AndroidAudioRecorder::stop() {
224
224
  }
225
225
 
226
226
  /// @brief Enables file output for the recorder with the specified properties.
227
- /// If the recorder is already active, it will prepare and open the file for writing immediately.
228
- /// Due to the nature of RN this might be called multiple times during recording session (especially during development),
229
- /// thus the requirement of handling the "already active" case.
227
+ /// The file itself is created by the next start(). An active (recording or paused) session keeps
228
+ /// the output it started with, so calling this during a session fails and changes nothing.
230
229
  /// This method should be called from the JS thread only.
231
230
  /// @param properties Properties defining the audio file format and encoding options.
232
- /// @returns On success, returns the file URI where the recording is being saved, otherwise returns an error message.
231
+ /// @returns Ok when the properties were stored, otherwise an error message.
233
232
  Result<NoneType, std::string> AndroidAudioRecorder::enableFileOutput(
234
233
  std::shared_ptr<AudioFileProperties> properties) {
235
- std::scoped_lock fileWriterLock(fileWriterMutex_);
236
- fileProperties_ = properties;
237
- fileOutputEnabled_.store(true, std::memory_order_release);
238
- fileOutputConfigured_.store(false, std::memory_order_release);
234
+ // start() holds fileWriterMutex_ for its whole idle check and setup, so the
235
+ // idle test below cannot interleave with a session that is just starting.
236
+ std::scoped_lock lock(fileWriterMutex_);
239
237
 
240
238
  if (!isIdle()) {
241
- auto writerResult = setupFileWriter(properties);
242
- if (!writerResult.is_ok()) {
243
- fileOutputEnabled_.store(false, std::memory_order_release);
244
- return writerResult;
245
- }
239
+ return Result<NoneType, std::string>::Err(
240
+ "File output cannot be changed while a recording session is active");
246
241
  }
247
242
 
243
+ fileProperties_ = properties;
244
+ fileOutputEnabled_.store(true, std::memory_order_release);
245
+ fileOutputConfigured_.store(false, std::memory_order_release);
246
+
248
247
  return Result<NoneType, std::string>::Ok(None);
249
248
  }
250
249
 
@@ -489,11 +488,11 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re
489
488
  return;
490
489
  }
491
490
 
492
- // Only restart if the recorder is still supposed to be running. Restarting
491
+ // Only reclaim the stream if the recorder still owns a session. Reopening
493
492
  // unconditionally resurrected stopped recorders, so any audio route change
494
493
  // (e.g. a Bluetooth headset connecting or disconnecting) turned the mic
495
494
  // back on after stop().
496
- if (isIdle() || isPaused()) {
495
+ if (isIdle()) {
497
496
  return;
498
497
  }
499
498
 
@@ -508,8 +507,16 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re
508
507
  return;
509
508
  }
510
509
 
510
+ // A paused session keeps its state across the reclaim: the fresh stream is
511
+ // left stopped until resume(), instead of forcing the recorder back to recording.
512
+ const auto stateBeforeTeardown = state_.load(std::memory_order_acquire);
513
+
511
514
  cleanup();
512
515
 
516
+ if (stateBeforeTeardown == RecorderState::Idle) {
517
+ return;
518
+ }
519
+
513
520
  auto streamResult = openAudioStream();
514
521
 
515
522
  if (!streamResult.is_ok()) {
@@ -518,8 +525,10 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re
518
525
  return;
519
526
  }
520
527
 
521
- mStream_->requestStart();
522
- state_.store(RecorderState::Recording, std::memory_order_release);
528
+ if (stateBeforeTeardown == RecorderState::Recording) {
529
+ mStream_->requestStart();
530
+ }
531
+ state_.store(stateBeforeTeardown, std::memory_order_release);
523
532
  }
524
533
 
525
534
  } // namespace audioapi
@@ -0,0 +1,41 @@
1
+ #include <audioapi/android/system/NativeRecorderControl.h>
2
+
3
+ #include <audioapi/core/inputs/ActiveRecorderHandle.h>
4
+ #include <audioapi/core/inputs/RecorderState.h>
5
+
6
+ namespace audioapi {
7
+
8
+ namespace {
9
+
10
+ jint toOrdinal(RecorderState state) {
11
+ return static_cast<jint>(state);
12
+ }
13
+
14
+ } // namespace
15
+
16
+ void NativeRecorderControl::registerNatives() {
17
+ javaClassStatic()->registerNatives({
18
+ makeNativeMethod("stopActiveRecording", NativeRecorderControl::stopActiveRecording),
19
+ makeNativeMethod("pauseActiveRecording", NativeRecorderControl::pauseActiveRecording),
20
+ makeNativeMethod("resumeActiveRecording", NativeRecorderControl::resumeActiveRecording),
21
+ makeNativeMethod("currentRecorderState", NativeRecorderControl::currentRecorderState),
22
+ });
23
+ }
24
+
25
+ jint NativeRecorderControl::stopActiveRecording(jni::alias_ref<jni::JClass> /*clazz*/) {
26
+ return toOrdinal(ActiveRecorderHandle::global().stopAndReturnState());
27
+ }
28
+
29
+ jint NativeRecorderControl::pauseActiveRecording(jni::alias_ref<jni::JClass> /*clazz*/) {
30
+ return toOrdinal(ActiveRecorderHandle::global().pause());
31
+ }
32
+
33
+ jint NativeRecorderControl::resumeActiveRecording(jni::alias_ref<jni::JClass> /*clazz*/) {
34
+ return toOrdinal(ActiveRecorderHandle::global().resume());
35
+ }
36
+
37
+ jint NativeRecorderControl::currentRecorderState(jni::alias_ref<jni::JClass> /*clazz*/) {
38
+ return toOrdinal(ActiveRecorderHandle::global().currentState());
39
+ }
40
+
41
+ } // namespace audioapi
@@ -0,0 +1,23 @@
1
+ #pragma once
2
+
3
+ #include <fbjni/fbjni.h>
4
+
5
+ namespace audioapi {
6
+
7
+ using namespace facebook;
8
+
9
+ /// @brief JNI statics that let Kotlin reach the active recorder without a JS runtime,
10
+ // e.g. from the recording-notification stop action after the app task was removed.
11
+ class NativeRecorderControl : public jni::JavaClass<NativeRecorderControl> {
12
+ public:
13
+ static auto constexpr kJavaDescriptor = "Lcom/swmansion/audioapi/system/NativeRecorderControl;";
14
+
15
+ static void registerNatives();
16
+
17
+ static jint stopActiveRecording(jni::alias_ref<jni::JClass>);
18
+ static jint pauseActiveRecording(jni::alias_ref<jni::JClass>);
19
+ static jint resumeActiveRecording(jni::alias_ref<jni::JClass>);
20
+ static jint currentRecorderState(jni::alias_ref<jni::JClass>);
21
+ };
22
+
23
+ } // namespace audioapi
@@ -26,4 +26,5 @@ enum class AudioEvent {
26
26
  BUFFER_ENDED,
27
27
  RECORDER_ERROR,
28
28
  BUFFERING_STATE_CHANGE,
29
+ RECORDING_NOTIFICATION_STOP,
29
30
  }
@@ -4,11 +4,15 @@ import android.app.Notification
4
4
  import android.app.NotificationChannel
5
5
  import android.app.NotificationManager
6
6
  import android.app.Service
7
+ import android.content.ComponentName
7
8
  import android.content.Context
8
9
  import android.content.Intent
10
+ import android.content.pm.PackageManager
11
+ import android.content.pm.ServiceInfo
9
12
  import android.os.Build
10
13
  import android.os.IBinder
11
14
  import android.util.Log
15
+ import androidx.annotation.RequiresApi
12
16
  import androidx.core.app.NotificationCompat
13
17
  import com.swmansion.audioapi.system.MediaSessionManager.CHANNEL_ID
14
18
  import com.swmansion.audioapi.system.notification.NotificationRegistry
@@ -23,10 +27,18 @@ class CentralizedForegroundService : Service() {
23
27
  private const val TAG = "CentralizedForegroundService"
24
28
  const val ACTION_START = "START_FOREGROUND"
25
29
  const val ACTION_STOP = "STOP_FOREGROUND"
30
+
31
+ private const val PLACEHOLDER_CHANNEL_ID = "audio_service_placeholder"
32
+ private const val PLACEHOLDER_NOTIFICATION_ID = 300
26
33
  }
27
34
 
28
35
  override fun onBind(intent: Intent?): IBinder? = null
29
36
 
37
+ override fun onCreate() {
38
+ super.onCreate()
39
+ ForegroundServiceManager.onServiceCreated()
40
+ }
41
+
30
42
  override fun onStartCommand(
31
43
  intent: Intent?,
32
44
  flags: Int,
@@ -38,34 +50,39 @@ class CentralizedForegroundService : Service() {
38
50
  }
39
51
 
40
52
  ACTION_STOP -> {
41
- stopForeground(STOP_FOREGROUND_REMOVE)
42
- stopSelf()
53
+ if (stopSelfResult(startId)) {
54
+ stopForeground(STOP_FOREGROUND_REMOVE)
55
+ } else {
56
+ Log.d(TAG, "Stop superseded by a newer start request, service stays in the foreground")
57
+ }
43
58
  }
44
59
  }
45
60
  return START_NOT_STICKY
46
61
  }
47
62
 
63
+ override fun onTaskRemoved(rootIntent: Intent?) {
64
+ // Fires only when the app opted into android:stopWithTask="false" — the service
65
+ // intentionally outlives the removed task.
66
+ Log.i(TAG, "App task removed, foreground service keeps running")
67
+ super.onTaskRemoved(rootIntent)
68
+ }
69
+
48
70
  private fun startForegroundWithNotification() {
49
71
  try {
50
- createNotificationChannelIfNeeded()
72
+ createLowImportanceChannelIfNeeded(CHANNEL_ID, "Audio Service", "Background audio processing")
51
73
 
52
74
  // Get the first available notification
53
75
  val existingNotification = findExistingNotification()
54
76
  if (existingNotification == null) {
55
- Log.w(TAG, "No notification available to start foreground service")
77
+ // The service was started with Context.startForegroundService(), so startForeground()
78
+ // must still be called — skipping it crashes with ForegroundServiceDidNotStartInTimeException.
79
+ Log.w(TAG, "No notification available, starting foreground with a placeholder and stopping")
80
+ startForegroundWithPlaceholderAndStop()
56
81
  return
57
82
  }
58
83
 
59
84
  val (notificationId, notification) = existingNotification
60
-
61
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
62
- startForeground(
63
- notificationId,
64
- notification,
65
- )
66
- } else {
67
- startForeground(notificationId, notification)
68
- }
85
+ startForegroundCompat(notificationId, notification)
69
86
 
70
87
  Log.d(TAG, "Centralized foreground service started with notification ID: $notificationId")
71
88
  } catch (e: Exception) {
@@ -73,6 +90,87 @@ class CentralizedForegroundService : Service() {
73
90
  }
74
91
  }
75
92
 
93
+ private fun startForegroundWithPlaceholderAndStop() {
94
+ createLowImportanceChannelIfNeeded(
95
+ PLACEHOLDER_CHANNEL_ID,
96
+ "Audio Service Placeholder",
97
+ "Short-lived notification shown while the audio service shuts down",
98
+ )
99
+
100
+ val placeholderNotification =
101
+ NotificationCompat
102
+ .Builder(this, PLACEHOLDER_CHANNEL_ID)
103
+ .setSmallIcon(android.R.drawable.ic_media_play)
104
+ .setContentTitle("Audio service")
105
+ .setPriority(NotificationCompat.PRIORITY_LOW)
106
+ .build()
107
+
108
+ try {
109
+ startForegroundCompat(PLACEHOLDER_NOTIFICATION_ID, placeholderNotification)
110
+ } finally {
111
+ // The service must exit even when startForeground throws (e.g. API 34+
112
+ // ForegroundServiceStartNotAllowedException) — otherwise the system kills the
113
+ // process with ForegroundServiceDidNotStartInTimeException. Unlike ACTION_STOP this
114
+ // exit is unconditional, because there is nothing to keep in the foreground; a start
115
+ // racing it is picked up again by ForegroundServiceManager.onServiceDestroyed.
116
+ stopForeground(STOP_FOREGROUND_REMOVE)
117
+ stopSelf()
118
+ }
119
+ }
120
+
121
+ private fun startForegroundCompat(
122
+ notificationId: Int,
123
+ notification: Notification,
124
+ ) {
125
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
126
+ startForeground(notificationId, notification)
127
+ return
128
+ }
129
+
130
+ val serviceTypes = activeNotificationServiceTypes() and manifestDeclaredServiceTypes()
131
+ when {
132
+ serviceTypes != 0 -> {
133
+ startForeground(notificationId, notification, serviceTypes)
134
+ }
135
+
136
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> {
137
+ startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST)
138
+ }
139
+
140
+ else -> {
141
+ startForeground(notificationId, notification)
142
+ }
143
+ }
144
+ }
145
+
146
+ @RequiresApi(Build.VERSION_CODES.Q)
147
+ private fun activeNotificationServiceTypes(): Int {
148
+ var serviceTypes = 0
149
+
150
+ if (NotificationRegistry.getBuiltNotification(PlaybackNotification.ID) != null) {
151
+ serviceTypes = serviceTypes or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
152
+ }
153
+
154
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
155
+ NotificationRegistry.getBuiltNotification(RecordingNotification.ID) != null
156
+ ) {
157
+ serviceTypes = serviceTypes or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
158
+ }
159
+
160
+ return serviceTypes
161
+ }
162
+
163
+ @RequiresApi(Build.VERSION_CODES.Q)
164
+ private fun manifestDeclaredServiceTypes(): Int =
165
+ try {
166
+ packageManager
167
+ .getServiceInfo(ComponentName(this, CentralizedForegroundService::class.java), PackageManager.GET_META_DATA)
168
+ .foregroundServiceType
169
+ } catch (e: PackageManager.NameNotFoundException) {
170
+ Log.w(TAG, "Unable to read foreground service types declared in the manifest: ${e.message}")
171
+ 0
172
+ }
173
+
76
174
  private fun findExistingNotification(): Pair<Int, Notification>? {
77
175
  // Check for playback notification first (priority)
78
176
  NotificationRegistry.getBuiltNotification(PlaybackNotification.ID)?.let {
@@ -86,28 +184,36 @@ class CentralizedForegroundService : Service() {
86
184
  return null
87
185
  }
88
186
 
89
- private fun createNotificationChannelIfNeeded() {
90
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
91
- val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
92
-
93
- if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) {
94
- val channel =
95
- NotificationChannel(
96
- CHANNEL_ID,
97
- "Audio Service",
98
- NotificationManager.IMPORTANCE_LOW,
99
- ).apply {
100
- description = "Background audio processing"
101
- setShowBadge(false)
102
- lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
103
- }
104
- notificationManager.createNotificationChannel(channel)
105
- }
187
+ private fun createLowImportanceChannelIfNeeded(
188
+ id: String,
189
+ name: String,
190
+ channelDescription: String,
191
+ ) {
192
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
193
+ return
106
194
  }
195
+
196
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
197
+ if (notificationManager.getNotificationChannel(id) != null) {
198
+ return
199
+ }
200
+
201
+ val channel =
202
+ NotificationChannel(
203
+ id,
204
+ name,
205
+ NotificationManager.IMPORTANCE_LOW,
206
+ ).apply {
207
+ description = channelDescription
208
+ setShowBadge(false)
209
+ lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
210
+ }
211
+ notificationManager.createNotificationChannel(channel)
107
212
  }
108
213
 
109
214
  override fun onDestroy() {
110
215
  Log.d(TAG, "Centralized foreground service destroyed")
216
+ ForegroundServiceManager.onServiceDestroyed()
111
217
  super.onDestroy()
112
218
  }
113
219
  }
@@ -10,6 +10,11 @@ import java.lang.ref.WeakReference
10
10
  /**
11
11
  * Centralized manager for foreground service lifecycle.
12
12
  * Handles starting/stopping foreground service based on active subscribers.
13
+ *
14
+ * Subscribe and unsubscribe run on the caller's thread (the JS thread, or the recording
15
+ * notification receiver's executor), while the service's own callbacks run on the main
16
+ * thread. The manager therefore tracks what it asked for ([startIntentSent]) separately
17
+ * from what the service reports ([isServiceRunning]), and never infers one from the other.
13
18
  */
14
19
  object ForegroundServiceManager {
15
20
  private const val TAG = "ForegroundServiceManager"
@@ -17,6 +22,11 @@ object ForegroundServiceManager {
17
22
  private lateinit var reactContext: WeakReference<ReactApplicationContext>
18
23
  private val subscribers = mutableSetOf<BaseNotification>()
19
24
  private var isServiceRunning = false
25
+ private var startIntentSent = false
26
+
27
+ /** Set while a restart triggered by [onServiceDestroyed] is in flight, so a service that
28
+ * never comes up cannot be restarted in a loop. */
29
+ private var restartAfterDestroyPending = false
20
30
 
21
31
  fun initialize(reactContext: WeakReference<ReactApplicationContext>) {
22
32
  this.reactContext = reactContext
@@ -49,21 +59,50 @@ object ForegroundServiceManager {
49
59
  /**
50
60
  * Get count of active subscribers
51
61
  */
62
+ @Synchronized
52
63
  fun getSubscriberCount(): Int = subscribers.size
53
64
 
54
65
  /**
55
66
  * Check if service is currently running
56
67
  */
68
+ @Synchronized
57
69
  fun isServiceRunning(): Boolean = isServiceRunning
58
70
 
71
+ /**
72
+ * Called from [CentralizedForegroundService.onCreate].
73
+ */
74
+ @Synchronized
75
+ internal fun onServiceCreated() {
76
+ isServiceRunning = true
77
+ restartAfterDestroyPending = false
78
+ }
79
+
80
+ @Synchronized
81
+ internal fun onServiceDestroyed() {
82
+ isServiceRunning = false
83
+
84
+ val stillWanted = startIntentSent && subscribers.isNotEmpty() && !restartAfterDestroyPending
85
+ // The instance is gone, so the previous request is spent either way: anything that
86
+ // needs the service from here on has to ask for it again.
87
+ startIntentSent = false
88
+
89
+ if (!stillWanted) {
90
+ return
91
+ }
92
+
93
+ Log.w(TAG, "Service destroyed while ${subscribers.size} subscriber(s) still need it, restarting")
94
+ restartAfterDestroyPending = true
95
+ startForegroundService()
96
+ }
97
+
59
98
  private fun startServiceIfNeeded() {
60
- if (!isServiceRunning && subscribers.isNotEmpty()) {
99
+ if (subscribers.isNotEmpty()) {
61
100
  startForegroundService()
62
101
  }
63
102
  }
64
103
 
65
104
  private fun stopServiceIfNotNeeded() {
66
- if (isServiceRunning && subscribers.isEmpty()) {
105
+ if ((startIntentSent || isServiceRunning) && subscribers.isEmpty()) {
67
106
  stopForegroundService()
68
107
  }
69
108
  }
@@ -81,14 +120,15 @@ object ForegroundServiceManager {
81
120
  context.startService(intent)
82
121
  }
83
122
 
84
- isServiceRunning = true
85
- Log.d(TAG, "Centralized foreground service started")
123
+ startIntentSent = true
86
124
  } catch (e: Exception) {
87
125
  Log.e(TAG, "Error starting foreground service: ${e.message}", e)
88
126
  }
89
127
  }
90
128
 
91
129
  private fun stopForegroundService() {
130
+ startIntentSent = false
131
+
92
132
  val context = reactContext.get() ?: return
93
133
 
94
134
  try {
@@ -96,8 +136,6 @@ object ForegroundServiceManager {
96
136
  intent.action = CentralizedForegroundService.ACTION_STOP
97
137
 
98
138
  context.startService(intent)
99
- isServiceRunning = false
100
- Log.d(TAG, "Centralized foreground service stopped")
101
139
  } catch (e: Exception) {
102
140
  Log.e(TAG, "Error stopping foreground service: ${e.message}", e)
103
141
  }
@@ -106,12 +144,9 @@ object ForegroundServiceManager {
106
144
  /**
107
145
  * Cleanup all subscribers and stop service
108
146
  */
147
+ @Synchronized
109
148
  fun cleanup() {
110
- synchronized(this) {
111
- subscribers.clear()
112
- if (isServiceRunning) {
113
- stopForegroundService()
114
- }
115
- }
149
+ subscribers.clear()
150
+ stopServiceIfNotNeeded()
116
151
  }
117
152
  }
@@ -24,6 +24,7 @@ import com.swmansion.audioapi.system.PermissionRequestListener.Companion.RECORDI
24
24
  import com.swmansion.audioapi.system.notification.NotificationRegistry
25
25
  import com.swmansion.audioapi.system.notification.PlaybackNotification
26
26
  import com.swmansion.audioapi.system.notification.PlaybackNotificationReceiver
27
+ import com.swmansion.audioapi.system.notification.RecordingNotification
27
28
  import java.lang.ref.WeakReference
28
29
 
29
30
  object MediaSessionManager {
@@ -262,5 +263,27 @@ object MediaSessionManager {
262
263
  notificationRegistry.hideNotification(key)
263
264
  }
264
265
 
266
+ /**
267
+ * Used by the notification stop action, which also
268
+ * unwinds the foreground service through the registry's unsubscribe path.
269
+ */
270
+ fun hideRecordingNotification() {
271
+ if (!::notificationRegistry.isInitialized) {
272
+ return
273
+ }
274
+ notificationRegistry.hideNotification(RecordingNotification.ID)
275
+ }
276
+
277
+ /**
278
+ * Used by native-initiated pause/resume, which can't go through [showNotification] — there
279
+ * is no JS to supply options.
280
+ */
281
+ fun setRecordingNotificationPaused(paused: Boolean) {
282
+ if (!::notificationRegistry.isInitialized) {
283
+ return
284
+ }
285
+ notificationRegistry.updateRecordingNotificationPausedState(paused)
286
+ }
287
+
265
288
  fun isNotificationActive(key: String): Boolean = notificationRegistry.isNotificationActive(key)
266
289
  }
@@ -0,0 +1,39 @@
1
+ package com.swmansion.audioapi.system
2
+
3
+ /**
4
+ * Direct access to the active C++ recorder, independent of the JS runtime.
5
+ * This is what allows the recording-notification stop action to end a recording
6
+ * after the app task has been removed.
7
+ */
8
+ object NativeRecorderControl {
9
+ init {
10
+ System.loadLibrary("react-native-audio-api")
11
+ }
12
+
13
+ /**
14
+ * The file info is stashed natively for
15
+ * `AudioRecorder.consumeLastRecordingResult()` on the JS side.
16
+ */
17
+ fun stop(): RecorderState = RecorderState.fromOrdinal(stopActiveRecording())
18
+
19
+ /** Pauses an actively recording session; a no-op in any other state. */
20
+ fun pause(): RecorderState = RecorderState.fromOrdinal(pauseActiveRecording())
21
+
22
+ /** Resumes a paused session; a no-op in any other state. */
23
+ fun resume(): RecorderState = RecorderState.fromOrdinal(resumeActiveRecording())
24
+
25
+ /** Non-blocking read of the recorder state; [RecorderState.IDLE] when there is none. */
26
+ fun currentState(): RecorderState = RecorderState.fromOrdinal(currentRecorderState())
27
+
28
+ @JvmStatic
29
+ private external fun stopActiveRecording(): Int
30
+
31
+ @JvmStatic
32
+ private external fun pauseActiveRecording(): Int
33
+
34
+ @JvmStatic
35
+ private external fun resumeActiveRecording(): Int
36
+
37
+ @JvmStatic
38
+ private external fun currentRecorderState(): Int
39
+ }
@@ -0,0 +1,17 @@
1
+ package com.swmansion.audioapi.system
2
+
3
+ /**
4
+ * Lifecycle state of the native audio recorder.
5
+ *
6
+ * Mirrors C++ `audioapi::RecorderState`
7
+ */
8
+ enum class RecorderState {
9
+ IDLE,
10
+ RECORDING,
11
+ PAUSED,
12
+ ;
13
+
14
+ companion object {
15
+ fun fromOrdinal(ordinal: Int): RecorderState = entries.getOrElse(ordinal) { IDLE }
16
+ }
17
+ }