Skip to content

Commit

Permalink
Merge pull request #20915 from wordpress-mobile/issue/93-recorder-lef…
Browse files Browse the repository at this point in the history
…tovers

[Voice To Content] AudioRecorder improvements
  • Loading branch information
pantstamp authored May 31, 2024
2 parents 53a007c + 91a4f5b commit 62d260a
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 57 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
import org.wordpress.android.util.BuildConfigWrapper;
import org.wordpress.android.util.audio.AudioRecorder;
import org.wordpress.android.util.audio.IAudioRecorder;
import org.wordpress.android.util.audio.RecordingStrategy;
import org.wordpress.android.util.audio.RecordingStrategy.VoiceToContentRecordingStrategy;
import org.wordpress.android.util.audio.VoiceToContentStrategy;
import org.wordpress.android.util.config.InAppUpdatesFeatureConfig;
import org.wordpress.android.util.config.RemoteConfigWrapper;
import org.wordpress.android.util.wizard.WizardManager;
Expand Down Expand Up @@ -124,8 +127,18 @@ public static SensorManager provideSensorManager(@ApplicationContext Context con
return (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
}

@VoiceToContentStrategy
@Provides
public static IAudioRecorder provideAudioRecorder(@ApplicationContext Context context) {
return new AudioRecorder(context);
public static IAudioRecorder provideAudioRecorder(
@ApplicationContext Context context,
@VoiceToContentStrategy RecordingStrategy recordingStrategy
) {
return new AudioRecorder(context, recordingStrategy);
}

@VoiceToContentStrategy
@Provides
public static RecordingStrategy provideVoiceToContentRecordingStrategy() {
return new VoiceToContentRecordingStrategy();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package org.wordpress.android.ui.voicetocontent
import kotlinx.coroutines.flow.Flow
import org.wordpress.android.util.audio.IAudioRecorder
import org.wordpress.android.util.audio.RecordingUpdate
import org.wordpress.android.util.audio.VoiceToContentStrategy
import javax.inject.Inject
import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult

class RecordingUseCase @Inject constructor(
private val audioRecorder: IAudioRecorder
@VoiceToContentStrategy private val audioRecorder: IAudioRecorder
) {
fun startRecording(onRecordingFinished: (String) -> Unit) {
fun startRecording(onRecordingFinished: (AudioRecorderResult) -> Unit) {
audioRecorder.startRecording(onRecordingFinished)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import org.wordpress.android.viewmodel.ScopedViewModel
import java.io.File
import javax.inject.Inject
import javax.inject.Named
import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Success
import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Error

@HiltViewModel
class VoiceToContentViewModel @Inject constructor(
Expand Down Expand Up @@ -54,12 +56,19 @@ class VoiceToContentViewModel @Inject constructor(
}

fun startRecording() {
recordingUseCase.startRecording { recordingPath ->
val file = getRecordingFile(recordingPath)
file?.let {
executeVoiceToContent(it)
} ?: run {
_uiState.postValue(VoiceToContentResult(isError = true))
recordingUseCase.startRecording { audioRecorderResult ->
when (audioRecorderResult) {
is Success -> {
val file = getRecordingFile(audioRecorderResult.recordingPath)
file?.let {
executeVoiceToContent(it)
} ?: run {
_uiState.postValue(VoiceToContentResult(isError = true))
}
}
is Error -> {
_uiState.postValue(VoiceToContentResult(isError = true))
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.MediaRecorder
import android.os.Build
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand All @@ -17,17 +18,15 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.IOException
import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult
import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Success
import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Error

class AudioRecorder(
private val applicationContext: Context
private val applicationContext: Context,
private val recordingStrategy: RecordingStrategy
) : IAudioRecorder {
// default recording params
private var recordingParams: RecordingParams = RecordingParams(
maxDuration = 60 * 5, // 5 minutes
maxFileSize = 1000000L * 25 // 25MB
)

private var onRecordingFinished: (String) -> Unit = {}
private var onRecordingFinished: (AudioRecorderResult) -> Unit = {}

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

@Suppress("DEPRECATION")
override fun startRecording(onRecordingFinished: (String) -> Unit) {
override fun startRecording(onRecordingFinished: (AudioRecorderResult) -> Unit) {
this.onRecordingFinished = onRecordingFinished
if (applicationContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED) {
recorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setOutputFile(filePath)
try {
recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(applicationContext)
} else {
MediaRecorder()
}.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setOutputFile(filePath)

try {
prepare()
start()
startRecordingUpdates()
_isRecording.value = true
_isPaused.value = false
} catch (e: IOException) {
// Use a logging framework like Timber
Log.e("AudioRecorder", "Error starting recording")
}
} catch (e: IOException) {
val errorMessage = "Error preparing MediaRecorder: ${e.message}"
Log.e(TAG, errorMessage)
onRecordingFinished(Error(errorMessage))
} catch (e: IllegalStateException) {
val errorMessage = "Illegal state when starting recording: ${e.message}"
Log.e(TAG, errorMessage)
onRecordingFinished(Error(errorMessage))
} catch (e: SecurityException) {
val errorMessage = "Security exception when starting recording: ${e.message}"
Log.e(TAG, errorMessage)
onRecordingFinished(Error(errorMessage))
}
} else {
// Handle permission not granted case, e.g., throw an exception or show a message
Log.e("AudioRecorder","Permission to record audio not granted")
val errorMessage = "Permission to record audio not granted"
Log.e(TAG, errorMessage)
onRecordingFinished(Error(errorMessage))
}
}

Expand All @@ -88,28 +101,26 @@ class AudioRecorder(
release()
}
} catch (e: IllegalStateException) {
Log.e("AudioRecorder", "Error stopping recording")
Log.e(TAG, "Error stopping recording: ${e.message}")
} finally {
recorder = null
stopRecordingUpdates()
_isPaused.value = false
_isRecording.value = false
}
// return filePath
onRecordingFinished(filePath)
onRecordingFinished(Success(filePath))
}

override fun pauseRecording() {
if (recorder != null) {
try {
recorder?.pause()
_isPaused.value = true
stopRecordingUpdates()
} catch (e: IllegalStateException) {
Log.e("AudioRecorder", "Error pausing recording")
}
} else {
Log.e("AudioRecorder","Pause not supported on this device")
try {
recorder?.pause()
_isPaused.value = true
stopRecordingUpdates()
} catch (e: IllegalStateException) {
Log.e(TAG, "Error pausing recording: ${e.message}")
} catch (e: UnsupportedOperationException) {
Log.e(TAG, "Pause not supported on this device: ${e.message}")
}
}

Expand All @@ -123,45 +134,71 @@ class AudioRecorder(
isPausedRecording = false
startRecordingUpdates()
} catch (e: IllegalStateException) {
Log.e("AudioRecorder", "Error resuming recording")
Log.e(TAG, "Error resuming recording")
}
}
}
}

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

override fun setRecordingParams(params: RecordingParams) {
recordingParams = params
}

@Suppress("MagicNumber")
private fun startRecordingUpdates() {
recordingJob = coroutineScope.launch {
var elapsedTime = 0
var elapsedTimeInSeconds = 0
while (recorder != null) {
delay(RECORDING_UPDATE_INTERVAL)
elapsedTime++
elapsedTimeInSeconds += (RECORDING_UPDATE_INTERVAL / 1000).toInt()
val fileSize = File(filePath).length()
_recordingUpdates.value = RecordingUpdate(
elapsedTime = elapsedTime,
elapsedTime = elapsedTimeInSeconds,
fileSize = fileSize,
fileSizeLimitExceeded = fileSize >= recordingParams.maxFileSize,
fileSizeLimitExceeded = fileSize >= recordingStrategy.maxFileSize,
)

if (fileSize >= recordingParams.maxFileSize
|| elapsedTime >= recordingParams.maxDuration) {
if ( maxFileSizeExceeded(fileSize) || maxDurationExceeded(elapsedTimeInSeconds) ) {
stopRecording()
break
}
}
}
}

/**
* Checks if the recorded file size has exceeded the specified maximum file size.
*
* @param fileSize The current size of the recorded file in bytes.
* @return `true` if the file size has exceeded the maximum file size minus the threshold, `false` otherwise.
* If `recordingParams.maxFileSize` is set to `-1L`, this function always returns `false` indicating
* no limit.
*/
private fun maxFileSizeExceeded(fileSize: Long): Boolean = when {
recordingStrategy.maxFileSize == -1L -> false
else -> fileSize >= recordingStrategy.maxFileSize - FILE_SIZE_THRESHOLD
}

/**
* Checks if the recording duration has exceeded the specified maximum duration.
*
* @param elapsedTimeInSeconds The elapsed recording time in seconds.
* @return `true` if the elapsed time has exceeded the maximum duration minus the threshold, `false` otherwise.
* If `recordingParams.maxDuration` is set to `-1`, this function always returns `false` indicating
* no limit.
*/
private fun maxDurationExceeded(elapsedTimeInSeconds: Int): Boolean = when {
recordingStrategy.maxDuration == -1 -> false
else -> elapsedTimeInSeconds >= recordingStrategy.maxDuration - DURATION_THRESHOLD
}

private fun stopRecordingUpdates() {
recordingJob?.cancel()
}

companion object {
private const val RECORDING_UPDATE_INTERVAL = 1000L
private const val RESUME_DELAY = 500L
private const val TAG = "AudioRecorder"
private const val RECORDING_UPDATE_INTERVAL = 1000L // in milliseconds
private const val RESUME_DELAY = 500L // in milliseconds
private const val FILE_SIZE_THRESHOLD = 100000L
private const val DURATION_THRESHOLD = 1
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import android.Manifest
import kotlinx.coroutines.flow.Flow

interface IAudioRecorder {
fun startRecording(onRecordingFinished: (String) -> Unit)
fun startRecording(onRecordingFinished: (AudioRecorderResult) -> Unit)
fun stopRecording()
fun pauseRecording()
fun resumeRecording()
fun recordingUpdates(): Flow<RecordingUpdate>
fun setRecordingParams(params: RecordingParams)

sealed class AudioRecorderResult {
data class Success(val recordingPath: String) : AudioRecorderResult()
data class Error(val errorMessage: String) : AudioRecorderResult()
}

companion object {
val REQUIRED_RECORDING_PERMISSIONS = arrayOf(
Expand All @@ -18,3 +22,5 @@ interface IAudioRecorder {
}
}



Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.wordpress.android.util.audio

import javax.inject.Qualifier

@Suppress("MagicNumber")
sealed class RecordingStrategy {
abstract val maxFileSize: Long
abstract val maxDuration: Int
abstract val storeInMemory: Boolean
abstract val recordingFileName: String

data class VoiceToContentRecordingStrategy(
override val maxFileSize: Long = 1000000L * 25, // 25MB
override val maxDuration: Int = 60 * 5, // 5 minutes
override val recordingFileName: String = "voice_recording.mp4",
override val storeInMemory: Boolean = true
) : RecordingStrategy()
}

// Declare here your custom annotation for each RecordingStrategy so it can be provided by Dagger
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class VoiceToContentStrategy


0 comments on commit 62d260a

Please sign in to comment.