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.
- package/android/src/main/cpp/audioapi/android/OnLoad.cpp +5 -1
- package/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp +26 -17
- package/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.cpp +41 -0
- package/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.h +23 -0
- package/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt +1 -0
- package/android/src/main/java/com/swmansion/audioapi/system/CentralizedForegroundService.kt +136 -30
- package/android/src/main/java/com/swmansion/audioapi/system/ForegroundServiceManager.kt +47 -12
- package/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt +23 -0
- package/android/src/main/java/com/swmansion/audioapi/system/NativeRecorderControl.kt +39 -0
- package/android/src/main/java/com/swmansion/audioapi/system/RecorderState.kt +17 -0
- package/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt +48 -20
- package/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt +221 -227
- package/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt +122 -9
- package/android/src/main/java/com/swmansion/audioapi/system/notification/state/RecordingNotificationState.kt +17 -11
- package/common/cpp/audioapi/AudioAPIModuleInstaller.h +44 -0
- package/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp +18 -11
- package/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h +1 -0
- package/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp +2 -0
- package/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp +117 -0
- package/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.h +85 -0
- package/common/cpp/audioapi/core/inputs/AudioRecorder.cpp +8 -0
- package/common/cpp/audioapi/core/inputs/AudioRecorder.h +3 -1
- package/common/cpp/audioapi/core/inputs/RecorderState.h +13 -0
- package/common/cpp/audioapi/core/utils/AudioRecorderCallback.cpp +4 -4
- package/common/cpp/audioapi/core/utils/AudioRecorderCallback.h +2 -4
- package/common/cpp/audioapi/events/AudioEvent.h +1 -0
- package/ios/audioapi/ios/AudioAPIModule.mm +30 -23
- package/ios/audioapi/ios/core/IOSAudioRecorder.mm +9 -18
- package/ios/audioapi/ios/system/SystemNotificationManager.mm +4 -1
- package/lib/commonjs/core/AudioRecorder.js +12 -4
- package/lib/commonjs/core/AudioRecorder.js.map +1 -1
- package/lib/commonjs/mock/index.js +11 -1
- package/lib/commonjs/mock/index.js.map +1 -1
- package/lib/commonjs/plugin/withAudioAPI.js +4 -2
- package/lib/commonjs/plugin/withAudioAPI.js.map +1 -1
- package/lib/module/core/AudioRecorder.js +12 -4
- package/lib/module/core/AudioRecorder.js.map +1 -1
- package/lib/module/mock/index.js +11 -1
- package/lib/module/mock/index.js.map +1 -1
- package/lib/module/plugin/withAudioAPI.js +4 -2
- package/lib/module/plugin/withAudioAPI.js.map +1 -1
- package/lib/typescript/core/AudioRecorder.d.ts +2 -0
- package/lib/typescript/core/AudioRecorder.d.ts.map +1 -1
- package/lib/typescript/mock/index.d.ts +3 -0
- package/lib/typescript/mock/index.d.ts.map +1 -1
- package/lib/typescript/plugin/withAudioAPI.d.ts +1 -0
- package/lib/typescript/plugin/withAudioAPI.d.ts.map +1 -1
- package/lib/typescript/system/notification/types.d.ts +32 -2
- package/lib/typescript/system/notification/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/AudioAPIModule/globals.d.ts +5 -0
- package/src/core/AudioRecorder.ts +17 -4
- package/src/mock/index.ts +14 -1
- package/src/plugin/withAudioAPI.ts +4 -2
- package/src/system/notification/types.ts +32 -2
- package/android/src/main/res/layout/btn_round_ripple.xml +0 -9
- package/android/src/main/res/layout/notification_collapsed.xml +0 -45
- 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, [] {
|
|
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
|
-
///
|
|
228
|
-
///
|
|
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
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
|
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()
|
|
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
|
-
|
|
522
|
-
|
|
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
|
|
@@ -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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
+
}
|