From ddf51971d176f64a1a521c190fc7b9b72c512f45 Mon Sep 17 00:00:00 2001 From: Siyabonga Buthelezi <114085572+ElliotBadinger@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:04:41 +0200 Subject: [PATCH] Agent Session 2025-10-07: Harden SaidIt service tests --- .../java/eu/mrogalski/saidit/AudioMemory.kt | 6 +- .../java/eu/mrogalski/saidit/SaidItService.kt | 53 ++-- .../eu/mrogalski/saidit/AacMp4Writer.kt | 7 +- .../eu/mrogalski/saidit/AudioSampleWriter.kt | 15 ++ .../eu/mrogalski/saidit/ExampleUnitTest.java | 17 -- .../eu/mrogalski/saidit/SaidItServiceTest.kt | 76 ------ .../eu/mrogalski/StringFormatTest.kt | 0 .../eu/mrogalski/saidit/AudioMemoryTest.kt | 0 .../eu/mrogalski/saidit/FakeClock.kt | 0 .../eu/mrogalski/saidit/MainDispatcherRule.kt | 24 ++ .../eu/mrogalski/saidit/SaidItServiceTest.kt | 233 ++++++++++++++++++ .../saidit/TestAudioSampleWriters.kt | 39 +++ .../eu/mrogalski/saidit/UserInfoTest.kt | 0 13 files changed, 356 insertions(+), 114 deletions(-) create mode 100644 SaidIt/src/main/kotlin/eu/mrogalski/saidit/AudioSampleWriter.kt delete mode 100644 SaidIt/src/test/java/eu/mrogalski/saidit/ExampleUnitTest.java delete mode 100644 SaidIt/src/test/java/eu/mrogalski/saidit/SaidItServiceTest.kt rename SaidIt/src/test/{java => kotlin}/eu/mrogalski/StringFormatTest.kt (100%) rename SaidIt/src/test/{java => kotlin}/eu/mrogalski/saidit/AudioMemoryTest.kt (100%) rename SaidIt/src/test/{java => kotlin}/eu/mrogalski/saidit/FakeClock.kt (100%) create mode 100644 SaidIt/src/test/kotlin/eu/mrogalski/saidit/MainDispatcherRule.kt create mode 100644 SaidIt/src/test/kotlin/eu/mrogalski/saidit/SaidItServiceTest.kt create mode 100644 SaidIt/src/test/kotlin/eu/mrogalski/saidit/TestAudioSampleWriters.kt rename SaidIt/src/test/{java => kotlin}/eu/mrogalski/saidit/UserInfoTest.kt (100%) diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/AudioMemory.kt b/SaidIt/src/main/java/eu/mrogalski/saidit/AudioMemory.kt index 5db0947b..193e329c 100644 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/AudioMemory.kt +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/AudioMemory.kt @@ -8,7 +8,7 @@ import java.nio.ByteBuffer * Modern Kotlin implementation of AudioMemory with Result error handling. * Manages a ring buffer for audio data with improved error handling and thread safety. */ -class AudioMemory(private val clock: Clock) { +open class AudioMemory(private val clock: Clock) { companion object { // Keep chunk size as allocation granularity (20s @ 48kHz mono 16-bit) @@ -54,7 +54,7 @@ class AudioMemory(private val clock: Clock) { * @return Result indicating success or failure */ @Synchronized - fun allocate(sizeToEnsure: Long): Result { + open fun allocate(sizeToEnsure: Long): Result { return try { var required = 0 while (required < sizeToEnsure) required += CHUNK_SIZE @@ -215,7 +215,7 @@ class AudioMemory(private val clock: Clock) { * @return Result indicating success or failure */ @Synchronized - fun dump(consumer: Consumer, bytesToDump: Int): Result { + open fun dump(consumer: Consumer, bytesToDump: Int): Result { if (capacity == 0 || ring == null || size == 0 || bytesToDump <= 0) { return Result.success(Unit) } diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItService.kt b/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItService.kt index b4c31406..747fe5c2 100644 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItService.kt +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItService.kt @@ -64,11 +64,36 @@ class SaidItService : Service() { private var mediaFile: File? = null private var audioRecord: AudioRecord? = null - private var aacWriter: AacMp4Writer? = null - private val audioMemory = AudioMemory(SystemClockWrapper()) + private var audioWriter: AudioSampleWriter? = null + private var audioMemory: AudioMemory = AudioMemory(SystemClockWrapper()) // Coroutine scope for audio operations - private val audioScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var audioDispatcher: CoroutineDispatcher = Dispatchers.Default + private var audioScope = CoroutineScope(audioDispatcher + SupervisorJob()) + + @VisibleForTesting + internal var audioSampleWriterFactory: (Int, Int, Int, File) -> AudioSampleWriter = + { sampleRate, channels, bitRate, file -> + AacMp4Writer(sampleRate, channels, bitRate, file) + } + + @VisibleForTesting + internal fun overrideAudioMemoryForTest(memory: AudioMemory) { + audioMemory = memory + } + + @VisibleForTesting + internal fun audioMemoryForTest(): AudioMemory = audioMemory + + @VisibleForTesting + internal fun overrideAudioDispatcherForTest(dispatcher: CoroutineDispatcher) { + audioScope.cancel() + audioDispatcher = dispatcher + audioScope = CoroutineScope(audioDispatcher + SupervisorJob()) + } + + @VisibleForTesting + internal fun stateForTest(): Int = state private var audioJob: Job? = null @Volatile @@ -188,8 +213,8 @@ class SaidItService : Service() { Log.e(TAG, "AUDIO RECORD ERROR: $read") return@Consumer Result.success(0) } - if (aacWriter != null && read > 0) { - aacWriter?.write(array, offset, read) + if (audioWriter != null && read > 0) { + audioWriter?.write(array, offset, read) } Result.success(read) } ?: Result.success(0) @@ -263,19 +288,19 @@ class SaidItService : Service() { try { if (isTestEnvironment) { mediaFile = null - aacWriter = null + audioWriter = null return@launch } - + mediaFile = File.createTempFile("saidit", ".m4a", cacheDir) - aacWriter = AacMp4Writer(sampleRate, 1, 96_000, mediaFile!!) + audioWriter = audioSampleWriterFactory(sampleRate, 1, 96_000, mediaFile!!) Log.d(TAG, "Recording to: ${mediaFile!!.absolutePath}") if (prependedMemorySeconds > 0) { val bytesPerSecond = (1f / getBytesToSeconds()).toInt() val bytesToDump = (prependedMemorySeconds * bytesPerSecond).toInt() audioMemory.dump(AudioMemory.LegacyConsumer { array, offset, count -> - aacWriter?.write(array, offset, count) + audioWriter?.write(array, offset, count) count }, bytesToDump) } @@ -295,18 +320,18 @@ class SaidItService : Service() { audioScope.launch { flushAudioRecord() - aacWriter?.let { writer -> + audioWriter?.let { writer -> try { writer.close() } catch (e: IOException) { Log.e(TAG, "CLOSING ERROR", e) } } - + if (wavFileReceiver != null && mediaFile != null) { saveFileToMediaStore(mediaFile!!, mediaFile!!.name, wavFileReceiver) } - aacWriter = null + audioWriter = null } } @@ -329,7 +354,7 @@ class SaidItService : Service() { val fileName = newFileName?.replace("[^a-zA-Z0-9.-]".toRegex(), "_") ?: "SaidIt_dump" dumpFile = File(cacheDir, "$fileName.m4a") - val dumper = AacMp4Writer(sampleRate, 1, 96_000, dumpFile) + val dumper = audioSampleWriterFactory(sampleRate, 1, 96_000, dumpFile) Log.d(TAG, "Dumping to: ${dumpFile.absolutePath}") val bytesPerSecond = (1f / getBytesToSeconds()).toInt() @@ -461,7 +486,7 @@ class SaidItService : Service() { val stats = audioMemory.getStats(fillRate) var recorded = 0 - aacWriter?.let { writer -> + audioWriter?.let { writer -> recorded += writer.getTotalSampleBytesWritten() recorded += stats.estimation } diff --git a/SaidIt/src/main/kotlin/eu/mrogalski/saidit/AacMp4Writer.kt b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/AacMp4Writer.kt index 588c9ae0..03bdf560 100644 --- a/SaidIt/src/main/kotlin/eu/mrogalski/saidit/AacMp4Writer.kt +++ b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/AacMp4Writer.kt @@ -5,7 +5,6 @@ import android.media.MediaCodecInfo import android.media.MediaFormat import android.media.MediaMuxer import android.util.Log -import java.io.Closeable import java.io.File import java.io.IOException import java.nio.ByteBuffer @@ -67,7 +66,7 @@ class AacMp4Writer( outFile: File, private val codecFactory: MediaCodecFactory = DefaultMediaCodecFactory(), private val muxerFactory: MediaMuxerFactory = DefaultMediaMuxerFactory() -) : Closeable { +) : AudioSampleWriter { companion object { private const val TAG = "AacMp4Writer" @@ -116,7 +115,7 @@ class AacMp4Writer( * @throws IOException if encoding or muxing fails */ @Throws(IOException::class) - fun write(data: ByteArray, offset: Int, length: Int) { + override fun write(data: ByteArray, offset: Int, length: Int) { var remaining = length var off = offset @@ -232,7 +231,7 @@ class AacMp4Writer( * Returns the total number of PCM sample bytes written. * Safe cast for typical audio file sizes. */ - fun getTotalSampleBytesWritten(): Int { + override fun getTotalSampleBytesWritten(): Int { return minOf(Int.MAX_VALUE.toLong(), totalPcmBytesWritten).toInt() } } \ No newline at end of file diff --git a/SaidIt/src/main/kotlin/eu/mrogalski/saidit/AudioSampleWriter.kt b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/AudioSampleWriter.kt new file mode 100644 index 00000000..56c75e5e --- /dev/null +++ b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/AudioSampleWriter.kt @@ -0,0 +1,15 @@ +package eu.mrogalski.saidit + +import java.io.Closeable +import java.io.IOException + +/** + * Abstraction for classes that persist PCM audio samples. + * Allows SaidItService to swap implementations during tests. + */ +interface AudioSampleWriter : Closeable { + @Throws(IOException::class) + fun write(data: ByteArray, offset: Int, length: Int) + + fun getTotalSampleBytesWritten(): Int +} diff --git a/SaidIt/src/test/java/eu/mrogalski/saidit/ExampleUnitTest.java b/SaidIt/src/test/java/eu/mrogalski/saidit/ExampleUnitTest.java deleted file mode 100644 index 0235c0ed..00000000 --- a/SaidIt/src/test/java/eu/mrogalski/saidit/ExampleUnitTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package eu.mrogalski.saidit; - -import org.junit.Test; - -import static org.junit.Assert.*; - -/** - * Example local unit test, which will execute on the development machine (host). - * - * @see Testing documentation - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() { - assertEquals(4, 2 + 2); - } -} diff --git a/SaidIt/src/test/java/eu/mrogalski/saidit/SaidItServiceTest.kt b/SaidIt/src/test/java/eu/mrogalski/saidit/SaidItServiceTest.kt deleted file mode 100644 index 82fa6abc..00000000 --- a/SaidIt/src/test/java/eu/mrogalski/saidit/SaidItServiceTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package eu.mrogalski.saidit - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.junit.MockitoJUnitRunner -import org.junit.Assert.* - -@ExperimentalCoroutinesApi -@RunWith(MockitoJUnitRunner.Silent::class) -class SaidItServiceTest { - - // Note: These are unit tests for SaidItService logic that can be tested without Android context - // Full integration tests with Android context should be in androidTest/ - - @Test - fun testServiceClassExists() { - // Verify the SaidItService class can be loaded and has expected methods - val serviceClass = SaidItService::class.java - assertNotNull("SaidItService class should exist", serviceClass) - - // Verify key methods exist - val methods = serviceClass.declaredMethods.map { it.name } - assertTrue("Should have enableListening method", methods.contains("enableListening")) - assertTrue("Should have disableListening method", methods.contains("disableListening")) - assertTrue("Should have startRecording method", methods.contains("startRecording")) - assertTrue("Should have stopRecording method", methods.contains("stopRecording")) - assertTrue("Should have dumpRecording method", methods.contains("dumpRecording")) - } - - @Test - fun testServiceConstants() { - // Test that service constants are properly defined - val companionClass = SaidItService.Companion::class.java - assertNotNull("Companion object should exist", companionClass) - } - - @Test - fun testServiceInterfaces() { - // Verify that required interfaces exist - val wavFileReceiverClass = SaidItService.WavFileReceiver::class.java - val stateCallbackClass = SaidItService.StateCallback::class.java - - assertNotNull("WavFileReceiver interface should exist", wavFileReceiverClass) - assertNotNull("StateCallback interface should exist", stateCallbackClass) - - // Verify interface methods - val wavMethods = wavFileReceiverClass.declaredMethods.map { it.name } - assertTrue("WavFileReceiver should have onSuccess method", wavMethods.contains("onSuccess")) - assertTrue("WavFileReceiver should have onFailure method", wavMethods.contains("onFailure")) - - val stateMethods = stateCallbackClass.declaredMethods.map { it.name } - assertTrue("StateCallback should have state method", stateMethods.contains("state")) - } - - @Test - fun testServiceInheritance() { - // Verify SaidItService properly extends Android Service - val serviceClass = SaidItService::class.java - val superClass = serviceClass.superclass - assertEquals("Should extend Android Service", "android.app.Service", superClass?.name) - } - - @Test - fun testBinder() { - // Test that the binder inner class exists - val binderClass = SaidItService.BackgroundRecorderBinder::class.java - assertNotNull("BackgroundRecorderBinder should exist", binderClass) - - val methods = binderClass.declaredMethods.map { it.name } - assertTrue("Binder should have getService method", methods.contains("getService")) - } - - // Note: Full functional tests with Android context mocking should be added to androidTest/ - // These unit tests verify the class structure and basic functionality without Android dependencies -} \ No newline at end of file diff --git a/SaidIt/src/test/java/eu/mrogalski/StringFormatTest.kt b/SaidIt/src/test/kotlin/eu/mrogalski/StringFormatTest.kt similarity index 100% rename from SaidIt/src/test/java/eu/mrogalski/StringFormatTest.kt rename to SaidIt/src/test/kotlin/eu/mrogalski/StringFormatTest.kt diff --git a/SaidIt/src/test/java/eu/mrogalski/saidit/AudioMemoryTest.kt b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/AudioMemoryTest.kt similarity index 100% rename from SaidIt/src/test/java/eu/mrogalski/saidit/AudioMemoryTest.kt rename to SaidIt/src/test/kotlin/eu/mrogalski/saidit/AudioMemoryTest.kt diff --git a/SaidIt/src/test/java/eu/mrogalski/saidit/FakeClock.kt b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/FakeClock.kt similarity index 100% rename from SaidIt/src/test/java/eu/mrogalski/saidit/FakeClock.kt rename to SaidIt/src/test/kotlin/eu/mrogalski/saidit/FakeClock.kt diff --git a/SaidIt/src/test/kotlin/eu/mrogalski/saidit/MainDispatcherRule.kt b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/MainDispatcherRule.kt new file mode 100644 index 00000000..e8ab8206 --- /dev/null +++ b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/MainDispatcherRule.kt @@ -0,0 +1,24 @@ +package eu.mrogalski.saidit + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher = StandardTestDispatcher() +) : TestWatcher() { + + override fun starting(description: Description?) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description?) { + Dispatchers.resetMain() + } +} diff --git a/SaidIt/src/test/kotlin/eu/mrogalski/saidit/SaidItServiceTest.kt b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/SaidItServiceTest.kt new file mode 100644 index 00000000..bc3efbd5 --- /dev/null +++ b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/SaidItServiceTest.kt @@ -0,0 +1,233 @@ +package eu.mrogalski.saidit + +import android.app.AlarmManager +import android.content.Context +import android.net.Uri +import android.os.Build +import com.siya.epistemophile.audio.pcm.PcmAudioHelper +import com.siya.epistemophile.audio.pcm.WavAudioFormat +import com.siya.epistemophile.audio.pcm.WavFileWriter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.rules.TemporaryFolder +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowLooper +import java.io.File +import java.io.IOException +import kotlin.math.PI +import kotlin.math.sin + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.R]) +class SaidItServiceTest { + + @get:Rule val mainDispatcherRule = MainDispatcherRule() + @get:Rule @JvmField val tempDir = TemporaryFolder() + + @Test + fun enableListening_reportsListeningStateViaCallback() = runTest(mainDispatcherRule.testDispatcher) { + val controller = Robolectric.buildService(SaidItService::class.java) + val service = controller.create().get() + service.overrideAudioDispatcherForTest(mainDispatcherRule.testDispatcher) + + val memory = AudioMemory(FakeClock()) + service.overrideAudioMemoryForTest(memory) + memory.allocate(AudioMemory.CHUNK_SIZE.toLong()) + fillAudioMemory(memory, generatePcmBytes(8_000, 1_600)) + + service.isTestEnvironment = true + service.enableListening() + service.isTestEnvironment = false + + val captured = mutableListOf() + service.getState(object : SaidItService.StateCallback { + override fun state( + listeningEnabled: Boolean, + recording: Boolean, + bufferedSeconds: Float, + totalSeconds: Float, + recordedSeconds: Float + ) { + captured += ServiceState(listeningEnabled, recording, bufferedSeconds, totalSeconds, recordedSeconds) + } + }) + + advanceUntilIdle() + ShadowLooper.idleMainLooper() + + val state = captured.single() + assertTrue(state.listeningEnabled) + assertFalse(state.recording) + assertTrue(state.bufferedSeconds > 0f) + assertTrue(state.totalSeconds > 0f) + controller.destroy() + } + + @Test + fun dumpRecording_persistsPcmBuffersToCache() = runTest(mainDispatcherRule.testDispatcher) { + val controller = Robolectric.buildService(SaidItService::class.java) + val service = controller.create().get() + service.overrideAudioDispatcherForTest(mainDispatcherRule.testDispatcher) + + val memory = AudioMemory(FakeClock()) + service.overrideAudioMemoryForTest(memory) + memory.allocate(AudioMemory.CHUNK_SIZE.toLong()) + val pcmBytes = generatePcmBytes(8_000, 4_000) + fillAudioMemory(memory, pcmBytes) + + service.setSampleRate(8_000) + service.audioSampleWriterFactory = { _, _, _, file -> FileBackedSampleWriter(file) } + service.isTestEnvironment = true + service.enableListening() + service.isTestEnvironment = false + + val bytesPerSecond = (1f / service.getBytesToSeconds()).toInt() + val memorySeconds = pcmBytes.size.toFloat() / bytesPerSecond + 0.05f + val fileName = "unit_test_dump" + + service.dumpRecording(memorySeconds, null, fileName) + + advanceUntilIdle() + ShadowLooper.idleMainLooper() + + val outputFile = File(service.cacheDir, "$fileName.m4a") + assertTrue(outputFile.exists()) + val persisted = outputFile.readBytes() + assertEquals(pcmBytes.size, persisted.size) + assertTrue(pcmBytes.contentEquals(persisted)) + controller.destroy() + } + + @Test + fun dumpRecording_notifiesReceiverOnIoFailure() = runTest(mainDispatcherRule.testDispatcher) { + val controller = Robolectric.buildService(SaidItService::class.java) + val service = controller.create().get() + service.overrideAudioDispatcherForTest(mainDispatcherRule.testDispatcher) + + val memory = AudioMemory(FakeClock()) + service.overrideAudioMemoryForTest(memory) + memory.allocate(AudioMemory.CHUNK_SIZE.toLong()) + fillAudioMemory(memory, generatePcmBytes(8_000, 2_000)) + + service.setSampleRate(8_000) + val failure = IOException("expected failure") + service.audioSampleWriterFactory = { _, _, _, _ -> FailingAudioSampleWriter(failure) } + service.isTestEnvironment = true + service.enableListening() + service.isTestEnvironment = false + + var receivedError: Exception? = null + val receiver = object : SaidItService.WavFileReceiver { + override fun onSuccess(fileUri: Uri) = Unit + override fun onFailure(e: Exception) { receivedError = e } + } + + val bytesPerSecond = (1f / service.getBytesToSeconds()).toInt() + val memorySeconds = 1.0f + (1f / bytesPerSecond) + service.dumpRecording(memorySeconds, receiver, "failing_dump") + + advanceUntilIdle() + ShadowLooper.idleMainLooper() + + assertNotNull(receivedError) + assertEquals(failure, receivedError) + controller.destroy() + } + + @Test + fun scheduleAutoSave_registersRepeatingAlarm() { + val controller = Robolectric.buildService(SaidItService::class.java) + val service = controller.create().get() + + val prefs = service.getSharedPreferences(SaidIt.PACKAGE_NAME, Context.MODE_PRIVATE) + prefs.edit() + .putBoolean("auto_save_enabled", true) + .putInt("auto_save_duration", 120) + .apply() + + service.scheduleAutoSave() + + val alarmManager = service.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val shadow = shadowOf(alarmManager) + val scheduled = shadow.nextScheduledAlarm + assertNotNull("Alarm should be scheduled", scheduled) + scheduled!! + assertEquals(AlarmManager.ELAPSED_REALTIME_WAKEUP, scheduled.type) + assertEquals(120_000L, scheduled.interval) + val pendingShadow = shadowOf(scheduled.operation) + assertEquals(SaidItService::class.java.name, pendingShadow.savedIntent.component?.className) + controller.destroy() + } + + @Test + fun enableListening_allocationFailureRevertsToReadyState() = runTest(mainDispatcherRule.testDispatcher) { + val controller = Robolectric.buildService(SaidItService::class.java) + val service = controller.create().get() + service.overrideAudioDispatcherForTest(mainDispatcherRule.testDispatcher) + + val failingMemory = object : AudioMemory(FakeClock()) { + var attempts = 0 + override fun allocate(sizeToEnsure: Long): Result { + attempts++ + return Result.failure(AudioMemoryException("failed to allocate")) + } + } + service.overrideAudioMemoryForTest(failingMemory) + + val initialState = service.stateForTest() + service.isTestEnvironment = false + service.enableListening() + + advanceUntilIdle() + ShadowLooper.idleMainLooper() + + assertEquals(1, failingMemory.attempts) + assertEquals(initialState, service.stateForTest()) + controller.destroy() + } + + private fun generatePcmBytes(sampleRate: Int, sampleCount: Int): ByteArray { + val wavFormat = WavAudioFormat.mono16Bit(sampleRate) + val samples = IntArray(sampleCount) { index -> + (Short.MAX_VALUE * sin(2.0 * PI * index / sampleCount)).toInt() + } + val wavFile = tempDir.newFile("input_${sampleRate}_$sampleCount.wav") + WavFileWriter(wavFormat, wavFile).use { writer -> writer.write(samples) } + val rawFile = tempDir.newFile("input_${sampleRate}_$sampleCount.pcm") + PcmAudioHelper.convertWavToRaw(wavFile, rawFile) + return rawFile.readBytes() + } + + private fun fillAudioMemory(memory: AudioMemory, pcmBytes: ByteArray) { + var offset = 0 + memory.fill(AudioMemory.Consumer { array, arrayOffset, available -> + if (offset >= pcmBytes.size) { + return@Consumer Result.success(0) + } + val toCopy = minOf(available, pcmBytes.size - offset) + System.arraycopy(pcmBytes, offset, array, arrayOffset, toCopy) + offset += toCopy + Result.success(toCopy) + }).getOrThrow() + } + + private data class ServiceState( + val listeningEnabled: Boolean, + val recording: Boolean, + val bufferedSeconds: Float, + val totalSeconds: Float, + val recordedSeconds: Float + ) +} diff --git a/SaidIt/src/test/kotlin/eu/mrogalski/saidit/TestAudioSampleWriters.kt b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/TestAudioSampleWriters.kt new file mode 100644 index 00000000..ff97551b --- /dev/null +++ b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/TestAudioSampleWriters.kt @@ -0,0 +1,39 @@ +package eu.mrogalski.saidit + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +class FileBackedSampleWriter(private val target: File) : AudioSampleWriter { + private val output = FileOutputStream(target) + private var written = 0 + + @Throws(IOException::class) + override fun write(data: ByteArray, offset: Int, length: Int) { + output.write(data, offset, length) + written += length + } + + override fun close() { + output.flush() + output.close() + } + + override fun getTotalSampleBytesWritten(): Int = written +} + +class FailingAudioSampleWriter( + private val failure: IOException = IOException("Deliberate write failure") +) : AudioSampleWriter { + + @Throws(IOException::class) + override fun write(data: ByteArray, offset: Int, length: Int) { + throw failure + } + + override fun close() { + // no-op + } + + override fun getTotalSampleBytesWritten(): Int = 0 +} diff --git a/SaidIt/src/test/java/eu/mrogalski/saidit/UserInfoTest.kt b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/UserInfoTest.kt similarity index 100% rename from SaidIt/src/test/java/eu/mrogalski/saidit/UserInfoTest.kt rename to SaidIt/src/test/kotlin/eu/mrogalski/saidit/UserInfoTest.kt