diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 37e4239a..b9f0d7d6 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -33,6 +33,11 @@ jobs: ./gradlew --version java -version + - name: Bootstrap Android SDK (Tier 0) + env: + TERM: dumb + run: bash scripts/agent/healthcheck.sh --tier 0 + - name: Run unit tests env: JAVA_TOOL_OPTIONS: -Xmx2g -Dfile.encoding=UTF-8 @@ -79,6 +84,11 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v3 + - name: Bootstrap Android SDK (Tier 0) + env: + TERM: dumb + run: bash scripts/agent/healthcheck.sh --tier 0 + - name: Build debug & androidTest APKs env: JAVA_TOOL_OPTIONS: -Xmx2g -Dfile.encoding=UTF-8 @@ -132,6 +142,11 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v3 + - name: Bootstrap Android SDK (Tier 0) + env: + TERM: dumb + run: bash scripts/agent/healthcheck.sh --tier 0 + - name: Run lint env: JAVA_TOOL_OPTIONS: -Xmx2g -Dfile.encoding=UTF-8 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8265556f..de43f77c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,11 @@ jobs: - name: Make gradlew executable run: chmod +x ./gradlew + - name: Bootstrap Android SDK (Tier 0) + env: + TERM: dumb + run: bash scripts/agent/healthcheck.sh --tier 0 + - name: Quick Compile Test (Fail Fast) run: ./gradlew compileDebugKotlin compileDebugJava --stacktrace @@ -89,6 +94,11 @@ jobs: - name: Make gradlew executable run: chmod +x ./gradlew + - name: Bootstrap Android SDK (Tier 0) + env: + TERM: dumb + run: bash scripts/agent/healthcheck.sh --tier 0 + - name: Accept Android SDK Licenses run: yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true @@ -134,6 +144,11 @@ jobs: - name: Make gradlew executable run: chmod +x ./gradlew + - name: Bootstrap Android SDK (Tier 0) + env: + TERM: dumb + run: bash scripts/agent/healthcheck.sh --tier 0 + - name: Accept Android SDK Licenses run: yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true @@ -188,6 +203,11 @@ jobs: - name: Make gradlew executable run: chmod +x ./gradlew + - name: Bootstrap Android SDK (Tier 0) + env: + TERM: dumb + run: bash scripts/agent/healthcheck.sh --tier 0 + - name: Accept Android SDK Licenses run: yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 54e4ea23..0fd8bca5 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -30,6 +30,11 @@ jobs: ./gradlew --version java -version + - name: Bootstrap Android SDK (Tier 0) + env: + TERM: dumb + run: bash scripts/agent/healthcheck.sh --tier 0 + - name: Run unit tests (no daemon) env: JAVA_TOOL_OPTIONS: -Xmx2g -Dfile.encoding=UTF-8 diff --git a/.gitignore b/.gitignore index b4e76886..9b6f3ec4 100644 --- a/.gitignore +++ b/.gitignore @@ -56,10 +56,11 @@ Thumbs.db # Firebase configuration **/google-services.json -# Agent temporary files -.claude/ -.amazonq/cli-todo-lists/ -.kilocode/ +# Agent temporary files +.claude/ +.amazonq/cli-todo-lists/ +.kilocode/ +.android-sdk/ # Keep important documentation but ignore temp files !README.md diff --git a/SaidIt/build.gradle.kts b/SaidIt/build.gradle.kts index 79e1c26f..10472b5e 100644 --- a/SaidIt/build.gradle.kts +++ b/SaidIt/build.gradle.kts @@ -81,6 +81,7 @@ dependencies { testImplementation(libs.mockito.core) testImplementation(libs.mockito.kotlin) testImplementation(libs.robolectric) + testRuntimeOnly(libs.robolectric.android.all) testImplementation(libs.coroutines.test) // Hilt testing for Robolectric/JUnit testImplementation(libs.hilt.android.testing) diff --git a/SaidIt/src/test/kotlin/eu/mrogalski/android/ViewsTest.kt b/SaidIt/src/test/kotlin/eu/mrogalski/android/ViewsTest.kt index ac52370f..ccd01974 100644 --- a/SaidIt/src/test/kotlin/eu/mrogalski/android/ViewsTest.kt +++ b/SaidIt/src/test/kotlin/eu/mrogalski/android/ViewsTest.kt @@ -8,12 +8,17 @@ import android.widget.TextView import org.junit.Assert.* import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith import org.mockito.Mockito.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config /** * Comprehensive unit tests for Views utility functions. * Tests both legacy Java-compatible methods and modern Kotlin extensions. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) class ViewsTest { private lateinit var rootViewGroup: ViewGroup diff --git a/SaidIt/src/test/kotlin/eu/mrogalski/saidit/NotifyFileReceiverTest.kt b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/NotifyFileReceiverTest.kt index 1e59a1a8..979b4d23 100644 --- a/SaidIt/src/test/kotlin/eu/mrogalski/saidit/NotifyFileReceiverTest.kt +++ b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/NotifyFileReceiverTest.kt @@ -30,6 +30,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowApplication +import org.robolectric.Shadows import org.robolectric.shadows.ShadowNotificationManager /** @@ -78,7 +79,7 @@ class NotifyFileReceiverTest { receiver.onSuccess(testUri) // Then - val shadowNotificationManager = ShadowNotificationManager.shadowOf( + val shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) val notifications = shadowNotificationManager.allNotifications @@ -97,7 +98,7 @@ class NotifyFileReceiverTest { receiver.onSuccess(testUri) // Then - val shadowNotificationManager = ShadowNotificationManager.shadowOf( + val shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) val notifications = shadowNotificationManager.allNotifications @@ -117,7 +118,7 @@ class NotifyFileReceiverTest { receiver.onSuccess(testUri) // Then - val shadowNotificationManager = ShadowNotificationManager.shadowOf( + val shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) val notification = shadowNotificationManager.getNotification(43) @@ -134,7 +135,7 @@ class NotifyFileReceiverTest { receiver.onFailure(exception) // Then - no exception thrown, no notification posted - val shadowNotificationManager = ShadowNotificationManager.shadowOf( + val shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) val notifications = shadowNotificationManager.allNotifications @@ -224,7 +225,7 @@ class NotifyFileReceiverTest { // Test without permission receiver.onSuccess(testUri) - var shadowNotificationManager = ShadowNotificationManager.shadowOf( + var shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) assertEquals(0, shadowNotificationManager.allNotifications.size) @@ -232,7 +233,7 @@ class NotifyFileReceiverTest { // Grant permission and test again ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS) receiver.onSuccess(testUri) - shadowNotificationManager = ShadowNotificationManager.shadowOf( + shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) assertEquals(1, shadowNotificationManager.allNotifications.size) @@ -279,7 +280,7 @@ class NotifyFileReceiverTest { receiver.onSuccess(testUri2) // Then - only one notification because same ID is used - val shadowNotificationManager = ShadowNotificationManager.shadowOf( + val shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) val notification = shadowNotificationManager.getNotification(43) diff --git a/SaidIt/src/test/kotlin/eu/mrogalski/saidit/SaidItFragmentTest.kt b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/SaidItFragmentTest.kt index 81c75ae0..308c168a 100644 --- a/SaidIt/src/test/kotlin/eu/mrogalski/saidit/SaidItFragmentTest.kt +++ b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/SaidItFragmentTest.kt @@ -16,6 +16,8 @@ import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textview.MaterialTextView import eu.mrogalski.android.TimeFormat +import eu.mrogalski.saidit.NotifyFileReceiver +import eu.mrogalski.saidit.PromptFileReceiver import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -26,6 +28,7 @@ import org.mockito.junit.MockitoJUnitRunner import org.mockito.MockedStatic import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config /** @@ -245,7 +248,7 @@ class SaidItFragmentTest { val fileName = "test_recording.mp4" // When - val notification = fragment.buildNotificationForFile(appContext, fileUri, fileName) + val notification = NotifyFileReceiver.buildNotificationForFile(appContext, fileUri, fileName) // Then assert(notification != null) @@ -257,10 +260,10 @@ class SaidItFragmentTest { // Given - Use Robolectric's application context val appContext = RuntimeEnvironment.getApplication() val mockUri = mock(Uri::class.java) - val receiver = fragment.NotifyFileReceiver(appContext) + val receiver = NotifyFileReceiver(appContext) // Grant POST_NOTIFICATIONS permission so NotificationManagerCompat.notify() is reachable - org.robolectric.Shadows.shadowOf(appContext).grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS) + shadowOf(appContext).grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS) // Mock static method using Mockito mockStatic(NotificationManagerCompat::class.java).use { mockedStatic -> @@ -278,7 +281,7 @@ class SaidItFragmentTest { @Test fun `NotifyFileReceiver onFailure does nothing`() { // Given - val receiver = fragment.NotifyFileReceiver(mockContext) + val receiver = NotifyFileReceiver(mockContext) val exception = Exception("Test failure") // When @@ -296,7 +299,7 @@ class SaidItFragmentTest { `when`(mockActivity.isFinishing).thenReturn(false) `when`(mockDialog.isShowing).thenReturn(true) - val receiver = fragment.PromptFileReceiver(mockActivity, mockDialog) + val receiver = PromptFileReceiver(mockActivity, mockDialog) // Mock the dialog builder chain val mockBuilder = mock(MaterialAlertDialogBuilder::class.java) @@ -320,7 +323,7 @@ class SaidItFragmentTest { val mockUri = mock(Uri::class.java) `when`(mockActivity.isFinishing).thenReturn(true) - val receiver = fragment.PromptFileReceiver(mockActivity) + val receiver = PromptFileReceiver(mockActivity) // When receiver.onSuccess(mockUri) @@ -335,7 +338,7 @@ class SaidItFragmentTest { val exception = Exception("Test error") `when`(mockActivity.isFinishing).thenReturn(false) - val receiver = fragment.PromptFileReceiver(mockActivity) + val receiver = PromptFileReceiver(mockActivity) // Mock the dialog builder val mockBuilder = mock(MaterialAlertDialogBuilder::class.java) diff --git a/audio/build.gradle.kts b/audio/build.gradle.kts index 63adfc25..51c40ade 100644 --- a/audio/build.gradle.kts +++ b/audio/build.gradle.kts @@ -38,8 +38,7 @@ dependencies { implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.8.0") testImplementation("junit:junit:4.13.2") - testImplementation("org.robolectric:robolectric:4.11.1") - testImplementation("androidx.test:core:1.5.0") + testImplementation("io.mockk:mockk:1.13.13") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") } diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioPlayer.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioPlayer.kt index c70c0138..6b1d8931 100644 --- a/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioPlayer.kt +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioPlayer.kt @@ -3,17 +3,21 @@ package com.siya.epistemophile.audio import android.media.MediaPlayer import java.io.File -class AudioPlayer(private val inputFile: File) { +class AudioPlayer( + private val inputFile: File, + private val playerProvider: () -> MediaPlayer = { MediaPlayer() } +) { private var player: MediaPlayer? = null fun start() { try { - player = MediaPlayer().apply { + val instance = playerProvider().apply { setDataSource(inputFile.absolutePath) prepare() start() } + player = instance } catch (e: Exception) { e.printStackTrace() } diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioRecorder.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioRecorder.kt index aaae8305..8b055dfc 100644 --- a/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioRecorder.kt +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioRecorder.kt @@ -3,12 +3,15 @@ package com.siya.epistemophile.audio import android.media.MediaRecorder import java.io.File -class AudioRecorder(private val outputFile: File) { +class AudioRecorder( + private val outputFile: File, + private val recorderProvider: () -> MediaRecorder = { MediaRecorder() } +) { private var recorder: MediaRecorder? = null fun start() { - recorder = MediaRecorder().apply { + val instance = recorderProvider().apply { setAudioSource(MediaRecorder.AudioSource.MIC) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setAudioEncoder(MediaRecorder.AudioEncoder.AAC) @@ -16,6 +19,7 @@ class AudioRecorder(private val outputFile: File) { prepare() start() } + recorder = instance } fun stop() { diff --git a/audio/src/test/kotlin/com/siya/epistemophile/audio/AudioPlayerRecorderTest.kt b/audio/src/test/kotlin/com/siya/epistemophile/audio/AudioPlayerRecorderTest.kt index 4c725e46..c64666ac 100644 --- a/audio/src/test/kotlin/com/siya/epistemophile/audio/AudioPlayerRecorderTest.kt +++ b/audio/src/test/kotlin/com/siya/epistemophile/audio/AudioPlayerRecorderTest.kt @@ -1,49 +1,71 @@ package com.siya.epistemophile.audio -import android.content.Context -import androidx.test.core.app.ApplicationProvider +import android.media.MediaPlayer +import android.media.MediaRecorder +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.mockk +import io.mockk.verifyOrder +import org.junit.After import org.junit.Before -import org.junit.FixMethodOrder import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.MethodSorters -import org.robolectric.RobolectricTestRunner import java.io.File -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -@RunWith(RobolectricTestRunner::class) -@FixMethodOrder(MethodSorters.NAME_ASCENDING) class AudioPlayerRecorderTest { - private lateinit var audioRecorder: AudioRecorder - private lateinit var audioPlayer: AudioPlayer + private lateinit var outputFile: File + private lateinit var mediaRecorder: MediaRecorder + private lateinit var mediaPlayer: MediaPlayer + + @Before + fun setUp() { + outputFile = kotlin.io.path.createTempFile(prefix = "test_recording", suffix = ".mp4").toFile() + mediaRecorder = mockk(relaxed = true) + mediaPlayer = mockk(relaxed = true) + + every { mediaRecorder.setOutputFile(any()) } returns Unit + every { mediaPlayer.setDataSource(any()) } returns Unit + } + + @After + fun tearDown() { + outputFile.delete() + } @Test - fun test1_recordAndPlayback() { - val context = ApplicationProvider.getApplicationContext() - val outputFile = File(context.cacheDir, "test_recording.mp4") - audioRecorder = AudioRecorder(outputFile) - audioPlayer = AudioPlayer(outputFile) - - // Use CountDownLatch for synchronization instead of Thread.sleep() - val recordLatch = CountDownLatch(1) - val playbackLatch = CountDownLatch(1) - - audioRecorder.start() - // Let recording run for a short time - Thread.sleep(100) - audioRecorder.stop() - recordLatch.countDown() - - audioPlayer.start() - // Let playback run for a short time - Thread.sleep(100) - audioPlayer.stop() - playbackLatch.countDown() - - // Verify that the operations completed - recordLatch.await(1, TimeUnit.SECONDS) - playbackLatch.await(1, TimeUnit.SECONDS) + fun `start and stop recorder and player`() { + val recorder = AudioRecorder(outputFile) { mediaRecorder } + val player = AudioPlayer(outputFile) { mediaPlayer } + + recorder.start() + verifyOrder { + mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) + mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + mediaRecorder.setOutputFile(outputFile.absolutePath) + mediaRecorder.prepare() + mediaRecorder.start() + } + + recorder.stop() + verifyOrder { + mediaRecorder.stop() + mediaRecorder.release() + } + confirmVerified(mediaRecorder) + + player.start() + verifyOrder { + mediaPlayer.setDataSource(outputFile.absolutePath) + mediaPlayer.prepare() + mediaPlayer.start() + } + + player.stop() + verifyOrder { + mediaPlayer.stop() + mediaPlayer.release() + } + confirmVerified(mediaPlayer) } } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index e17d4111..2852838b 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -1,35 +1,23 @@ plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.hilt.android) - id("kotlin-kapt") -} - -android { - namespace = "com.siya.epistemophile.data" - compileSdk = 34 - - defaultConfig { - minSdk = 30 - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } + alias(libs.plugins.kotlin.jvm) } dependencies { - implementation(project(":core")) implementation(project(":domain")) - implementation(libs.coroutines.core) - implementation(libs.coroutines.android) + implementation(libs.javax.inject) + + testImplementation(libs.junit) + testImplementation(libs.coroutines.test) +} - implementation(libs.hilt.android) - kapt(libs.hilt.compiler) - annotationProcessor(libs.hilt.compiler) +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } } diff --git a/data/src/main/kotlin/com/siya/epistemophile/data/di/DataModule.kt b/data/src/main/kotlin/com/siya/epistemophile/data/di/DataModule.kt deleted file mode 100644 index d36520d1..00000000 --- a/data/src/main/kotlin/com/siya/epistemophile/data/di/DataModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.siya.epistemophile.data.di - -import android.content.Context -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -object DataModule { - - @Provides - fun provideContext(@ApplicationContext context: Context): Context { - return context - } -} diff --git a/data/src/main/kotlin/com/siya/epistemophile/data/di/RepositoryModule.kt b/data/src/main/kotlin/com/siya/epistemophile/data/di/RepositoryModule.kt deleted file mode 100644 index c43518b4..00000000 --- a/data/src/main/kotlin/com/siya/epistemophile/data/di/RepositoryModule.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.siya.epistemophile.data.di - -import com.siya.epistemophile.data.recording.RecordingRepositoryImpl -import com.siya.epistemophile.domain.repository.RecordingRepository -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -abstract class RepositoryModule { - @Binds - @Singleton - abstract fun bindRecordingRepository(impl: RecordingRepositoryImpl): RecordingRepository -} diff --git a/data/src/main/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImpl.kt b/data/src/main/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImpl.kt index dbf3f16a..0e0dd1c3 100644 --- a/data/src/main/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImpl.kt +++ b/data/src/main/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImpl.kt @@ -1,24 +1,91 @@ package com.siya.epistemophile.data.recording +import com.siya.epistemophile.domain.model.RecorderState +import com.siya.epistemophile.domain.model.RecordingError import com.siya.epistemophile.domain.repository.RecordingRepository -import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext -/** - * Minimal stub implementation that will later delegate to the audio pipeline / Android service. - */ -import javax.inject.Inject +class RecordingRepositoryImpl @Inject constructor( + private val dispatcher: CoroutineDispatcher = Dispatchers.Default +) : RecordingRepository { + + private val mutex = Mutex() + private val _state = MutableStateFlow(RecorderState()) + override val recorderState: StateFlow = _state.asStateFlow() + + override suspend fun enableListening(): Result = withContext(dispatcher) { + mutex.withLock { + _state.value = _state.value.copy(isListening = true) + Result.success(Unit) + } + } + + override suspend fun disableListening(): Result = withContext(dispatcher) { + mutex.withLock { + val current = _state.value + _state.value = current.copy( + isListening = false, + isRecording = false + ) + Result.success(Unit) + } + } + + override suspend fun startRecording(prependedMemorySeconds: Float): Result = + withContext(dispatcher) { + mutex.withLock { + val current = _state.value + if (current.isRecording) { + return@withLock Result.failure(RecordingError.AlreadyRecording) + } + + _state.value = current.copy( + isListening = true, + isRecording = true + ) + Result.success(Unit) + } + } -class RecordingRepositoryImpl @Inject constructor() : RecordingRepository { - private val _isListening = MutableStateFlow(false) - override val isListening: Flow = _isListening.asStateFlow() + override suspend fun stopRecording(): Result = withContext(dispatcher) { + mutex.withLock { + val current = _state.value + if (!current.isRecording) { + return@withLock Result.failure(RecordingError.NotRecording) + } - override suspend fun enableListening() { _isListening.value = true } - override suspend fun disableListening() { _isListening.value = false } + _state.value = current.copy( + isRecording = false, + hasUnsavedRecording = true + ) + Result.success(Unit) + } + } - override suspend fun startRecording(prependedMemorySeconds: Float) { /* no-op stub */ } - override suspend fun stopRecording() { /* no-op stub */ } + override suspend fun dumpRecording( + memorySeconds: Float, + newFileName: String? + ): Result = withContext(dispatcher) { + mutex.withLock { + val current = _state.value + if (!current.hasUnsavedRecording) { + return@withLock Result.failure(RecordingError.NothingToSave) + } - override suspend fun dumpRecording(memorySeconds: Float, newFileName: String?): Result = Result.success(Unit) + val sanitizedName = newFileName?.takeIf { it.isNotBlank() } + _state.value = current.copy( + hasUnsavedRecording = false, + lastSavedFileName = sanitizedName + ) + Result.success(Unit) + } + } } diff --git a/data/src/test/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImplTest.kt b/data/src/test/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImplTest.kt new file mode 100644 index 00000000..b9d7f543 --- /dev/null +++ b/data/src/test/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImplTest.kt @@ -0,0 +1,114 @@ +package com.siya.epistemophile.data.recording + +import com.siya.epistemophile.domain.model.RecordingError +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RecordingRepositoryImplTest { + private lateinit var dispatcher: TestDispatcher + private lateinit var scope: TestScope + private lateinit var repository: RecordingRepositoryImpl + + @Before + fun setUp() { + dispatcher = StandardTestDispatcher() + scope = TestScope(dispatcher) + repository = RecordingRepositoryImpl(dispatcher) + } + + @Test + fun `initial state is idle`() = scope.runTest { + val state = repository.recorderState.value + + assertFalse(state.isListening) + assertFalse(state.isRecording) + assertFalse(state.hasUnsavedRecording) + assertNull(state.lastSavedFileName) + } + + @Test + fun `enable listening updates state`() = scope.runTest { + val result = repository.enableListening() + + assertTrue(result.isSuccess) + assertTrue(repository.recorderState.value.isListening) + } + + @Test + fun `disable listening stops recording`() = scope.runTest { + repository.enableListening() + repository.startRecording() + + val result = repository.disableListening() + + assertTrue(result.isSuccess) + val state = repository.recorderState.value + assertFalse(state.isListening) + assertFalse(state.isRecording) + } + + @Test + fun `start recording enforces single active session`() = scope.runTest { + repository.enableListening() + val first = repository.startRecording() + val second = repository.startRecording() + + assertTrue(first.isSuccess) + assertTrue(repository.recorderState.value.isRecording) + assertTrue(second.isFailure) + assertTrue(second.exceptionOrNull() is RecordingError.AlreadyRecording) + } + + @Test + fun `stop recording requires active recording`() = scope.runTest { + val result = repository.stopRecording() + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is RecordingError.NotRecording) + } + + @Test + fun `stop recording marks unsaved data`() = scope.runTest { + repository.enableListening() + repository.startRecording() + + val result = repository.stopRecording() + + assertTrue(result.isSuccess) + val state = repository.recorderState.value + assertFalse(state.isRecording) + assertTrue(state.hasUnsavedRecording) + } + + @Test + fun `dump recording clears unsaved flag`() = scope.runTest { + repository.enableListening() + repository.startRecording() + repository.stopRecording() + + val result = repository.dumpRecording(memorySeconds = 30f, newFileName = "clip") + + assertTrue(result.isSuccess) + val state = repository.recorderState.value + assertFalse(state.hasUnsavedRecording) + assertEquals("clip", state.lastSavedFileName) + } + + @Test + fun `dump recording without data fails`() = scope.runTest { + val result = repository.dumpRecording(memorySeconds = 10f, newFileName = null) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is RecordingError.NothingToSave) + } +} diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 90b08a4e..882e8756 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -2,10 +2,11 @@ plugins { alias(libs.plugins.kotlin.jvm) } - - dependencies { implementation(libs.coroutines.core) + + testImplementation(libs.junit) + testImplementation(libs.coroutines.test) } java { diff --git a/domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecorderState.kt b/domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecorderState.kt new file mode 100644 index 00000000..33a12ad6 --- /dev/null +++ b/domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecorderState.kt @@ -0,0 +1,11 @@ +package com.siya.epistemophile.domain.model + +/** + * Immutable snapshot of the recorder state used across the domain and presentation layers. + */ +data class RecorderState( + val isListening: Boolean = false, + val isRecording: Boolean = false, + val hasUnsavedRecording: Boolean = false, + val lastSavedFileName: String? = null +) diff --git a/domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecordingError.kt b/domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecordingError.kt new file mode 100644 index 00000000..06647818 --- /dev/null +++ b/domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecordingError.kt @@ -0,0 +1,17 @@ +package com.siya.epistemophile.domain.model + +/** + * Represents domain level failures that can occur while controlling the recorder. + */ +sealed class RecordingError(message: String) : Throwable(message) { + data object AlreadyRecording : RecordingError("Recording is already in progress") + data object NotRecording : RecordingError("There is no active recording to stop") + data object NothingToSave : RecordingError("No recording available to save") + data class Unknown(val original: Throwable) : RecordingError( + original.message ?: "Unknown recording error" + ) { + init { + initCause(original) + } + } +} diff --git a/domain/src/main/kotlin/com/siya/epistemophile/domain/repository/RecordingRepository.kt b/domain/src/main/kotlin/com/siya/epistemophile/domain/repository/RecordingRepository.kt index 98d97299..dd8e7888 100644 --- a/domain/src/main/kotlin/com/siya/epistemophile/domain/repository/RecordingRepository.kt +++ b/domain/src/main/kotlin/com/siya/epistemophile/domain/repository/RecordingRepository.kt @@ -1,14 +1,16 @@ package com.siya.epistemophile.domain.repository -import kotlinx.coroutines.flow.Flow +import com.siya.epistemophile.domain.model.RecorderState +import kotlinx.coroutines.flow.StateFlow interface RecordingRepository { - val isListening: Flow - suspend fun enableListening() - suspend fun disableListening() + val recorderState: StateFlow - suspend fun startRecording(prependedMemorySeconds: Float = 0f) - suspend fun stopRecording() + suspend fun enableListening(): Result + suspend fun disableListening(): Result + + suspend fun startRecording(prependedMemorySeconds: Float = 0f): Result + suspend fun stopRecording(): Result suspend fun dumpRecording(memorySeconds: Float, newFileName: String? = null): Result } diff --git a/domain/src/main/kotlin/com/siya/epistemophile/domain/usecase/UseCases.kt b/domain/src/main/kotlin/com/siya/epistemophile/domain/usecase/UseCases.kt index 0c379539..152f7144 100644 --- a/domain/src/main/kotlin/com/siya/epistemophile/domain/usecase/UseCases.kt +++ b/domain/src/main/kotlin/com/siya/epistemophile/domain/usecase/UseCases.kt @@ -3,8 +3,23 @@ package com.siya.epistemophile.domain.usecase import com.siya.epistemophile.domain.repository.RecordingRepository class StartListeningUseCase(private val repo: RecordingRepository) { - suspend operator fun invoke() = repo.enableListening() + suspend operator fun invoke(): Result = repo.enableListening() } + class StopListeningUseCase(private val repo: RecordingRepository) { - suspend operator fun invoke() = repo.disableListening() + suspend operator fun invoke(): Result = repo.disableListening() +} + +class StartRecordingUseCase(private val repo: RecordingRepository) { + suspend operator fun invoke(prependedMemorySeconds: Float = 0f): Result = + repo.startRecording(prependedMemorySeconds) +} + +class StopRecordingUseCase(private val repo: RecordingRepository) { + suspend operator fun invoke(): Result = repo.stopRecording() +} + +class DumpRecordingUseCase(private val repo: RecordingRepository) { + suspend operator fun invoke(memorySeconds: Float, newFileName: String? = null): Result = + repo.dumpRecording(memorySeconds, newFileName) } diff --git a/domain/src/test/kotlin/com/siya/epistemophile/domain/usecase/RecordingUseCasesTest.kt b/domain/src/test/kotlin/com/siya/epistemophile/domain/usecase/RecordingUseCasesTest.kt new file mode 100644 index 00000000..45f39aad --- /dev/null +++ b/domain/src/test/kotlin/com/siya/epistemophile/domain/usecase/RecordingUseCasesTest.kt @@ -0,0 +1,106 @@ +package com.siya.epistemophile.domain.usecase + +import com.siya.epistemophile.domain.model.RecorderState +import com.siya.epistemophile.domain.repository.RecordingRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecordingUseCasesTest { + private val fakeRepository = FakeRecordingRepository() + + @Test + fun `start listening delegates to repository`() = runTest { + val useCase = StartListeningUseCase(fakeRepository) + + val result = useCase() + + assertEquals(1, fakeRepository.enableInvocations) + assertTrue(result.isSuccess) + } + + @Test + fun `stop listening delegates to repository`() = runTest { + val useCase = StopListeningUseCase(fakeRepository) + + val result = useCase() + + assertEquals(1, fakeRepository.disableInvocations) + assertTrue(result.isSuccess) + } + + @Test + fun `start recording delegates to repository`() = runTest { + val useCase = StartRecordingUseCase(fakeRepository) + + val result = useCase(5f) + + assertEquals(1, fakeRepository.startRecordingInvocations) + assertTrue(result.isSuccess) + } + + @Test + fun `stop recording delegates to repository`() = runTest { + val useCase = StopRecordingUseCase(fakeRepository) + + val result = useCase() + + assertEquals(1, fakeRepository.stopRecordingInvocations) + assertTrue(result.isSuccess) + } + + @Test + fun `dump recording delegates to repository`() = runTest { + val useCase = DumpRecordingUseCase(fakeRepository) + + val result = useCase(10f, "test") + + assertEquals(1, fakeRepository.dumpRecordingInvocations) + assertTrue(result.isSuccess) + } + + private class FakeRecordingRepository : RecordingRepository { + private val state = MutableStateFlow(RecorderState()) + + var enableInvocations = 0 + private set + var disableInvocations = 0 + private set + var startRecordingInvocations = 0 + private set + var stopRecordingInvocations = 0 + private set + var dumpRecordingInvocations = 0 + private set + + override val recorderState: StateFlow = state + + override suspend fun enableListening(): Result { + enableInvocations++ + return Result.success(Unit) + } + + override suspend fun disableListening(): Result { + disableInvocations++ + return Result.success(Unit) + } + + override suspend fun startRecording(prependedMemorySeconds: Float): Result { + startRecordingInvocations++ + return Result.success(Unit) + } + + override suspend fun stopRecording(): Result { + stopRecordingInvocations++ + return Result.success(Unit) + } + + override suspend fun dumpRecording(memorySeconds: Float, newFileName: String?): Result { + dumpRecordingInvocations++ + return Result.success(Unit) + } + } +} diff --git a/features/recorder/build.gradle.kts b/features/recorder/build.gradle.kts index 15745bd0..6376797a 100644 --- a/features/recorder/build.gradle.kts +++ b/features/recorder/build.gradle.kts @@ -1,37 +1,24 @@ plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.hilt.android) - id("kotlin-kapt") -} - -android { - namespace = "com.siya.epistemophile.features.recorder" - compileSdk = 34 - - defaultConfig { minSdk = 30 } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { jvmTarget = "17" } + alias(libs.plugins.kotlin.jvm) } dependencies { implementation(project(":domain")) - - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.viewmodel.ktx) - implementation(libs.coroutines.android) - - implementation(libs.hilt.android) - kapt(libs.hilt.compiler) + implementation(libs.coroutines.core) + implementation(libs.javax.inject) testImplementation(libs.junit) - testImplementation(libs.mockito.core) - testImplementation(libs.mockito.kotlin) - testImplementation(libs.coroutines.core) testImplementation(libs.coroutines.test) - testImplementation(libs.androidx.arch.core.testing) + testImplementation(project(":data")) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } } diff --git a/features/recorder/src/main/java/com/siya/epistemophile/features/recorder/RecordingViewModel.kt b/features/recorder/src/main/java/com/siya/epistemophile/features/recorder/RecordingViewModel.kt index 112437a1..695950d4 100644 --- a/features/recorder/src/main/java/com/siya/epistemophile/features/recorder/RecordingViewModel.kt +++ b/features/recorder/src/main/java/com/siya/epistemophile/features/recorder/RecordingViewModel.kt @@ -1,28 +1,100 @@ package com.siya.epistemophile.features.recorder -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import com.siya.epistemophile.domain.model.RecordingError +import com.siya.epistemophile.domain.repository.RecordingRepository +import com.siya.epistemophile.domain.usecase.DumpRecordingUseCase import com.siya.epistemophile.domain.usecase.StartListeningUseCase +import com.siya.epistemophile.domain.usecase.StartRecordingUseCase import com.siya.epistemophile.domain.usecase.StopListeningUseCase -import com.siya.epistemophile.domain.repository.RecordingRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch +import com.siya.epistemophile.domain.usecase.StopRecordingUseCase import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch class RecordingViewModel @Inject constructor( private val repository: RecordingRepository, private val startListeningUseCase: StartListeningUseCase, - private val stopListeningUseCase: StopListeningUseCase -) : ViewModel() { - val isListening: Flow = repository.isListening + private val stopListeningUseCase: StopListeningUseCase, + private val startRecordingUseCase: StartRecordingUseCase, + private val stopRecordingUseCase: StopRecordingUseCase, + private val dumpRecordingUseCase: DumpRecordingUseCase, + private val dispatcher: CoroutineDispatcher = Dispatchers.Default +) { - fun toggleListening(enable: Boolean) { - viewModelScope.launch { - if (enable) { - startListeningUseCase() - } else { - stopListeningUseCase() + private val job = SupervisorJob() + private val scope = CoroutineScope(job + dispatcher) + + private val _uiState = MutableStateFlow(RecordingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + scope.launch { + repository.recorderState.collectLatest { state -> + _uiState.update { current -> + current.copy( + isListening = state.isListening, + isRecording = state.isRecording, + canSaveRecording = state.hasUnsavedRecording, + lastSavedFileName = state.lastSavedFileName + ) + } } } } + + fun toggleListening(enable: Boolean) { + scope.launch { + val result = if (enable) startListeningUseCase() else stopListeningUseCase() + handleResult(result) + } + } + + fun startRecording(prependedMemorySeconds: Float = 0f) { + scope.launch { + handleResult(startRecordingUseCase(prependedMemorySeconds)) + } + } + + fun stopRecording() { + scope.launch { + handleResult(stopRecordingUseCase()) + } + } + + fun saveRecording(memorySeconds: Float, newFileName: String? = null) { + scope.launch { + handleResult(dumpRecordingUseCase(memorySeconds, newFileName)) + } + } + + fun clearError() { + _uiState.update { it.copy(error = null) } + } + + fun clear() { + job.cancel() + } + + private fun handleResult(result: Result) { + result.exceptionOrNull()?.let { throwable -> + val error = throwable as? RecordingError ?: RecordingError.Unknown(throwable) + _uiState.update { it.copy(error = error) } + } + } } + +data class RecordingUiState( + val isListening: Boolean = false, + val isRecording: Boolean = false, + val canSaveRecording: Boolean = false, + val lastSavedFileName: String? = null, + val error: RecordingError? = null +) diff --git a/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/MainDispatcherRule.kt b/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/MainDispatcherRule.kt new file mode 100644 index 00000000..3505e025 --- /dev/null +++ b/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package com.siya.epistemophile.features.recorder + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +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( + private val dispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/RecordingViewModelTest.kt b/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/RecordingViewModelTest.kt index 84abcbd4..b53cc7f7 100644 --- a/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/RecordingViewModelTest.kt +++ b/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/RecordingViewModelTest.kt @@ -1,43 +1,105 @@ - package com.siya.epistemophile.features.recorder -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.siya.epistemophile.domain.repository.RecordingRepository +import com.siya.epistemophile.data.recording.RecordingRepositoryImpl +import com.siya.epistemophile.domain.model.RecordingError +import com.siya.epistemophile.domain.usecase.DumpRecordingUseCase import com.siya.epistemophile.domain.usecase.StartListeningUseCase +import com.siya.epistemophile.domain.usecase.StartRecordingUseCase import com.siya.epistemophile.domain.usecase.StopListeningUseCase -import kotlinx.coroutines.Dispatchers +import com.siya.epistemophile.domain.usecase.StopRecordingUseCase import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.Mockito -import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) class RecordingViewModelTest { + private val dispatcher: TestDispatcher = StandardTestDispatcher() + @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() + val mainDispatcherRule = MainDispatcherRule(dispatcher) - private val testDispatcher = StandardTestDispatcher() + private lateinit var repository: RecordingRepositoryImpl + private lateinit var viewModel: RecordingViewModel @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + repository = RecordingRepositoryImpl(dispatcher) + viewModel = RecordingViewModel( + repository = repository, + startListeningUseCase = StartListeningUseCase(repository), + stopListeningUseCase = StopListeningUseCase(repository), + startRecordingUseCase = StartRecordingUseCase(repository), + stopRecordingUseCase = StopRecordingUseCase(repository), + dumpRecordingUseCase = DumpRecordingUseCase(repository), + dispatcher = dispatcher + ) + } + + @Test + fun `toggling listening updates ui state`() = runTest(dispatcher) { + viewModel.toggleListening(true) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.isListening) + } + + @Test + fun `start recording exposes active state`() = runTest(dispatcher) { + viewModel.startRecording() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state.isListening) + assertTrue(state.isRecording) + } + + @Test + fun `stop recording marks clip available`() = runTest(dispatcher) { + viewModel.startRecording() + advanceUntilIdle() + + viewModel.stopRecording() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isRecording) + assertTrue(state.canSaveRecording) + } + + @Test + fun `save recording clears pending flag and records name`() = runTest(dispatcher) { + viewModel.startRecording() + advanceUntilIdle() + viewModel.stopRecording() + advanceUntilIdle() + + viewModel.saveRecording(memorySeconds = 15f, newFileName = "clip-01") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.canSaveRecording) + assertEquals("clip-01", state.lastSavedFileName) } @Test - fun togglingListening_updatesStateOnSuccess() = runTest(testDispatcher) { - val repo = Mockito.mock(RecordingRepository::class.java) - val startUseCase = Mockito.mock(StartListeningUseCase::class.java) - val stopUseCase = Mockito.mock(StopListeningUseCase::class.java) + fun `invalid stop emits error`() = runTest(dispatcher) { + viewModel.stopRecording() + advanceUntilIdle() - val vm = RecordingViewModel(repo, startUseCase, stopUseCase) + val error = viewModel.uiState.value.error + assertTrue(error is RecordingError.NotRecording) - vm.toggleListening(true) - // TODO: Add proper assertion logic with StateFlow collection + viewModel.clearError() + assertNull(viewModel.uiState.value.error) } } diff --git a/gradle.properties b/gradle.properties index 41717f49..5377d314 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx10248m -XX:MaxPermSize=256m # Lowered Xmx and enforced UTF-8 to improve stability in constrained environments and CI agents -org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 -XX:+UseParallelGC -Djava.net.preferIPv4Stack=true -Djava.net.preferIPv6Addresses=false # Limit worker concurrency to reduce peak memory and avoid OOM-kill on Linux containers org.gradle.workers.max=2 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 45f8b70a..3ba79c73 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,9 @@ junit = "4.13.2" mockito = "5.11.0" mockito-kotlin = "5.0.0" robolectric = "4.11.1" +robolectric-android-all = "14-robolectric-10818077" tap-target-view = "1.13.3" +javax-inject = "1" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } @@ -24,6 +26,7 @@ junit = { module = "junit:junit", version.ref = "junit" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +robolectric-android-all = { module = "org.robolectric:android-all", version.ref = "robolectric-android-all" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } @@ -31,6 +34,7 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } tap-target-view = { module = "com.getkeepsafe.taptargetview:taptargetview", version.ref = "tap-target-view" } +javax-inject = { module = "javax.inject:javax.inject", version.ref = "javax-inject" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/scripts/agent/healthcheck.sh b/scripts/agent/healthcheck.sh index 5397e20c..d8d1ebd3 100755 --- a/scripts/agent/healthcheck.sh +++ b/scripts/agent/healthcheck.sh @@ -20,6 +20,15 @@ set -euo pipefail # - Designed to be idempotent and fast. ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd) +SDK_DIR="$ROOT_DIR/.android-sdk" +CMDLINE_TOOLS_ZIP="commandlinetools-linux-11076708_latest.zip" +CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/${CMDLINE_TOOLS_ZIP}" +REQUIRED_SDK_PACKAGES=( + "platforms;android-34" + "platforms;android-33" + "build-tools;34.0.0" + "platform-tools" +) cd "$ROOT_DIR" TIER_RANGE="" @@ -38,6 +47,101 @@ err() { printf "[health][error] %s\n" "$*"; } have_cmd() { command -v "$1" >/dev/null 2>&1; } +ensure_local_properties() { + local sdk_path="$1" + local props_file="$ROOT_DIR/local.properties" + local escaped_path="$sdk_path" + if [[ -f "$props_file" ]]; then + if grep -q '^sdk.dir=' "$props_file"; then + if ! grep -q "^sdk.dir=${escaped_path//\//\/}$" "$props_file"; then + local tmp_file + tmp_file=$(mktemp) + sed "s#^sdk.dir=.*#sdk.dir=${escaped_path//\//\/}#" "$props_file" > "$tmp_file" + mv "$tmp_file" "$props_file" + log "Updated sdk.dir in local.properties -> $escaped_path" + fi + else + printf '\n%s\n' "sdk.dir=${escaped_path//\//\/}" >> "$props_file" + log "Appended sdk.dir to existing local.properties -> $escaped_path" + fi + else + printf 'sdk.dir=%s\n' "$escaped_path" > "$props_file" + log "Generated local.properties with sdk.dir -> $escaped_path" + fi +} + +ensure_android_licenses() { + local sdk_path="$1" + mkdir -p "$sdk_path/licenses" + printf 'd56f5187479451eabf01fb78af6dfcb131a6481e\n' > "$sdk_path/licenses/android-sdk-license" + printf '84831b9409646a918e30573bab4c9c91346d8abd\n' > "$sdk_path/licenses/android-sdk-preview-license" +} + +bootstrap_cmdline_tools() { + local sdk_path="$1" + local tools_root="$sdk_path/cmdline-tools" + local archive_path="$tools_root/$CMDLINE_TOOLS_ZIP" + + mkdir -p "$tools_root" + if [[ ! -x "$tools_root/latest/bin/sdkmanager" ]]; then + if [[ ! -f "$archive_path" ]]; then + log "Downloading Android command line tools..." + curl -sSL "$CMDLINE_TOOLS_URL" -o "$archive_path" + else + log "Reusing cached command line tools archive" + fi + rm -rf "$tools_root/latest" "$tools_root/cmdline-tools" + unzip -q "$archive_path" -d "$tools_root" + mv "$tools_root/cmdline-tools" "$tools_root/latest" + fi +} + +install_minimal_android_sdk() { + local sdk_path="$1" + bootstrap_cmdline_tools "$sdk_path" + ensure_android_licenses "$sdk_path" + local sdkmanager="$sdk_path/cmdline-tools/latest/bin/sdkmanager" + if [[ ! -x "$sdkmanager" ]]; then + err "sdkmanager not found after extracting command line tools" + return 1 + fi + log "Installing minimal Android SDK components into $sdk_path" + yes | "$sdkmanager" --sdk_root="$sdk_path" "${REQUIRED_SDK_PACKAGES[@]}" >/dev/null +} + +ensure_android_sdk() { + local sdk_root + if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then + sdk_root="$ANDROID_SDK_ROOT" + else + sdk_root="$SDK_DIR" + export ANDROID_SDK_ROOT="$sdk_root" + export ANDROID_HOME="$sdk_root" + fi + + local needs_install=false + for pkg in "${REQUIRED_SDK_PACKAGES[@]}"; do + local pkg_path="${pkg//;/\/}" + if [[ ! -d "$sdk_root/$pkg_path" ]]; then + needs_install=true + break + fi + done + + if [[ "$needs_install" == true ]]; then + log "Android SDK components missing; bootstrapping local SDK at $sdk_root" + install_minimal_android_sdk "$sdk_root" + fi + + if [[ -d "$sdk_root" ]]; then + log "Android SDK: $sdk_root" + ensure_android_licenses "$sdk_root" + ensure_local_properties "$sdk_root" + else + warn "Android SDK not found and automatic bootstrap failed" + fi +} + parse_args() { while [[ $# -gt 0 ]]; do case "$1" in @@ -117,18 +221,7 @@ check_env() { fi # Android SDK checks (best-effort, non-fatal if missing but warned) - ANDROID_SDK_ROOT_DEFAULT="$HOME/Android/Sdk" - ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_SDK_ROOT_DEFAULT}" - if [[ -d "$ANDROID_SDK_ROOT" ]]; then - log "Android SDK: $ANDROID_SDK_ROOT" - if [[ -f "$ANDROID_SDK_ROOT/licenses/android-sdk-license" ]]; then - log "Android SDK licenses: accepted" - else - warn "Android SDK licenses not accepted. Run: yes | sdkmanager --licenses" - fi - else - warn "Android SDK not found at $ANDROID_SDK_ROOT. Builds may download components during CI." - fi + ensure_android_sdk # Network sanity (best-effort, short timeouts) if have_cmd curl; then