Files
life-echo/docs/plans/2026-03-12-conversation-architecture-modernization.md
2026-03-12 11:12:22 +08:00

28 KiB

Conversation Feature Architecture Modernization Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Refactor the conversation/create-memory flow to a clearer Android architecture built around Compose + UDF + ViewModel + Repository, with domain orchestration extracted out of the current giant ViewModel.

Architecture: Keep the app in a single Android module for now, but reorganize the conversation feature internally as presentation/, domain/, and data/. Replace the current CreateMemoryViewModel orchestration with a feature-scoped repository, a ConversationSessionCoordinator for realtime/session logic, and a VoiceMessageOrchestrator for recording/upload logic. Keep existing network ports/adapters as the transport boundary; do not add Hilt or new Gradle modules in this plan.

Tech Stack: Kotlin, Jetpack Compose, Android ViewModel, StateFlow, Coroutines, Room DAOs, existing ConversationApiPort / ConversationRealtimePort, JUnit4, kotlinx-coroutines-test, mockito-inline.

Implementation Rules:

  • Use @test-driven-development for every task.
  • Use @verification-before-completion before every commit.
  • Do not introduce Hilt or multi-module Gradle changes in this plan.
  • Treat this as the pilot feature; memoir/payment/profile can adopt the same pattern after this is stable.

Task 1: Introduce Conversation Presentation Contracts

Files:

  • Create: app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/ConversationMessageUi.kt
  • Create: app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/CreateMemoryUiState.kt
  • Create: app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/CreateMemoryAction.kt
  • Test: app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/presentation/CreateMemoryUiStateTest.kt

Step 1: Write the failing test

@Test
fun default_state_represents_a_new_idle_conversation() {
    val state = CreateMemoryUiState()

    assertNull(state.conversationId)
    assertEquals(CreateMemoryUiState.Connection.Disconnected, state.connection)
    assertTrue(state.messages.isEmpty())
    assertFalse(state.isSending)
    assertFalse(state.isRecording)
    assertNull(state.errorMessage)
}

Step 2: Run the test to verify it fails

Run: ./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.presentation.CreateMemoryUiStateTest

Expected: FAIL with unresolved references for CreateMemoryUiState.

Step 3: Write the minimal implementation

data class ConversationMessageUi(
    val id: String,
    val text: String,
    val sender: Sender,
    val timestamp: Long,
    val isStreaming: Boolean = false,
) {
    enum class Sender { USER, ASSISTANT, SYSTEM }
}

data class CreateMemoryUiState(
    val conversationId: String? = null,
    val messages: List<ConversationMessageUi> = emptyList(),
    val draft: String = "",
    val transcript: String = "",
    val connection: Connection = Connection.Disconnected,
    val isSending: Boolean = false,
    val isRecording: Boolean = false,
    val recordingDurationSeconds: Int = 0,
    val isProcessing: Boolean = false,
    val processingMessage: String? = null,
    val errorMessage: String? = null,
) {
    enum class Connection { Disconnected, Preparing, Connecting, Connected }
}

sealed interface CreateMemoryAction {
    data class DraftChanged(val value: String) : CreateMemoryAction
    data object SendText : CreateMemoryAction
    data object StartRecording : CreateMemoryAction
    data object StopRecording : CreateMemoryAction
    data object CancelGeneration : CreateMemoryAction
    data class Initialize(val conversationId: String?) : CreateMemoryAction
}

Step 4: Run the test to verify it passes

Run: ./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.presentation.CreateMemoryUiStateTest

Expected: PASS

Step 5: Commit

git add app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/ConversationMessageUi.kt \
  app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/CreateMemoryUiState.kt \
  app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/CreateMemoryAction.kt \
  app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/presentation/CreateMemoryUiStateTest.kt
git commit -m "refactor: add conversation presentation contracts"

Task 2: Create a Feature-Scoped Conversation Repository

Files:

  • Create: app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/domain/ConversationRepository.kt
  • Create: app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/data/ConversationRemoteDataSource.kt
  • Create: app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/data/ConversationLocalDataSource.kt
  • Create: app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/data/ConversationRepositoryImpl.kt
  • Test: app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/data/ConversationRepositoryImplTest.kt

Step 1: Write the failing test

@Test
fun sync_messages_fetches_remote_and_persists_locally() = runTest {
    val remote = FakeConversationRemoteDataSource(
        messages = listOf(messageDto(id = "m1", conversationId = "c1"))
    )
    val local = FakeConversationLocalDataSource()
    val repository = ConversationRepositoryImpl(remote, local)

    repository.syncMessages("c1")

    assertEquals(listOf("c1"), remote.requestedMessageConversationIds)
    assertEquals(listOf("m1"), local.insertedMessages.flatten().map { it.id })
}

Step 2: Run the test to verify it fails

Run: ./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.data.ConversationRepositoryImplTest

Expected: FAIL because the repository and data source classes do not exist.

Step 3: Write the minimal implementation

interface ConversationRepository {
    fun observeConversations(): Flow<List<Conversation>>
    fun observeMessages(conversationId: String): Flow<List<Message>>
    suspend fun syncConversations()
    suspend fun syncMessages(conversationId: String)
    suspend fun createConversation(): Result<String>
    suspend fun deleteConversation(conversationId: String): Result<Unit>
}

class ConversationRemoteDataSource(
    private val conversationApi: ConversationApiPort,
) {
    suspend fun createConversation() = conversationApi.createConversation()
    suspend fun getConversationList() = conversationApi.getConversationList()
    suspend fun getMessages(conversationId: String) = conversationApi.getMessages(conversationId)
    suspend fun deleteConversation(conversationId: String) = conversationApi.deleteConversation(conversationId)
}

class ConversationLocalDataSource(
    private val conversationDao: ConversationDao,
    private val messageDao: MessageDao,
    private val segmentDao: ConversationSegmentDao,
) {
    fun observeConversations() = conversationDao.getAllConversations()
    fun observeMessages(conversationId: String) = messageDao.getMessagesByConversationId(conversationId)
    suspend fun insertConversations(items: List<Conversation>) = items.forEach(conversationDao::insertConversation)
    suspend fun insertMessages(items: List<Message>) = messageDao.insertMessages(items)
    suspend fun getConversation(id: String) = conversationDao.getConversationById(id)
    suspend fun deleteConversation(entity: Conversation) = conversationDao.deleteConversation(entity)
    suspend fun latestEmptyConversation() = conversationDao.getLatestEmptyConversation()
    suspend fun deleteOtherEmptyConversations(keepId: String) = conversationDao.deleteOtherEmptyConversations(keepId)
}

class ConversationRepositoryImpl(
    private val remote: ConversationRemoteDataSource,
    private val local: ConversationLocalDataSource,
) : ConversationRepository {
    // Port existing create/sync/delete logic out of the old repositories here.
}

Map MessageDto -> Message and ConversationListItemDto/CreateConversationResponse -> Conversation inside ConversationRepositoryImpl.

Step 4: Run the test to verify it passes

Run: ./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.data.ConversationRepositoryImplTest

Expected: PASS

Step 5: Commit

git add app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/domain/ConversationRepository.kt \
  app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/data/ConversationRemoteDataSource.kt \
  app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/data/ConversationLocalDataSource.kt \
  app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/data/ConversationRepositoryImpl.kt \
  app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/data/ConversationRepositoryImplTest.kt
git commit -m "refactor: add conversation feature repository"

Task 3: Extract Conversation Session Orchestration

Files:

  • Create: app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/domain/ConversationSessionCoordinator.kt
  • Test: app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/domain/ConversationSessionCoordinatorTest.kt

Step 1: Write the failing test

@Test
fun initialize_existing_conversation_prepares_loads_history_and_connects() = runTest {
    val repository = FakeConversationRepository(messages = listOf(message(id = "m1")))
    val realtime = FakeConversationRealtimePort()
    val coordinator = ConversationSessionCoordinator(
        repository = repository,
        realtime = realtime,
        accessTokenProvider = { "token-1" },
    )

    coordinator.initializeExistingConversation("conversation-1")
    advanceUntilIdle()

    assertEquals(listOf("prepare", "connect:conversation-1:token-1"), realtime.calls)
    assertEquals("conversation-1", coordinator.state.value.conversationId)
    assertEquals(1, coordinator.state.value.messages.size)
}

Step 2: Run the test to verify it fails

Run: ./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.domain.ConversationSessionCoordinatorTest

Expected: FAIL because ConversationSessionCoordinator does not exist.

Step 3: Write the minimal implementation

data class ConversationSessionState(
    val conversationId: String? = null,
    val messages: List<Message> = emptyList(),
    val connection: CreateMemoryUiState.Connection = CreateMemoryUiState.Connection.Disconnected,
)

class ConversationSessionCoordinator(
    private val repository: ConversationRepository,
    private val realtime: ConversationRealtimePort,
    private val accessTokenProvider: suspend () -> String?,
) {
    private val _state = MutableStateFlow(ConversationSessionState())
    val state: StateFlow<ConversationSessionState> = _state.asStateFlow()

    suspend fun initializeExistingConversation(conversationId: String) {
        _state.value = _state.value.copy(
            conversationId = conversationId,
            connection = CreateMemoryUiState.Connection.Preparing,
        )
        realtime.prepare()
        repository.syncMessages(conversationId)
        val token = accessTokenProvider()
        _state.value = _state.value.copy(
            connection = CreateMemoryUiState.Connection.Connecting,
            messages = repository.observeMessages(conversationId).first(),
        )
        realtime.connect(conversationId, token, onMessage = ::handleIncomingMessage)
    }

    suspend fun startNewConversation() { /* create via repository + connect */ }
    suspend fun sendText(text: String) { /* delegate to realtime */ }
    suspend fun endConversation() { /* delegate to realtime */ }
    private fun handleIncomingMessage(message: WebSocketMessage) { /* minimal state updates */ }
}

Keep the first implementation intentionally narrow: existing conversation init, new conversation start, send text, end conversation, and basic incoming message handling for transcript/agent text/connect/error.

Step 4: Run the test to verify it passes

Run: ./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.domain.ConversationSessionCoordinatorTest

Expected: PASS

Step 5: Commit

git add app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/domain/ConversationSessionCoordinator.kt \
  app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/domain/ConversationSessionCoordinatorTest.kt
git commit -m "refactor: extract conversation session coordinator"

Task 4: Extract Voice Recording and Upload Orchestration

Files:

  • Create: app-android/app/src/main/java/com/huaga/life_echo/feature/voice/VoiceMessageOrchestrator.kt
  • Test: app-android/app/src/test/java/com/huaga/life_echo/feature/voice/VoiceMessageOrchestratorTest.kt

Step 1: Write the failing test

@Test
fun stop_and_send_recording_dispatches_segments_in_order() = runTest {
    val recordingCoordinator = FakeRecordingCoordinator(
        finishedResult = finishedRecording(
            voiceSessionId = "voice-1",
            files = listOf(segment(index = 0), segment(index = 1)),
        )
    )
    val sessionCoordinator = FakeConversationSessionCoordinator(conversationId = "conversation-1")
    val orchestrator = VoiceMessageOrchestrator(
        recordingCoordinator = recordingCoordinator,
        sessionCoordinator = sessionCoordinator,
    )

    orchestrator.stopAndSendRecording()

    assertEquals(listOf(0, 1), sessionCoordinator.sentSegmentIndexes)
}

Step 2: Run the test to verify it fails

Run: ./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.voice.VoiceMessageOrchestratorTest

Expected: FAIL because VoiceMessageOrchestrator does not exist.

Step 3: Write the minimal implementation

class VoiceMessageOrchestrator(
    private val recordingCoordinator: RecordingCoordinator,
    private val sessionCoordinator: ConversationSessionCoordinator,
) {
    suspend fun startRecording(): RecordingStartResult =
        recordingCoordinator.startRecording()

    suspend fun stopAndSendRecording(recordingId: String? = null): RecordingFinishResult {
        val finishResult = recordingCoordinator.finishRecording(recordingId)
        if (finishResult is RecordingFinishResult.Success) {
            finishResult.segmentFiles.forEach { file ->
                sessionCoordinator.sendAudioSegment(
                    voiceSessionId = finishResult.voiceSessionId,
                    segment = file,
                )
            }
        }
        return finishResult
    }

    suspend fun cancelRecording(): RecordingCancelResult =
        recordingCoordinator.cancelRecording()
}

Move only recording/segment dispatch and retry scheduling into this class. Leave audio playback UI state in the ViewModel for now.

Step 4: Run the test to verify it passes

Run: ./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.voice.VoiceMessageOrchestratorTest

Expected: PASS

Step 5: Commit

git add app-android/app/src/main/java/com/huaga/life_echo/feature/voice/VoiceMessageOrchestrator.kt \
  app-android/app/src/test/java/com/huaga/life_echo/feature/voice/VoiceMessageOrchestratorTest.kt
git commit -m "refactor: extract voice message orchestrator"

Task 5: Rebuild CreateMemoryViewModel Around UiState and Actions

Files:

  • Modify: app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModel.kt
  • Modify: app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt
  • Test: app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelTest.kt
  • Modify: app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelWarmupTest.kt
  • Modify: app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelRecordingCoordinatorTest.kt

Step 1: Write the failing test

@Test
fun send_text_action_updates_ui_state_and_delegates_to_session() = runTest {
    val sessionCoordinator = FakeConversationSessionCoordinator(conversationId = "conversation-1")
    val viewModel = CreateMemoryViewModel(
        sessionCoordinator = sessionCoordinator,
        voiceOrchestrator = FakeVoiceMessageOrchestrator(),
    )

    viewModel.onAction(CreateMemoryAction.DraftChanged("hello"))
    viewModel.onAction(CreateMemoryAction.SendText)
    advanceUntilIdle()

    assertEquals("", viewModel.uiState.value.draft)
    assertEquals(listOf("hello"), sessionCoordinator.sentTexts)
}

Step 2: Run the test to verify it fails

Run: ./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelWarmupTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelRecordingCoordinatorTest

Expected: FAIL because the ViewModel still exposes dozens of mutable flows and does not support uiState/onAction.

Step 3: Write the minimal implementation

class CreateMemoryViewModel(
    private val sessionCoordinator: ConversationSessionCoordinator,
    private val voiceOrchestrator: VoiceMessageOrchestrator,
) : ViewModel() {
    private val _uiState = MutableStateFlow(CreateMemoryUiState())
    val uiState: StateFlow<CreateMemoryUiState> = _uiState.asStateFlow()

    fun onAction(action: CreateMemoryAction) {
        when (action) {
            is CreateMemoryAction.DraftChanged -> {
                _uiState.update { it.copy(draft = action.value) }
            }
            CreateMemoryAction.SendText -> viewModelScope.launch {
                val text = _uiState.value.draft.trim()
                if (text.isBlank()) return@launch
                sessionCoordinator.sendText(text)
                _uiState.update { it.copy(draft = "", isSending = false) }
            }
            is CreateMemoryAction.Initialize -> viewModelScope.launch {
                action.conversationId?.let(sessionCoordinator::initializeExistingConversation)
                    ?: sessionCoordinator.startNewConversation()
            }
            CreateMemoryAction.StartRecording -> viewModelScope.launch { voiceOrchestrator.startRecording() }
            CreateMemoryAction.StopRecording -> viewModelScope.launch { voiceOrchestrator.stopAndSendRecording() }
            CreateMemoryAction.CancelGeneration -> viewModelScope.launch { sessionCoordinator.cancelGeneration() }
        }
    }
}

In CreateMemoryScreen, replace the many collectAsState() calls with one val uiState by viewModel.uiState.collectAsState(). Read uiState.messages, uiState.connection, uiState.draft, uiState.isRecording, etc., and dispatch UI events via viewModel.onAction(...).

Step 4: Run the tests to verify they pass

Run: ./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelWarmupTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelRecordingCoordinatorTest

Expected: PASS

Step 5: Commit

git add app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModel.kt \
  app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt \
  app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelTest.kt \
  app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelWarmupTest.kt \
  app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelRecordingCoordinatorTest.kt
git commit -m "refactor: migrate create memory flow to ui state"

Task 6: Move Conversation List to the Same Feature Repository Pattern

Files:

  • Create: app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/ConversationListUiState.kt
  • Modify: app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ConversationListViewModel.kt
  • Modify: app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ConversationListScreen.kt
  • Test: app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/ConversationListViewModelTest.kt

Step 1: Write the failing test

@Test
fun refresh_conversations_updates_ui_state_from_repository() = runTest {
    val repository = FakeConversationRepository(
        conversations = flowOf(listOf(conversation(id = "c1", title = "One")))
    )
    val viewModel = ConversationListViewModel(repository)

    viewModel.refreshConversations()
    advanceUntilIdle()

    assertEquals(listOf("c1"), viewModel.uiState.value.conversations.map { it.id })
    assertTrue(viewModel.uiState.value.hasLoadedInitialConversations)
}

Step 2: Run the test to verify it fails

Run: ./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.ConversationListViewModelTest

Expected: FAIL because the ViewModel does not expose uiState.

Step 3: Write the minimal implementation

data class ConversationListUiState(
    val conversations: List<Conversation> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val hasLoadedInitialConversations: Boolean = false,
)

class ConversationListViewModel(
    private val repository: ConversationRepository,
) : ViewModel() {
    private val _uiState = MutableStateFlow(ConversationListUiState())
    val uiState: StateFlow<ConversationListUiState> = _uiState.asStateFlow()

    init {
        viewModelScope.launch {
            repository.observeConversations().collect { items ->
                _uiState.update { it.copy(conversations = items) }
            }
        }
        refreshConversations()
    }

    fun refreshConversations() { /* set loading, call repository.syncConversations(), update state */ }
}

Update ConversationListScreen to consume uiState instead of separate conversations, isLoading, error, and hasLoadedInitialConversations flows.

Step 4: Run the test to verify it passes

Run: ./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.ConversationListViewModelTest

Expected: PASS

Step 5: Commit

git add app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/ConversationListUiState.kt \
  app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ConversationListViewModel.kt \
  app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ConversationListScreen.kt \
  app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/ConversationListViewModelTest.kt
git commit -m "refactor: migrate conversation list to ui state"

Task 7: Wire the New Feature Components and Remove Legacy Repositories

Files:

  • Modify: app-android/app/src/main/java/com/huaga/life_echo/app/AppContainer.kt
  • Modify: app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt
  • Delete: app-android/app/src/main/java/com/huaga/life_echo/data/repository/ConversationRepository.kt
  • Delete: app-android/app/src/main/java/com/huaga/life_echo/data/repository/MessageRepository.kt
  • Modify: docs/plans/2026-03-12-conversation-architecture-modernization.md

Step 1: Write the failing verification test

Add one smoke-style test that proves CreateMemoryViewModel no longer requires the deleted repositories:

@Test
fun create_memory_view_model_accepts_feature_components_only() {
    val constructor = CreateMemoryViewModel::class.primaryConstructor!!
    val parameterTypes = constructor.parameters.mapNotNull { it.type.classifier as? KClass<*> }

    assertFalse(parameterTypes.contains(com.huaga.life_echo.data.repository.ConversationRepository::class))
    assertFalse(parameterTypes.contains(com.huaga.life_echo.data.repository.MessageRepository::class))
}

Test file: app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelConstructorTest.kt

Step 2: Run the verification test to verify it fails

Run: ./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelConstructorTest

Expected: FAIL because the old repositories are still present in the constructor and wiring.

Step 3: Write the minimal implementation

In AppContainer, construct and expose:

val conversationRemoteDataSource by lazy { ConversationRemoteDataSource(conversationApi) }
val conversationLocalDataSource by lazy {
    ConversationLocalDataSource(
        conversationDao = database.conversationDao(),
        messageDao = database.messageDao(),
        segmentDao = database.conversationSegmentDao(),
    )
}
val conversationRepository: com.huaga.life_echo.feature.conversation.domain.ConversationRepository by lazy {
    ConversationRepositoryImpl(conversationRemoteDataSource, conversationLocalDataSource)
}
val conversationSessionCoordinator by lazy {
    ConversationSessionCoordinator(
        repository = conversationRepository,
        realtime = ConversationRealtimeAdapter(webSocketClient),
        accessTokenProvider = TokenManager::getAccessToken,
    )
}

Then update ViewModelFactory to inject conversationRepository, conversationSessionCoordinator, and VoiceMessageOrchestrator. Delete the legacy data/repository/ConversationRepository.kt and MessageRepository.kt once there are no remaining imports.

Step 4: Run focused and full verification

Run:

./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelConstructorTest
./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelTest --tests com.huaga.life_echo.ui.viewmodel.ConversationListViewModelTest
./gradlew :app:testDebugUnitTest
git diff --check
rg -n "data\\.repository\\.(ConversationRepository|MessageRepository)" app-android/app/src/main/java

Expected:

  • all tests PASS
  • git diff --check prints nothing
  • rg finds no remaining imports of the deleted repositories in production code

Step 5: Commit

git add app-android/app/src/main/java/com/huaga/life_echo/app/AppContainer.kt \
  app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt \
  app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelConstructorTest.kt \
  docs/plans/2026-03-12-conversation-architecture-modernization.md
git add -u app-android/app/src/main/java/com/huaga/life_echo/data/repository
git commit -m "refactor: finish conversation architecture modernization"

Task 8: Write the Follow-Up Architecture Note

Files:

  • Create: docs/plans/2026-03-12-conversation-architecture-followups.md

Step 1: Write the failing checklist

Write a markdown checklist covering the next phases that are explicitly out of scope for this plan:

- [ ] Move auth/token state behind an injected session abstraction
- [ ] Split `ApiService` into feature remote data sources for memoir/payment/profile
- [ ] Introduce Hilt once feature boundaries stop moving
- [ ] Evaluate feature-first Gradle modules after the conversation pilot stabilizes

Step 2: Save the checklist

Create docs/plans/2026-03-12-conversation-architecture-followups.md

Expected: file exists with the checklist.

Step 3: Keep it intentionally short

Do not add implementation detail. The point is to freeze the next-phase scope, not start a second refactor.

Step 4: Verify the file exists and is tracked

Run:

test -f docs/plans/2026-03-12-conversation-architecture-followups.md
git status --short docs/plans/2026-03-12-conversation-architecture-followups.md

Expected:

  • test exits 0
  • git status shows the file as added or staged

Step 5: Commit

git add docs/plans/2026-03-12-conversation-architecture-followups.md
git commit -m "docs: capture conversation architecture follow-ups"