# 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 { val readiness: StateFlow 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 suspend fun getMessages(conversationId: String): Result> } interface ConversationRealtimePort { val state: StateFlow 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" ```