From c4eb481c5c8b8b400de8943a915db5d06f9c4306 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 12 Mar 2026 10:14:46 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=B7=BB=E5=8A=A0=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E5=B1=82=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-03-12-network-runtime-ports-refactor.md | 404 ++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 docs/plans/2026-03-12-network-runtime-ports-refactor.md diff --git a/docs/plans/2026-03-12-network-runtime-ports-refactor.md b/docs/plans/2026-03-12-network-runtime-ports-refactor.md new file mode 100644 index 0000000..231bd1c --- /dev/null +++ b/docs/plans/2026-03-12-network-runtime-ports-refactor.md @@ -0,0 +1,404 @@ +# 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" +``` +