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>
This commit was merged in pull request #4.
This commit is contained in:
2026-08-25 01:59:00 +08:00
committed by KremeCN
parent 6f7d3248b0
commit 06ccfdf952
59 changed files with 4982 additions and 209 deletions
@@ -0,0 +1,230 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredCode
import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.SocketStatus
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.domain.TemperatureHumidityReading
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ProviderRoutingGatewaysTest {
@Test
fun socketRoutesAboxToRealAndLocalToSimulation() = runTest {
val store = FakeProviderProfileStore()
val real = RecordingSocketGateway()
val simulation = RecordingSocketGateway()
val router = RoutingSocketGateway(store, real, simulation)
val aboxId = activate(store, ProviderType.ABOX)
router.getStatus(aboxId, DeviceName("socket-1"))
assertEquals(1, real.calls)
assertEquals(0, simulation.calls)
val localId = activate(store, ProviderType.LOCAL_EXPERIENCE)
router.getStatus(localId, DeviceName("socket-1"))
assertEquals(1, real.calls)
assertEquals(1, simulation.calls)
}
@Test
fun temperatureHumidityRoutesAboxToRealAndLocalToSimulation() = runTest {
val store = FakeProviderProfileStore()
val real = RecordingTemperatureHumidityGateway()
val simulation = RecordingTemperatureHumidityGateway()
val router = RoutingTemperatureHumidityGateway(store, real, simulation)
val aboxId = activate(store, ProviderType.ABOX)
router.getReading(aboxId, DeviceName("th-1"))
assertEquals(1, real.calls)
assertEquals(0, simulation.calls)
val localId = activate(store, ProviderType.LOCAL_EXPERIENCE)
router.getReading(localId, DeviceName("th-1"))
assertEquals(1, real.calls)
assertEquals(1, simulation.calls)
}
@Test
fun infraredRoutesAboxToRealAndLocalToSimulation() = runTest {
val store = FakeProviderProfileStore()
val real = RecordingInfraredGateway()
val simulation = RecordingInfraredGateway()
val router = RoutingInfraredGateway(store, real, simulation)
val aboxId = activate(store, ProviderType.ABOX)
router.sendKey(aboxId, DeviceName("ir-1"), "power")
assertEquals(1, real.calls)
assertEquals(0, simulation.calls)
val localId = activate(store, ProviderType.LOCAL_EXPERIENCE)
router.sendKey(localId, DeviceName("ir-1"), "power")
assertEquals(1, real.calls)
assertEquals(1, simulation.calls)
}
@Test
fun missingActiveProfileReturnsInvalidRequest() = runTest {
val router = RoutingSocketGateway(
FakeProviderProfileStore(),
RecordingSocketGateway(),
RecordingSocketGateway(),
)
val result = router.getStatus(ProviderProfileId("any"), DeviceName("socket-1"))
assertTrue(result is DomainResult.Failure)
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
}
@Test
fun mismatchedActiveProfileReturnsInvalidRequest() = runTest {
val store = FakeProviderProfileStore()
val real = RecordingSocketGateway()
val simulation = RecordingSocketGateway()
val router = RoutingSocketGateway(store, real, simulation)
activate(store, ProviderType.ABOX)
val result = router.getStatus(ProviderProfileId("other"), DeviceName("socket-1"))
assertTrue(result is DomainResult.Failure)
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertEquals(0, real.calls)
assertEquals(0, simulation.calls)
}
@Test
fun cancellationFromProfileStoreIsRethrown() = runTest {
val router = RoutingSocketGateway(
CancellingProviderProfileStore(),
RecordingSocketGateway(),
RecordingSocketGateway(),
)
try {
router.getStatus(ProviderProfileId("any"), DeviceName("socket-1"))
error("Expected CancellationException")
} catch (expected: CancellationException) {
// Rethrown so coroutine cancellation is never swallowed.
}
}
@Test
fun unexpectedProfileStoreErrorReturnsUnknown() = runTest {
val router = RoutingSocketGateway(
FailingProviderProfileStore(),
RecordingSocketGateway(),
RecordingSocketGateway(),
)
val result = router.getStatus(ProviderProfileId("any"), DeviceName("socket-1"))
assertTrue(result is DomainResult.Failure)
val error = (result as DomainResult.Failure).error
assertTrue(error is DomainError.Unknown)
assertEquals("storage exploded", error.detail)
}
private suspend fun activate(
store: FakeProviderProfileStore,
type: ProviderType,
): ProviderProfileId {
val id = ProviderProfileId(type.name.lowercase())
store.saveProfile(ProviderProfile(id, type, type.name))
store.setActiveProfile(id)
return id
}
}
private class RecordingSocketGateway : SocketGateway {
var calls: Int = 0
private set
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> {
calls += 1
return DomainResult.Success(SocketStatus(profileId, deviceName, SocketPowerState.OFF))
}
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> {
calls += 1
return DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
}
}
private class RecordingTemperatureHumidityGateway : TemperatureHumidityGateway {
var calls: Int = 0
private set
override suspend fun getReading(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<TemperatureHumidityReading> {
calls += 1
return DomainResult.Success(TemperatureHumidityReading(profileId, deviceName, "0", "0"))
}
}
private class RecordingInfraredGateway : InfraredGateway {
var calls: Int = 0
private set
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
calls += 1
return DomainResult.Success(Unit)
}
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
calls += 1
return DomainResult.Success(Unit)
}
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> {
calls += 1
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
}
}
private class CancellingProviderProfileStore(
private val delegate: ProviderProfileStore = FakeProviderProfileStore(),
) : ProviderProfileStore by delegate {
override suspend fun activeProfile(): Nothing = throw CancellationException("cancelled")
}
private class FailingProviderProfileStore(
private val delegate: ProviderProfileStore = FakeProviderProfileStore(),
) : ProviderProfileStore by delegate {
override suspend fun activeProfile(): Nothing =
throw IllegalStateException("storage exploded")
}
@@ -0,0 +1,23 @@
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()
}
}
@@ -0,0 +1,51 @@
package com.flagship.abox.manager.ui.provider
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeCredentialStore
import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ProviderSelectionViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val coordinator = SessionCoordinator(
profileStore = FakeProviderProfileStore(),
credentialStore = FakeCredentialStore(),
remoteGateway = FakeSessionGateway(),
localGateway = FakeLocalSessionGateway(),
)
@Test
fun exposesExactlyAboxAndLocalExperience() {
val viewModel = ProviderSelectionViewModel(coordinator)
assertEquals(
listOf(ProviderType.ABOX, ProviderType.LOCAL_EXPERIENCE),
viewModel.uiState.value.providers.map { it.type },
)
}
@Test
fun selectingLocalExperienceEstablishesSessionImmediately() = runTest {
val viewModel = ProviderSelectionViewModel(coordinator)
val local = viewModel.uiState.value.providers
.single { it.type == ProviderType.LOCAL_EXPERIENCE }
viewModel.select(local)
advanceUntilIdle()
assertEquals(local.id, coordinator.observeActiveSession().first()?.profileId)
}
}
@@ -0,0 +1,126 @@
package com.flagship.abox.manager.ui.session
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeCredentialStore
import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/**
* Regression tests for real ABox login gating: the authenticated UI may appear only when the
* active provider profile owns a successful session, and a failed ABox login stays on Login.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AppSessionViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profiles = FakeProviderProfileStore()
private val remote = FakeSessionGateway()
private val local = FakeLocalSessionGateway()
private val credentials = LoginCredentials(
username = Username("user"),
password = Password("password"),
)
private fun newCoordinator(remoteGateway: SessionGateway = remote) = SessionCoordinator(
profileStore = profiles,
credentialStore = FakeCredentialStore(),
remoteGateway = remoteGateway,
localGateway = local,
)
private suspend fun saveAndActivateAboxProfile(id: ProviderProfileId) {
profiles.saveProfile(ProviderProfile(id, ProviderType.ABOX, id.value))
profiles.setActiveProfile(id)
}
@Test
fun failedAboxLoginLeavesUiOnLogin() = runTest {
saveAndActivateAboxProfile(aboxProfileId)
remote.failLogin(aboxProfileId, DomainError.Authentication())
val coordinator = newCoordinator()
val viewModel = AppSessionViewModel(coordinator, profiles)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
advanceUntilIdle()
coordinator.login(
profileId = aboxProfileId,
credentials = credentials,
rememberUsername = false,
autoLogin = false,
)
advanceUntilIdle()
assertTrue(viewModel.uiState.value is AppSessionUiState.Login)
}
@Test
fun sessionForDifferentProfileCannotAuthenticateActiveProfile() = runTest {
profiles.saveProfile(ProviderProfile(otherProfileId, ProviderType.ABOX, otherProfileId.value))
remote.login(otherProfileId, credentials)
saveAndActivateAboxProfile(aboxProfileId)
// The gateway exposes the other profile's session no matter which profile is observed.
val crossProfileGateway = object : SessionGateway by remote {
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> =
remote.observeSession(otherProfileId)
}
val viewModel = AppSessionViewModel(newCoordinator(crossProfileGateway), profiles)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
advanceUntilIdle()
assertTrue(viewModel.uiState.value is AppSessionUiState.Login)
}
@Test
fun successfulAboxLoginAuthenticatesActiveProfile() = runTest {
saveAndActivateAboxProfile(aboxProfileId)
val coordinator = newCoordinator()
val viewModel = AppSessionViewModel(coordinator, profiles)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
advanceUntilIdle()
coordinator.login(
profileId = aboxProfileId,
credentials = credentials,
rememberUsername = false,
autoLogin = false,
)
advanceUntilIdle()
val state = viewModel.uiState.value
assertTrue(state is AppSessionUiState.Authenticated)
}
private companion object {
val aboxProfileId = ProviderProfileId("abox-default")
val otherProfileId = ProviderProfileId("abox-other")
}
}