@independo/capacitor-voice-recorder 8.0.2-dev.1 → 8.1.0-dev.2
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/README.md +130 -32
- package/android/build.gradle +44 -1
- package/android/src/main/java/app/independo/capacitorvoicerecorder/VoiceRecorder.java +146 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/adapters/PermissionChecker.java +8 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/adapters/RecordDataMapper.java +32 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/adapters/RecorderAdapter.java +39 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/adapters/RecorderPlatform.java +25 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/core/CurrentRecordingStatus.java +9 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/core/ErrorCodes.java +19 -0
- package/android/src/main/java/{com/tchvu3/capacitorvoicerecorder → app/independo/capacitorvoicerecorder/core}/Messages.java +2 -1
- package/android/src/main/java/{com/tchvu3/capacitorvoicerecorder → app/independo/capacitorvoicerecorder/core}/RecordData.java +15 -1
- package/android/src/main/java/app/independo/capacitorvoicerecorder/core/RecordOptions.java +4 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/core/ResponseFormat.java +18 -0
- package/android/src/main/java/{com/tchvu3/capacitorvoicerecorder → app/independo/capacitorvoicerecorder/core}/ResponseGenerator.java +7 -1
- package/android/src/main/java/app/independo/capacitorvoicerecorder/platform/CustomMediaRecorder.java +281 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/platform/DefaultRecorderPlatform.java +86 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/platform/NotSupportedOsVersion.java +4 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/service/VoiceRecorderService.java +144 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/service/VoiceRecorderServiceException.java +23 -0
- package/dist/docs.json +145 -5
- package/dist/esm/adapters/VoiceRecorderWebAdapter.d.ts +23 -0
- package/dist/esm/adapters/VoiceRecorderWebAdapter.js +41 -0
- package/dist/esm/adapters/VoiceRecorderWebAdapter.js.map +1 -0
- package/dist/esm/core/error-codes.d.ts +4 -0
- package/dist/esm/core/error-codes.js +21 -0
- package/dist/esm/core/error-codes.js.map +1 -0
- package/dist/esm/core/recording-contract.d.ts +3 -0
- package/dist/esm/core/recording-contract.js +15 -0
- package/dist/esm/core/recording-contract.js.map +1 -0
- package/dist/esm/core/response-format.d.ts +8 -0
- package/dist/esm/core/response-format.js +17 -0
- package/dist/esm/core/response-format.js.map +1 -0
- package/dist/esm/definitions.d.ts +36 -3
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/platform/web/VoiceRecorderImpl.d.ts +45 -0
- package/dist/esm/{VoiceRecorderImpl.js → platform/web/VoiceRecorderImpl.js} +20 -2
- package/dist/esm/platform/web/VoiceRecorderImpl.js.map +1 -0
- package/dist/esm/platform/web/get-blob-duration.js.map +1 -0
- package/dist/esm/{predefined-web-responses.d.ts → platform/web/predefined-web-responses.d.ts} +12 -1
- package/dist/esm/{predefined-web-responses.js → platform/web/predefined-web-responses.js} +11 -0
- package/dist/esm/platform/web/predefined-web-responses.js.map +1 -0
- package/dist/esm/service/VoiceRecorderService.d.ts +47 -0
- package/dist/esm/service/VoiceRecorderService.js +60 -0
- package/dist/esm/service/VoiceRecorderService.js.map +1 -0
- package/dist/esm/web.d.ts +12 -1
- package/dist/esm/web.js +26 -12
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +200 -9
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +200 -9
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/VoiceRecorder/Adapters/DefaultRecorderPlatform.swift +33 -0
- package/ios/Sources/VoiceRecorder/Adapters/RecordDataMapper.swift +38 -0
- package/ios/Sources/VoiceRecorder/Adapters/RecorderAdapter.swift +24 -0
- package/ios/Sources/VoiceRecorder/Adapters/RecorderPlatform.swift +11 -0
- package/ios/Sources/VoiceRecorder/Bridge/VoiceRecorder.swift +172 -0
- package/ios/Sources/VoiceRecorder/{CurrentRecordingStatus.swift → Core/CurrentRecordingStatus.swift} +2 -0
- package/ios/Sources/VoiceRecorder/Core/ErrorCodes.swift +16 -0
- package/ios/Sources/VoiceRecorder/{Messages.swift → Core/Messages.swift} +2 -0
- package/ios/Sources/VoiceRecorder/{RecordData.swift → Core/RecordData.swift} +6 -0
- package/ios/Sources/VoiceRecorder/Core/RecordOptions.swift +11 -0
- package/ios/Sources/VoiceRecorder/Core/ResponseFormat.swift +22 -0
- package/ios/Sources/VoiceRecorder/{ResponseGenerator.swift → Core/ResponseGenerator.swift} +6 -0
- package/ios/Sources/VoiceRecorder/Platform/CustomMediaRecorder.swift +359 -0
- package/ios/Sources/VoiceRecorder/Service/VoiceRecorderService.swift +128 -0
- package/ios/Sources/VoiceRecorder/Service/VoiceRecorderServiceError.swift +14 -0
- package/package.json +10 -4
- package/android/src/main/java/com/tchvu3/capacitorvoicerecorder/CurrentRecordingStatus.java +0 -7
- package/android/src/main/java/com/tchvu3/capacitorvoicerecorder/CustomMediaRecorder.java +0 -149
- package/android/src/main/java/com/tchvu3/capacitorvoicerecorder/NotSupportedOsVersion.java +0 -3
- package/android/src/main/java/com/tchvu3/capacitorvoicerecorder/RecordOptions.java +0 -3
- package/android/src/main/java/com/tchvu3/capacitorvoicerecorder/VoiceRecorder.java +0 -203
- package/dist/esm/VoiceRecorderImpl.d.ts +0 -27
- package/dist/esm/VoiceRecorderImpl.js.map +0 -1
- package/dist/esm/helper/get-blob-duration.js.map +0 -1
- package/dist/esm/predefined-web-responses.js.map +0 -1
- package/ios/Sources/VoiceRecorder/CustomMediaRecorder.swift +0 -113
- package/ios/Sources/VoiceRecorder/RecordOptions.swift +0 -8
- package/ios/Sources/VoiceRecorder/VoiceRecorder.swift +0 -147
- /package/dist/esm/{helper → platform/web}/get-blob-duration.d.ts +0 -0
- /package/dist/esm/{helper → platform/web}/get-blob-duration.js +0 -0
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
package
|
|
1
|
+
package app.independo.capacitorvoicerecorder.core;
|
|
2
2
|
|
|
3
3
|
import com.getcapacitor.JSObject;
|
|
4
4
|
|
|
5
|
+
/** Recording payload returned to the bridge layer. */
|
|
5
6
|
public class RecordData {
|
|
6
7
|
|
|
8
|
+
/** File URI when returning recordings by reference. */
|
|
7
9
|
private String uri;
|
|
10
|
+
/** Base64 payload for inline recording data. */
|
|
8
11
|
private String recordDataBase64;
|
|
12
|
+
/** MIME type of the audio payload. */
|
|
9
13
|
private String mimeType;
|
|
14
|
+
/** Recording duration in milliseconds. */
|
|
10
15
|
private int msDuration;
|
|
11
16
|
|
|
12
17
|
public RecordData() {}
|
|
@@ -18,6 +23,7 @@ public class RecordData {
|
|
|
18
23
|
this.uri = uri;
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
/** Returns the base64 payload, if present. */
|
|
21
27
|
public String getRecordDataBase64() {
|
|
22
28
|
return recordDataBase64;
|
|
23
29
|
}
|
|
@@ -26,6 +32,7 @@ public class RecordData {
|
|
|
26
32
|
this.recordDataBase64 = recordDataBase64;
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
/** Returns the recording duration in milliseconds. */
|
|
29
36
|
public int getMsDuration() {
|
|
30
37
|
return msDuration;
|
|
31
38
|
}
|
|
@@ -34,6 +41,7 @@ public class RecordData {
|
|
|
34
41
|
this.msDuration = msDuration;
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
/** Returns the MIME type of the recording. */
|
|
37
45
|
public String getMimeType() {
|
|
38
46
|
return mimeType;
|
|
39
47
|
}
|
|
@@ -42,6 +50,12 @@ public class RecordData {
|
|
|
42
50
|
this.mimeType = mimeType;
|
|
43
51
|
}
|
|
44
52
|
|
|
53
|
+
/** Returns the file URI, if present. */
|
|
54
|
+
public String getUri() {
|
|
55
|
+
return uri;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Serializes the record data into the legacy JS payload shape. */
|
|
45
59
|
public JSObject toJSObject() {
|
|
46
60
|
JSObject toReturn = new JSObject();
|
|
47
61
|
toReturn.put("recordDataBase64", recordDataBase64);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
package app.independo.capacitorvoicerecorder.core;
|
|
2
|
+
|
|
3
|
+
import com.getcapacitor.PluginConfig;
|
|
4
|
+
|
|
5
|
+
/** Supported response payload shapes. */
|
|
6
|
+
public enum ResponseFormat {
|
|
7
|
+
LEGACY,
|
|
8
|
+
NORMALIZED;
|
|
9
|
+
|
|
10
|
+
/** Reads the response format from plugin configuration. */
|
|
11
|
+
public static ResponseFormat fromConfig(PluginConfig config) {
|
|
12
|
+
String value = config.getString("responseFormat", "legacy");
|
|
13
|
+
if ("normalized".equalsIgnoreCase(value)) {
|
|
14
|
+
return NORMALIZED;
|
|
15
|
+
}
|
|
16
|
+
return LEGACY;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -1,34 +1,40 @@
|
|
|
1
|
-
package
|
|
1
|
+
package app.independo.capacitorvoicerecorder.core;
|
|
2
2
|
|
|
3
3
|
import com.getcapacitor.JSObject;
|
|
4
4
|
|
|
5
|
+
/** Helper for building JS payloads in the legacy response shape. */
|
|
5
6
|
public class ResponseGenerator {
|
|
6
7
|
|
|
7
8
|
private static final String VALUE_RESPONSE_KEY = "value";
|
|
8
9
|
private static final String STATUS_RESPONSE_KEY = "status";
|
|
9
10
|
|
|
11
|
+
/** Wraps a boolean value into the response shape. */
|
|
10
12
|
public static JSObject fromBoolean(boolean value) {
|
|
11
13
|
return value ? successResponse() : failResponse();
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
/** Returns a success response with value=true. */
|
|
14
17
|
public static JSObject successResponse() {
|
|
15
18
|
JSObject success = new JSObject();
|
|
16
19
|
success.put(VALUE_RESPONSE_KEY, true);
|
|
17
20
|
return success;
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
/** Returns a failure response with value=false. */
|
|
20
24
|
public static JSObject failResponse() {
|
|
21
25
|
JSObject success = new JSObject();
|
|
22
26
|
success.put(VALUE_RESPONSE_KEY, false);
|
|
23
27
|
return success;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/** Wraps arbitrary data into the response shape. */
|
|
26
31
|
public static JSObject dataResponse(Object data) {
|
|
27
32
|
JSObject success = new JSObject();
|
|
28
33
|
success.put(VALUE_RESPONSE_KEY, data);
|
|
29
34
|
return success;
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
/** Wraps the recording status into the response shape. */
|
|
32
38
|
public static JSObject statusResponse(CurrentRecordingStatus status) {
|
|
33
39
|
JSObject success = new JSObject();
|
|
34
40
|
success.put(STATUS_RESPONSE_KEY, status.name());
|
package/android/src/main/java/app/independo/capacitorvoicerecorder/platform/CustomMediaRecorder.java
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
package app.independo.capacitorvoicerecorder.platform;
|
|
2
|
+
|
|
3
|
+
import android.content.Context;
|
|
4
|
+
import android.media.AudioAttributes;
|
|
5
|
+
import android.media.AudioFocusRequest;
|
|
6
|
+
import android.media.AudioManager;
|
|
7
|
+
import android.media.MediaRecorder;
|
|
8
|
+
import android.os.Build;
|
|
9
|
+
import android.os.Environment;
|
|
10
|
+
import app.independo.capacitorvoicerecorder.adapters.RecorderAdapter;
|
|
11
|
+
import app.independo.capacitorvoicerecorder.core.CurrentRecordingStatus;
|
|
12
|
+
import app.independo.capacitorvoicerecorder.core.RecordOptions;
|
|
13
|
+
import java.io.File;
|
|
14
|
+
import java.io.IOException;
|
|
15
|
+
import java.util.regex.Matcher;
|
|
16
|
+
import java.util.regex.Pattern;
|
|
17
|
+
|
|
18
|
+
/** MediaRecorder wrapper that manages audio focus and interruptions. */
|
|
19
|
+
public class CustomMediaRecorder implements AudioManager.OnAudioFocusChangeListener, RecorderAdapter {
|
|
20
|
+
|
|
21
|
+
/** Android context for file paths and system services. */
|
|
22
|
+
private final Context context;
|
|
23
|
+
/** Recording options passed from the service layer. */
|
|
24
|
+
private final RecordOptions options;
|
|
25
|
+
/** Active MediaRecorder instance for the session. */
|
|
26
|
+
private MediaRecorder mediaRecorder;
|
|
27
|
+
/** Output file for the current recording session. */
|
|
28
|
+
private File outputFile;
|
|
29
|
+
/** Current session status tracked locally. */
|
|
30
|
+
private CurrentRecordingStatus currentRecordingStatus = CurrentRecordingStatus.NONE;
|
|
31
|
+
/** Audio manager for focus changes. */
|
|
32
|
+
private AudioManager audioManager;
|
|
33
|
+
/** Focus request for Android O and above. */
|
|
34
|
+
private AudioFocusRequest audioFocusRequest;
|
|
35
|
+
/** Callback invoked when an interruption begins. */
|
|
36
|
+
private Runnable onInterruptionBegan;
|
|
37
|
+
/** Callback invoked when an interruption ends. */
|
|
38
|
+
private Runnable onInterruptionEnded;
|
|
39
|
+
|
|
40
|
+
public CustomMediaRecorder(Context context, RecordOptions options) throws IOException {
|
|
41
|
+
this.context = context;
|
|
42
|
+
this.options = options;
|
|
43
|
+
this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
|
|
44
|
+
generateMediaRecorder();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Sets the callback for interruption begin events. */
|
|
48
|
+
public void setOnInterruptionBegan(Runnable callback) {
|
|
49
|
+
this.onInterruptionBegan = callback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Sets the callback for interruption end events. */
|
|
53
|
+
public void setOnInterruptionEnded(Runnable callback) {
|
|
54
|
+
this.onInterruptionEnded = callback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Configures the MediaRecorder with audio settings. */
|
|
58
|
+
private void generateMediaRecorder() throws IOException {
|
|
59
|
+
mediaRecorder = new MediaRecorder();
|
|
60
|
+
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
|
|
61
|
+
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS);
|
|
62
|
+
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
|
|
63
|
+
mediaRecorder.setAudioEncodingBitRate(96000);
|
|
64
|
+
mediaRecorder.setAudioSamplingRate(44100);
|
|
65
|
+
setRecorderOutputFile();
|
|
66
|
+
mediaRecorder.prepare();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Picks a directory and allocates the output file for this session. */
|
|
70
|
+
private void setRecorderOutputFile() throws IOException {
|
|
71
|
+
File outputDir = context.getCacheDir();
|
|
72
|
+
|
|
73
|
+
String directory = options.directory();
|
|
74
|
+
String subDirectory = options.subDirectory();
|
|
75
|
+
|
|
76
|
+
if (directory != null) {
|
|
77
|
+
outputDir = this.getDirectory(directory);
|
|
78
|
+
if (subDirectory != null) {
|
|
79
|
+
Pattern pattern = Pattern.compile("^/?(.+[^/])/?$");
|
|
80
|
+
Matcher matcher = pattern.matcher(subDirectory);
|
|
81
|
+
if (matcher.matches()) {
|
|
82
|
+
outputDir = new File(outputDir, matcher.group(1));
|
|
83
|
+
if (!outputDir.exists()) {
|
|
84
|
+
outputDir.mkdirs();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
outputFile = File.createTempFile(String.format("recording-%d", System.currentTimeMillis()), ".aac", outputDir);
|
|
91
|
+
|
|
92
|
+
if (directory == null) {
|
|
93
|
+
outputFile.deleteOnExit();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
mediaRecorder.setOutputFile(outputFile.getAbsolutePath());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Maps directory strings to Android file locations. */
|
|
100
|
+
private File getDirectory(String directory) {
|
|
101
|
+
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();
|
|
107
|
+
default -> null;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Starts recording and requests audio focus. */
|
|
112
|
+
public void startRecording() {
|
|
113
|
+
requestAudioFocus();
|
|
114
|
+
mediaRecorder.start();
|
|
115
|
+
currentRecordingStatus = CurrentRecordingStatus.RECORDING;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Stops recording and releases audio resources. */
|
|
119
|
+
public void stopRecording() {
|
|
120
|
+
if (mediaRecorder == null) {
|
|
121
|
+
abandonAudioFocus();
|
|
122
|
+
currentRecordingStatus = CurrentRecordingStatus.NONE;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
if (currentRecordingStatus == CurrentRecordingStatus.RECORDING
|
|
128
|
+
|| currentRecordingStatus == CurrentRecordingStatus.PAUSED
|
|
129
|
+
|| currentRecordingStatus == CurrentRecordingStatus.INTERRUPTED) {
|
|
130
|
+
mediaRecorder.stop();
|
|
131
|
+
}
|
|
132
|
+
} catch (IllegalStateException ignore) {
|
|
133
|
+
} finally {
|
|
134
|
+
mediaRecorder.release();
|
|
135
|
+
mediaRecorder = null;
|
|
136
|
+
abandonAudioFocus();
|
|
137
|
+
currentRecordingStatus = CurrentRecordingStatus.NONE;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Returns the output file for the current session. */
|
|
142
|
+
public File getOutputFile() {
|
|
143
|
+
return outputFile;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Returns the options provided at start time. */
|
|
147
|
+
public RecordOptions getRecordOptions() {
|
|
148
|
+
return options;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Pauses recording when supported by the OS version. */
|
|
152
|
+
public boolean pauseRecording() throws NotSupportedOsVersion {
|
|
153
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
|
154
|
+
throw new NotSupportedOsVersion();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (currentRecordingStatus == CurrentRecordingStatus.RECORDING) {
|
|
158
|
+
mediaRecorder.pause();
|
|
159
|
+
currentRecordingStatus = CurrentRecordingStatus.PAUSED;
|
|
160
|
+
return true;
|
|
161
|
+
} else {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Resumes a paused or interrupted recording session. */
|
|
167
|
+
public boolean resumeRecording() throws NotSupportedOsVersion {
|
|
168
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
|
169
|
+
throw new NotSupportedOsVersion();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (currentRecordingStatus == CurrentRecordingStatus.PAUSED || currentRecordingStatus == CurrentRecordingStatus.INTERRUPTED) {
|
|
173
|
+
requestAudioFocus();
|
|
174
|
+
mediaRecorder.resume();
|
|
175
|
+
currentRecordingStatus = CurrentRecordingStatus.RECORDING;
|
|
176
|
+
return true;
|
|
177
|
+
} else {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Returns the current recording status. */
|
|
183
|
+
public CurrentRecordingStatus getCurrentStatus() {
|
|
184
|
+
return currentRecordingStatus;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Deletes the output file from disk. */
|
|
188
|
+
public boolean deleteOutputFile() {
|
|
189
|
+
return outputFile.delete();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Simple capability check used for device validation. */
|
|
193
|
+
public static boolean canPhoneCreateMediaRecorder(Context context) {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Attempts to record a short sample to validate permission and hardware. */
|
|
198
|
+
private static boolean canPhoneCreateMediaRecorderWhileHavingPermission(Context context) {
|
|
199
|
+
CustomMediaRecorder tempMediaRecorder = null;
|
|
200
|
+
try {
|
|
201
|
+
tempMediaRecorder = new CustomMediaRecorder(context, new RecordOptions(null, null));
|
|
202
|
+
tempMediaRecorder.startRecording();
|
|
203
|
+
tempMediaRecorder.stopRecording();
|
|
204
|
+
return true;
|
|
205
|
+
} catch (Exception exp) {
|
|
206
|
+
return exp.getMessage().startsWith("stop failed");
|
|
207
|
+
} finally {
|
|
208
|
+
if (tempMediaRecorder != null) tempMediaRecorder.deleteOutputFile();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Requests audio focus for the recording session. */
|
|
213
|
+
private void requestAudioFocus() {
|
|
214
|
+
if (audioManager == null) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
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
|
+
|
|
229
|
+
audioManager.requestAudioFocus(audioFocusRequest);
|
|
230
|
+
} else {
|
|
231
|
+
audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Releases audio focus when recording completes. */
|
|
236
|
+
private void abandonAudioFocus() {
|
|
237
|
+
if (audioManager == null) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && audioFocusRequest != null) {
|
|
242
|
+
audioManager.abandonAudioFocusRequest(audioFocusRequest);
|
|
243
|
+
audioFocusRequest = null;
|
|
244
|
+
} else {
|
|
245
|
+
audioManager.abandonAudioFocus(this);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Handles audio focus changes as recording interruptions. */
|
|
250
|
+
@Override
|
|
251
|
+
public void onAudioFocusChange(int focusChange) {
|
|
252
|
+
switch (focusChange) {
|
|
253
|
+
case AudioManager.AUDIOFOCUS_LOSS:
|
|
254
|
+
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
|
|
255
|
+
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
|
|
256
|
+
// For voice recording, ducking still degrades captured audio, so treat all loss types as interruptions.
|
|
257
|
+
if (currentRecordingStatus == CurrentRecordingStatus.RECORDING) {
|
|
258
|
+
try {
|
|
259
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
260
|
+
mediaRecorder.pause();
|
|
261
|
+
currentRecordingStatus = CurrentRecordingStatus.INTERRUPTED;
|
|
262
|
+
if (onInterruptionBegan != null) {
|
|
263
|
+
onInterruptionBegan.run();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} catch (Exception ignore) {
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
break;
|
|
270
|
+
case AudioManager.AUDIOFOCUS_GAIN:
|
|
271
|
+
if (currentRecordingStatus == CurrentRecordingStatus.INTERRUPTED) {
|
|
272
|
+
if (onInterruptionEnded != null) {
|
|
273
|
+
onInterruptionEnded.run();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
break;
|
|
277
|
+
default:
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
package app.independo.capacitorvoicerecorder.platform;
|
|
2
|
+
|
|
3
|
+
import android.content.Context;
|
|
4
|
+
import android.media.AudioManager;
|
|
5
|
+
import android.media.MediaPlayer;
|
|
6
|
+
import android.net.Uri;
|
|
7
|
+
import android.util.Base64;
|
|
8
|
+
import app.independo.capacitorvoicerecorder.adapters.RecorderAdapter;
|
|
9
|
+
import app.independo.capacitorvoicerecorder.adapters.RecorderPlatform;
|
|
10
|
+
import app.independo.capacitorvoicerecorder.core.RecordOptions;
|
|
11
|
+
import java.io.BufferedInputStream;
|
|
12
|
+
import java.io.ByteArrayOutputStream;
|
|
13
|
+
import java.io.File;
|
|
14
|
+
import java.io.FileInputStream;
|
|
15
|
+
import java.io.IOException;
|
|
16
|
+
|
|
17
|
+
/** Default Android platform adapter for recording and file IO. */
|
|
18
|
+
public class DefaultRecorderPlatform implements RecorderPlatform {
|
|
19
|
+
|
|
20
|
+
/** Android context used to access system services. */
|
|
21
|
+
private final Context context;
|
|
22
|
+
|
|
23
|
+
public DefaultRecorderPlatform(Context context) {
|
|
24
|
+
this.context = context;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Returns whether the device can create a MediaRecorder instance. */
|
|
28
|
+
@Override
|
|
29
|
+
public boolean canDeviceVoiceRecord() {
|
|
30
|
+
return CustomMediaRecorder.canPhoneCreateMediaRecorder(context);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Returns true when the system audio mode indicates another recorder. */
|
|
34
|
+
@Override
|
|
35
|
+
public boolean isMicrophoneOccupied() {
|
|
36
|
+
AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
|
|
37
|
+
if (audioManager == null) return true;
|
|
38
|
+
return audioManager.getMode() != AudioManager.MODE_NORMAL;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Creates the recorder adapter for the provided options. */
|
|
42
|
+
@Override
|
|
43
|
+
public RecorderAdapter createRecorder(RecordOptions options) throws Exception {
|
|
44
|
+
return new CustomMediaRecorder(context, options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Reads the recorded file as base64, returning null on failure. */
|
|
48
|
+
@Override
|
|
49
|
+
public String readFileAsBase64(File recordedFile) {
|
|
50
|
+
try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(recordedFile))) {
|
|
51
|
+
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
|
52
|
+
byte[] buffer = new byte[8192];
|
|
53
|
+
int bytesRead;
|
|
54
|
+
while ((bytesRead = bufferedInputStream.read(buffer)) != -1) {
|
|
55
|
+
outputStream.write(buffer, 0, bytesRead);
|
|
56
|
+
}
|
|
57
|
+
return Base64.encodeToString(outputStream.toByteArray(), Base64.DEFAULT);
|
|
58
|
+
} catch (IOException exp) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Returns the file duration in milliseconds, or -1 on failure. */
|
|
64
|
+
@Override
|
|
65
|
+
public int getDurationMs(File recordedFile) {
|
|
66
|
+
MediaPlayer mediaPlayer = null;
|
|
67
|
+
try {
|
|
68
|
+
mediaPlayer = new MediaPlayer();
|
|
69
|
+
mediaPlayer.setDataSource(recordedFile.getAbsolutePath());
|
|
70
|
+
mediaPlayer.prepare();
|
|
71
|
+
return mediaPlayer.getDuration();
|
|
72
|
+
} catch (Exception ignore) {
|
|
73
|
+
return -1;
|
|
74
|
+
} finally {
|
|
75
|
+
if (mediaPlayer != null) {
|
|
76
|
+
mediaPlayer.release();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Returns a file:// URI for the given recording. */
|
|
82
|
+
@Override
|
|
83
|
+
public String toUri(File recordedFile) {
|
|
84
|
+
return Uri.fromFile(recordedFile).toString();
|
|
85
|
+
}
|
|
86
|
+
}
|
package/android/src/main/java/app/independo/capacitorvoicerecorder/service/VoiceRecorderService.java
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package app.independo.capacitorvoicerecorder.service;
|
|
2
|
+
|
|
3
|
+
import app.independo.capacitorvoicerecorder.adapters.PermissionChecker;
|
|
4
|
+
import app.independo.capacitorvoicerecorder.adapters.RecorderAdapter;
|
|
5
|
+
import app.independo.capacitorvoicerecorder.adapters.RecorderPlatform;
|
|
6
|
+
import app.independo.capacitorvoicerecorder.core.CurrentRecordingStatus;
|
|
7
|
+
import app.independo.capacitorvoicerecorder.core.ErrorCodes;
|
|
8
|
+
import app.independo.capacitorvoicerecorder.core.RecordData;
|
|
9
|
+
import app.independo.capacitorvoicerecorder.core.RecordOptions;
|
|
10
|
+
import app.independo.capacitorvoicerecorder.platform.NotSupportedOsVersion;
|
|
11
|
+
import java.io.File;
|
|
12
|
+
|
|
13
|
+
/** Service layer that orchestrates recording operations. */
|
|
14
|
+
public class VoiceRecorderService {
|
|
15
|
+
|
|
16
|
+
/** Platform adapter that owns file and recorder creation. */
|
|
17
|
+
private final RecorderPlatform platform;
|
|
18
|
+
/** Permission checker injected from the bridge layer. */
|
|
19
|
+
private final PermissionChecker permissionChecker;
|
|
20
|
+
/** Current recorder instance for an active session. */
|
|
21
|
+
private RecorderAdapter recorder;
|
|
22
|
+
|
|
23
|
+
public VoiceRecorderService(RecorderPlatform platform, PermissionChecker permissionChecker) {
|
|
24
|
+
this.platform = platform;
|
|
25
|
+
this.permissionChecker = permissionChecker;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Returns whether the device can record audio. */
|
|
29
|
+
public boolean canDeviceVoiceRecord() {
|
|
30
|
+
return platform.canDeviceVoiceRecord();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Returns whether the app has microphone permission. */
|
|
34
|
+
public boolean hasAudioRecordingPermission() {
|
|
35
|
+
return permissionChecker.hasAudioPermission();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Starts a recording session or throws a service exception. */
|
|
39
|
+
public void startRecording(
|
|
40
|
+
RecordOptions options,
|
|
41
|
+
Runnable onInterruptionBegan,
|
|
42
|
+
Runnable onInterruptionEnded
|
|
43
|
+
) throws VoiceRecorderServiceException {
|
|
44
|
+
if (!platform.canDeviceVoiceRecord()) {
|
|
45
|
+
throw new VoiceRecorderServiceException(ErrorCodes.DEVICE_CANNOT_VOICE_RECORD);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!permissionChecker.hasAudioPermission()) {
|
|
49
|
+
throw new VoiceRecorderServiceException(ErrorCodes.MISSING_PERMISSION);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (platform.isMicrophoneOccupied()) {
|
|
53
|
+
throw new VoiceRecorderServiceException(ErrorCodes.MICROPHONE_BEING_USED);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (recorder != null) {
|
|
57
|
+
throw new VoiceRecorderServiceException(ErrorCodes.ALREADY_RECORDING);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
recorder = platform.createRecorder(options);
|
|
62
|
+
recorder.setOnInterruptionBegan(onInterruptionBegan);
|
|
63
|
+
recorder.setOnInterruptionEnded(onInterruptionEnded);
|
|
64
|
+
recorder.startRecording();
|
|
65
|
+
} catch (Exception exp) {
|
|
66
|
+
recorder = null;
|
|
67
|
+
throw new VoiceRecorderServiceException(ErrorCodes.FAILED_TO_RECORD, exp);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Stops the active recording session and returns the payload. */
|
|
72
|
+
public RecordData stopRecording() throws VoiceRecorderServiceException {
|
|
73
|
+
if (recorder == null) {
|
|
74
|
+
throw new VoiceRecorderServiceException(ErrorCodes.RECORDING_HAS_NOT_STARTED);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
RecordOptions options = recorder.getRecordOptions();
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
recorder.stopRecording();
|
|
81
|
+
File recordedFile = recorder.getOutputFile();
|
|
82
|
+
if (recordedFile == null) {
|
|
83
|
+
throw new VoiceRecorderServiceException(ErrorCodes.FAILED_TO_FETCH_RECORDING);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
String recordDataBase64 = null;
|
|
87
|
+
String uri = null;
|
|
88
|
+
if (options.directory() != null) {
|
|
89
|
+
uri = platform.toUri(recordedFile);
|
|
90
|
+
} else {
|
|
91
|
+
recordDataBase64 = platform.readFileAsBase64(recordedFile);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
int duration = platform.getDurationMs(recordedFile);
|
|
95
|
+
RecordData recordData = new RecordData(recordDataBase64, duration, "audio/aac", uri);
|
|
96
|
+
if ((recordDataBase64 == null && uri == null) || recordData.getMsDuration() < 0) {
|
|
97
|
+
throw new VoiceRecorderServiceException(ErrorCodes.EMPTY_RECORDING);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return recordData;
|
|
101
|
+
} catch (VoiceRecorderServiceException exp) {
|
|
102
|
+
throw exp;
|
|
103
|
+
} catch (Exception exp) {
|
|
104
|
+
throw new VoiceRecorderServiceException(ErrorCodes.FAILED_TO_FETCH_RECORDING, exp);
|
|
105
|
+
} finally {
|
|
106
|
+
if (options.directory() == null && recorder != null) {
|
|
107
|
+
recorder.deleteOutputFile();
|
|
108
|
+
}
|
|
109
|
+
recorder = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Pauses the active recording session. */
|
|
114
|
+
public boolean pauseRecording() throws VoiceRecorderServiceException {
|
|
115
|
+
if (recorder == null) {
|
|
116
|
+
throw new VoiceRecorderServiceException(ErrorCodes.RECORDING_HAS_NOT_STARTED);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return recorder.pauseRecording();
|
|
120
|
+
} catch (NotSupportedOsVersion exception) {
|
|
121
|
+
throw new VoiceRecorderServiceException(ErrorCodes.NOT_SUPPORTED_OS_VERSION, exception);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Resumes a paused recording session. */
|
|
126
|
+
public boolean resumeRecording() throws VoiceRecorderServiceException {
|
|
127
|
+
if (recorder == null) {
|
|
128
|
+
throw new VoiceRecorderServiceException(ErrorCodes.RECORDING_HAS_NOT_STARTED);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
return recorder.resumeRecording();
|
|
132
|
+
} catch (NotSupportedOsVersion exception) {
|
|
133
|
+
throw new VoiceRecorderServiceException(ErrorCodes.NOT_SUPPORTED_OS_VERSION, exception);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Returns the current recording status. */
|
|
138
|
+
public CurrentRecordingStatus getCurrentStatus() {
|
|
139
|
+
if (recorder == null) {
|
|
140
|
+
return CurrentRecordingStatus.NONE;
|
|
141
|
+
}
|
|
142
|
+
return recorder.getCurrentStatus();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package app.independo.capacitorvoicerecorder.service;
|
|
2
|
+
|
|
3
|
+
/** Exception wrapper that carries a canonical error code. */
|
|
4
|
+
public class VoiceRecorderServiceException extends Exception {
|
|
5
|
+
|
|
6
|
+
/** Canonical error code mapped to plugin responses. */
|
|
7
|
+
private final String code;
|
|
8
|
+
|
|
9
|
+
public VoiceRecorderServiceException(String code) {
|
|
10
|
+
super(code);
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public VoiceRecorderServiceException(String code, Exception cause) {
|
|
15
|
+
super(code, cause);
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Returns the canonical error code for this failure. */
|
|
20
|
+
public String getCode() {
|
|
21
|
+
return code;
|
|
22
|
+
}
|
|
23
|
+
}
|