Files
life-echo/docs/plans/2026-03-12-network-runtime-ports-refactor.md

405 lines
18 KiB
Markdown

# Network Runtime Ports Refactor Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Move network client lifecycle and warm-up policy out of `ApiService` / `AuthService` / `WebSocketClient`, then migrate feature code to narrow ports so business code no longer depends on Ktor or WebSocket implementation details.
**Architecture:** Introduce a composition-root-owned app container with separate REST and realtime provider layers. The REST side must support at least two client profiles (`auth` and `api`) because `AuthService` and `ApiService` have different plugin stacks today. `ApiService`, `AuthService`, and `WebSocketClient` become adapters over those providers; feature code depends on capability-level ports and explicit readiness methods instead of concrete transport classes.
**Tech Stack:** Android `Application`, `ViewModelFactory`, Ktor HTTP/WebSocket clients, Kotlin coroutines and `StateFlow`, JUnit4, `mockito-inline`.
---
### Task 1: Introduce the Composition Root and Client Providers
**Files:**
- Create: `app-android/app/src/main/java/com/huaga/life_echo/app/LifeEchoApp.kt`
- Create: `app-android/app/src/main/java/com/huaga/life_echo/app/AppContainer.kt`
- Create: `app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RestClientProvider.kt`
- Create: `app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RealtimeTransportProvider.kt`
- Create: `app-android/app/src/main/java/com/huaga/life_echo/network/runtime/NetworkReadiness.kt`
- Modify: `app-android/app/src/main/AndroidManifest.xml`
- Test: `app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RestClientProviderTest.kt`
- Test: `app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RealtimeTransportProviderTest.kt`
**Step 1: Write the failing tests**
```kotlin
@Test
fun auth_and_api_clients_are_stable_per_profile_until_close() {
val provider = RestClientProvider(factory = fakeFactory)
val auth1 = provider.getClient(RestClientProfile.AUTH)
val auth2 = provider.getClient(RestClientProfile.AUTH)
val api1 = provider.getClient(RestClientProfile.API)
assertSame(auth1, auth2)
assertNotSame(auth1, api1)
}
```
```kotlin
@Test
fun realtime_provider_only_reports_client_ready_after_warmup_or_getClient() {
val provider = RealtimeTransportProvider(factory = fakeFactory)
assertEquals(NetworkReadiness.NotInitialized, provider.readiness.value)
provider.warmUp()
assertEquals(NetworkReadiness.ClientReady, provider.readiness.value)
}
```
**Step 2: Run tests to verify they fail**
Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.network.runtime.RestClientProviderTest --tests com.huaga.life_echo.network.runtime.RealtimeTransportProviderTest`
Expected: FAIL because the runtime/provider classes do not exist yet.
**Step 3: Write the minimal implementation**
Create provider APIs with explicit ownership:
```kotlin
enum class RestClientProfile { AUTH, API }
interface ClosableClientProvider<T> {
val readiness: StateFlow<NetworkReadiness>
fun warmUp()
fun getClient(): T
fun close()
}
```
`RestClientProvider` should internally manage one client instance per `RestClientProfile`, not a single global client. `AppContainer` should own these providers and expose them to the rest of the app. `LifeEchoApp` should create one `AppContainer` and make it reachable from `ViewModelFactory`.
**Step 4: Run tests to verify they pass**
Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.network.runtime.RestClientProviderTest --tests com.huaga.life_echo.network.runtime.RealtimeTransportProviderTest`
Expected: PASS
**Step 5: Commit**
```bash
git add app-android/app/src/main/java/com/huaga/life_echo/app/LifeEchoApp.kt \
app-android/app/src/main/java/com/huaga/life_echo/app/AppContainer.kt \
app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RestClientProvider.kt \
app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RealtimeTransportProvider.kt \
app-android/app/src/main/java/com/huaga/life_echo/network/runtime/NetworkReadiness.kt \
app-android/app/src/main/AndroidManifest.xml \
app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RestClientProviderTest.kt \
app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RealtimeTransportProviderTest.kt
git commit -m "refactor: add network client providers"
```
### Task 2: Turn `AuthService`, `ApiService`, and `WebSocketClient` Into Adapters
**Files:**
- Modify: `app-android/app/src/main/java/com/huaga/life_echo/network/AuthService.kt`
- Modify: `app-android/app/src/main/java/com/huaga/life_echo/network/ApiService.kt`
- Modify: `app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketClient.kt`
- Create: `app-android/app/src/test/java/com/huaga/life_echo/network/AuthServiceProviderTest.kt`
- Create: `app-android/app/src/test/java/com/huaga/life_echo/network/ApiServiceProviderTest.kt`
**Step 1: Write the failing tests**
```kotlin
@Test
fun api_service_uses_api_profile_from_provider() {
val provider = FakeRestClientProvider()
ApiService(provider = provider, tokenManager = fakeTokenManager, authService = fakeAuthService)
provider.assertNoClientRequested()
}
```
```kotlin
@Test
fun auth_service_uses_auth_profile_from_provider_when_called() = runTest {
val provider = FakeRestClientProvider()
val service = AuthService(provider = provider)
service.login("13800000000", "pw", true)
assertEquals(listOf(RestClientProfile.AUTH), provider.requestedProfiles)
}
```
**Step 2: Run tests to verify they fail**
Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.network.AuthServiceProviderTest --tests com.huaga.life_echo.network.ApiServiceProviderTest`
Expected: FAIL because the services still own raw `HttpClient` instances.
**Step 3: Write the minimal implementation**
Refactor constructors so the adapters receive providers instead of owning lifecycle:
```kotlin
class AuthService(
private val provider: RestClientProvider,
) { /* use provider.getClient(RestClientProfile.AUTH) */ }
class ApiService(
private val provider: RestClientProvider,
private val tokenManager: TokenManager,
private val authService: AuthService,
) { /* use provider.getClient(RestClientProfile.API) */ }
```
`WebSocketClient` should take `RealtimeTransportProvider` and should no longer own a `HttpClient(OkHttp)` field directly.
**Step 4: Run tests to verify they pass**
Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.network.AuthServiceProviderTest --tests com.huaga.life_echo.network.ApiServiceProviderTest`
Expected: PASS
**Step 5: Commit**
```bash
git add app-android/app/src/main/java/com/huaga/life_echo/network/AuthService.kt \
app-android/app/src/main/java/com/huaga/life_echo/network/ApiService.kt \
app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketClient.kt \
app-android/app/src/test/java/com/huaga/life_echo/network/AuthServiceProviderTest.kt \
app-android/app/src/test/java/com/huaga/life_echo/network/ApiServiceProviderTest.kt
git commit -m "refactor: move network adapters onto providers"
```
### Task 3: Add Capability-Level Ports for the Conversation Flow
**Files:**
- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationApiPort.kt`
- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationRealtimePort.kt`
- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationApiAdapter.kt`
- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapter.kt`
- Create: `app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapterTest.kt`
**Step 1: Write the failing tests**
```kotlin
@Test
fun connect_reports_realtime_connected_only_after_session_connects() = runTest {
val adapter = ConversationRealtimeAdapter(fakeSocketClient)
adapter.connect("conversation-1", "token")
assertEquals(RealtimeConversationPort.State.Connected("conversation-1"), adapter.state.value)
}
```
**Step 2: Run tests to verify they fail**
Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.adapters.ConversationRealtimeAdapterTest`
Expected: FAIL because the port and adapter do not exist.
**Step 3: Write the minimal implementation**
Define ports in business terms, not transport terms:
```kotlin
interface ConversationApiPort {
suspend fun createConversation(): Result<CreateConversationResponse>
suspend fun getMessages(conversationId: String): Result<List<MessageDto>>
}
interface ConversationRealtimePort {
val state: StateFlow<State>
suspend fun prepare()
suspend fun connect(conversationId: String, token: String?)
suspend fun sendText(conversationId: String, text: String)
suspend fun sendAudioSegment(request: AudioSegmentRequest)
}
```
The adapters should delegate to `ApiService` and `WebSocketClient` without leaking Ktor or `DefaultWebSocketSession` to callers.
**Step 4: Run tests to verify they pass**
Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.adapters.ConversationRealtimeAdapterTest`
Expected: PASS
**Step 5: Commit**
```bash
git add app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationApiPort.kt \
app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationRealtimePort.kt \
app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationApiAdapter.kt \
app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapter.kt \
app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapterTest.kt
git commit -m "refactor: add conversation network ports"
```
### Task 4: Migrate `CreateMemoryViewModel` and `ViewModelFactory` to Ports and Explicit Warm-Up
**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/viewmodel/ViewModelFactory.kt`
- Modify: `app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelRecordingCoordinatorTest.kt`
- Create: `app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelWarmupTest.kt`
**Step 1: Write the failing tests**
```kotlin
@Test
fun initialize_conversation_prepares_realtime_before_connect() = runTest {
val realtime = FakeConversationRealtimePort()
val viewModel = newViewModel(realtime = realtime)
viewModel.initializeConversation("conversation-1")
advanceUntilIdle()
assertEquals(listOf("prepare", "connect:conversation-1"), realtime.calls)
}
```
**Step 2: Run tests to verify they fail**
Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelWarmupTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelRecordingCoordinatorTest`
Expected: FAIL because `CreateMemoryViewModel` still constructs `WebSocketClient` and `ApiService` directly.
**Step 3: Write the minimal implementation**
`CreateMemoryViewModel` should take ports instead of concrete network classes. `ViewModelFactory` should fetch the ports from `AppContainer` and decide warm-up policy there or in the screen entry path. The warm-up rule for this feature should be:
- `initializeConversation()` or page entry triggers realtime `prepare()`
- conversation creation / connect still happens explicitly
- recording code remains independent of network transport implementation
**Step 4: Run tests to verify they pass**
Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelWarmupTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelRecordingCoordinatorTest`
Expected: PASS
**Step 5: Commit**
```bash
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/viewmodel/ViewModelFactory.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: inject conversation ports into create memory flow"
```
### Task 5: Extract Feature-Specific REST Ports for Memoir, Profile, Payment, and Repository Callers
**Files:**
- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/memoir/ports/MemoirApiPort.kt`
- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/profile/ports/ProfileApiPort.kt`
- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/payment/ports/PaymentApiPort.kt`
- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/memoir/adapters/MemoirApiAdapter.kt`
- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/profile/adapters/ProfileApiAdapter.kt`
- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/payment/adapters/PaymentApiAdapter.kt`
- Modify: `app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModel.kt`
- Modify: `app-android/app/src/main/java/com/huaga/life_echo/data/repository/ProfileRepository.kt`
- Modify: `app-android/app/src/main/java/com/huaga/life_echo/data/repository/PaymentRepository.kt`
- Modify: `app-android/app/src/main/java/com/huaga/life_echo/data/repository/ConversationRepository.kt`
- Modify: `app-android/app/src/main/java/com/huaga/life_echo/data/repository/MessageRepository.kt`
- Modify: `app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt`
- Test: `app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModelTest.kt`
**Step 1: Write the failing tests**
```kotlin
@Test
fun refresh_chapters_uses_memoir_port_instead_of_raw_api_service() = runTest {
val memoirApi = FakeMemoirApiPort(chaptersResult = Result.success(listOf()))
val viewModel = MyMemoirViewModel(chapterRepository = fakeChapterRepository, memoirApi = memoirApi)
viewModel.refreshChapters()
advanceUntilIdle()
assertEquals(1, memoirApi.getChaptersCalls)
}
```
**Step 2: Run tests to verify they fail**
Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.MyMemoirViewModelTest`
Expected: FAIL because the viewmodel and repositories still depend on `ApiService` directly.
**Step 3: Write the minimal implementation**
Replace direct `ApiService` dependencies with feature ports one feature at a time. The rule is:
- ViewModels depend on ports or repositories
- Repositories depend on ports
- Only adapters depend on `ApiService`
Do not attempt to create one giant `EverythingApiPort`. Keep ports small and feature-shaped.
**Step 4: Run tests to verify they pass**
Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.MyMemoirViewModelTest`
Expected: PASS
**Step 5: Commit**
```bash
git add app-android/app/src/main/java/com/huaga/life_echo/feature/memoir/ports/MemoirApiPort.kt \
app-android/app/src/main/java/com/huaga/life_echo/feature/profile/ports/ProfileApiPort.kt \
app-android/app/src/main/java/com/huaga/life_echo/feature/payment/ports/PaymentApiPort.kt \
app-android/app/src/main/java/com/huaga/life_echo/feature/memoir/adapters/MemoirApiAdapter.kt \
app-android/app/src/main/java/com/huaga/life_echo/feature/profile/adapters/ProfileApiAdapter.kt \
app-android/app/src/main/java/com/huaga/life_echo/feature/payment/adapters/PaymentApiAdapter.kt \
app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModel.kt \
app-android/app/src/main/java/com/huaga/life_echo/data/repository/ProfileRepository.kt \
app-android/app/src/main/java/com/huaga/life_echo/data/repository/PaymentRepository.kt \
app-android/app/src/main/java/com/huaga/life_echo/data/repository/ConversationRepository.kt \
app-android/app/src/main/java/com/huaga/life_echo/data/repository/MessageRepository.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/MyMemoirViewModelTest.kt
git commit -m "refactor: migrate feature code onto network ports"
```
### Task 6: Final Verification and Cleanup
**Files:**
- Modify: `app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RestClientProvider.kt`
- Modify: `app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RealtimeTransportProvider.kt`
- Modify: `app-android/app/src/main/java/com/huaga/life_echo/app/AppContainer.kt`
- Modify: `docs/plans/2026-03-12-network-runtime-ports-refactor.md`
**Step 1: Write the failing verification test if any behavior regressed**
Use the most fragile edge first:
```kotlin
@Test
fun create_memory_recording_limit_race_still_passes_after_network_refactor() = runTest {
// reuse the existing regression path and keep it green during cleanup
}
```
**Step 2: Run focused and full verification**
Run:
```bash
./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelRecordingCoordinatorTest
./gradlew :app:testDebugUnitTest
git diff --check
```
Expected: all PASS, no whitespace errors
**Step 3: Remove temporary compatibility shims**
If `ApiService` or `WebSocketClient` still expose old constructors only for migration, remove them once every caller is port-based. Keep `AppContainer.close()` and provider `close()` wired for future lifecycle cleanup.
**Step 4: Commit**
```bash
git add app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RestClientProvider.kt \
app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RealtimeTransportProvider.kt \
app-android/app/src/main/java/com/huaga/life_echo/app/AppContainer.kt \
docs/plans/2026-03-12-network-runtime-ports-refactor.md
git commit -m "refactor: finalize network runtime composition"
```