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
ABOXandLOCAL_EXPERIENCE;COMPATIBLE_BACKENDis removed. - ABox uses the same process-wide
AbsdkBridgeinstance 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:fakesremains 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.
DeviceCatalogis 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— deterministicDispatchers.Mainreplacement 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— removesCOMPATIBLE_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-bridgeand:runtime:simulation, removes runtimetesting: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
ProviderProfileStoreandProviderProfileStoreImplAPIs. -
Produces:
ProviderTypewith onlyABOXandLOCAL_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_BACKENDand 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,InfraredGatewayfrom:domain. -
Produces:
SimulationSessionGateway,SimulationDeviceCatalog,SimulationSocketGateway,SimulationTemperatureHumidityGateway, andSimulationInfraredGatewaywith 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 existingSessionCoordinatorconstructor. -
Produces: one all-build Hilt binding where
SessionGatewayis the process-wideAbsdkBridgeandLocalSessionGatewayisSimulationSessionGateway. -
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-valueProviderType. -
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(), realAbsdkBridge, simulation gateway implementations. -
Produces:
RoutingSocketGateway,RoutingTemperatureHumidityGateway, andRoutingInfraredGateway, 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.ktonly 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.stateForbehavior via publicuiState, realSessionGatewayHilt 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.1was reachable on ports 80 and 443 on 2026-08-24; -
POST http://192.168.100.1/abox/index.php/abjhsdk/user/validatereturnedcode=00000,status=0without a Token; -
hardware testing on
feat/moler-infrared-controlestablished 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 bysetHostInfo. -
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.mdwith 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:
- Install the debug APK.
- Choose ABox.
- Enter a deliberately invalid non-secret credential pair.
- Verify the app remains on the login screen and displays the localized authentication error.
- Enter an authorized valid credential pair without recording it.
- Verify the app enters the authenticated screen only after ABSDK success.
- 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
- Switch provider.
- Choose Local experience.
- Verify no credential page appears.
- Verify the authenticated screen opens immediately.
- 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.