diff --git a/docs/superpowers/plans/2026-08-24-lan-absdk-and-local-experience.md b/docs/superpowers/plans/2026-08-24-lan-absdk-and-local-experience.md new file mode 100644 index 0000000..625781c --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-lan-absdk-and-local-experience.md @@ -0,0 +1,1059 @@ +# 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.