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

42 KiB

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

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:

.\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:

enum class ProviderType {
    ABOX,
    LOCAL_EXPERIENCE,
}

Keep the existing tolerant parser:

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:

.\gradlew :domain:test :android:core:session:testDebugUnitTest

Expected: PASS; stale compatible data is ignored, current ABox persistence tests remain green.

  • Step 5: Commit
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:

include(":runtime:simulation")

Create runtime/simulation/build.gradle.kts:

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:

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:

.\gradlew :runtime:simulation:test --tests "*SimulationSessionGatewayTest"

Expected: FAIL because SimulationSessionGateway does not exist.

  • Step 3: Implement SimulationSessionGateway
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:

@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:

.\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:

class SimulationDeviceCatalog : DeviceCatalog
class SimulationSocketGateway : SocketGateway
class SimulationTemperatureHumidityGateway : TemperatureHumidityGateway
class SimulationInfraredGateway : InfraredGateway

Required default values:

socket-1 -> OFF
th-1     -> temperature "23.5", humidity "45"
ir-1     -> send/learn/download success
  • Step 7: Run all simulation tests

Run:

.\gradlew :runtime:simulation:test

Expected: PASS.

  • Step 8: Commit
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:

@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:

@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:

.\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:

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:

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:

@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:

.\gradlew :android:app:compileDebugKotlin :android:app:compileReleaseKotlin :testing:fakes:test --tests "*SessionCoordinatorTest"

Expected: PASS. Hilt reports no duplicate SessionGateway binding.

  • Step 6: Commit
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:

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:

@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:

@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:

.\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:

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:

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:

<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:

<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
@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:

.\gradlew :android:app:testDebugUnitTest --tests "*ProviderSelectionViewModelTest" :android:app:compileDebugAndroidTestKotlin

If an emulator/device is available, also run:

.\gradlew :android:app:connectedDebugAndroidTest

Expected: unit tests pass; instrumentation source compiles; connected test passes when a device is available.

  • Step 7: Commit
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:

@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:

@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:

.\gradlew :android:app:testDebugUnitTest --tests "*ProviderRoutingGatewaysTest"

Expected: FAIL because routing adapters do not exist.

  • Step 3: Implement focused routing adapters

Use a shared resolver:

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:

@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:

.\gradlew :android:app:testDebugUnitTest --tests "*ProviderRoutingGatewaysTest" :android:app:compileDebugKotlin :android:app:compileReleaseKotlin

Expected: PASS; no duplicate gateway bindings.

  • Step 6: Commit
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:

@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:

.\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:

<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:

.\gradlew :android:app:processDebugMainManifest :android:app:processReleaseMainManifest :android:app:testDebugUnitTest --tests "*AppSessionViewModelTest"

Expected: PASS; merged manifests contain INTERNET and usesCleartextTraffic=true.

  • Step 5: Commit
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:

**连接方式**
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:

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:

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
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:

.\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:

.\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:

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:

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
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
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.