18 KiB
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
@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)
}
@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:
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
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
@Test
fun api_service_uses_api_profile_from_provider() {
val provider = FakeRestClientProvider()
ApiService(provider = provider, tokenManager = fakeTokenManager, authService = fakeAuthService)
provider.assertNoClientRequested()
}
@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:
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
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
@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:
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
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
@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 realtimeprepare()- 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
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
@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
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:
@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:
./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
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"