@independo/capacitor-voice-recorder 8.1.0-dev.3 → 8.1.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.
@@ -67,6 +67,7 @@ dependencies {
67
67
  implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
68
68
  testImplementation "junit:junit:$junitVersion"
69
69
  testImplementation "org.json:json:20240303"
70
+ testImplementation "org.mockito:mockito-core:5.21.0"
70
71
  androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
71
72
  androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
72
73
  }
@@ -18,10 +18,109 @@ import java.util.regex.Pattern;
18
18
  /** MediaRecorder wrapper that manages audio focus and interruptions. */
19
19
  public class CustomMediaRecorder implements AudioManager.OnAudioFocusChangeListener, RecorderAdapter {
20
20
 
21
+ interface MediaRecorderFactory {
22
+ MediaRecorder create();
23
+ }
24
+
25
+ interface AudioManagerProvider {
26
+ AudioManager getAudioManager(Context context);
27
+ }
28
+
29
+ interface DirectoryProvider {
30
+ File getDocumentsDirectory();
31
+
32
+ File getFilesDir(Context context);
33
+
34
+ File getCacheDir(Context context);
35
+
36
+ File getExternalFilesDir(Context context);
37
+
38
+ File getExternalStorageDirectory();
39
+ }
40
+
41
+ interface SdkIntProvider {
42
+ int getSdkInt();
43
+ }
44
+
45
+ interface AudioFocusRequestFactory {
46
+ AudioFocusRequest create(AudioManager.OnAudioFocusChangeListener listener);
47
+ }
48
+
49
+ private static final class DefaultMediaRecorderFactory implements MediaRecorderFactory {
50
+ @Override
51
+ public MediaRecorder create() {
52
+ return new MediaRecorder();
53
+ }
54
+ }
55
+
56
+ private static final class DefaultAudioManagerProvider implements AudioManagerProvider {
57
+ @Override
58
+ public AudioManager getAudioManager(Context context) {
59
+ return (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
60
+ }
61
+ }
62
+
63
+ private static final class DefaultDirectoryProvider implements DirectoryProvider {
64
+ @Override
65
+ public File getDocumentsDirectory() {
66
+ return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS);
67
+ }
68
+
69
+ @Override
70
+ public File getFilesDir(Context context) {
71
+ return context.getFilesDir();
72
+ }
73
+
74
+ @Override
75
+ public File getCacheDir(Context context) {
76
+ return context.getCacheDir();
77
+ }
78
+
79
+ @Override
80
+ public File getExternalFilesDir(Context context) {
81
+ return context.getExternalFilesDir(null);
82
+ }
83
+
84
+ @Override
85
+ public File getExternalStorageDirectory() {
86
+ return Environment.getExternalStorageDirectory();
87
+ }
88
+ }
89
+
90
+ private static final class DefaultSdkIntProvider implements SdkIntProvider {
91
+ @Override
92
+ public int getSdkInt() {
93
+ return Build.VERSION.SDK_INT;
94
+ }
95
+ }
96
+
97
+ private static final class DefaultAudioFocusRequestFactory implements AudioFocusRequestFactory {
98
+ @Override
99
+ public AudioFocusRequest create(AudioManager.OnAudioFocusChangeListener listener) {
100
+ AudioAttributes audioAttributes = new AudioAttributes.Builder()
101
+ .setUsage(AudioAttributes.USAGE_MEDIA)
102
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
103
+ .build();
104
+
105
+ return new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
106
+ .setAudioAttributes(audioAttributes)
107
+ .setOnAudioFocusChangeListener(listener)
108
+ .build();
109
+ }
110
+ }
111
+
21
112
  /** Android context for file paths and system services. */
22
113
  private final Context context;
23
114
  /** Recording options passed from the service layer. */
24
115
  private final RecordOptions options;
116
+ /** Factory for MediaRecorder instances. */
117
+ private final MediaRecorderFactory mediaRecorderFactory;
118
+ /** Directory provider for file output. */
119
+ private final DirectoryProvider directoryProvider;
120
+ /** SDK version provider for API gating. */
121
+ private final SdkIntProvider sdkIntProvider;
122
+ /** Audio focus request factory for O and above. */
123
+ private final AudioFocusRequestFactory audioFocusRequestFactory;
25
124
  /** Active MediaRecorder instance for the session. */
26
125
  private MediaRecorder mediaRecorder;
27
126
  /** Output file for the current recording session. */
@@ -38,9 +137,33 @@ public class CustomMediaRecorder implements AudioManager.OnAudioFocusChangeListe
38
137
  private Runnable onInterruptionEnded;
39
138
 
40
139
  public CustomMediaRecorder(Context context, RecordOptions options) throws IOException {
140
+ this(
141
+ context,
142
+ options,
143
+ new DefaultMediaRecorderFactory(),
144
+ new DefaultAudioManagerProvider(),
145
+ new DefaultDirectoryProvider(),
146
+ new DefaultSdkIntProvider(),
147
+ new DefaultAudioFocusRequestFactory()
148
+ );
149
+ }
150
+
151
+ CustomMediaRecorder(
152
+ Context context,
153
+ RecordOptions options,
154
+ MediaRecorderFactory mediaRecorderFactory,
155
+ AudioManagerProvider audioManagerProvider,
156
+ DirectoryProvider directoryProvider,
157
+ SdkIntProvider sdkIntProvider,
158
+ AudioFocusRequestFactory audioFocusRequestFactory
159
+ ) throws IOException {
41
160
  this.context = context;
42
161
  this.options = options;
43
- this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
162
+ this.mediaRecorderFactory = mediaRecorderFactory;
163
+ this.directoryProvider = directoryProvider;
164
+ this.sdkIntProvider = sdkIntProvider;
165
+ this.audioFocusRequestFactory = audioFocusRequestFactory;
166
+ this.audioManager = audioManagerProvider.getAudioManager(context);
44
167
  generateMediaRecorder();
45
168
  }
46
169
 
@@ -56,7 +179,7 @@ public class CustomMediaRecorder implements AudioManager.OnAudioFocusChangeListe
56
179
 
57
180
  /** Configures the MediaRecorder with audio settings. */
58
181
  private void generateMediaRecorder() throws IOException {
59
- mediaRecorder = new MediaRecorder();
182
+ mediaRecorder = mediaRecorderFactory.create();
60
183
  mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
61
184
  mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS);
62
185
  mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
@@ -68,7 +191,7 @@ public class CustomMediaRecorder implements AudioManager.OnAudioFocusChangeListe
68
191
 
69
192
  /** Picks a directory and allocates the output file for this session. */
70
193
  private void setRecorderOutputFile() throws IOException {
71
- File outputDir = context.getCacheDir();
194
+ File outputDir = directoryProvider.getCacheDir(context);
72
195
 
73
196
  String directory = options.directory();
74
197
  String subDirectory = options.subDirectory();
@@ -99,11 +222,11 @@ public class CustomMediaRecorder implements AudioManager.OnAudioFocusChangeListe
99
222
  /** Maps directory strings to Android file locations. */
100
223
  private File getDirectory(String directory) {
101
224
  return switch (directory) {
102
- case "DOCUMENTS" -> Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS);
103
- case "DATA", "LIBRARY" -> context.getFilesDir();
104
- case "CACHE" -> context.getCacheDir();
105
- case "EXTERNAL" -> context.getExternalFilesDir(null);
106
- case "EXTERNAL_STORAGE" -> Environment.getExternalStorageDirectory();
225
+ case "DOCUMENTS" -> directoryProvider.getDocumentsDirectory();
226
+ case "DATA", "LIBRARY" -> directoryProvider.getFilesDir(context);
227
+ case "CACHE" -> directoryProvider.getCacheDir(context);
228
+ case "EXTERNAL" -> directoryProvider.getExternalFilesDir(context);
229
+ case "EXTERNAL_STORAGE" -> directoryProvider.getExternalStorageDirectory();
107
230
  default -> null;
108
231
  };
109
232
  }
@@ -150,7 +273,7 @@ public class CustomMediaRecorder implements AudioManager.OnAudioFocusChangeListe
150
273
 
151
274
  /** Pauses recording when supported by the OS version. */
152
275
  public boolean pauseRecording() throws NotSupportedOsVersion {
153
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
276
+ if (sdkIntProvider.getSdkInt() < Build.VERSION_CODES.N) {
154
277
  throw new NotSupportedOsVersion();
155
278
  }
156
279
 
@@ -165,7 +288,7 @@ public class CustomMediaRecorder implements AudioManager.OnAudioFocusChangeListe
165
288
 
166
289
  /** Resumes a paused or interrupted recording session. */
167
290
  public boolean resumeRecording() throws NotSupportedOsVersion {
168
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
291
+ if (sdkIntProvider.getSdkInt() < Build.VERSION_CODES.N) {
169
292
  throw new NotSupportedOsVersion();
170
293
  }
171
294
 
@@ -215,17 +338,8 @@ public class CustomMediaRecorder implements AudioManager.OnAudioFocusChangeListe
215
338
  return;
216
339
  }
217
340
 
218
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
219
- AudioAttributes audioAttributes = new AudioAttributes.Builder()
220
- .setUsage(AudioAttributes.USAGE_MEDIA)
221
- .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
222
- .build();
223
-
224
- audioFocusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
225
- .setAudioAttributes(audioAttributes)
226
- .setOnAudioFocusChangeListener(this)
227
- .build();
228
-
341
+ if (sdkIntProvider.getSdkInt() >= Build.VERSION_CODES.O) {
342
+ audioFocusRequest = audioFocusRequestFactory.create(this);
229
343
  audioManager.requestAudioFocus(audioFocusRequest);
230
344
  } else {
231
345
  audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
@@ -238,7 +352,7 @@ public class CustomMediaRecorder implements AudioManager.OnAudioFocusChangeListe
238
352
  return;
239
353
  }
240
354
 
241
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && audioFocusRequest != null) {
355
+ if (sdkIntProvider.getSdkInt() >= Build.VERSION_CODES.O && audioFocusRequest != null) {
242
356
  audioManager.abandonAudioFocusRequest(audioFocusRequest);
243
357
  audioFocusRequest = null;
244
358
  } else {
@@ -256,7 +370,7 @@ public class CustomMediaRecorder implements AudioManager.OnAudioFocusChangeListe
256
370
  // For voice recording, ducking still degrades captured audio, so treat all loss types as interruptions.
257
371
  if (currentRecordingStatus == CurrentRecordingStatus.RECORDING) {
258
372
  try {
259
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
373
+ if (sdkIntProvider.getSdkInt() >= Build.VERSION_CODES.N) {
260
374
  mediaRecorder.pause();
261
375
  currentRecordingStatus = CurrentRecordingStatus.INTERRUPTED;
262
376
  if (onInterruptionBegan != null) {
@@ -17,11 +17,77 @@ import java.io.IOException;
17
17
  /** Default Android platform adapter for recording and file IO. */
18
18
  public class DefaultRecorderPlatform implements RecorderPlatform {
19
19
 
20
+ interface RecorderFactory {
21
+ RecorderAdapter create(Context context, RecordOptions options) throws Exception;
22
+ }
23
+
24
+ interface MediaPlayerFactory {
25
+ MediaPlayer create();
26
+ }
27
+
28
+ interface UriConverter {
29
+ String toUri(File recordedFile);
30
+ }
31
+
32
+ interface Base64Encoder {
33
+ String encode(byte[] data);
34
+ }
35
+
36
+ private static final class DefaultRecorderFactory implements RecorderFactory {
37
+ @Override
38
+ public RecorderAdapter create(Context context, RecordOptions options) throws Exception {
39
+ return new CustomMediaRecorder(context, options);
40
+ }
41
+ }
42
+
43
+ private static final class DefaultMediaPlayerFactory implements MediaPlayerFactory {
44
+ @Override
45
+ public MediaPlayer create() {
46
+ return new MediaPlayer();
47
+ }
48
+ }
49
+
50
+ private static final class DefaultUriConverter implements UriConverter {
51
+ @Override
52
+ public String toUri(File recordedFile) {
53
+ return Uri.fromFile(recordedFile).toString();
54
+ }
55
+ }
56
+
57
+ private static final class DefaultBase64Encoder implements Base64Encoder {
58
+ @Override
59
+ public String encode(byte[] data) {
60
+ return Base64.encodeToString(data, Base64.DEFAULT);
61
+ }
62
+ }
63
+
20
64
  /** Android context used to access system services. */
21
65
  private final Context context;
66
+ /** Recorder factory for platform creation. */
67
+ private final RecorderFactory recorderFactory;
68
+ /** Media player factory for duration lookup. */
69
+ private final MediaPlayerFactory mediaPlayerFactory;
70
+ /** Uri converter for response payloads. */
71
+ private final UriConverter uriConverter;
72
+ /** Base64 encoder for file payloads. */
73
+ private final Base64Encoder base64Encoder;
22
74
 
23
75
  public DefaultRecorderPlatform(Context context) {
76
+ this(context, new DefaultRecorderFactory(), new DefaultMediaPlayerFactory(), new DefaultUriConverter(), new DefaultBase64Encoder());
77
+ }
78
+
79
+ DefaultRecorderPlatform(
80
+ Context context,
81
+ RecorderFactory recorderFactory,
82
+ MediaPlayerFactory mediaPlayerFactory,
83
+ UriConverter uriConverter,
84
+ Base64Encoder base64Encoder
85
+ ) {
24
86
  this.context = context;
87
+ this.recorderFactory = recorderFactory;
88
+ this.mediaPlayerFactory = mediaPlayerFactory;
89
+ this.uriConverter = uriConverter;
90
+ this.base64Encoder = base64Encoder;
25
91
  }
26
92
 
27
93
  /** Returns whether the device can create a MediaRecorder instance. */
@@ -41,7 +107,7 @@ public class DefaultRecorderPlatform implements RecorderPlatform {
41
107
  /** Creates the recorder adapter for the provided options. */
42
108
  @Override
43
109
  public RecorderAdapter createRecorder(RecordOptions options) throws Exception {
44
- return new CustomMediaRecorder(context, options);
110
+ return recorderFactory.create(context, options);
45
111
  }
46
112
 
47
113
  /** Reads the recorded file as base64, returning null on failure. */
@@ -54,7 +120,7 @@ public class DefaultRecorderPlatform implements RecorderPlatform {
54
120
  while ((bytesRead = bufferedInputStream.read(buffer)) != -1) {
55
121
  outputStream.write(buffer, 0, bytesRead);
56
122
  }
57
- return Base64.encodeToString(outputStream.toByteArray(), Base64.DEFAULT);
123
+ return base64Encoder.encode(outputStream.toByteArray());
58
124
  } catch (IOException exp) {
59
125
  return null;
60
126
  }
@@ -65,7 +131,7 @@ public class DefaultRecorderPlatform implements RecorderPlatform {
65
131
  public int getDurationMs(File recordedFile) {
66
132
  MediaPlayer mediaPlayer = null;
67
133
  try {
68
- mediaPlayer = new MediaPlayer();
134
+ mediaPlayer = mediaPlayerFactory.create();
69
135
  mediaPlayer.setDataSource(recordedFile.getAbsolutePath());
70
136
  mediaPlayer.prepare();
71
137
  return mediaPlayer.getDuration();
@@ -81,6 +147,6 @@ public class DefaultRecorderPlatform implements RecorderPlatform {
81
147
  /** Returns a file:// URI for the given recording. */
82
148
  @Override
83
149
  public String toUri(File recordedFile) {
84
- return Uri.fromFile(recordedFile).toString();
150
+ return uriConverter.toUri(recordedFile);
85
151
  }
86
152
  }
@@ -2,6 +2,9 @@ import Foundation
2
2
  import AVFoundation
3
3
  import Capacitor
4
4
 
5
+ typealias PermissionRequester = (@escaping (Bool) -> Void) -> Void
6
+ typealias PermissionStatusProvider = () -> AVAudioSession.RecordPermission
7
+
5
8
  /// Capacitor bridge for the VoiceRecorder plugin.
6
9
  @objc(VoiceRecorder)
7
10
  public class VoiceRecorder: CAPPlugin, CAPBridgedPlugin {
@@ -25,6 +28,14 @@ public class VoiceRecorder: CAPPlugin, CAPBridgedPlugin {
25
28
  private var service: VoiceRecorderService?
26
29
  /// Response format derived from plugin configuration.
27
30
  private var responseFormat: ResponseFormat = .legacy
31
+ /// Permission requester used by the bridge.
32
+ private var permissionRequester: PermissionRequester = { completion in
33
+ AVAudioSession.sharedInstance().requestRecordPermission(completion)
34
+ }
35
+ /// Permission status provider used by the bridge.
36
+ private var permissionStatusProvider: PermissionStatusProvider = {
37
+ AVAudioSession.sharedInstance().recordPermission
38
+ }
28
39
 
29
40
  /// Initializes dependencies after the plugin loads.
30
41
  public override func load() {
@@ -38,6 +49,24 @@ public class VoiceRecorder: CAPPlugin, CAPBridgedPlugin {
38
49
  )
39
50
  }
40
51
 
52
+ func configureForTesting(
53
+ service: VoiceRecorderService?,
54
+ responseFormat: ResponseFormat? = nil,
55
+ permissionRequester: PermissionRequester? = nil,
56
+ permissionStatusProvider: PermissionStatusProvider? = nil
57
+ ) {
58
+ self.service = service
59
+ if let responseFormat = responseFormat {
60
+ self.responseFormat = responseFormat
61
+ }
62
+ if let permissionRequester = permissionRequester {
63
+ self.permissionRequester = permissionRequester
64
+ }
65
+ if let permissionStatusProvider = permissionStatusProvider {
66
+ self.permissionStatusProvider = permissionStatusProvider
67
+ }
68
+ }
69
+
41
70
  /// Returns whether the device can record audio.
42
71
  @objc func canDeviceVoiceRecord(_ call: CAPPluginCall) {
43
72
  let canRecord = service?.canDeviceVoiceRecord() ?? false
@@ -46,7 +75,7 @@ public class VoiceRecorder: CAPPlugin, CAPBridgedPlugin {
46
75
 
47
76
  /// Requests microphone permission from the user.
48
77
  @objc func requestAudioRecordingPermission(_ call: CAPPluginCall) {
49
- AVAudioSession.sharedInstance().requestRecordPermission { granted in
78
+ permissionRequester { granted in
50
79
  if granted {
51
80
  call.resolve(ResponseGenerator.successResponse())
52
81
  } else {
@@ -159,7 +188,7 @@ public class VoiceRecorder: CAPPlugin, CAPBridgedPlugin {
159
188
 
160
189
  /// Returns whether AVAudioSession reports granted permission.
161
190
  func doesUserGaveAudioRecordingPermission() -> Bool {
162
- return AVAudioSession.sharedInstance().recordPermission == AVAudioSession.RecordPermission.granted
191
+ return permissionStatusProvider() == AVAudioSession.RecordPermission.granted
163
192
  }
164
193
 
165
194
  /// Maps canonical error codes back to legacy messages.
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "url": "https://github.com/independo-gmbh/capacitor-voice-recorder.git"
14
14
  },
15
15
  "description": "Capacitor plugin for voice recording",
16
- "version": "8.1.0-dev.3",
16
+ "version": "8.1.0",
17
17
  "devDependencies": {
18
18
  "@capacitor/android": "^8.0.0",
19
19
  "conventional-changelog-conventionalcommits": "^9.1.0",