Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion WARP.md → AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# WARP.md
# AGENTS.md

This file provides guidance to WARP (warp.dev) when working with code in this repository.

Expand Down
42 changes: 15 additions & 27 deletions data/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
}
18 changes: 0 additions & 18 deletions data/src/main/kotlin/com/siya/epistemophile/data/di/DataModule.kt

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<RecorderState> = _state.asStateFlow()

override suspend fun enableListening(): Result<Unit> = withContext(dispatcher) {
mutex.withLock {
_state.value = _state.value.copy(isListening = true)
Result.success(Unit)
}
}

override suspend fun disableListening(): Result<Unit> = 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<Unit> =
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<Boolean> = _isListening.asStateFlow()
override suspend fun stopRecording(): Result<Unit> = 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<Unit> = 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<Unit> = Result.success(Unit)
val sanitizedName = newFileName?.takeIf { it.isNotBlank() }
_state.value = current.copy(
hasUnsavedRecording = false,
lastSavedFileName = sanitizedName
)
Result.success(Unit)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 3 additions & 2 deletions domain/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ plugins {
alias(libs.plugins.kotlin.jvm)
}



dependencies {
implementation(libs.coroutines.core)

testImplementation(libs.junit)
testImplementation(libs.coroutines.test)
}

java {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Boolean>
suspend fun enableListening()
suspend fun disableListening()
val recorderState: StateFlow<RecorderState>

suspend fun startRecording(prependedMemorySeconds: Float = 0f)
suspend fun stopRecording()
suspend fun enableListening(): Result<Unit>
suspend fun disableListening(): Result<Unit>

suspend fun startRecording(prependedMemorySeconds: Float = 0f): Result<Unit>
suspend fun stopRecording(): Result<Unit>

suspend fun dumpRecording(memorySeconds: Float, newFileName: String? = null): Result<Unit>
}
Loading
Loading