Skip to content

Commit 62d260a

Browse files
authored
Merge pull request #20915 from wordpress-mobile/issue/93-recorder-leftovers
[Voice To Content] AudioRecorder improvements
2 parents 53a007c + 91a4f5b commit 62d260a

6 files changed

Lines changed: 149 additions & 57 deletions

File tree

WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
import org.wordpress.android.util.BuildConfigWrapper;
3131
import org.wordpress.android.util.audio.AudioRecorder;
3232
import org.wordpress.android.util.audio.IAudioRecorder;
33+
import org.wordpress.android.util.audio.RecordingStrategy;
34+
import org.wordpress.android.util.audio.RecordingStrategy.VoiceToContentRecordingStrategy;
35+
import org.wordpress.android.util.audio.VoiceToContentStrategy;
3336
import org.wordpress.android.util.config.InAppUpdatesFeatureConfig;
3437
import org.wordpress.android.util.config.RemoteConfigWrapper;
3538
import org.wordpress.android.util.wizard.WizardManager;
@@ -124,8 +127,18 @@ public static SensorManager provideSensorManager(@ApplicationContext Context con
124127
return (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
125128
}
126129

130+
@VoiceToContentStrategy
127131
@Provides
128-
public static IAudioRecorder provideAudioRecorder(@ApplicationContext Context context) {
129-
return new AudioRecorder(context);
132+
public static IAudioRecorder provideAudioRecorder(
133+
@ApplicationContext Context context,
134+
@VoiceToContentStrategy RecordingStrategy recordingStrategy
135+
) {
136+
return new AudioRecorder(context, recordingStrategy);
137+
}
138+
139+
@VoiceToContentStrategy
140+
@Provides
141+
public static RecordingStrategy provideVoiceToContentRecordingStrategy() {
142+
return new VoiceToContentRecordingStrategy();
130143
}
131144
}

WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/RecordingUseCase.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package org.wordpress.android.ui.voicetocontent
33
import kotlinx.coroutines.flow.Flow
44
import org.wordpress.android.util.audio.IAudioRecorder
55
import org.wordpress.android.util.audio.RecordingUpdate
6+
import org.wordpress.android.util.audio.VoiceToContentStrategy
67
import javax.inject.Inject
8+
import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult
79

810
class RecordingUseCase @Inject constructor(
9-
private val audioRecorder: IAudioRecorder
11+
@VoiceToContentStrategy private val audioRecorder: IAudioRecorder
1012
) {
11-
fun startRecording(onRecordingFinished: (String) -> Unit) {
13+
fun startRecording(onRecordingFinished: (AudioRecorderResult) -> Unit) {
1214
audioRecorder.startRecording(onRecordingFinished)
1315
}
1416

WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModel.kt

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import org.wordpress.android.viewmodel.ScopedViewModel
1818
import java.io.File
1919
import javax.inject.Inject
2020
import javax.inject.Named
21+
import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Success
22+
import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Error
2123

2224
@HiltViewModel
2325
class VoiceToContentViewModel @Inject constructor(
@@ -54,12 +56,19 @@ class VoiceToContentViewModel @Inject constructor(
5456
}
5557

5658
fun startRecording() {
57-
recordingUseCase.startRecording { recordingPath ->
58-
val file = getRecordingFile(recordingPath)
59-
file?.let {
60-
executeVoiceToContent(it)
61-
} ?: run {
62-
_uiState.postValue(VoiceToContentResult(isError = true))
59+
recordingUseCase.startRecording { audioRecorderResult ->
60+
when (audioRecorderResult) {
61+
is Success -> {
62+
val file = getRecordingFile(audioRecorderResult.recordingPath)
63+
file?.let {
64+
executeVoiceToContent(it)
65+
} ?: run {
66+
_uiState.postValue(VoiceToContentResult(isError = true))
67+
}
68+
}
69+
is Error -> {
70+
_uiState.postValue(VoiceToContentResult(isError = true))
71+
}
6372
}
6473
}
6574
}

WordPress/src/main/java/org/wordpress/android/util/audio/AudioRecorder.kt

Lines changed: 82 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.Manifest
44
import android.content.Context
55
import android.content.pm.PackageManager
66
import android.media.MediaRecorder
7+
import android.os.Build
78
import android.util.Log
89
import kotlinx.coroutines.CoroutineScope
910
import kotlinx.coroutines.Dispatchers
@@ -17,17 +18,15 @@ import kotlinx.coroutines.flow.asStateFlow
1718
import kotlinx.coroutines.launch
1819
import java.io.File
1920
import java.io.IOException
21+
import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult
22+
import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Success
23+
import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Error
2024

2125
class AudioRecorder(
22-
private val applicationContext: Context
26+
private val applicationContext: Context,
27+
private val recordingStrategy: RecordingStrategy
2328
) : IAudioRecorder {
24-
// default recording params
25-
private var recordingParams: RecordingParams = RecordingParams(
26-
maxDuration = 60 * 5, // 5 minutes
27-
maxFileSize = 1000000L * 25 // 25MB
28-
)
29-
30-
private var onRecordingFinished: (String) -> Unit = {}
29+
private var onRecordingFinished: (AudioRecorderResult) -> Unit = {}
3130

3231
private val storeInMemory = true
3332
private val filePath by lazy {
@@ -54,30 +53,44 @@ class AudioRecorder(
5453
val isPaused: StateFlow<Boolean> = _isPaused
5554

5655
@Suppress("DEPRECATION")
57-
override fun startRecording(onRecordingFinished: (String) -> Unit) {
56+
override fun startRecording(onRecordingFinished: (AudioRecorderResult) -> Unit) {
5857
this.onRecordingFinished = onRecordingFinished
5958
if (applicationContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO)
6059
== PackageManager.PERMISSION_GRANTED) {
61-
recorder = MediaRecorder().apply {
62-
setAudioSource(MediaRecorder.AudioSource.MIC)
63-
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
64-
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
65-
setOutputFile(filePath)
60+
try {
61+
recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
62+
MediaRecorder(applicationContext)
63+
} else {
64+
MediaRecorder()
65+
}.apply {
66+
setAudioSource(MediaRecorder.AudioSource.MIC)
67+
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
68+
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
69+
setOutputFile(filePath)
6670

67-
try {
6871
prepare()
6972
start()
7073
startRecordingUpdates()
7174
_isRecording.value = true
7275
_isPaused.value = false
73-
} catch (e: IOException) {
74-
// Use a logging framework like Timber
75-
Log.e("AudioRecorder", "Error starting recording")
7676
}
77+
} catch (e: IOException) {
78+
val errorMessage = "Error preparing MediaRecorder: ${e.message}"
79+
Log.e(TAG, errorMessage)
80+
onRecordingFinished(Error(errorMessage))
81+
} catch (e: IllegalStateException) {
82+
val errorMessage = "Illegal state when starting recording: ${e.message}"
83+
Log.e(TAG, errorMessage)
84+
onRecordingFinished(Error(errorMessage))
85+
} catch (e: SecurityException) {
86+
val errorMessage = "Security exception when starting recording: ${e.message}"
87+
Log.e(TAG, errorMessage)
88+
onRecordingFinished(Error(errorMessage))
7789
}
7890
} else {
79-
// Handle permission not granted case, e.g., throw an exception or show a message
80-
Log.e("AudioRecorder","Permission to record audio not granted")
91+
val errorMessage = "Permission to record audio not granted"
92+
Log.e(TAG, errorMessage)
93+
onRecordingFinished(Error(errorMessage))
8194
}
8295
}
8396

@@ -88,28 +101,26 @@ class AudioRecorder(
88101
release()
89102
}
90103
} catch (e: IllegalStateException) {
91-
Log.e("AudioRecorder", "Error stopping recording")
104+
Log.e(TAG, "Error stopping recording: ${e.message}")
92105
} finally {
93106
recorder = null
94107
stopRecordingUpdates()
95108
_isPaused.value = false
96109
_isRecording.value = false
97110
}
98111
// return filePath
99-
onRecordingFinished(filePath)
112+
onRecordingFinished(Success(filePath))
100113
}
101114

102115
override fun pauseRecording() {
103-
if (recorder != null) {
104-
try {
105-
recorder?.pause()
106-
_isPaused.value = true
107-
stopRecordingUpdates()
108-
} catch (e: IllegalStateException) {
109-
Log.e("AudioRecorder", "Error pausing recording")
110-
}
111-
} else {
112-
Log.e("AudioRecorder","Pause not supported on this device")
116+
try {
117+
recorder?.pause()
118+
_isPaused.value = true
119+
stopRecordingUpdates()
120+
} catch (e: IllegalStateException) {
121+
Log.e(TAG, "Error pausing recording: ${e.message}")
122+
} catch (e: UnsupportedOperationException) {
123+
Log.e(TAG, "Pause not supported on this device: ${e.message}")
113124
}
114125
}
115126

@@ -123,45 +134,71 @@ class AudioRecorder(
123134
isPausedRecording = false
124135
startRecordingUpdates()
125136
} catch (e: IllegalStateException) {
126-
Log.e("AudioRecorder", "Error resuming recording")
137+
Log.e(TAG, "Error resuming recording")
127138
}
128139
}
129140
}
130141
}
131142

132143
override fun recordingUpdates(): Flow<RecordingUpdate> = recordingUpdates
133144

134-
override fun setRecordingParams(params: RecordingParams) {
135-
recordingParams = params
136-
}
137-
145+
@Suppress("MagicNumber")
138146
private fun startRecordingUpdates() {
139147
recordingJob = coroutineScope.launch {
140-
var elapsedTime = 0
148+
var elapsedTimeInSeconds = 0
141149
while (recorder != null) {
142150
delay(RECORDING_UPDATE_INTERVAL)
143-
elapsedTime++
151+
elapsedTimeInSeconds += (RECORDING_UPDATE_INTERVAL / 1000).toInt()
144152
val fileSize = File(filePath).length()
145153
_recordingUpdates.value = RecordingUpdate(
146-
elapsedTime = elapsedTime,
154+
elapsedTime = elapsedTimeInSeconds,
147155
fileSize = fileSize,
148-
fileSizeLimitExceeded = fileSize >= recordingParams.maxFileSize,
156+
fileSizeLimitExceeded = fileSize >= recordingStrategy.maxFileSize,
149157
)
150158

151-
if (fileSize >= recordingParams.maxFileSize
152-
|| elapsedTime >= recordingParams.maxDuration) {
159+
if ( maxFileSizeExceeded(fileSize) || maxDurationExceeded(elapsedTimeInSeconds) ) {
153160
stopRecording()
161+
break
154162
}
155163
}
156164
}
157165
}
158166

167+
/**
168+
* Checks if the recorded file size has exceeded the specified maximum file size.
169+
*
170+
* @param fileSize The current size of the recorded file in bytes.
171+
* @return `true` if the file size has exceeded the maximum file size minus the threshold, `false` otherwise.
172+
* If `recordingParams.maxFileSize` is set to `-1L`, this function always returns `false` indicating
173+
* no limit.
174+
*/
175+
private fun maxFileSizeExceeded(fileSize: Long): Boolean = when {
176+
recordingStrategy.maxFileSize == -1L -> false
177+
else -> fileSize >= recordingStrategy.maxFileSize - FILE_SIZE_THRESHOLD
178+
}
179+
180+
/**
181+
* Checks if the recording duration has exceeded the specified maximum duration.
182+
*
183+
* @param elapsedTimeInSeconds The elapsed recording time in seconds.
184+
* @return `true` if the elapsed time has exceeded the maximum duration minus the threshold, `false` otherwise.
185+
* If `recordingParams.maxDuration` is set to `-1`, this function always returns `false` indicating
186+
* no limit.
187+
*/
188+
private fun maxDurationExceeded(elapsedTimeInSeconds: Int): Boolean = when {
189+
recordingStrategy.maxDuration == -1 -> false
190+
else -> elapsedTimeInSeconds >= recordingStrategy.maxDuration - DURATION_THRESHOLD
191+
}
192+
159193
private fun stopRecordingUpdates() {
160194
recordingJob?.cancel()
161195
}
162196

163197
companion object {
164-
private const val RECORDING_UPDATE_INTERVAL = 1000L
165-
private const val RESUME_DELAY = 500L
198+
private const val TAG = "AudioRecorder"
199+
private const val RECORDING_UPDATE_INTERVAL = 1000L // in milliseconds
200+
private const val RESUME_DELAY = 500L // in milliseconds
201+
private const val FILE_SIZE_THRESHOLD = 100000L
202+
private const val DURATION_THRESHOLD = 1
166203
}
167204
}

WordPress/src/main/java/org/wordpress/android/util/audio/IAudioRecorder.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import android.Manifest
44
import kotlinx.coroutines.flow.Flow
55

66
interface IAudioRecorder {
7-
fun startRecording(onRecordingFinished: (String) -> Unit)
7+
fun startRecording(onRecordingFinished: (AudioRecorderResult) -> Unit)
88
fun stopRecording()
99
fun pauseRecording()
1010
fun resumeRecording()
1111
fun recordingUpdates(): Flow<RecordingUpdate>
12-
fun setRecordingParams(params: RecordingParams)
12+
13+
sealed class AudioRecorderResult {
14+
data class Success(val recordingPath: String) : AudioRecorderResult()
15+
data class Error(val errorMessage: String) : AudioRecorderResult()
16+
}
1317

1418
companion object {
1519
val REQUIRED_RECORDING_PERMISSIONS = arrayOf(
@@ -18,3 +22,5 @@ interface IAudioRecorder {
1822
}
1923
}
2024

25+
26+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package org.wordpress.android.util.audio
2+
3+
import javax.inject.Qualifier
4+
5+
@Suppress("MagicNumber")
6+
sealed class RecordingStrategy {
7+
abstract val maxFileSize: Long
8+
abstract val maxDuration: Int
9+
abstract val storeInMemory: Boolean
10+
abstract val recordingFileName: String
11+
12+
data class VoiceToContentRecordingStrategy(
13+
override val maxFileSize: Long = 1000000L * 25, // 25MB
14+
override val maxDuration: Int = 60 * 5, // 5 minutes
15+
override val recordingFileName: String = "voice_recording.mp4",
16+
override val storeInMemory: Boolean = true
17+
) : RecordingStrategy()
18+
}
19+
20+
// Declare here your custom annotation for each RecordingStrategy so it can be provided by Dagger
21+
@Qualifier
22+
@Retention(AnnotationRetention.BINARY)
23+
annotation class VoiceToContentStrategy
24+
25+

0 commit comments

Comments
 (0)