@independo/capacitor-voice-recorder 8.1.0-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.
Files changed (76) hide show
  1. package/README.md +40 -30
  2. package/android/build.gradle +44 -1
  3. package/android/src/main/java/app/independo/capacitorvoicerecorder/VoiceRecorder.java +146 -0
  4. package/android/src/main/java/app/independo/capacitorvoicerecorder/adapters/PermissionChecker.java +8 -0
  5. package/android/src/main/java/app/independo/capacitorvoicerecorder/adapters/RecordDataMapper.java +32 -0
  6. package/android/src/main/java/app/independo/capacitorvoicerecorder/adapters/RecorderAdapter.java +39 -0
  7. package/android/src/main/java/app/independo/capacitorvoicerecorder/adapters/RecorderPlatform.java +25 -0
  8. package/android/src/main/java/app/independo/capacitorvoicerecorder/core/CurrentRecordingStatus.java +9 -0
  9. package/android/src/main/java/app/independo/capacitorvoicerecorder/core/ErrorCodes.java +19 -0
  10. package/android/src/main/java/{com/tchvu3/capacitorvoicerecorder → app/independo/capacitorvoicerecorder/core}/Messages.java +2 -1
  11. package/android/src/main/java/{com/tchvu3/capacitorvoicerecorder → app/independo/capacitorvoicerecorder/core}/RecordData.java +15 -1
  12. package/android/src/main/java/app/independo/capacitorvoicerecorder/core/RecordOptions.java +4 -0
  13. package/android/src/main/java/app/independo/capacitorvoicerecorder/core/ResponseFormat.java +18 -0
  14. package/android/src/main/java/{com/tchvu3/capacitorvoicerecorder → app/independo/capacitorvoicerecorder/core}/ResponseGenerator.java +7 -1
  15. package/android/src/main/java/{com/tchvu3/capacitorvoicerecorder → app/independo/capacitorvoicerecorder/platform}/CustomMediaRecorder.java +33 -2
  16. package/android/src/main/java/app/independo/capacitorvoicerecorder/platform/DefaultRecorderPlatform.java +86 -0
  17. package/android/src/main/java/app/independo/capacitorvoicerecorder/platform/NotSupportedOsVersion.java +4 -0
  18. package/android/src/main/java/app/independo/capacitorvoicerecorder/service/VoiceRecorderService.java +144 -0
  19. package/android/src/main/java/app/independo/capacitorvoicerecorder/service/VoiceRecorderServiceException.java +23 -0
  20. package/dist/esm/adapters/VoiceRecorderWebAdapter.d.ts +23 -0
  21. package/dist/esm/adapters/VoiceRecorderWebAdapter.js +41 -0
  22. package/dist/esm/adapters/VoiceRecorderWebAdapter.js.map +1 -0
  23. package/dist/esm/core/error-codes.d.ts +4 -0
  24. package/dist/esm/core/error-codes.js +21 -0
  25. package/dist/esm/core/error-codes.js.map +1 -0
  26. package/dist/esm/core/recording-contract.d.ts +3 -0
  27. package/dist/esm/core/recording-contract.js +15 -0
  28. package/dist/esm/core/recording-contract.js.map +1 -0
  29. package/dist/esm/core/response-format.d.ts +8 -0
  30. package/dist/esm/core/response-format.js +17 -0
  31. package/dist/esm/core/response-format.js.map +1 -0
  32. package/dist/esm/platform/web/VoiceRecorderImpl.d.ts +45 -0
  33. package/dist/esm/{VoiceRecorderImpl.js → platform/web/VoiceRecorderImpl.js} +20 -2
  34. package/dist/esm/platform/web/VoiceRecorderImpl.js.map +1 -0
  35. package/dist/esm/platform/web/get-blob-duration.js.map +1 -0
  36. package/dist/esm/{predefined-web-responses.d.ts → platform/web/predefined-web-responses.d.ts} +12 -1
  37. package/dist/esm/{predefined-web-responses.js → platform/web/predefined-web-responses.js} +11 -0
  38. package/dist/esm/platform/web/predefined-web-responses.js.map +1 -0
  39. package/dist/esm/service/VoiceRecorderService.d.ts +47 -0
  40. package/dist/esm/service/VoiceRecorderService.js +60 -0
  41. package/dist/esm/service/VoiceRecorderService.js.map +1 -0
  42. package/dist/esm/web.d.ts +12 -1
  43. package/dist/esm/web.js +26 -12
  44. package/dist/esm/web.js.map +1 -1
  45. package/dist/plugin.cjs.js +200 -9
  46. package/dist/plugin.cjs.js.map +1 -1
  47. package/dist/plugin.js +200 -9
  48. package/dist/plugin.js.map +1 -1
  49. package/ios/Sources/VoiceRecorder/Adapters/DefaultRecorderPlatform.swift +33 -0
  50. package/ios/Sources/VoiceRecorder/Adapters/RecordDataMapper.swift +38 -0
  51. package/ios/Sources/VoiceRecorder/Adapters/RecorderAdapter.swift +24 -0
  52. package/ios/Sources/VoiceRecorder/Adapters/RecorderPlatform.swift +11 -0
  53. package/ios/Sources/VoiceRecorder/Bridge/VoiceRecorder.swift +172 -0
  54. package/ios/Sources/VoiceRecorder/{CurrentRecordingStatus.swift → Core/CurrentRecordingStatus.swift} +1 -0
  55. package/ios/Sources/VoiceRecorder/Core/ErrorCodes.swift +16 -0
  56. package/ios/Sources/VoiceRecorder/{Messages.swift → Core/Messages.swift} +1 -0
  57. package/ios/Sources/VoiceRecorder/{RecordData.swift → Core/RecordData.swift} +6 -0
  58. package/ios/Sources/VoiceRecorder/Core/RecordOptions.swift +11 -0
  59. package/ios/Sources/VoiceRecorder/Core/ResponseFormat.swift +22 -0
  60. package/ios/Sources/VoiceRecorder/{ResponseGenerator.swift → Core/ResponseGenerator.swift} +6 -0
  61. package/ios/Sources/VoiceRecorder/{CustomMediaRecorder.swift → Platform/CustomMediaRecorder.swift} +25 -1
  62. package/ios/Sources/VoiceRecorder/Service/VoiceRecorderService.swift +128 -0
  63. package/ios/Sources/VoiceRecorder/Service/VoiceRecorderServiceError.swift +14 -0
  64. package/package.json +10 -4
  65. package/android/src/main/java/com/tchvu3/capacitorvoicerecorder/CurrentRecordingStatus.java +0 -8
  66. package/android/src/main/java/com/tchvu3/capacitorvoicerecorder/NotSupportedOsVersion.java +0 -3
  67. package/android/src/main/java/com/tchvu3/capacitorvoicerecorder/RecordOptions.java +0 -3
  68. package/android/src/main/java/com/tchvu3/capacitorvoicerecorder/VoiceRecorder.java +0 -205
  69. package/dist/esm/VoiceRecorderImpl.d.ts +0 -27
  70. package/dist/esm/VoiceRecorderImpl.js.map +0 -1
  71. package/dist/esm/helper/get-blob-duration.js.map +0 -1
  72. package/dist/esm/predefined-web-responses.js.map +0 -1
  73. package/ios/Sources/VoiceRecorder/RecordOptions.swift +0 -8
  74. package/ios/Sources/VoiceRecorder/VoiceRecorder.swift +0 -170
  75. /package/dist/esm/{helper → platform/web}/get-blob-duration.d.ts +0 -0
  76. /package/dist/esm/{helper → platform/web}/get-blob-duration.js +0 -0
@@ -0,0 +1,172 @@
1
+ import Foundation
2
+ import AVFoundation
3
+ import Capacitor
4
+
5
+ /// Capacitor bridge for the VoiceRecorder plugin.
6
+ @objc(VoiceRecorder)
7
+ public class VoiceRecorder: CAPPlugin, CAPBridgedPlugin {
8
+ /// Plugin identifier used by Capacitor.
9
+ public let identifier = "VoiceRecorder"
10
+ /// JavaScript name used for the plugin proxy.
11
+ public let jsName = "VoiceRecorder"
12
+ /// Supported plugin methods exposed to the JS layer.
13
+ public let pluginMethods: [CAPPluginMethod] = [
14
+ CAPPluginMethod(name: "canDeviceVoiceRecord", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "requestAudioRecordingPermission", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "hasAudioRecordingPermission", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "startRecording", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "stopRecording", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "pauseRecording", returnType: CAPPluginReturnPromise),
20
+ CAPPluginMethod(name: "resumeRecording", returnType: CAPPluginReturnPromise),
21
+ CAPPluginMethod(name: "getCurrentStatus", returnType: CAPPluginReturnPromise),
22
+ ]
23
+
24
+ /// Service layer that performs recording operations.
25
+ private var service: VoiceRecorderService?
26
+ /// Response format derived from plugin configuration.
27
+ private var responseFormat: ResponseFormat = .legacy
28
+
29
+ /// Initializes dependencies after the plugin loads.
30
+ public override func load() {
31
+ super.load()
32
+ responseFormat = ResponseFormat(config: getConfig())
33
+ service = VoiceRecorderService(
34
+ platform: DefaultRecorderPlatform(),
35
+ permissionChecker: { [weak self] in
36
+ self?.doesUserGaveAudioRecordingPermission() ?? false
37
+ }
38
+ )
39
+ }
40
+
41
+ /// Returns whether the device can record audio.
42
+ @objc func canDeviceVoiceRecord(_ call: CAPPluginCall) {
43
+ let canRecord = service?.canDeviceVoiceRecord() ?? false
44
+ call.resolve(ResponseGenerator.fromBoolean(canRecord))
45
+ }
46
+
47
+ /// Requests microphone permission from the user.
48
+ @objc func requestAudioRecordingPermission(_ call: CAPPluginCall) {
49
+ AVAudioSession.sharedInstance().requestRecordPermission { granted in
50
+ if granted {
51
+ call.resolve(ResponseGenerator.successResponse())
52
+ } else {
53
+ call.resolve(ResponseGenerator.failResponse())
54
+ }
55
+ }
56
+ }
57
+
58
+ /// Returns whether the app has microphone permission.
59
+ @objc func hasAudioRecordingPermission(_ call: CAPPluginCall) {
60
+ let hasPermission = service?.hasAudioRecordingPermission() ?? false
61
+ call.resolve(ResponseGenerator.fromBoolean(hasPermission))
62
+ }
63
+
64
+ /// Starts a recording session with optional file output.
65
+ @objc func startRecording(_ call: CAPPluginCall) {
66
+ guard let service = service else {
67
+ call.reject(Messages.FAILED_TO_RECORD, ErrorCodes.failedToRecord)
68
+ return
69
+ }
70
+
71
+ let directory: String? = call.getString("directory")
72
+ let subDirectory: String? = call.getString("subDirectory")
73
+ let recordOptions = RecordOptions(directory: directory, subDirectory: subDirectory)
74
+ do {
75
+ try service.startRecording(
76
+ options: recordOptions,
77
+ onInterruptionBegan: { [weak self] in
78
+ self?.notifyListeners("voiceRecordingInterrupted", data: [:])
79
+ },
80
+ onInterruptionEnded: { [weak self] in
81
+ self?.notifyListeners("voiceRecordingInterruptionEnded", data: [:])
82
+ }
83
+ )
84
+ call.resolve(ResponseGenerator.successResponse())
85
+ } catch let error as VoiceRecorderServiceError {
86
+ call.reject(toLegacyMessage(error.code), error.code, error.underlyingError ?? error)
87
+ } catch {
88
+ call.reject(Messages.FAILED_TO_RECORD, ErrorCodes.failedToRecord, error)
89
+ }
90
+ }
91
+
92
+ /// Stops recording and returns the audio payload.
93
+ @objc func stopRecording(_ call: CAPPluginCall) {
94
+ guard let service = service else {
95
+ call.reject(Messages.FAILED_TO_FETCH_RECORDING, ErrorCodes.failedToFetchRecording)
96
+ return
97
+ }
98
+
99
+ service.stopRecording { [weak self] result in
100
+ DispatchQueue.main.async {
101
+ guard let self = self else {
102
+ call.reject(Messages.FAILED_TO_FETCH_RECORDING, ErrorCodes.failedToFetchRecording)
103
+ return
104
+ }
105
+
106
+ switch result {
107
+ case .success(let recordData):
108
+ let payload: Dictionary<String, Any>
109
+ if self.responseFormat == .normalized {
110
+ payload = RecordDataMapper.toNormalizedDictionary(recordData)
111
+ } else {
112
+ payload = RecordDataMapper.toLegacyDictionary(recordData)
113
+ }
114
+ call.resolve(ResponseGenerator.dataResponse(payload))
115
+ case .failure(let error):
116
+ call.reject(self.toLegacyMessage(error.code), error.code, error.underlyingError ?? error)
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ /// Pauses a recording session if supported.
123
+ @objc func pauseRecording(_ call: CAPPluginCall) {
124
+ guard let service = service else {
125
+ call.reject(Messages.RECORDING_HAS_NOT_STARTED, ErrorCodes.recordingHasNotStarted)
126
+ return
127
+ }
128
+
129
+ do {
130
+ call.resolve(ResponseGenerator.fromBoolean(try service.pauseRecording()))
131
+ } catch let error as VoiceRecorderServiceError {
132
+ call.reject(toLegacyMessage(error.code), error.code, error.underlyingError ?? error)
133
+ } catch {
134
+ call.reject(Messages.FAILED_TO_RECORD, ErrorCodes.failedToRecord, error)
135
+ }
136
+ }
137
+
138
+ /// Resumes a paused recording session if supported.
139
+ @objc func resumeRecording(_ call: CAPPluginCall) {
140
+ guard let service = service else {
141
+ call.reject(Messages.RECORDING_HAS_NOT_STARTED, ErrorCodes.recordingHasNotStarted)
142
+ return
143
+ }
144
+
145
+ do {
146
+ call.resolve(ResponseGenerator.fromBoolean(try service.resumeRecording()))
147
+ } catch let error as VoiceRecorderServiceError {
148
+ call.reject(toLegacyMessage(error.code), error.code, error.underlyingError ?? error)
149
+ } catch {
150
+ call.reject(Messages.FAILED_TO_RECORD, ErrorCodes.failedToRecord, error)
151
+ }
152
+ }
153
+
154
+ /// Returns the current recording status.
155
+ @objc func getCurrentStatus(_ call: CAPPluginCall) {
156
+ let status = service?.getCurrentStatus() ?? .NONE
157
+ call.resolve(ResponseGenerator.statusResponse(status))
158
+ }
159
+
160
+ /// Returns whether AVAudioSession reports granted permission.
161
+ func doesUserGaveAudioRecordingPermission() -> Bool {
162
+ return AVAudioSession.sharedInstance().recordPermission == AVAudioSession.RecordPermission.granted
163
+ }
164
+
165
+ /// Maps canonical error codes back to legacy messages.
166
+ private func toLegacyMessage(_ canonicalCode: String) -> String {
167
+ if canonicalCode == ErrorCodes.deviceCannotVoiceRecord {
168
+ return Messages.CANNOT_RECORD_ON_THIS_PHONE
169
+ }
170
+ return canonicalCode
171
+ }
172
+ }
@@ -1,5 +1,6 @@
1
1
  import Foundation
2
2
 
3
+ /// Represents the current recording state.
3
4
  enum CurrentRecordingStatus: String {
4
5
 
5
6
  case RECORDING
@@ -0,0 +1,16 @@
1
+ import Foundation
2
+
3
+ /// Canonical error codes returned by the plugin.
4
+ struct ErrorCodes {
5
+ static let missingPermission = "MISSING_PERMISSION"
6
+ static let alreadyRecording = "ALREADY_RECORDING"
7
+ static let microphoneBeingUsed = "MICROPHONE_BEING_USED"
8
+ static let deviceCannotVoiceRecord = "DEVICE_CANNOT_VOICE_RECORD"
9
+ static let failedToRecord = "FAILED_TO_RECORD"
10
+ static let emptyRecording = "EMPTY_RECORDING"
11
+ static let recordingHasNotStarted = "RECORDING_HAS_NOT_STARTED"
12
+ static let failedToFetchRecording = "FAILED_TO_FETCH_RECORDING"
13
+ static let failedToMergeRecording = "FAILED_TO_MERGE_RECORDING"
14
+ static let notSupportedOsVersion = "NOT_SUPPORTED_OS_VERSION"
15
+ static let couldNotQueryPermissionStatus = "COULD_NOT_QUERY_PERMISSION_STATUS"
16
+ }
@@ -1,5 +1,6 @@
1
1
  import Foundation
2
2
 
3
+ /// Legacy error messages preserved for backward compatibility.
3
4
  struct Messages {
4
5
 
5
6
  static let MISSING_PERMISSION = "MISSING_PERMISSION"
@@ -1,12 +1,18 @@
1
1
  import Foundation
2
2
 
3
+ /// Recording payload returned to the bridge layer.
3
4
  struct RecordData {
4
5
 
6
+ /// Base64-encoded recording data (legacy payloads).
5
7
  public let recordDataBase64: String?
8
+ /// MIME type of the recorded audio.
6
9
  public let mimeType: String
10
+ /// Recording duration in milliseconds.
7
11
  public let msDuration: Int
12
+ /// File path or URI to the recorded audio.
8
13
  public let uri: String?
9
14
 
15
+ /// Serializes record data into the legacy payload shape.
10
16
  public func toDictionary() -> Dictionary<String, Any> {
11
17
  return [
12
18
  "recordDataBase64": recordDataBase64 ?? "",
@@ -0,0 +1,11 @@
1
+ import Foundation
2
+
3
+ /// Optional output configuration for recordings.
4
+ struct RecordOptions {
5
+
6
+ /// Directory name provided by the caller.
7
+ public let directory: String?
8
+ /// Subdirectory name provided by the caller.
9
+ public let subDirectory: String?
10
+
11
+ }
@@ -0,0 +1,22 @@
1
+ import Foundation
2
+ import Capacitor
3
+
4
+ /// Supported response payload shapes.
5
+ enum ResponseFormat: String {
6
+ case legacy
7
+ case normalized
8
+
9
+ /// Converts a raw config value into a response format.
10
+ static func from(value: String?) -> ResponseFormat {
11
+ guard let value = value?.lowercased(), value == "normalized" else {
12
+ return .legacy
13
+ }
14
+ return .normalized
15
+ }
16
+
17
+ /// Reads the response format from plugin configuration.
18
+ init(config: PluginConfig) {
19
+ let value = config.getString("responseFormat", "legacy") ?? "legacy"
20
+ self = ResponseFormat.from(value: value)
21
+ }
22
+ }
@@ -1,26 +1,32 @@
1
1
  import Foundation
2
2
 
3
+ /// Helper for building JS payloads in the legacy response shape.
3
4
  struct ResponseGenerator {
4
5
 
5
6
  private static let VALUE_RESPONSE_KEY = "value"
6
7
  private static let STATUS_RESPONSE_KEY = "status"
7
8
 
9
+ /// Wraps a boolean value into the response shape.
8
10
  public static func fromBoolean(_ value: Bool) -> Dictionary<String, Bool> {
9
11
  return value ? successResponse() : failResponse()
10
12
  }
11
13
 
14
+ /// Returns a success response with value=true.
12
15
  public static func successResponse() -> Dictionary<String, Bool> {
13
16
  return [VALUE_RESPONSE_KEY: true]
14
17
  }
15
18
 
19
+ /// Returns a failure response with value=false.
16
20
  public static func failResponse() -> Dictionary<String, Bool> {
17
21
  return [VALUE_RESPONSE_KEY: false]
18
22
  }
19
23
 
24
+ /// Wraps arbitrary data into the response shape.
20
25
  public static func dataResponse(_ data: Any) -> Dictionary<String, Any> {
21
26
  return [VALUE_RESPONSE_KEY: data]
22
27
  }
23
28
 
29
+ /// Wraps the recording status into the response shape.
24
30
  public static func statusResponse(_ data: CurrentRecordingStatus) -> Dictionary<String, String> {
25
31
  return [STATUS_RESPONSE_KEY: data.rawValue]
26
32
  }
@@ -1,19 +1,31 @@
1
1
  import Foundation
2
2
  import AVFoundation
3
3
 
4
- class CustomMediaRecorder {
4
+ /// AVAudioRecorder wrapper that supports interruptions and segment merging.
5
+ class CustomMediaRecorder: RecorderAdapter {
5
6
 
7
+ /// Options provided by the service layer.
6
8
  public var options: RecordOptions?
9
+ /// Active audio session for recording.
7
10
  private var recordingSession: AVAudioSession!
11
+ /// Active recorder instance for the current segment.
8
12
  private var audioRecorder: AVAudioRecorder!
13
+ /// Base file path for the merged recording.
9
14
  private var baseAudioFilePath: URL!
15
+ /// List of segment files created during interruptions.
10
16
  private var audioFileSegments: [URL] = []
17
+ /// Audio session category before recording starts.
11
18
  private var originalRecordingSessionCategory: AVAudioSession.Category!
19
+ /// Current recording status.
12
20
  private var status = CurrentRecordingStatus.NONE
21
+ /// Notification observer for audio interruptions.
13
22
  private var interruptionObserver: NSObjectProtocol?
23
+ /// Callback invoked when interruptions begin.
14
24
  var onInterruptionBegan: (() -> Void)?
25
+ /// Callback invoked when interruptions end.
15
26
  var onInterruptionEnded: (() -> Void)?
16
27
 
28
+ /// Recorder settings used for all segments.
17
29
  private let settings = [
18
30
  AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
19
31
  AVSampleRateKey: 44100,
@@ -21,6 +33,7 @@ class CustomMediaRecorder {
21
33
  AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
22
34
  ]
23
35
 
36
+ /// Resolves the directory where audio files should be saved.
24
37
  private func getDirectoryToSaveAudioFile() -> URL {
25
38
  if options?.directory != nil,
26
39
  let directory = getDirectory(directory: options?.directory),
@@ -43,6 +56,7 @@ class CustomMediaRecorder {
43
56
  return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
44
57
  }
45
58
 
59
+ /// Starts recording audio and prepares the session.
46
60
  public func startRecording(recordOptions: RecordOptions?) -> Bool {
47
61
  do {
48
62
  options = recordOptions
@@ -62,6 +76,7 @@ class CustomMediaRecorder {
62
76
  }
63
77
  }
64
78
 
79
+ /// Stops recording and merges segments if needed.
65
80
  public func stopRecording(completion: @escaping (Bool) -> Void) {
66
81
  removeInterruptionHandling()
67
82
  audioRecorder.stop()
@@ -100,10 +115,12 @@ class CustomMediaRecorder {
100
115
  }
101
116
  }
102
117
 
118
+ /// Returns the output file for the recording.
103
119
  public func getOutputFile() -> URL {
104
120
  return baseAudioFilePath
105
121
  }
106
122
 
123
+ /// Maps directory strings to FileManager search paths.
107
124
  public func getDirectory(directory: String?) -> FileManager.SearchPathDirectory? {
108
125
  if let directory = directory {
109
126
  switch directory {
@@ -118,6 +135,7 @@ class CustomMediaRecorder {
118
135
  return nil
119
136
  }
120
137
 
138
+ /// Pauses recording when currently active.
121
139
  public func pauseRecording() -> Bool {
122
140
  if(status == CurrentRecordingStatus.RECORDING) {
123
141
  audioRecorder.pause()
@@ -128,6 +146,7 @@ class CustomMediaRecorder {
128
146
  }
129
147
  }
130
148
 
149
+ /// Resumes recording after pause or interruption.
131
150
  public func resumeRecording() -> Bool {
132
151
  if(status == CurrentRecordingStatus.PAUSED || status == CurrentRecordingStatus.INTERRUPTED) {
133
152
  let wasInterrupted = status == CurrentRecordingStatus.INTERRUPTED
@@ -155,10 +174,12 @@ class CustomMediaRecorder {
155
174
  return false
156
175
  }
157
176
 
177
+ /// Returns the current recording status.
158
178
  public func getCurrentStatus() -> CurrentRecordingStatus {
159
179
  return status
160
180
  }
161
181
 
182
+ /// Registers for interruption notifications.
162
183
  private func setupInterruptionHandling() {
163
184
  interruptionObserver = NotificationCenter.default.addObserver(
164
185
  forName: AVAudioSession.interruptionNotification,
@@ -169,6 +190,7 @@ class CustomMediaRecorder {
169
190
  }
170
191
  }
171
192
 
193
+ /// Removes interruption observers.
172
194
  private func removeInterruptionHandling() {
173
195
  if let observer = interruptionObserver {
174
196
  NotificationCenter.default.removeObserver(observer)
@@ -176,6 +198,7 @@ class CustomMediaRecorder {
176
198
  }
177
199
  }
178
200
 
201
+ /// Handles audio session interruptions.
179
202
  private func handleInterruption(notification: Notification) {
180
203
  guard let userInfo = notification.userInfo,
181
204
  let interruptionTypeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
@@ -201,6 +224,7 @@ class CustomMediaRecorder {
201
224
  }
202
225
  }
203
226
 
227
+ /// Merges recorded segments into a single file when interruptions occur.
204
228
  private func mergeAudioSegments(completion: @escaping (Bool) -> Void) {
205
229
  if audioFileSegments.count <= 1 {
206
230
  completion(true)
@@ -0,0 +1,128 @@
1
+ import Foundation
2
+
3
+ /// Service layer that orchestrates recording operations.
4
+ final class VoiceRecorderService {
5
+ /// Platform adapter for device and file operations.
6
+ private let platform: RecorderPlatform
7
+ /// Closure used to check microphone permission.
8
+ private let permissionChecker: () -> Bool
9
+ /// Factory for creating recorder adapters.
10
+ private let recorderFactory: () -> RecorderAdapter
11
+ /// Active recorder instance for the current session.
12
+ private var recorder: RecorderAdapter?
13
+
14
+ init(
15
+ platform: RecorderPlatform,
16
+ permissionChecker: @escaping () -> Bool,
17
+ recorderFactory: @escaping () -> RecorderAdapter = { CustomMediaRecorder() }
18
+ ) {
19
+ self.platform = platform
20
+ self.permissionChecker = permissionChecker
21
+ self.recorderFactory = recorderFactory
22
+ }
23
+
24
+ /// Returns whether the device can record audio.
25
+ func canDeviceVoiceRecord() -> Bool {
26
+ return platform.canDeviceVoiceRecord()
27
+ }
28
+
29
+ /// Returns whether the app has microphone permission.
30
+ func hasAudioRecordingPermission() -> Bool {
31
+ return permissionChecker()
32
+ }
33
+
34
+ /// Starts a recording session or throws a service error.
35
+ func startRecording(
36
+ options: RecordOptions?,
37
+ onInterruptionBegan: @escaping () -> Void,
38
+ onInterruptionEnded: @escaping () -> Void
39
+ ) throws {
40
+ if !platform.canDeviceVoiceRecord() {
41
+ throw VoiceRecorderServiceError(code: ErrorCodes.deviceCannotVoiceRecord)
42
+ }
43
+
44
+ if !permissionChecker() {
45
+ throw VoiceRecorderServiceError(code: ErrorCodes.missingPermission)
46
+ }
47
+
48
+ if recorder != nil {
49
+ throw VoiceRecorderServiceError(code: ErrorCodes.alreadyRecording)
50
+ }
51
+
52
+ let nextRecorder = recorderFactory()
53
+ nextRecorder.onInterruptionBegan = onInterruptionBegan
54
+ nextRecorder.onInterruptionEnded = onInterruptionEnded
55
+ let started = nextRecorder.startRecording(recordOptions: options)
56
+ if !started {
57
+ recorder = nil
58
+ throw VoiceRecorderServiceError(code: ErrorCodes.deviceCannotVoiceRecord)
59
+ }
60
+
61
+ recorder = nextRecorder
62
+ }
63
+
64
+ /// Stops recording and returns the payload asynchronously.
65
+ func stopRecording(completion: @escaping (Result<RecordData, VoiceRecorderServiceError>) -> Void) {
66
+ guard let recorder = recorder else {
67
+ completion(.failure(VoiceRecorderServiceError(code: ErrorCodes.recordingHasNotStarted)))
68
+ return
69
+ }
70
+
71
+ recorder.stopRecording { [weak self] stopSuccess in
72
+ guard let self = self else {
73
+ completion(.failure(VoiceRecorderServiceError(code: ErrorCodes.failedToFetchRecording)))
74
+ return
75
+ }
76
+
77
+ if !stopSuccess {
78
+ self.recorder = nil
79
+ completion(.failure(VoiceRecorderServiceError(code: ErrorCodes.failedToMergeRecording)))
80
+ return
81
+ }
82
+
83
+ let audioFileUrl = recorder.getOutputFile()
84
+ let fileExtension = audioFileUrl.pathExtension.lowercased()
85
+ let mimeType = fileExtension == "m4a" ? "audio/mp4" : "audio/aac"
86
+ let sendDataAsBase64 = recorder.options?.directory == nil
87
+ let recordDataBase64 = sendDataAsBase64 ? self.platform.readFileAsBase64(audioFileUrl) : nil
88
+ let uri = sendDataAsBase64 ? nil : audioFileUrl.path
89
+ let recordData = RecordData(
90
+ recordDataBase64: recordDataBase64,
91
+ mimeType: mimeType,
92
+ msDuration: self.platform.getDurationMs(audioFileUrl),
93
+ uri: uri
94
+ )
95
+
96
+ self.recorder = nil
97
+ if (sendDataAsBase64 && recordData.recordDataBase64 == nil) || recordData.msDuration < 0 {
98
+ completion(.failure(VoiceRecorderServiceError(code: ErrorCodes.emptyRecording)))
99
+ } else {
100
+ completion(.success(recordData))
101
+ }
102
+ }
103
+ }
104
+
105
+ /// Pauses the active recording session.
106
+ func pauseRecording() throws -> Bool {
107
+ guard let recorder = recorder else {
108
+ throw VoiceRecorderServiceError(code: ErrorCodes.recordingHasNotStarted)
109
+ }
110
+ return recorder.pauseRecording()
111
+ }
112
+
113
+ /// Resumes a paused recording session.
114
+ func resumeRecording() throws -> Bool {
115
+ guard let recorder = recorder else {
116
+ throw VoiceRecorderServiceError(code: ErrorCodes.recordingHasNotStarted)
117
+ }
118
+ return recorder.resumeRecording()
119
+ }
120
+
121
+ /// Returns the current recording status.
122
+ func getCurrentStatus() -> CurrentRecordingStatus {
123
+ guard let recorder = recorder else {
124
+ return .NONE
125
+ }
126
+ return recorder.getCurrentStatus()
127
+ }
128
+ }
@@ -0,0 +1,14 @@
1
+ import Foundation
2
+
3
+ /// Error wrapper that carries a canonical error code.
4
+ struct VoiceRecorderServiceError: Error {
5
+ /// Canonical error code for bridge mapping.
6
+ let code: String
7
+ /// Underlying error, when available.
8
+ let underlyingError: Error?
9
+
10
+ init(code: String, underlyingError: Error? = nil) {
11
+ self.code = code
12
+ self.underlyingError = underlyingError
13
+ }
14
+ }
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.1",
16
+ "version": "8.1.0-dev.2",
17
17
  "devDependencies": {
18
18
  "@capacitor/android": "^8.0.0",
19
19
  "conventional-changelog-conventionalcommits": "^9.1.0",
@@ -60,12 +60,18 @@
60
60
  "prepublishOnly": "npm run build",
61
61
  "verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
62
62
  "verify:android": "node scripts/verify-android.js",
63
+ "verify:ios": "xcodebuild -scheme IndependoCapacitorVoiceRecorder -destination generic/platform=iOS",
64
+ "verify:web": "npm run build",
63
65
  "watch": "tsc --watch",
64
- "test": "jest",
66
+ "test": "npm run test:web && npm run test:android && npm run test:ios",
67
+ "test:web": "jest",
68
+ "test:web:coverage": "jest --coverage --coverageReporters=lcov --coverageReporters=text-summary",
69
+ "test:android": "node scripts/verify-android.js testDebugUnitTest",
70
+ "test:android:coverage": "node scripts/verify-android.js testDebugUnitTest jacocoTestReport",
71
+ "test:ios": "node scripts/test-ios.js",
72
+ "test:ios:coverage": "node scripts/test-ios.js --coverage --result-bundle-path coverage/ios.xcresult && node scripts/xccov-to-cobertura.js --xcresult coverage/ios.xcresult --output coverage/ios-cobertura.xml",
65
73
  "eslint": "eslint . --ext ts",
66
- "verify:ios": "xcodebuild -scheme IndependoCapacitorVoiceRecorder -destination generic/platform=iOS",
67
74
  "clean": "rm -rf dist",
68
- "verify:web": "npm run build",
69
75
  "swiftlint": "node-swiftlint",
70
76
  "docgen": "docgen --api VoiceRecorderPlugin --output-readme README.md --output-json dist/docs.json",
71
77
  "fmt": "npm run eslint -- --fix"
@@ -1,8 +0,0 @@
1
- package com.tchvu3.capacitorvoicerecorder;
2
-
3
- public enum CurrentRecordingStatus {
4
- RECORDING,
5
- PAUSED,
6
- INTERRUPTED,
7
- NONE
8
- }
@@ -1,3 +0,0 @@
1
- package com.tchvu3.capacitorvoicerecorder;
2
-
3
- public class NotSupportedOsVersion extends Exception {}
@@ -1,3 +0,0 @@
1
- package com.tchvu3.capacitorvoicerecorder;
2
-
3
- public record RecordOptions(String directory, String subDirectory) {}