Files
abox-mgr/docs/superpowers/plans/2026-08-24-lan-absdk-and-local-experience.md
T
KremeCN 06ccfdf952 feat: add provider login flow (#4)
## Summary

- replace the former three-provider model with two pre-login choices:
  - **ABox**: authenticate through the vendor ABSDK on the current local network
  - **Local experience**: establish a production simulation session without credentials, network access, or ABSDK calls
- remove the compatible-backend product/server scope and safely ignore legacy persisted compatible-provider values
- bind one process-wide `AbsdkBridge` for ABox session, socket, temperature/humidity, and infrared operations
- add production `:runtime:simulation` implementations and route device operations by the active provider
- retain provider-scoped username preferences, Keystore-encrypted automatic-login credentials, token recovery, logout, and session isolation
- add regression coverage for authentication gating, profile/session alignment, provider switching cleanup, routing, and simulation behavior
- align requirement, plan, architecture, and protocol documentation with LAN ABSDK access

## Verification

```text
./gradlew check testDebugUnitTest test
BUILD SUCCESSFUL (220 actionable tasks)

./gradlew :android:app:assembleDebug :android:app:assembleRelease
BUILD SUCCESSFUL (217 actionable tasks)

./gradlew :android:app:compileDebugAndroidTestKotlin
BUILD SUCCESSFUL

git diff --check
passed
```

Emulator smoke validation:

- provider selection displays ABox and Local experience only
- Local experience enters the authenticated route without credentials
- Local experience also enters while emulator Wi-Fi/data are disabled
- Local Sign out and Switch provider return to provider selection
- ABox opens the credential form; empty submission stays on login with the required-fields error

## Known constraints and accepted risks

- The vendor JAR uses cleartext HTTP and hardcodes `a-box.com.cn`; Debug and Release intentionally share the same ABox capability under the approved no-Release-exception decision. `@CleartextAbsdkTransport`, `usesCleartextTraffic`, and the architecture/protocol documents explicitly record this risk. This does not provide confidentiality or integrity.
- Real authenticated ABox and read-only hardware smoke validation is deferred because the gateway is currently unavailable locally. This PR does not claim that current-environment validation completed.
- The mapping from the vendor JAR's hardcoded hostname to the LAN gateway remains an external network/deployment prerequisite. The ABSDK JAR and bridge path remain identical to the previously hardware-validated `feat/moler-infrared-control` branch.
- `FakeDeviceCatalog` remains a temporary debug-only placeholder until the device-catalog module provides the production ABox catalog. ABox catalog requests are not routed to Local experience simulation.

Reviewed-on: #4
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-25 01:59:00 +08:00

1060 lines
42 KiB
Markdown

# 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<ProviderProfileId, MutableStateFlow<Session?>>()
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> = flow(profileId)
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session> = DomainResult.Success(
Session(profileId, credentials.username).also { flow(profileId).value = it },
)
override suspend fun validateSession(
profileId: ProviderProfileId,
): DomainResult<SessionValidity> = DomainResult.Success(
if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID,
)
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> {
flow(profileId).value = null
return DomainResult.Success(Unit)
}
private fun flow(profileId: ProviderProfileId): MutableStateFlow<Session?> =
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
<string name="provider_abox_description">Connect to the ABox smart-home gateway through ABSDK on the current local network.</string>
<string name="provider_local_description">Explore the app with simulated local devices and no network connection.</string>
```
Simplified Chinese:
```xml
<string name="provider_abox_description">通过 ABSDK 连接当前局域网中的 ABox 智能家居网关。</string>
<string name="provider_local_description">无需网络,通过本地模拟设备体验应用。</string>
```
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<ProviderType> {
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
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".ABoxManagerApplication"
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.ABoxManager">
...
</application>
</manifest>
```
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.