diff --git a/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java b/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java index adf1928cd08c..ddd839347355 100644 --- a/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java +++ b/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java @@ -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; @@ -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(); } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/RecordingUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/RecordingUseCase.kt index 52155db015c5..3552ea314fe2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/RecordingUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/RecordingUseCase.kt @@ -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) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModel.kt index 35aa99f89964..845d7e533f98 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModel.kt @@ -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( @@ -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)) + } } } } diff --git a/WordPress/src/main/java/org/wordpress/android/util/audio/AudioRecorder.kt b/WordPress/src/main/java/org/wordpress/android/util/audio/AudioRecorder.kt index d80ae2801819..7b6f73ad6d90 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/audio/AudioRecorder.kt +++ b/WordPress/src/main/java/org/wordpress/android/util/audio/AudioRecorder.kt @@ -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 @@ -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 { @@ -54,30 +53,44 @@ class AudioRecorder( val isPaused: StateFlow = _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)) } } @@ -88,7 +101,7 @@ class AudioRecorder( release() } } catch (e: IllegalStateException) { - Log.e("AudioRecorder", "Error stopping recording") + Log.e(TAG, "Error stopping recording: ${e.message}") } finally { recorder = null stopRecordingUpdates() @@ -96,20 +109,18 @@ class AudioRecorder( _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}") } } @@ -123,7 +134,7 @@ class AudioRecorder( isPausedRecording = false startRecordingUpdates() } catch (e: IllegalStateException) { - Log.e("AudioRecorder", "Error resuming recording") + Log.e(TAG, "Error resuming recording") } } } @@ -131,37 +142,63 @@ class AudioRecorder( override fun recordingUpdates(): Flow = 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 } } diff --git a/WordPress/src/main/java/org/wordpress/android/util/audio/IAudioRecorder.kt b/WordPress/src/main/java/org/wordpress/android/util/audio/IAudioRecorder.kt index 696416bd8372..73ab0ca30725 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/audio/IAudioRecorder.kt +++ b/WordPress/src/main/java/org/wordpress/android/util/audio/IAudioRecorder.kt @@ -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 - 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( @@ -18,3 +22,5 @@ interface IAudioRecorder { } } + + diff --git a/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingStrategy.kt b/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingStrategy.kt new file mode 100644 index 000000000000..24339b323230 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingStrategy.kt @@ -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 + +