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 @@
<manifest />
@@ -0,0 +1,75 @@
package com.flagship.abox.manager.session.credential
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import com.flagship.abox.manager.domain.ProviderProfileId
import java.nio.ByteBuffer
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class AndroidKeystoreCredentialCipher(
private val keyAlias: String = KEY_ALIAS,
) : CredentialCipher {
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
cipher.updateAAD(profileId.value.toByteArray(Charsets.UTF_8))
val ciphertext = cipher.doFinal(plaintext)
return ByteBuffer.allocate(2 + cipher.iv.size + ciphertext.size)
.put(VERSION)
.put(cipher.iv.size.toByte())
.put(cipher.iv)
.put(ciphertext)
.array()
}
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
require(encrypted.size >= HEADER_SIZE) { "Credential payload is truncated" }
val buffer = ByteBuffer.wrap(encrypted)
require(buffer.get() == VERSION) { "Unsupported credential format" }
val ivLength = buffer.get().toInt() and 0xFF
require(ivLength in 12..16 && buffer.remaining() > ivLength) { "Invalid credential payload" }
val iv = ByteArray(ivLength).also(buffer::get)
val ciphertext = ByteArray(buffer.remaining()).also(buffer::get)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
cipher.updateAAD(profileId.value.toByteArray(Charsets.UTF_8))
return cipher.doFinal(ciphertext)
}
override fun resetPermanentlyInvalidatedKey() {
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
if (keyStore.containsAlias(keyAlias)) keyStore.deleteEntry(keyAlias)
}
private fun secretKey(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
(keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE).run {
init(
KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setKeySize(KEY_SIZE_BITS)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
generateKey()
}
}
companion object {
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
private const val KEY_ALIAS = "abox_manager_credentials_v1"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val KEY_SIZE_BITS = 256
private const val GCM_TAG_BITS = 128
private const val HEADER_SIZE = 2
private const val VERSION: Byte = 1
}
}
@@ -0,0 +1,11 @@
package com.flagship.abox.manager.session.credential
import com.flagship.abox.manager.domain.ProviderProfileId
interface CredentialCipher {
fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray
fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray
fun resetPermanentlyInvalidatedKey()
}
@@ -0,0 +1,138 @@
package com.flagship.abox.manager.session.credential
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.flagship.abox.manager.domain.CredentialStore
import com.flagship.abox.manager.domain.DomainError
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.Username
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import android.security.keystore.KeyPermanentlyInvalidatedException
import java.util.Base64
import javax.crypto.AEADBadTagException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
class KeystoreCredentialStore(
private val dataStore: DataStore<Preferences>,
private val cipher: CredentialCipher,
) : CredentialStore {
override suspend fun saveCredentials(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Unit> = operation {
val encrypted = cipher.encrypt(profileId, encode(credentials))
dataStore.edit { it[credentialKey(profileId)] = Base64.getEncoder().encodeToString(encrypted) }
}
override suspend fun loadCredentials(
profileId: ProviderProfileId,
): DomainResult<LoginCredentials?> {
return try {
val value = dataStore.data.first()[credentialKey(profileId)]
?: return DomainResult.Success(null)
val encrypted = Base64.getDecoder().decode(value)
DomainResult.Success(decode(cipher.decrypt(profileId, encrypted)))
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (error.isPermanentCredentialFailure()) {
try {
if (error.hasPermanentlyInvalidatedKey()) {
cipher.resetPermanentlyInvalidatedKey()
}
dataStore.edit { it.remove(credentialKey(profileId)) }
DomainResult.Success(null)
} catch (cleanupError: CancellationException) {
throw cleanupError
} catch (cleanupError: Exception) {
DomainResult.Failure(
DomainError.Unknown(
detail = cleanupError.message ?: cleanupError::class.java.simpleName,
),
)
}
} else {
DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
)
}
}
}
override suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult<Unit> = operation {
dataStore.edit { it.remove(credentialKey(profileId)) }
}
private fun encode(credentials: LoginCredentials): ByteArray = ByteArrayOutputStream().use { output ->
DataOutputStream(output).use { data ->
data.writeByte(CREDENTIAL_FORMAT_VERSION)
data.writeByteArray(credentials.username.value.toByteArray(Charsets.UTF_8))
data.writeByteArray(credentials.password.value.toByteArray(Charsets.UTF_8))
}
output.toByteArray()
}
private fun decode(value: ByteArray): LoginCredentials = DataInputStream(
ByteArrayInputStream(value),
).use { data ->
require(data.readUnsignedByte() == CREDENTIAL_FORMAT_VERSION) {
"Unsupported credential data format"
}
val username = data.readByteArray(MAX_CREDENTIAL_FIELD_BYTES).toString(Charsets.UTF_8)
val password = data.readByteArray(MAX_CREDENTIAL_FIELD_BYTES).toString(Charsets.UTF_8)
require(data.available() == 0) { "Unexpected credential data" }
LoginCredentials(Username(username), Password(password))
}
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
block()
DomainResult.Success(Unit)
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
)
}
companion object {
private const val CREDENTIAL_FORMAT_VERSION = 1
private const val MAX_CREDENTIAL_FIELD_BYTES = 1_048_576
private fun credentialKey(profileId: ProviderProfileId) =
stringPreferencesKey("profile_${profileId.value}_credential")
}
}
private fun DataOutputStream.writeByteArray(value: ByteArray) {
require(value.size <= 1_048_576) { "Credential field is too large" }
writeInt(value.size)
write(value)
}
private fun DataInputStream.readByteArray(maxSize: Int): ByteArray {
val length = readInt()
require(length in 0..maxSize && length <= available()) { "Invalid credential field length" }
return ByteArray(length).also(::readFully)
}
private fun Throwable.hasPermanentlyInvalidatedKey(): Boolean =
this is KeyPermanentlyInvalidatedException || cause?.hasPermanentlyInvalidatedKey() == true
private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) {
is AEADBadTagException,
is KeyPermanentlyInvalidatedException,
is IllegalArgumentException,
-> true
else -> cause?.isPermanentCredentialFailure() == true
}
@@ -0,0 +1,138 @@
package com.flagship.abox.manager.session.profile
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginPreferences
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.Username
import java.io.IOException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen
class ProviderProfileStoreImpl(
private val dataStore: DataStore<Preferences>,
) : ProviderProfileStore {
private val safeData: Flow<Preferences>
get() = dataStore.data
.retryWhen { cause, attempt ->
val retry = cause is IOException && attempt < MAX_READ_RETRIES
if (retry) delay(READ_RETRY_DELAY_MILLIS)
retry
}
.distinctUntilChanged()
override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
override fun observeActiveProfile(): Flow<ProviderProfile?> = safeData.map(::activeProfileFrom)
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
safeData.map { preferencesFrom(it, profileId) }
override suspend fun profiles(): List<ProviderProfile> = profilesFrom(safeData.first())
override suspend fun activeProfile(): ProviderProfile? = activeProfileFrom(safeData.first())
override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences =
preferencesFrom(safeData.first(), profileId)
override suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit> = operation {
dataStore.edit { preferences ->
preferences[PROFILE_IDS] = preferences[PROFILE_IDS].orEmpty() + profile.id.value
preferences[profileTypeKey(profile.id)] = profile.type.name
preferences[profileNameKey(profile.id)] = profile.displayName
}
}
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> = operation {
dataStore.edit { preferences ->
if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID)
else preferences[ACTIVE_PROFILE_ID] = profileId.value
}
}
override suspend fun saveLoginPreferences(
preferences: LoginPreferences,
): DomainResult<Unit> = operation {
dataStore.edit { values ->
val profileId = preferences.profileId
values[rememberUsernameKey(profileId)] = preferences.rememberUsername
values[autoLoginKey(profileId)] = preferences.autoLoginEnabled
val username = preferences.rememberedUsername?.value
if (username == null) values.remove(usernameKey(profileId))
else values[usernameKey(profileId)] = username
}
}
private fun profilesFrom(preferences: Preferences): List<ProviderProfile> =
preferences[PROFILE_IDS].orEmpty().mapNotNull { idValue ->
val id = ProviderProfileId(idValue)
val type = preferences[profileTypeKey(id)]
?.let { runCatching { ProviderType.valueOf(it) }.getOrNull() }
?: return@mapNotNull null
val displayName = preferences[profileNameKey(id)] ?: return@mapNotNull null
ProviderProfile(id, type, displayName)
}.sortedBy { it.id.value }
private fun activeProfileFrom(preferences: Preferences): ProviderProfile? {
val activeId = preferences[ACTIVE_PROFILE_ID] ?: return null
return profilesFrom(preferences).firstOrNull { it.id.value == activeId }
}
private fun preferencesFrom(
preferences: Preferences,
profileId: ProviderProfileId,
): LoginPreferences = LoginPreferences(
profileId = profileId,
rememberedUsername = preferences[usernameKey(profileId)]?.let(::Username),
rememberUsername = preferences[rememberUsernameKey(profileId)] ?: false,
autoLoginEnabled = preferences[autoLoginKey(profileId)] ?: false,
)
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
block()
DomainResult.Success(Unit)
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
DomainResult.Failure(
com.flagship.abox.manager.domain.DomainError.Unknown(
detail = error.message ?: error::class.java.simpleName,
),
)
}
companion object {
private const val MAX_READ_RETRIES = 2L
private const val READ_RETRY_DELAY_MILLIS = 100L
private val PROFILE_IDS = stringSetPreferencesKey("profile_ids")
private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id")
private fun profileTypeKey(id: ProviderProfileId) =
stringPreferencesKey("profile_${id.value}_type")
private fun profileNameKey(id: ProviderProfileId) =
stringPreferencesKey("profile_${id.value}_name")
private fun usernameKey(id: ProviderProfileId) =
stringPreferencesKey("profile_${id.value}_username")
private fun rememberUsernameKey(id: ProviderProfileId) =
booleanPreferencesKey("profile_${id.value}_remember_username")
private fun autoLoginKey(id: ProviderProfileId) =
booleanPreferencesKey("profile_${id.value}_auto_login")
}
}
@@ -0,0 +1,15 @@
package com.flagship.abox.manager.session.profile
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStore
private const val SESSION_DATA_STORE_NAME = "provider_session"
val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore(
name = SESSION_DATA_STORE_NAME,
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
)