From 7e59c656021080cd8d8bfbd3e4e9dbdd0a27d764 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 12 Mar 2026 10:35:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E6=B7=BB=E5=8A=A0=20Conv?= =?UTF-8?q?ersationApiPort=20=E5=92=8C=20ConversationRealtimePort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为对话流程定义按能力划分的端口,并提供委托给 ApiService 和 WebSocketClient 的适配器。 --- .../adapters/ConversationApiAdapter.kt | 33 +++++++ .../adapters/ConversationRealtimeAdapter.kt | 94 +++++++++++++++++++ .../conversation/ports/ConversationApiPort.kt | 14 +++ .../ports/ConversationRealtimePort.kt | 68 ++++++++++++++ .../ConversationRealtimeAdapterTest.kt | 93 ++++++++++++++++++ 5 files changed, 302 insertions(+) create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationApiAdapter.kt create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapter.kt create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationApiPort.kt create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationRealtimePort.kt create mode 100644 app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapterTest.kt diff --git a/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationApiAdapter.kt b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationApiAdapter.kt new file mode 100644 index 0000000..d9b4197 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationApiAdapter.kt @@ -0,0 +1,33 @@ +package com.huaga.life_echo.feature.conversation.adapters + +import com.huaga.life_echo.feature.conversation.ports.ConversationApiPort +import com.huaga.life_echo.network.ApiService +import com.huaga.life_echo.network.models.ChapterDto +import com.huaga.life_echo.network.models.CreateConversationResponse +import com.huaga.life_echo.network.models.MessageDto +import com.huaga.life_echo.network.models.TasksStatusDto + +class ConversationApiAdapter( + private val apiService: ApiService, +) : ConversationApiPort { + + override suspend fun createConversation(): Result { + return apiService.createConversation() + } + + override suspend fun getMessages(conversationId: String): Result> { + return apiService.getMessages(conversationId) + } + + override suspend fun getTasksStatus(): Result { + return apiService.getTasksStatus() + } + + override suspend fun getChapters(): Result> { + return apiService.getChapters() + } + + override suspend fun clearTasks(): Result { + return apiService.clearTasks() + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapter.kt b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapter.kt new file mode 100644 index 0000000..a504b0d --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapter.kt @@ -0,0 +1,94 @@ +package com.huaga.life_echo.feature.conversation.adapters + +import com.huaga.life_echo.feature.conversation.ports.AudioSegmentRequest +import com.huaga.life_echo.feature.conversation.ports.ConversationRealtimePort +import com.huaga.life_echo.network.WebSocketClient +import com.huaga.life_echo.network.WebSocketMessage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class ConversationRealtimeAdapter( + private val webSocketClient: WebSocketClient, +) : ConversationRealtimePort { + + private val _state = MutableStateFlow( + ConversationRealtimePort.State.NotConnected + ) + override val state: StateFlow = _state.asStateFlow() + + override suspend fun prepare() { + _state.value = ConversationRealtimePort.State.Preparing + } + + override suspend fun connect( + conversationId: String, + token: String?, + onMessage: (WebSocketMessage) -> Unit, + onError: ((String) -> Unit)?, + ) { + webSocketClient.connect( + conversationId = conversationId, + token = token, + onMessage = { message -> + if (message.type == com.huaga.life_echo.network.MessageType.connect) { + _state.value = ConversationRealtimePort.State.Connected(conversationId) + } + onMessage(message) + }, + onError = { errorMsg -> + _state.value = ConversationRealtimePort.State.NotConnected + onError?.invoke(errorMsg) + } + ) + } + + override suspend fun disconnect() { + webSocketClient.disconnect() + _state.value = ConversationRealtimePort.State.NotConnected + } + + override fun isConnected(): Boolean = webSocketClient.isConnected() + + override suspend fun sendText(conversationId: String, text: String) { + webSocketClient.sendTextMessage(text, conversationId) + } + + override suspend fun sendAudioChunk(chunk: ByteArray, conversationId: String) { + webSocketClient.sendAudioChunk(chunk, conversationId) + } + + override suspend fun sendAudioSegment(request: AudioSegmentRequest) { + webSocketClient.sendAudioSegment( + audioBytes = request.audioBytes, + conversationId = request.conversationId, + voiceSessionId = request.voiceSessionId, + segmentIndex = request.segmentIndex, + duration = request.duration, + isLast = request.isLast, + clientSegmentId = request.clientSegmentId, + ) + } + + override suspend fun sendAudioMessage(audioBytes: ByteArray, conversationId: String, duration: Int) { + webSocketClient.sendAudioMessage(audioBytes, conversationId, duration) + } + + override suspend fun sendTranscribeOnly(audioBytes: ByteArray, conversationId: String) { + webSocketClient.sendTranscribeOnly(audioBytes, conversationId) + } + + override suspend fun sendEndConversation(conversationId: String) { + webSocketClient.sendEndConversation(conversationId) + } + + override suspend fun cancelGeneration(conversationId: String) { + webSocketClient.cancelGeneration(conversationId) + } + + override fun isGenerating(): Boolean = webSocketClient.isGenerating() + + override fun setGenerating(generating: Boolean) { + webSocketClient.setGenerating(generating) + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationApiPort.kt b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationApiPort.kt new file mode 100644 index 0000000..dd7442e --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationApiPort.kt @@ -0,0 +1,14 @@ +package com.huaga.life_echo.feature.conversation.ports + +import com.huaga.life_echo.network.models.ChapterDto +import com.huaga.life_echo.network.models.CreateConversationResponse +import com.huaga.life_echo.network.models.MessageDto +import com.huaga.life_echo.network.models.TasksStatusDto + +interface ConversationApiPort { + suspend fun createConversation(): Result + suspend fun getMessages(conversationId: String): Result> + suspend fun getTasksStatus(): Result + suspend fun getChapters(): Result> + suspend fun clearTasks(): Result +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationRealtimePort.kt b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationRealtimePort.kt new file mode 100644 index 0000000..1fa89ff --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationRealtimePort.kt @@ -0,0 +1,68 @@ +package com.huaga.life_echo.feature.conversation.ports + +import com.huaga.life_echo.network.WebSocketMessage +import kotlinx.coroutines.flow.StateFlow + +interface ConversationRealtimePort { + val state: StateFlow + + suspend fun prepare() + suspend fun connect( + conversationId: String, + token: String?, + onMessage: (WebSocketMessage) -> Unit, + onError: ((String) -> Unit)? = null, + ) + suspend fun disconnect() + fun isConnected(): Boolean + + suspend fun sendText(conversationId: String, text: String) + suspend fun sendAudioChunk(chunk: ByteArray, conversationId: String) + suspend fun sendAudioSegment(request: AudioSegmentRequest) + suspend fun sendAudioMessage(audioBytes: ByteArray, conversationId: String, duration: Int) + suspend fun sendTranscribeOnly(audioBytes: ByteArray, conversationId: String) + suspend fun sendEndConversation(conversationId: String) + suspend fun cancelGeneration(conversationId: String) + + fun isGenerating(): Boolean + fun setGenerating(generating: Boolean) + + sealed class State { + data object NotConnected : State() + data object Preparing : State() + data class Connected(val conversationId: String) : State() + } +} + +data class AudioSegmentRequest( + val audioBytes: ByteArray, + val conversationId: String, + val voiceSessionId: String, + val segmentIndex: Int, + val duration: Int, + val isLast: Boolean, + val clientSegmentId: String? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AudioSegmentRequest) return false + return conversationId == other.conversationId + && voiceSessionId == other.voiceSessionId + && segmentIndex == other.segmentIndex + && duration == other.duration + && isLast == other.isLast + && clientSegmentId == other.clientSegmentId + && audioBytes.contentEquals(other.audioBytes) + } + + override fun hashCode(): Int { + var result = audioBytes.contentHashCode() + result = 31 * result + conversationId.hashCode() + result = 31 * result + voiceSessionId.hashCode() + result = 31 * result + segmentIndex + result = 31 * result + duration + result = 31 * result + isLast.hashCode() + result = 31 * result + (clientSegmentId?.hashCode() ?: 0) + return result + } +} diff --git a/app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapterTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapterTest.kt new file mode 100644 index 0000000..283ab15 --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapterTest.kt @@ -0,0 +1,93 @@ +package com.huaga.life_echo.feature.conversation.adapters + +import com.huaga.life_echo.feature.conversation.ports.ConversationRealtimePort +import com.huaga.life_echo.network.MessageType +import com.huaga.life_echo.network.WebSocketClient +import com.huaga.life_echo.network.WebSocketMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.buildJsonObject +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.Mockito + +@OptIn(ExperimentalCoroutinesApi::class) +class ConversationRealtimeAdapterTest { + + @Test + fun connect_reports_realtime_connected_only_after_session_connects() = runTest { + val fakeSocketClient = FakeWebSocketClient() + val adapter = ConversationRealtimeAdapter(fakeSocketClient) + + assertEquals(ConversationRealtimePort.State.NotConnected, adapter.state.value) + + adapter.connect("conversation-1", "token", onMessage = {}, onError = {}) + + fakeSocketClient.simulateServerConnect() + + assertEquals( + ConversationRealtimePort.State.Connected("conversation-1"), + adapter.state.value + ) + } + + @Test + fun prepare_moves_state_to_preparing() = runTest { + val fakeSocketClient = FakeWebSocketClient() + val adapter = ConversationRealtimeAdapter(fakeSocketClient) + + adapter.prepare() + + assertEquals(ConversationRealtimePort.State.Preparing, adapter.state.value) + } + + @Test + fun disconnect_resets_state_to_not_connected() = runTest { + val fakeSocketClient = FakeWebSocketClient() + val adapter = ConversationRealtimeAdapter(fakeSocketClient) + + adapter.connect("conversation-1", "token", onMessage = {}, onError = {}) + fakeSocketClient.simulateServerConnect() + + adapter.disconnect() + + assertEquals(ConversationRealtimePort.State.NotConnected, adapter.state.value) + } + + /** + * Minimal fake that captures callbacks so the test can simulate server messages. + * Extends [WebSocketClient] using the no-arg legacy constructor. + */ + private class FakeWebSocketClient : WebSocketClient() { + private var capturedOnMessage: ((WebSocketMessage) -> Unit)? = null + private var capturedOnError: ((String) -> Unit)? = null + private var connected = false + + override suspend fun connect( + conversationId: String, + token: String?, + onMessage: (WebSocketMessage) -> Unit, + onError: ((String) -> Unit)? + ) { + capturedOnMessage = onMessage + capturedOnError = onError + } + + override suspend fun disconnect() { + connected = false + } + + override fun isConnected(): Boolean = connected + + fun simulateServerConnect() { + connected = true + capturedOnMessage?.invoke( + WebSocketMessage( + type = MessageType.connect, + conversation_id = "conversation-1", + data = buildJsonObject { } + ) + ) + } + } +}