# LAN ABSDK and Local Experience Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the three-provider simulation login flow with two pre-login choices: real ABox ABSDK authentication on the LAN and a production-safe local experience that creates a simulated session immediately. **Architecture:** `SessionCoordinator` keeps routing by `ProviderType`: `ABOX` delegates to the process-wide real `AbsdkBridge`, while `LOCAL_EXPERIENCE` delegates to a new production `:runtime:simulation` module. Socket, temperature/humidity, and infrared calls use provider-aware routing adapters; ABSDK device discovery is not invented, so the ABox device catalog remains a provider-scoped local persistence responsibility owned by the device-catalog module. **Tech Stack:** Kotlin 1.9.24, Coroutines/Flow, Android API 26+, Jetpack Compose Material 3, Hilt, Preferences DataStore, Android Keystore, JUnit 4, AndroidX Compose UI tests, vendor `libABSDK0630.jar`. **Spec:** `docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md` ## Global Constraints - The provider selection screen exposes exactly `ABOX` and `LOCAL_EXPERIENCE`; `COMPATIBLE_BACKEND` is removed. - ABox uses the same process-wide `AbsdkBridge` instance for session, socket, temperature/humidity, and infrared operations in debug and release. - Local experience creates `Username("local")` without a credential page, Keystore access, ABSDK call, or network dependency. - `testing:fakes` remains test-only and is never an application runtime dependency. - Production simulation implementations do not expose failure injection or mutable call-recording APIs. - The Android app targets API 34, supports API 26+, and explicitly opts into the vendor JAR's required cleartext LAN transport. - No password, Token, API key, raw vendor response, or infrared payload may be logged. - `DeviceCatalog` is not implemented by ABSDK 0630; do not add a remote discovery assumption. - Every task follows red-green-refactor TDD and ends with an independently reviewable commit. - Do not merge or modify PR #6 while executing this plan. --- ## File and Responsibility Map ### New files - `runtime/simulation/build.gradle.kts` — production Kotlin/JVM simulation module configuration. - `runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGateway.kt` — local-experience session lifecycle. - `runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt` — deterministic built-in local-experience devices. - `runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt` — production socket, temperature/humidity, and infrared simulation behavior. - `runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGatewayTest.kt` — session contract tests. - `runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGatewaysTest.kt` — local device behavior and profile-isolation tests. - `android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt` — active-provider routing for ABSDK-backed device operations. - `android/app/src/test/kotlin/com/flagship/abox/manager/di/ProviderRoutingGatewaysTest.kt` — routing tests using test doubles. - `android/app/src/test/kotlin/com/flagship/abox/manager/testing/MainDispatcherRule.kt` — deterministic `Dispatchers.Main` replacement for ViewModel unit tests. - `android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt` — exactly-two-provider behavior and local direct-entry tests. - `android/core/session/src/test/kotlin/com/flagship/abox/manager/session/RemovedProviderProfileTest.kt` — stale compatible-profile persistence behavior. ### Modified files - `settings.gradle.kts` — includes `:runtime:simulation`. - `domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt` — removes `COMPATIBLE_BACKEND`. - `domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt` — retains only ABox/local routing and enforces local credential isolation. - `testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt` — verifies real/local delegation and no credential use for local experience. - `android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt` — ignores removed persisted provider values and clears stale active selection behavior. - `android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt` — deleted after the simulation implementation replaces it. - `android/app/build.gradle.kts` — adds runtime `:absdk-bridge` and `:runtime:simulation`, removes runtime `testing:fakes`, and adds packaging exclusions for legacy JAR metadata. - `android/app/src/main/AndroidManifest.xml` — adds Internet permission and cleartext transport opt-in required by ABSDK. - `android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt` — binds the real Bridge and simulation session gateway uniformly for every build type. - `android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt` — deleted. - `android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt` — deleted. - `android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt` — removes runtime fake session/device bindings superseded by uniform routing. - `android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt` — reduces availability to two enabled providers. - `android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt` — exposes exactly ABox and local experience. - `android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt` — removes compatible/simulation/disabled branches. - `android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt` — removes compatible-backend text branches. - `android/app/src/main/res/values/strings.xml` — updates ABox LAN copy and removes compatible-provider strings. - `android/app/src/main/res/values-zh-rCN/strings.xml` — matching Simplified Chinese copy. - `android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt` — verifies both supported options are selectable and no third option renders. - `docs/requirement_spec.md`, `docs/project_plan.md`, `docs/architecture.md`, `docs/protocol/absdk-0630.md` — align product and transport documentation with the approved scope. --- ### Task 1: Remove the Compatible Provider from the Domain and Persisted Model **Files:** - Modify: `domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt:26-30` - Modify: `android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt:79-92` - Create: `android/core/session/src/test/kotlin/com/flagship/abox/manager/session/RemovedProviderProfileTest.kt` **Interfaces:** - Consumes: existing `ProviderProfileStore` and `ProviderProfileStoreImpl` APIs. - Produces: `ProviderType` with only `ABOX` and `LOCAL_EXPERIENCE`; stale serialized provider names are ignored and cannot become active. - [ ] **Step 1: Write the failing stale-profile persistence test** ```kotlin package com.flagship.abox.manager.session import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl import java.io.File import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test class RemovedProviderProfileTest { @Test fun removedCompatibleProfileIsIgnoredAndCannotRemainActive() = runTest { val file = File.createTempFile("removed-provider", ".preferences_pb").apply { delete() } val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } dataStore.edit { values -> values[stringSetPreferencesKey("profile_ids")] = setOf("compatible-default") values[stringPreferencesKey("profile_compatible-default_type")] = "COMPATIBLE_BACKEND" values[stringPreferencesKey("profile_compatible-default_name")] = "Compatible backend" values[stringPreferencesKey("active_profile_id")] = "compatible-default" } val store = ProviderProfileStoreImpl(dataStore) assertTrue(store.profiles().isEmpty()) assertNull(store.activeProfile()) assertNull(store.observeActiveProfile().first()) } } ``` - [ ] **Step 2: Run the focused test and verify the baseline behavior** Run: ```powershell .\gradlew :android:core:session:testDebugUnitTest --tests "*RemovedProviderProfileTest" ``` Expected before the enum removal: FAIL because the compatible profile is still parsed and becomes active. - [ ] **Step 3: Remove `COMPATIBLE_BACKEND` and preserve tolerant parsing** Change `ProviderType` to: ```kotlin enum class ProviderType { ABOX, LOCAL_EXPERIENCE, } ``` Keep the existing tolerant parser: ```kotlin val type = preferences[profileTypeKey(id)] ?.let { runCatching { ProviderType.valueOf(it) }.getOrNull() } ?: return@mapNotNull null ``` Do not map the removed value to ABox or local experience. - [ ] **Step 4: Run domain and session tests** Run: ```powershell .\gradlew :domain:test :android:core:session:testDebugUnitTest ``` Expected: PASS; stale compatible data is ignored, current ABox persistence tests remain green. - [ ] **Step 5: Commit** ```powershell git add domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt android/core/session/src/test/kotlin/com/flagship/abox/manager/session/RemovedProviderProfileTest.kt git commit -m "refactor: remove compatible provider type" ``` --- ### Task 2: Add the Production Simulation Runtime Module **Files:** - Modify: `settings.gradle.kts:25-30` - Create: `runtime/simulation/build.gradle.kts` - Create: `runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGateway.kt` - Create: `runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt` - Create: `runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt` - Create: `runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGatewayTest.kt` - Create: `runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGatewaysTest.kt` **Interfaces:** - Consumes: `LocalSessionGateway`, `DeviceCatalog`, `SocketGateway`, `TemperatureHumidityGateway`, `InfraredGateway` from `:domain`. - Produces: `SimulationSessionGateway`, `SimulationDeviceCatalog`, `SimulationSocketGateway`, `SimulationTemperatureHumidityGateway`, and `SimulationInfraredGateway` with public no-argument constructors. - [ ] **Step 1: Add the module skeleton and failing session test** Add to `settings.gradle.kts`: ```kotlin include(":runtime:simulation") ``` Create `runtime/simulation/build.gradle.kts`: ```kotlin plugins { alias(libs.plugins.kotlin.jvm) } kotlin { jvmToolchain(17) } dependencies { api(project(":domain")) implementation(libs.kotlinx.coroutines.core) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) } ``` Create the test: ```kotlin package com.flagship.abox.manager.simulation import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.LoginCredentials import com.flagship.abox.manager.domain.Password import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.SessionValidity import com.flagship.abox.manager.domain.Username import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test class SimulationSessionGatewayTest { @Test fun loginObserveValidateAndLogoutAreProfileScoped() = runTest { val gateway = SimulationSessionGateway() val first = ProviderProfileId("local-one") val second = ProviderProfileId("local-two") val result = gateway.login(first, LoginCredentials(Username("local"), Password(""))) assertEquals(Username("local"), (result as DomainResult.Success).value.username) assertEquals(first, gateway.observeSession(first).first()?.profileId) assertNull(gateway.observeSession(second).first()) assertEquals(DomainResult.Success(SessionValidity.VALID), gateway.validateSession(first)) gateway.logout(first) assertEquals(DomainResult.Success(SessionValidity.INVALID), gateway.validateSession(first)) } } ``` - [ ] **Step 2: Run the session test and verify it fails to compile** Run: ```powershell .\gradlew :runtime:simulation:test --tests "*SimulationSessionGatewayTest" ``` Expected: FAIL because `SimulationSessionGateway` does not exist. - [ ] **Step 3: Implement `SimulationSessionGateway`** ```kotlin package com.flagship.abox.manager.simulation import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.LocalSessionGateway import com.flagship.abox.manager.domain.LoginCredentials import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.Session import com.flagship.abox.manager.domain.SessionValidity import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow class SimulationSessionGateway : LocalSessionGateway { private val sessions = ConcurrentHashMap>() override fun observeSession(profileId: ProviderProfileId): Flow = flow(profileId) override suspend fun login( profileId: ProviderProfileId, credentials: LoginCredentials, ): DomainResult = DomainResult.Success( Session(profileId, credentials.username).also { flow(profileId).value = it }, ) override suspend fun validateSession( profileId: ProviderProfileId, ): DomainResult = DomainResult.Success( if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID, ) override suspend fun logout(profileId: ProviderProfileId): DomainResult { flow(profileId).value = null return DomainResult.Success(Unit) } private fun flow(profileId: ProviderProfileId): MutableStateFlow = sessions.getOrPut(profileId) { MutableStateFlow(null) } } ``` - [ ] **Step 4: Add failing deterministic device behavior tests** Create tests that assert: ```kotlin @Test fun catalogProvidesSocketTemperatureHumidityAndInfraredDevicesForEachProfile() = runTest { val profile = ProviderProfileId("local") val devices = (SimulationDeviceCatalog().observeDevices(profile).first() as DomainResult.Success).value assertEquals(setOf(DeviceType.SOCKET, DeviceType.TEMPERATURE_HUMIDITY, DeviceType.INFRARED), devices.map { it.type }.toSet()) assertTrue(devices.all { it.profileId == profile }) } @Test fun socketStateIsMutableAndIsolatedByProfile() = runTest { val gateway = SimulationSocketGateway() val device = DeviceName("socket-1") val first = ProviderProfileId("first") val second = ProviderProfileId("second") gateway.setPower(first, device, SocketPowerState.ON) assertEquals(SocketPowerState.ON, (gateway.getStatus(first, device) as DomainResult.Success).value.powerState) assertEquals(SocketPowerState.OFF, (gateway.getStatus(second, device) as DomainResult.Success).value.powerState) } @Test fun sensorAndInfraredOperationsSucceedWithoutNetworkConfiguration() = runTest { val profile = ProviderProfileId("local") val th = SimulationTemperatureHumidityGateway().getReading(profile, DeviceName("th-1")) assertTrue(th is DomainResult.Success) assertEquals(DomainResult.Success(Unit), SimulationInfraredGateway().sendKey(profile, DeviceName("ir-1"), "power")) } ``` - [ ] **Step 5: Run device tests and verify they fail to compile** Run: ```powershell .\gradlew :runtime:simulation:test --tests "*SimulationDeviceGatewaysTest" ``` Expected: FAIL because the device simulation classes do not exist. - [ ] **Step 6: Implement the deterministic production simulations** Use the same deterministic defaults as the current test fakes, but expose only gateway interfaces. `SimulationInfraredGateway` returns success without public call-recording fields. `SimulationDeviceCatalog` creates IDs prefixed by the supplied profile ID. Core signatures: ```kotlin class SimulationDeviceCatalog : DeviceCatalog class SimulationSocketGateway : SocketGateway class SimulationTemperatureHumidityGateway : TemperatureHumidityGateway class SimulationInfraredGateway : InfraredGateway ``` Required default values: ```text socket-1 -> OFF th-1 -> temperature "23.5", humidity "45" ir-1 -> send/learn/download success ``` - [ ] **Step 7: Run all simulation tests** Run: ```powershell .\gradlew :runtime:simulation:test ``` Expected: PASS. - [ ] **Step 8: Commit** ```powershell git add settings.gradle.kts runtime/simulation git commit -m "feat: add production local experience simulation" ``` --- ### Task 3: Route Session Selection to Real ABSDK and Simulation **Files:** - Modify: `android/app/build.gradle.kts:54-83` - Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt:1-67` - Delete: `android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt` - Delete: `android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt` - Delete: `android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt` - Modify: `testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt` **Interfaces:** - Consumes: `AbsdkBridge.create(): AbsdkBridge`, `SimulationSessionGateway`, and existing `SessionCoordinator` constructor. - Produces: one all-build Hilt binding where `SessionGateway` is the process-wide `AbsdkBridge` and `LocalSessionGateway` is `SimulationSessionGateway`. - [ ] **Step 1: Extend the coordinator test to prove local selection never calls ABox or credentials** Add counters to test-local doubles or use existing fakes plus an explicit failing remote gateway. The test must assert: ```kotlin @Test fun localSelectionCreatesSessionWithoutAboxOrCredentialAccess() = runTest { val remote = CountingSessionGateway() val credentials = CountingCredentialStore() val local = FakeLocalSessionGateway() val profiles = FakeProviderProfileStore() val coordinator = SessionCoordinator(profiles, credentials, remote, local) val result = coordinator.selectProvider(localProfile) assertTrue(result is DomainResult.Success) assertEquals(0, remote.loginCalls) assertEquals(0, credentials.loadCalls + credentials.saveCalls) assertEquals(localProfile.id, coordinator.observeActiveSession().first()?.profileId) } ``` Also add: ```kotlin @Test fun aboxAuthenticationFailureDoesNotCreateSession() = runTest { val fixture = Fixture() fixture.coordinator.selectProvider(remoteProfile) fixture.remote.failLogin(remoteProfile.id, DomainError.Authentication()) val result = fixture.coordinator.login(remoteProfile.id, credentials, false, false) assertTrue(result is DomainResult.Failure) assertNull(fixture.coordinator.observeActiveSession().first()) } ``` - [ ] **Step 2: Run coordinator tests** Run: ```powershell .\gradlew :testing:fakes:test --tests "*SessionCoordinatorTest" ``` Expected: existing routing tests pass; the new counter-based test initially fails if it reveals any credential access during local selection. - [ ] **Step 3: Add runtime dependencies and legacy JAR packaging exclusions** In `android/app/build.gradle.kts`, add: ```kotlin implementation(project(":absdk-bridge")) implementation(project(":runtime:simulation")) testImplementation(project(":testing:fakes")) ``` Temporarily keep the existing `debugImplementation(project(":testing:fakes"))` until Task 5 removes the remaining debug device bindings; this keeps every intermediate commit compilable. Keep Compose tooling as `debugImplementation`. Add: ```kotlin packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/DEPENDENCIES" excludes += "/META-INF/LICENSE*" excludes += "/META-INF/NOTICE*" } } ``` - [ ] **Step 4: Replace build-type session bindings with uniform production bindings** In `SessionModule.kt`, bind: ```kotlin @Provides @Singleton @OptIn(CleartextAbsdkTransport::class) fun provideAbsdkBridge(): AbsdkBridge = AbsdkBridge.create() @Provides @Singleton fun provideRemoteSessionGateway( @OptIn(CleartextAbsdkTransport::class) bridge: AbsdkBridge, ): SessionGateway = bridge @Provides @Singleton fun provideLocalSessionGateway(): LocalSessionGateway = SimulationSessionGateway() ``` Delete `DebugSessionModule.kt`, `ReleaseSessionModule.kt`, and `LocalSessionGatewayImpl.kt`. Remove obsolete imports. - [ ] **Step 5: Compile both build types and run coordinator tests** Run: ```powershell .\gradlew :android:app:compileDebugKotlin :android:app:compileReleaseKotlin :testing:fakes:test --tests "*SessionCoordinatorTest" ``` Expected: PASS. Hilt reports no duplicate `SessionGateway` binding. - [ ] **Step 6: Commit** ```powershell git add android/app/build.gradle.kts android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt git commit -m "feat: route provider sessions to ABSDK and simulation" ``` --- ### Task 4: Reduce Provider Selection to ABox and Local Experience **Files:** - Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt` - Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt` - Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt` - Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt` - Create: `android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt` - Modify: `android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt` - Modify: `android/app/src/main/res/values/strings.xml` - Modify: `android/app/src/main/res/values-zh-rCN/strings.xml` **Interfaces:** - Consumes: `SessionCoordinator.selectProvider(profile)` and two-value `ProviderType`. - Produces: provider selection with exactly two enabled options; ABox continues to login, local experience creates a session and transitions directly through `AppSessionViewModel`. - [ ] **Step 1: Add a Main dispatcher rule and write a failing ViewModel provider-list test** Create `android/app/src/test/kotlin/com/flagship/abox/manager/testing/MainDispatcherRule.kt`: ```kotlin package com.flagship.abox.manager.testing import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.rules.TestWatcher import org.junit.runner.Description @OptIn(ExperimentalCoroutinesApi::class) class MainDispatcherRule( val dispatcher: TestDispatcher = StandardTestDispatcher(), ) : TestWatcher() { override fun starting(description: Description) { Dispatchers.setMain(dispatcher) } override fun finished(description: Description) { Dispatchers.resetMain() } } ``` Create a test fixture with `FakeProviderProfileStore`, `FakeCredentialStore`, `FakeSessionGateway`, and `FakeLocalSessionGateway`. Add `@get:Rule val mainDispatcherRule = MainDispatcherRule()`. Test: ```kotlin @Test fun exposesExactlyAboxAndLocalExperience() { val viewModel = ProviderSelectionViewModel(coordinator, ProviderAvailability()) assertEquals( listOf(ProviderType.ABOX, ProviderType.LOCAL_EXPERIENCE), viewModel.uiState.value.providers.map { it.profile.type }, ) assertTrue(viewModel.uiState.value.providers.all { it.availability == Availability.Enabled }) } ``` And: ```kotlin @Test fun selectingLocalExperienceEstablishesSessionImmediately() = runTest { val viewModel = ProviderSelectionViewModel(coordinator, ProviderAvailability()) val local = viewModel.uiState.value.providers.single { it.profile.type == ProviderType.LOCAL_EXPERIENCE } viewModel.select(local) advanceUntilIdle() assertEquals(local.profile.id, coordinator.observeActiveSession().first()?.profileId) } ``` - [ ] **Step 2: Run the ViewModel test and verify failure** Run: ```powershell .\gradlew :android:app:testDebugUnitTest --tests "*ProviderSelectionViewModelTest" ``` Expected: FAIL because current state contains three providers and the availability constructor requires compatible-provider values. - [ ] **Step 3: Simplify availability and provider profiles** Use: ```kotlin data class ProviderAvailability( val abox: Availability = Availability.Enabled, val local: Availability = Availability.Enabled, ) { fun forType(type: ProviderType): Availability = when (type) { ProviderType.ABOX -> abox ProviderType.LOCAL_EXPERIENCE -> local } } sealed interface Availability { data object Enabled : Availability data class Disabled(val reason: String) : Availability } ``` If no supported provider is disabled, prefer deleting `Disabled` entirely and reduce `ProviderOption` to `ProviderProfile`. Keep it only if the current UI error-state contract still requires it. Set profiles to: ```kotlin private val PROFILES = listOf( ProviderProfile(ProviderProfileId("abox-default"), ProviderType.ABOX, "ABox"), ProviderProfile( ProviderProfileId("local-experience"), ProviderType.LOCAL_EXPERIENCE, "Local experience", ), ) ``` - [ ] **Step 4: Remove compatible-provider UI branches and update copy** English: ```xml Connect to the ABox smart-home gateway through ABSDK on the current local network. Explore the app with simulated local devices and no network connection. ``` Simplified Chinese: ```xml 通过 ABSDK 连接当前局域网中的 ABox 智能家居网关。 无需网络,通过本地模拟设备体验应用。 ``` Delete all `provider_compatible_*`, protocol-pending, transport-pending, and simulation-status resources that are no longer referenced. - [ ] **Step 5: Replace the Compose test with the two-option contract** ```kotlin @Test fun aboxAndLocalExperienceAreBothSelectableAndNoCompatibleOptionIsShown() { val abox = ProviderOption( ProviderProfile(ProviderProfileId("abox"), ProviderType.ABOX, "ABox"), Availability.Enabled, ) val local = ProviderOption( ProviderProfile(ProviderProfileId("local"), ProviderType.LOCAL_EXPERIENCE, "Local"), Availability.Enabled, ) var selected: ProviderOption? = null composeRule.setContent { ABoxTheme { ProviderSelectionScreen(ProviderSelectionUiState(listOf(abox, local))) { selected = it } } } composeRule.onNodeWithText("ABox").assertIsEnabled() composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick() assertEquals(local, selected) composeRule.onNodeWithText("Compatible backend").assertDoesNotExist() } ``` - [ ] **Step 6: Run provider unit and instrumentation compilation/tests** Run: ```powershell .\gradlew :android:app:testDebugUnitTest --tests "*ProviderSelectionViewModelTest" :android:app:compileDebugAndroidTestKotlin ``` If an emulator/device is available, also run: ```powershell .\gradlew :android:app:connectedDebugAndroidTest ``` Expected: unit tests pass; instrumentation source compiles; connected test passes when a device is available. - [ ] **Step 7: Commit** ```powershell git add android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt android/app/src/main/res android/app/src/test android/app/src/androidTest git commit -m "refactor: offer ABox and local experience providers" ``` --- ### Task 5: Add Provider-Aware Device Gateway Routing **Files:** - Create: `android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt` - Create: `android/app/src/test/kotlin/com/flagship/abox/manager/di/ProviderRoutingGatewaysTest.kt` - Modify: `android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt` - Create or modify: `android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt` **Interfaces:** - Consumes: `ProviderProfileStore.activeProfile()`, real `AbsdkBridge`, simulation gateway implementations. - Produces: `RoutingSocketGateway`, `RoutingTemperatureHumidityGateway`, and `RoutingInfraredGateway`, each implementing the public domain gateway interface. - [ ] **Step 1: Write failing routing tests** Define simple recording test doubles and test both provider types: ```kotlin @Test fun socketRoutesAboxToRealAndLocalToSimulation() = runTest { val store = FakeProviderProfileStore() val real = RecordingSocketGateway("real") val simulation = RecordingSocketGateway("simulation") val router = RoutingSocketGateway(store, real, simulation) activate(store, ProviderType.ABOX) router.getStatus(activeId, DeviceName("socket-1")) assertEquals(1, real.calls) assertEquals(0, simulation.calls) activate(store, ProviderType.LOCAL_EXPERIENCE) router.getStatus(activeId, DeviceName("socket-1")) assertEquals(1, simulation.calls) } ``` Add equivalent tests for temperature/humidity and infrared, plus: ```kotlin @Test fun mismatchedOrMissingActiveProfileReturnsInvalidRequest() = runTest { val result = router.getStatus(ProviderProfileId("other"), DeviceName("socket-1")) assertTrue(result is DomainResult.Failure) assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) } ``` - [ ] **Step 2: Run the routing tests and verify failure** Run: ```powershell .\gradlew :android:app:testDebugUnitTest --tests "*ProviderRoutingGatewaysTest" ``` Expected: FAIL because routing adapters do not exist. - [ ] **Step 3: Implement focused routing adapters** Use a shared resolver: ```kotlin internal class ActiveProviderGatewayResolver( private val profileStore: ProviderProfileStore, ) { suspend fun resolve(profileId: ProviderProfileId): DomainResult { val active = profileStore.activeProfile() ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "No active provider")) if (active.id != profileId) { return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active")) } return DomainResult.Success(active.type) } } ``` Each router delegates only after a successful resolution. Rethrow `CancellationException`; map unexpected store exceptions to `DomainError.Unknown`. - [ ] **Step 4: Bind public device gateways to routers** Create an all-build Hilt module that receives distinct concrete types: ```kotlin @Provides @Singleton fun provideSocketGateway( profileStore: ProviderProfileStore, real: AbsdkBridge, simulation: SimulationSocketGateway, ): SocketGateway = RoutingSocketGateway(profileStore, real, simulation) ``` Repeat for temperature/humidity and infrared. Provide simulation concrete instances as singletons. Remove the corresponding fake bindings from debug `FoundationModule`. Keep the existing debug-only `FakeDeviceCatalog` binding for now because ABSDK has no catalog and the provider-scoped production ABox catalog belongs to the device-catalog module owner. Do not route ABox catalog requests to `SimulationDeviceCatalog`. Document this temporary boundary in `FoundationModule`. Because `FakeDeviceCatalog` remains debug-only, keep `debugImplementation(project(":testing:fakes"))`; release compilation must not request a `DeviceCatalog` binding until a release consumer actually exists. If Hilt requires one during release compilation, add an explicit release `UnavailableDeviceCatalog` returning `DomainError.InvalidRequest("ABox device catalog is not implemented yet")`, not the simulation catalog. - [ ] **Step 5: Run routing and Hilt compilation tests** Run: ```powershell .\gradlew :android:app:testDebugUnitTest --tests "*ProviderRoutingGatewaysTest" :android:app:compileDebugKotlin :android:app:compileReleaseKotlin ``` Expected: PASS; no duplicate gateway bindings. - [ ] **Step 6: Commit** ```powershell git add android/app/src/main/kotlin/com/flagship/abox/manager/di android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt android/app/src/test/kotlin/com/flagship/abox/manager/di git commit -m "feat: route device operations by active provider" ``` --- ### Task 6: Configure Android LAN Transport and Verify Real Login Gating **Files:** - Modify: `android/app/src/main/AndroidManifest.xml` - Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt` only if an additional regression assertion reveals a mismatch. - Create: `android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModelTest.kt` **Interfaces:** - Consumes: `AppSessionViewModel.stateFor` behavior via public `uiState`, real `SessionGateway` Hilt binding, Android manifest merge. - Produces: authenticated UI only for a session matching the active ABox profile; manifest permits vendor LAN HTTP in every build type. - [ ] **Step 1: Add a regression test for failed ABox login and mismatched sessions** Use `MainDispatcherRule` from Task 4 plus `FakeProviderProfileStore`, `FakeCredentialStore`, `FakeSessionGateway`, `FakeLocalSessionGateway`, and a test `ProviderAvailability`. Assert: ```kotlin @Test fun failedAboxLoginLeavesUiOnLogin() = runTest { profiles.saveProfile(abox) profiles.setActiveProfile(abox.id) remote.failLogin(abox.id, DomainError.Authentication()) val viewModel = AppSessionViewModel(coordinator, profiles, ProviderAvailability()) coordinator.login(abox.id, invalidCredentials, false, false) advanceUntilIdle() assertTrue(viewModel.uiState.value is AppSessionUiState.Login) } @Test fun sessionForDifferentProfileCannotAuthenticateActiveProfile() = runTest { // Activate ABox profile A while the gateway exposes a session for profile B. // Assert uiState is Login, not Authenticated. } ``` - [ ] **Step 2: Run the regression test** Run: ```powershell .\gradlew :android:app:testDebugUnitTest --tests "*AppSessionViewModelTest" ``` Expected: PASS for the current profile-ID guard; if the failed-login case exposes asynchronous setup issues, keep the test and make the smallest state-flow correction. - [ ] **Step 3: Add required application transport declarations** Update the main manifest: ```xml ... ``` Do not add credentials or gateway IP addresses to the manifest. - [ ] **Step 4: Verify manifest merge and both build variants** Run: ```powershell .\gradlew :android:app:processDebugMainManifest :android:app:processReleaseMainManifest :android:app:testDebugUnitTest --tests "*AppSessionViewModelTest" ``` Expected: PASS; merged manifests contain `INTERNET` and `usesCleartextTraffic=true`. - [ ] **Step 5: Commit** ```powershell git add android/app/src/main/AndroidManifest.xml android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModelTest.kt android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt git commit -m "fix: require real ABSDK success before authentication" ``` --- ### Task 7: Align Product, Architecture, and Protocol Documentation **Files:** - Modify: `docs/requirement_spec.md` - Modify: `docs/project_plan.md` - Modify: `docs/architecture.md` - Modify: `docs/protocol/absdk-0630.md` **Interfaces:** - Consumes: completed code behavior from Tasks 1-6. - Produces: repository documentation that no longer describes cloud or compatible-backend product modes. - [ ] **Step 1: Update the requirement specification** State explicitly: ```markdown **连接方式** 1. ABox:通过 ABSDK 连接当前局域网中的智能家居网关,登录成功后进入应用。 2. 本地体验:不访问网络或 ABSDK,直接建立模拟会话并进入应用。 3. 首版不提供自有兼容后端入口。 ``` Retain remember-username and automatic-login requirements only for ABox. - [ ] **Step 2: Update the project plan** Replace the three-provider scope with two providers, remove `feat/compatible-server`, Ktor/Docker compatibility-server deliverables, and compatible-backend responsibility statements. Preserve the fixed five-person ownership model. - [ ] **Step 3: Update architecture documentation** Document: ```text ABOX -> AbsdkBridge singleton LOCAL_EXPERIENCE -> :runtime:simulation ``` State that `testing:fakes` remains test-only and that ABSDK supplies no device catalog. - [ ] **Step 4: Update protocol documentation with observed LAN evidence** Record without overclaiming: - `192.168.100.1` was reachable on ports 80 and 443 on 2026-08-24; - `POST http://192.168.100.1/abox/index.php/abjhsdk/user/validate` returned `code=00000`, `status=0` without a Token; - hardware testing on `feat/moler-infrared-control` established that the vendor JAR reaches the real gateway in the current network environment; - the JAR still hardcodes `a-box.com.cn`; the mechanism mapping it to the LAN gateway remains an environmental dependency and is not exposed by `setHostInfo`. - [ ] **Step 5: Search for obsolete product claims** Run: ```powershell git grep -n -E "COMPATIBLE_BACKEND|Compatible backend|自有兼容后端|feat/compatible-server|Ktor|PostgreSQL|Docker" -- docs domain android ``` Expected: only explicit historical/removed-value migration notes remain. Any product-scope references are removed. - [ ] **Step 6: Commit** ```powershell git add docs/requirement_spec.md docs/project_plan.md docs/architecture.md docs/protocol/absdk-0630.md git commit -m "docs: align product scope with LAN ABSDK access" ``` --- ### Task 8: Full Verification and Authorized Hardware Smoke Test **Files:** - Modify only if verification reveals a specific defect. - Update: `docs/protocol/absdk-0630.md` with the final authorized hardware result, excluding secrets. **Interfaces:** - Consumes: all deliverables from Tasks 1-7. - Produces: build evidence and a documented real-vs-local acceptance result. - [ ] **Step 1: Run the full repository verification** Run: ```powershell .\gradlew check testDebugUnitTest test ``` Expected: `BUILD SUCCESSFUL`; all domain, Bridge, simulation, session, app unit, and lint tasks pass. - [ ] **Step 2: Build both APK variants** Run: ```powershell .\gradlew :android:app:assembleDebug :android:app:assembleRelease ``` Expected: both APKs build successfully with the same two provider choices. - [ ] **Step 3: Run the authorized ABox hardware login smoke test** On a device connected to the gateway LAN: 1. Install the debug APK. 2. Choose **ABox**. 3. Enter a deliberately invalid non-secret credential pair. 4. Verify the app remains on the login screen and displays the localized authentication error. 5. Enter an authorized valid credential pair without recording it. 6. Verify the app enters the authenticated screen only after ABSDK success. 7. Log out and verify the app returns to ABox login without exposing the password. Expected: invalid credentials cannot create a session; valid credentials can establish the real Bridge session. - [ ] **Step 4: Run the local-experience smoke test** 1. Switch provider. 2. Choose **Local experience**. 3. Verify no credential page appears. 4. Verify the authenticated screen opens immediately. 5. Verify simulated socket, temperature/humidity, and infrared operations work without network access. Expected: local experience is independent from the ABSDK session and credentials. - [ ] **Step 5: Record the sanitized result** Add a dated table row to `docs/protocol/absdk-0630.md` containing: ```text App commit | build type | provider | invalid-login result | valid-login result | read-only device result | limitations ``` Do not record credentials, Token, API keys, or raw payloads. - [ ] **Step 6: Verify the final diff and working tree** Run: ```powershell git diff --check git status --short git log --oneline main..HEAD ``` Expected: no whitespace errors; only intentional uncommitted hardware-result documentation remains before the final commit. - [ ] **Step 7: Commit the hardware validation record** ```powershell git add docs/protocol/absdk-0630.md git commit -m "test: record LAN ABSDK login validation" ``` - [ ] **Step 8: Push the branch and update PR #4** ```powershell git push origin feat/provider-login ``` Use `tea` to update the PR description so it states the new two-provider scope and removes cloud/compatible-backend claims. Do not merge; wait for review and required checks.