diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4f62234..f16ec80 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -75,25 +75,16 @@ dependencies { //compose implementation(platform(Dependencies.Compose.BOM)) - androidTestImplementation(platform(Dependencies.Compose.BOM)) implementation(Dependencies.Compose.MATERIAL3) // Android Studio Preview support debugImplementation(Dependencies.Compose.UI_TOOLING_PREVIEW) debugImplementation(Dependencies.Compose.UI_TOOLING) - // UI Tests - androidTestImplementation(Dependencies.Compose.UI_TEST_JUNIT4) - debugImplementation(Dependencies.Compose.UI_TEST_MANIFEST) - // Optional - Integration with LiveData - implementation(Dependencies.Compose.LIVEDATA) - // Optional - Integration with activities + // Integration with activities implementation(Dependencies.Compose.ACTIVITY_COMPOSE) - // Optional - Integration with ViewModels + // Integration with ViewModels implementation(Dependencies.Compose.LIFECYCLE_VIEWMODEL_COMPOSE) //testing testImplementation(Dependencies.Testing.JUNIT) - testImplementation(Dependencies.Testing.JUNIT_JUPITER) - androidTestImplementation(Dependencies.Testing.ANDROIDX_TEST_EXT) - androidTestImplementation(Dependencies.Testing.ANDROIDX_TEST_ESPRESSO) } \ No newline at end of file diff --git a/app/src/androidTest/java/com/critt/interp/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/critt/interp/ExampleInstrumentedTest.kt deleted file mode 100644 index feae1cc..0000000 --- a/app/src/androidTest/java/com/critt/interp/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.critt.interp - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.critt.interp", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/critt/interp/ui/MainViewModel.kt b/app/src/main/java/com/critt/interp/ui/MainViewModel.kt index 4e9408b..07c17bf 100644 --- a/app/src/main/java/com/critt/interp/ui/MainViewModel.kt +++ b/app/src/main/java/com/critt/interp/ui/MainViewModel.kt @@ -1,8 +1,11 @@ package com.critt.interp.ui +import android.annotation.SuppressLint +import androidx.annotation.RequiresPermission import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.critt.data.ApiResult +import com.critt.data.AudioRecorderFactory import com.critt.data.AudioSource import com.critt.data.LanguageRepository import com.critt.domain.LanguageData @@ -23,10 +26,13 @@ import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( - private val audioSource: AudioSource, + private val audioRecorderFactory: AudioRecorderFactory, private val translationSource: TranslationSource, private val languageRepo: LanguageRepository ) : ViewModel() { + // AudioSource + lateinit var audioSource: AudioSource + // Supported languages state private val _supportedLanguages = MutableStateFlow>>(ApiResult.Loading) @@ -98,6 +104,13 @@ class MainViewModel @Inject constructor( } } + @RequiresPermission(android.Manifest.permission.RECORD_AUDIO) + private fun initAudioSource() { + if (!::audioSource.isInitialized) { + audioSource = AudioSource(audioRecorderFactory.create()) + } + } + /** * Cleans up resources when the ViewModel is cleared. * @@ -169,9 +182,14 @@ class MainViewModel @Inject constructor( * * It starts or stops recording and streaming based on the current state. */ + @RequiresPermission(android.Manifest.permission.RECORD_AUDIO) fun toggleStreaming() { when (_streamingState.value) { - is AudioStreamingState.Idle -> startRecordingAndStreaming() + is AudioStreamingState.Idle -> { + initAudioSource() + startRecordingAndStreaming() + } + is AudioStreamingState.Streaming -> { stopRecordingAndStreaming() _streamingState.update { AudioStreamingState.Idle } @@ -179,6 +197,7 @@ class MainViewModel @Inject constructor( is AudioStreamingState.Error -> { stopRecordingAndStreaming() + initAudioSource() startRecordingAndStreaming() } } @@ -262,7 +281,9 @@ class MainViewModel @Inject constructor( * or when an error occurs that requires stopping the streaming process. */ private fun stopRecordingAndStreaming() { - audioSource.stopRecording() + if (::audioSource.isInitialized) { + audioSource.stopRecording() + } translationSource.disconnectAllSockets() } diff --git a/build.gradle.kts b/build.gradle.kts index 8c06eb3..a43be6d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("com.android.application") version Dependencies.PluginVersions.ANDROID_GRADLE apply false id("com.android.library") version Dependencies.PluginVersions.ANDROID_GRADLE apply false id("org.jetbrains.kotlin.android") version Dependencies.PluginVersions.KOTLIN apply false - kotlin("plugin.serialization") version Dependencies.PluginVersions.SERIALIZATION apply false + // kotlin("plugin.serialization") version Dependencies.PluginVersions.SERIALIZATION apply false id("com.google.dagger.hilt.android") version Dependencies.PluginVersions.HILT apply false id("com.google.gms.google-services") version Dependencies.PluginVersions.GOOGLE_SERVICES apply false } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index cd96c47..9af2799 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -35,12 +35,8 @@ object Dependencies { object Compose { const val BOM = "androidx.compose:compose-bom:2024.10.01" const val MATERIAL3 = "androidx.compose.material3:material3" - const val LIVEDATA = "androidx.compose.runtime:runtime-livedata" const val UI_TOOLING_PREVIEW = "androidx.compose.ui:ui-tooling-preview" const val UI_TOOLING = "androidx.compose.ui:ui-tooling" - const val UI_TEST_JUNIT4 = "androidx.compose.ui:ui-test-junit4" - const val UI_TEST_MANIFEST = "androidx.compose.ui:ui-test-manifest" - const val ACTIVITY_COMPOSE = "androidx.activity:activity-compose:1.10.0" const val LIFECYCLE_VIEWMODEL_COMPOSE = "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7" } @@ -50,10 +46,34 @@ object Dependencies { } object Testing { - const val JUNIT = "junit:junit:4.13.2" - const val JUNIT_JUPITER = "org.junit.jupiter:junit-jupiter:5.8.1" - const val ANDROIDX_TEST_EXT = "androidx.test.ext:junit:1.2.1" - const val ANDROIDX_TEST_ESPRESSO = "androidx.test.espresso:espresso-core:3.6.1" + // JUnit + private const val JUNIT_VERSION = "4.13.2" + const val JUNIT = "junit:junit:${JUNIT_VERSION}" + + // Mockito + private const val MOCKITO_VERSION = "5.2.0" + private const val MOCKITO_KOTLIN_VERSION = "5.1.0" + const val MOCKITO_CORE = "org.mockito:mockito-core:${MOCKITO_VERSION}" + const val MOCKITO_INLINE = "org.mockito:mockito-inline:${MOCKITO_VERSION}" + const val MOCKITO_KOTLIN = "org.mockito.kotlin:mockito-kotlin:${MOCKITO_KOTLIN_VERSION}" + + // Truth + private const val TRUTH_VERSION = "1.1.5" + const val TRUTH = "com.google.truth:truth:${TRUTH_VERSION}" + + // Coroutines test support + private const val COROUTINES_TEST_VERSION = "1.7.3" + const val COROUTINES_TEST = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${COROUTINES_TEST_VERSION}" + + // AndroidX Test library for testing Android components + private const val ANDROIDX_TEST_VERSION = "1.5.0" + private const val ANDROIDX_TEST_EXT_VERSION = "1.1.5" + + const val ANDROIDX_TEST_CORE = "androidx.test:core:${ANDROIDX_TEST_VERSION}" + const val ANDROIDX_TEST_RULES = "androidx.test:rules:${ANDROIDX_TEST_VERSION}" + const val ANDROIDX_TEST_EXT_JUNIT = "androidx.test.ext:junit:${ANDROIDX_TEST_EXT_VERSION}" + const val ANDROIDX_TEST_EXT_JUNIT_KTX = "androidx.test.ext:junit-ktx:${ANDROIDX_TEST_EXT_VERSION}" + const val ANDROIDX_TEST_RUNNER = "androidx.test:runner:${ANDROIDX_TEST_VERSION}" } object Crypto { diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 3f40fc1..4977976 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -33,5 +33,4 @@ android { dependencies { //testing testImplementation(Dependencies.Testing.JUNIT) - testImplementation(Dependencies.Testing.JUNIT_JUPITER) } \ No newline at end of file diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 546bfc4..b2e92b3 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -45,6 +45,7 @@ android { dependencies { implementation(project(":domain")) implementation(project(":core")) + testImplementation(kotlin("test")) implementation(Dependencies.SocketIO.SOCKET_IO) { exclude(group = "org.json", module = "json") @@ -66,7 +67,31 @@ dependencies { kapt(Dependencies.Hilt.ANDROID_COMPILER) implementation(Dependencies.Hilt.ANDROID) - //testing - testImplementation(Dependencies.Testing.JUNIT) - testImplementation(Dependencies.Testing.JUNIT_JUPITER) -} \ No newline at end of file + //testing - JUnit + //testImplementation(Dependencies.Testing.JUNIT) + testImplementation("org.junit.jupiter:junit-jupiter:5.8.1") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.1") + //testing - Mockito + testImplementation(Dependencies.Testing.MOCKITO_CORE) + testImplementation(Dependencies.Testing.MOCKITO_INLINE) + testImplementation(Dependencies.Testing.MOCKITO_KOTLIN) + //testing - Truth + testImplementation(Dependencies.Testing.TRUTH) + //testing - support + testImplementation(Dependencies.Testing.COROUTINES_TEST) + testImplementation(Dependencies.Testing.ANDROIDX_TEST_CORE) + testImplementation(Dependencies.Testing.ANDROIDX_TEST_RULES) + testImplementation(Dependencies.Testing.ANDROIDX_TEST_RUNNER) + testImplementation(Dependencies.Testing.ANDROIDX_TEST_EXT_JUNIT) + testImplementation(Dependencies.Testing.ANDROIDX_TEST_EXT_JUNIT_KTX) +} + + + +tasks.withType { + useJUnitPlatform() + + testLogging { // This is for logging and can be removed. + events("passed", "skipped", "failed") + } +} diff --git a/data/src/main/java/com/critt/data/AudioRecorder.kt b/data/src/main/java/com/critt/data/AudioRecorder.kt new file mode 100644 index 0000000..cdd8d14 --- /dev/null +++ b/data/src/main/java/com/critt/data/AudioRecorder.kt @@ -0,0 +1,68 @@ +package com.critt.data + +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import androidx.annotation.RequiresPermission + + +class AudioRecorder(val audioRecord: AudioRecord, override val bufferSize: Int) : IAudioRecorder { + override val recordingState: Int + get() = audioRecord.recordingState + override val state: Int + get() = audioRecord.state + + override fun startRecording() { + audioRecord.startRecording() + } + + override fun stop() { + audioRecord.stop() + } + + override fun read(audioBuffer: ByteArray, offsetInBytes: Int, sizeInBytes: Int): Int { + return audioRecord.read(audioBuffer, offsetInBytes, sizeInBytes) + } + + override fun release() { + audioRecord.release() + } +} + +interface IAudioRecorder { + val bufferSize: Int + val recordingState: Int + val state: Int + fun startRecording() + fun stop() + fun read(audioBuffer: ByteArray, offsetInBytes: Int, sizeInBytes: Int): Int + fun release() +} + +class AudioRecorderFactory { + @RequiresPermission(android.Manifest.permission.RECORD_AUDIO) + fun create( + audioSource: Int = MediaRecorder.AudioSource.MIC, + sampleRateInHz: Int = 16000, + channelConfig: Int = AudioFormat.CHANNEL_IN_MONO, + audioFormat: Int = AudioFormat.ENCODING_PCM_16BIT, + minBufferSize: Int = 2048 + ): IAudioRecorder { + val minDeviceBufferSize: Int = AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat) + when (minDeviceBufferSize) { + AudioRecord.ERROR_BAD_VALUE -> throw UnsupportedOperationException("Recording parameters not supported") + AudioRecord.ERROR -> throw RuntimeException("Failed to query device") + } + + return AudioRecorder( + AudioRecord( + audioSource, + sampleRateInHz, + channelConfig, + audioFormat, + minDeviceBufferSize.coerceAtLeast(minBufferSize) + ), + minDeviceBufferSize.coerceAtLeast(minBufferSize) + ) + } +} \ No newline at end of file diff --git a/data/src/main/java/com/critt/data/AudioSource.kt b/data/src/main/java/com/critt/data/AudioSource.kt index 4dc783a..0208f31 100644 --- a/data/src/main/java/com/critt/data/AudioSource.kt +++ b/data/src/main/java/com/critt/data/AudioSource.kt @@ -1,9 +1,7 @@ package com.critt.data import android.annotation.SuppressLint -import android.media.AudioFormat import android.media.AudioRecord -import android.media.MediaRecorder import android.os.Process import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -25,12 +23,9 @@ import kotlinx.coroutines.withContext * @param audioFormat The audio format (default: PCM 16-bit). */ class AudioSource( - private val sampleRate: Int = 16000, - private val channelConfig: Int = AudioFormat.CHANNEL_IN_MONO, - private val audioFormat: Int = AudioFormat.ENCODING_PCM_16BIT + private val audioRecorder: IAudioRecorder ) { - private var recorder: AudioRecord? = null private var recordingJob: Job? = null /** @@ -39,38 +34,21 @@ class AudioSource( * @param onData Callback function to receive audio data as a ByteArray. * @throws IllegalStateException If the audio recording is already started. */ - @SuppressLint("MissingPermission") fun startRecording(scope: CoroutineScope, onData: (ByteArray) -> Unit) { if (recordingJob?.isActive == true) { throw IllegalStateException("Audio recording is already started.") } - val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) - if (minBufferSize == AudioRecord.ERROR_BAD_VALUE || minBufferSize == AudioRecord.ERROR) { - throw IllegalStateException("Invalid audio parameters.") - } - - val bufferSize = minBufferSize.coerceAtLeast(2048) // Ensure a minimum buffer size - - recorder = AudioRecord( - /* audioSource = */ MediaRecorder.AudioSource.MIC, - /* sampleRateInHz = */ sampleRate, - /* channelConfig = */ channelConfig, - /* audioFormat = */ audioFormat, - /* bufferSizeInBytes = */ bufferSize - ).apply { - if (state != AudioRecord.STATE_INITIALIZED) { - throw IllegalStateException("AudioRecord initialization failed.") - } + if (audioRecorder.state != AudioRecord.STATE_INITIALIZED) { + throw IllegalStateException("AudioRecord initialization failed.") } recordingJob = scope.launch(Dispatchers.IO) { - Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO) - recorder?.startRecording() + audioRecorder.startRecording() - val buffer = ByteArray(bufferSize) + val buffer = ByteArray(audioRecorder.bufferSize) while (isActive) { - val bytesRead = recorder?.read(buffer, 0, buffer.size) ?: -1 + val bytesRead = audioRecorder.read(buffer, 0, buffer.size) ?: -1 when { bytesRead > 0 -> { val data = buffer.copyOf(bytesRead) @@ -103,7 +81,7 @@ class AudioSource( private suspend fun stopRecordingInternal() { withContext(NonCancellable) { var exception: Throwable? = null - recorder?.apply { + audioRecorder.apply { if (recordingState == AudioRecord.RECORDSTATE_RECORDING) { runCatching { stop() } .exceptionOrNull() @@ -119,8 +97,8 @@ class AudioSource( println("Error releasing AudioRecord: ${it.message}") } } - recorder = null exception?.let { throw it } } } -} \ No newline at end of file +} + diff --git a/data/src/main/java/com/critt/data/di/AudioModule.kt b/data/src/main/java/com/critt/data/di/AudioModule.kt index e42f2d5..53e31f3 100644 --- a/data/src/main/java/com/critt/data/di/AudioModule.kt +++ b/data/src/main/java/com/critt/data/di/AudioModule.kt @@ -1,6 +1,6 @@ package com.critt.data.di -import com.critt.data.AudioSource +import com.critt.data.AudioRecorderFactory import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -12,6 +12,6 @@ import javax.inject.Singleton object AudioModule { @Provides @Singleton - fun provideAudioSource(): AudioSource = - AudioSource() + fun provideAudioRecorderFactory(): AudioRecorderFactory = + AudioRecorderFactory() } \ No newline at end of file diff --git a/data/src/test/java/.DS_Store b/data/src/test/java/.DS_Store new file mode 100644 index 0000000..0a8bc21 Binary files /dev/null and b/data/src/test/java/.DS_Store differ diff --git a/data/src/test/java/com/critt/data/AudioSourceTest.kt b/data/src/test/java/com/critt/data/AudioSourceTest.kt new file mode 100644 index 0000000..3091665 --- /dev/null +++ b/data/src/test/java/com/critt/data/AudioSourceTest.kt @@ -0,0 +1,77 @@ +package com.critt.data + +import android.media.AudioRecord +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import org.junit.jupiter.api.BeforeEach +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + + +class AudioSourceTest { + private lateinit var audioSource: AudioSource + private lateinit var mockAudioRecord: IAudioRecorder + private lateinit var testScope: TestScope + private lateinit var testDispatcher: TestDispatcher + + @OptIn(ExperimentalCoroutinesApi::class) + @BeforeEach + fun setup() { + testDispatcher = StandardTestDispatcher(TestCoroutineScheduler()) + Dispatchers.setMain(testDispatcher) + testScope = TestScope(testDispatcher) + mockAudioRecord = mock(IAudioRecorder::class.java) + audioSource = AudioSource(mockAudioRecord) + } + + @AfterEach + fun tearDown() { + testScope.cancel() + } + + @Test + fun `startRecording should throw IllegalStateException if already started`() = runTest { + // Given + whenever(mockAudioRecord.state).thenReturn(AudioRecord.STATE_INITIALIZED) + audioSource.startRecording(testScope) {} + + // When & Then + assertThrows { + audioSource.startRecording(testScope) {} + } + } + + @Test + fun `startRecording should throw IllegalStateException if AudioRecorder not initialized`() = runTest { + // Given + whenever(mockAudioRecord.state).thenReturn(AudioRecord.STATE_UNINITIALIZED) + + // When & Then + assertThrows { + audioSource.startRecording(testScope) {} + } + } + + @Test + fun `startRecording should call AudioRecorder startRecording()`() = runTest { + // Given + whenever(mockAudioRecord.state).thenReturn(AudioRecord.STATE_INITIALIZED) + + // When + audioSource.startRecording(testScope) {} + + // Then + verify(mockAudioRecord).startRecording() + } +} \ No newline at end of file diff --git a/data/src/test/java/com/critt/data/ExampleUnitTest.kt b/data/src/test/java/com/critt/data/ExampleUnitTest.kt index 4cb090d..fa60c11 100644 --- a/data/src/test/java/com/critt/data/ExampleUnitTest.kt +++ b/data/src/test/java/com/critt/data/ExampleUnitTest.kt @@ -1,17 +1,17 @@ package com.critt.data -import org.junit.Test - -import org.junit.Assert.* +//import org.junit.Test +// +//import org.junit.Assert.* /** * Example local unit test, which will execute on the development machine (host). * * See [testing documentation](http://d.android.com/tools/testing). */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file +//class ExampleUnitTest { +// @Test +// fun addition_isCorrect() { +// assertEquals(4, 2 + 2) +// } +//} \ No newline at end of file diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index e2d735f..b24d4eb 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -37,5 +37,4 @@ dependencies { //testing testImplementation(Dependencies.Testing.JUNIT) - testImplementation(Dependencies.Testing.JUNIT_JUPITER) } \ No newline at end of file diff --git a/ui_common/build.gradle.kts b/ui_common/build.gradle.kts index eabfedd..038fd5a 100644 --- a/ui_common/build.gradle.kts +++ b/ui_common/build.gradle.kts @@ -50,5 +50,4 @@ dependencies { //testing testImplementation(Dependencies.Testing.JUNIT) - testImplementation(Dependencies.Testing.JUNIT_JUPITER) } \ No newline at end of file