feat: add ABSDK 0630 bridge (#3)

## Summary

- add a pure Kotlin/JVM anti-corruption layer for ABSDK 0630
- serialize blocking SDK calls and enforce one provider-scoped session
- map `ABRet`, raw maps, SDK exceptions, and error codes into typed domain results
- implement session, socket, temperature/humidity, and infrared gateways
- document bytecode-confirmed protocol facts, SDK limitations, and unresolved behavior
- align JVM module bytecode with the Android Java 17 target

## Scope boundaries

- the bridge is not wired into the Android app in this PR; debug continues using Fakes
- `setHostInfo` is intentionally not called or implemented as a provider flow
- logout clears only the bridge-local session because the JAR exposes no logout or token setter
- no real credentials, network calls, or hardware were used in tests
- `docs/requirement_spec.md` and `docs/project_plan.md` are unchanged

## Verification

```text
gradlew.bat --stop
gradlew.bat clean :absdk-bridge:test :domain:test :testing:fakes:test :android:app:lintDebug :android:app:assembleDebug :android:app:assembleRelease build
```

Result: `BUILD SUCCESSFUL`

The public JAR API was also rechecked with `javap`, including the actual `getThStatus(String)` method, absence of a public logout API, `ABRet.getDicDatas()`, `IrInfo.getStatus()`, and `downloadIrCodes(List<IrInfo>)`.
Reviewed-on: #3
Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #3.
This commit is contained in:
2026-08-23 07:19:05 +08:00
committed by KremeCN
parent 1e10d2ab00
commit 6f7d3248b0
18 changed files with 1298 additions and 5 deletions
+20
View File
@@ -0,0 +1,20 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
kotlin {
jvmToolchain(17)
}
dependencies {
api(project(":domain"))
implementation(
fileTree("../samples/abox-sdk-sample/login-mac/libs") {
include("*.jar")
},
)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,344 @@
package com.flagship.abox.manager.absdk.bridge
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.LoginCredentials
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.SessionValidity
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class AbsdkBridge internal constructor(
private val client: AbsdkClient,
private val dispatcher: CoroutineDispatcher,
) : SessionGateway, SocketGateway, TemperatureHumidityGateway, InfraredGateway {
private val mutex = Mutex()
private val activeSession = MutableStateFlow<Session?>(null)
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> =
activeSession.map { session -> session?.takeIf { it.profileId == profileId } }
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session> = serialized {
val currentSession = activeSession.value
if (currentSession != null && currentSession.profileId != profileId) {
return@serialized DomainResult.Failure(
DomainError.InvalidRequest(detail = "Exit the active provider session before logging in"),
)
}
when (val response = call { client.login(credentials.username.value, credentials.password.value) }) {
is ClientCall.Failed -> DomainResult.Failure(response.error)
is ClientCall.Completed -> {
if (!response.response.isSuccess()) {
DomainResult.Failure(AbsdkErrorMapper.fromResponse(response.response))
} else {
DomainResult.Success(
Session(profileId, credentials.username).also { activeSession.value = it },
)
}
}
}
}
override suspend fun validateSession(
profileId: ProviderProfileId,
): DomainResult<SessionValidity> = serialized {
if (!hasActiveSession(profileId)) {
return@serialized DomainResult.Success(SessionValidity.INVALID)
}
when (val response = call(client::validateToken)) {
is ClientCall.Failed -> DomainResult.Failure(response.error)
is ClientCall.Completed -> {
if (!response.response.isSuccess()) {
val error = AbsdkErrorMapper.fromResponse(response.response)
if (error is DomainError.TokenInvalid) activeSession.value = null
DomainResult.Failure(error)
} else {
when (response.response?.data?.stringValue(STATUS_KEY)) {
ENABLED_STATUS -> DomainResult.Success(SessionValidity.VALID)
DISABLED_STATUS -> DomainResult.Success(SessionValidity.INVALID).also {
activeSession.value = null
}
else -> DomainResult.Failure(DomainError.NoData(detail = "Missing token status"))
}
}
}
}
}
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> = serialized {
if (activeSession.value?.profileId == profileId) activeSession.value = null
DomainResult.Success(Unit)
}
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> = serialized {
withActiveSession(profileId) {
when (val result = call { client.getSocketStatus(deviceName.value) }) {
is ClientCall.Failed -> DomainResult.Failure(result.error)
is ClientCall.Completed -> socketStatus(profileId, deviceName, result.response)
}
}
}
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> = serialized {
withActiveSession(profileId) {
when (val control = call {
client.setSocketPower(deviceName.value, powerState.toSdkStatus())
}) {
is ClientCall.Failed -> DomainResult.Failure(control.error)
is ClientCall.Completed -> {
if (!control.response.isSuccess()) {
DomainResult.Failure(AbsdkErrorMapper.fromResponse(control.response))
} else {
when (val confirmation = call {
client.getSocketStatus(deviceName.value)
}) {
is ClientCall.Failed -> DomainResult.Failure(confirmation.error)
is ClientCall.Completed -> socketStatus(
profileId,
deviceName,
confirmation.response,
)
}
}
}
}
}
}
override suspend fun getReading(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<TemperatureHumidityReading> = serialized {
withActiveSession(profileId) {
when (val result = call { client.getTemperatureHumidity(deviceName.value) }) {
is ClientCall.Failed -> DomainResult.Failure(result.error)
is ClientCall.Completed -> {
val response = result.response
if (!response.isSuccess()) {
DomainResult.Failure(AbsdkErrorMapper.fromResponse(response))
} else {
val temperature = response?.data?.stringValue(TEMPERATURE_KEY)
val humidity = response?.data?.stringValue(HUMIDITY_KEY)
if (temperature.isNullOrBlank() || humidity.isNullOrBlank()) {
DomainResult.Failure(DomainError.NoData(detail = "Missing temperature or humidity"))
} else {
DomainResult.Success(
TemperatureHumidityReading(
profileId = profileId,
deviceName = deviceName,
temperature = temperature,
humidity = humidity,
),
)
}
}
}
}
}
}
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = serialized {
withActiveSession(profileId) {
unitResult(call { client.sendInfrared(deviceName.value, key) })
}
}
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = serialized {
withActiveSession(profileId) {
unitResult(call { client.learnInfrared(deviceName.value, key) })
}
}
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = serialized {
withActiveSession(profileId) {
when (val result = call {
client.downloadInfrared(
codes.map { AbsdkDownloadCode(it.deviceName.value, it.key, it.code) },
)
}) {
is ClientCall.Failed -> DomainResult.Failure(result.error)
is ClientCall.Completed -> parseDownloadResult(codes, result.response)
}
}
}
private suspend fun <T> serialized(block: suspend () -> T): T = withContext(dispatcher) {
mutex.withLock { block() }
}
private inline fun <T> withActiveSession(
profileId: ProviderProfileId,
block: () -> DomainResult<T>,
): DomainResult<T> {
if (!hasActiveSession(profileId)) {
return DomainResult.Failure(
DomainError.TokenInvalid(detail = "No active ABSDK session for this provider"),
)
}
return block().also { result ->
if (result is DomainResult.Failure && result.error is DomainError.TokenInvalid) {
activeSession.value = null
}
}
}
private fun hasActiveSession(profileId: ProviderProfileId): Boolean =
activeSession.value?.profileId == profileId
private fun socketStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
response: AbsdkResponse?,
): DomainResult<SocketStatus> {
if (!response.isSuccess()) {
return DomainResult.Failure(AbsdkErrorMapper.fromResponse(response))
}
return when (val status = response?.data?.stringValue(STATUS_KEY)) {
DISABLED_STATUS -> DomainResult.Success(
SocketStatus(profileId, deviceName, SocketPowerState.OFF),
)
ENABLED_STATUS -> DomainResult.Success(
SocketStatus(profileId, deviceName, SocketPowerState.ON),
)
else -> DomainResult.Failure(
DomainError.NoData(detail = "Missing or invalid socket status: $status"),
)
}
}
private fun parseDownloadResult(
codes: List<InfraredCode>,
response: AbsdkResponse?,
): DomainResult<List<InfraredCodeDownloadResult>> {
val validResponse = response?.takeIf { it.code in IR_DOWNLOAD_RESULT_CODES }
?: return DomainResult.Failure(AbsdkErrorMapper.fromResponse(response))
val results = validResponse.data[IR_RESULTS_KEY] as? List<*>
?: return DomainResult.Failure(
DomainError.OperationFailed(
externalCode = validResponse.code,
detail = "Missing infrared download results",
),
)
if (results.size != codes.size || results.any { it !is AbsdkIrResult }) {
return DomainResult.Failure(
DomainError.OperationFailed(
externalCode = validResponse.code,
detail = "Malformed infrared download result",
),
)
}
val typedResults = results.filterIsInstance<AbsdkIrResult>()
val orderedResults = codes.zip(typedResults)
if (orderedResults.any { (code, result) ->
result.deviceName != code.deviceName.value || result.key != code.key
}
) {
return DomainResult.Failure(
DomainError.OperationFailed(
externalCode = validResponse.code,
detail = "Infrared download results do not match their requests",
),
)
}
return DomainResult.Success(
orderedResults.map { (code, result) ->
InfraredCodeDownloadResult(code, successful = result.status == ENABLED_STATUS)
},
)
}
private fun unitResult(result: ClientCall): DomainResult<Unit> = when (result) {
is ClientCall.Failed -> DomainResult.Failure(result.error)
is ClientCall.Completed -> if (result.response.isSuccess()) {
DomainResult.Success(Unit)
} else {
DomainResult.Failure(AbsdkErrorMapper.fromResponse(result.response))
}
}
private fun call(block: () -> AbsdkResponse?): ClientCall = try {
ClientCall.Completed(block())
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
ClientCall.Failed(AbsdkErrorMapper.fromException(error))
}
companion object {
private const val STATUS_KEY = "status"
private const val TEMPERATURE_KEY = "temperature"
private const val HUMIDITY_KEY = "humidity"
private const val IR_RESULTS_KEY = "ret"
private const val DISABLED_STATUS = "0"
private const val ENABLED_STATUS = "1"
@OptIn(CleartextAbsdkTransport::class)
private val processInstance: AbsdkBridge by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
AbsdkBridge(
client = VendorAbsdkClient.create(),
dispatcher = Dispatchers.IO,
)
}
@CleartextAbsdkTransport
fun create(): AbsdkBridge = processInstance
}
}
private const val SUCCESS_CODE = "00000"
private const val PARTIAL_IR_DOWNLOAD_CODE = "20204"
private val IR_DOWNLOAD_RESULT_CODES = setOf(SUCCESS_CODE, PARTIAL_IR_DOWNLOAD_CODE)
private sealed interface ClientCall {
data class Completed(val response: AbsdkResponse?) : ClientCall
data class Failed(val error: DomainError) : ClientCall
}
private fun AbsdkResponse?.isSuccess(): Boolean = this?.code == SUCCESS_CODE
private fun Map<String, Any?>.stringValue(key: String): String? = this[key] as? String
private fun SocketPowerState.toSdkStatus(): String = when (this) {
SocketPowerState.OFF -> "0"
SocketPowerState.ON -> "1"
}
@@ -0,0 +1,37 @@
package com.flagship.abox.manager.absdk.bridge
internal data class AbsdkResponse(
val code: String?,
val message: String?,
val data: Map<String, Any?>,
)
internal data class AbsdkIrResult(
val deviceName: String?,
val key: String?,
val status: String?,
)
internal interface AbsdkClient {
fun login(username: String, password: String): AbsdkResponse?
fun validateToken(): AbsdkResponse?
fun getSocketStatus(deviceName: String): AbsdkResponse?
fun setSocketPower(deviceName: String, status: String): AbsdkResponse?
fun getTemperatureHumidity(deviceName: String): AbsdkResponse?
fun sendInfrared(deviceName: String, key: String): AbsdkResponse?
fun learnInfrared(deviceName: String, key: String): AbsdkResponse?
fun downloadInfrared(codes: List<AbsdkDownloadCode>): AbsdkResponse?
}
internal data class AbsdkDownloadCode(
val deviceName: String,
val key: String,
val code: String,
)
@@ -0,0 +1,51 @@
package com.flagship.abox.manager.absdk.bridge
import com.flagship.abox.manager.domain.DomainError
import java.io.IOException
import java.net.SocketException
import java.net.SocketTimeoutException
internal object AbsdkErrorMapper {
private val tokenInvalidCodes = setOf("10001", "10002", "10003")
private val authenticationCodes = setOf("10006", "20001", "20002", "20003", "20004", "20005")
private val invalidRequestCodes = setOf(
"10004", "10005", "20101",
"20201", "20202",
"20301", "20302", "20303",
"20501",
"20601", "20602",
"20701", "20702", "20703", "20704", "20705",
"20801", "20804",
)
private val deviceNotFoundCodes = setOf("20203", "20304", "20502", "20603", "20706", "20802")
private val deviceOfflineCodes = setOf("20206", "20306", "20504", "20605", "20805")
private val operationFailedCodes = setOf("00001", "20204", "20205", "20305", "20503", "20604", "20707", "20806")
fun fromResponse(response: AbsdkResponse?): DomainError {
if (response == null) {
return DomainError.Unknown(detail = "ABSDK returned no response")
}
return fromCode(response.code, response.message)
}
fun fromCode(code: String?, message: String?): DomainError = when (code) {
in tokenInvalidCodes -> DomainError.TokenInvalid(code, message)
in authenticationCodes -> DomainError.Authentication(code, message)
"20000" -> DomainError.Network(code, message)
in invalidRequestCodes -> DomainError.InvalidRequest(code, message)
in deviceNotFoundCodes -> DomainError.DeviceNotFound(code, message)
in deviceOfflineCodes -> DomainError.DeviceOffline(code, message)
"20803" -> DomainError.NoData(code, message)
in operationFailedCodes -> DomainError.OperationFailed(code, message)
else -> DomainError.Unknown(code, message)
}
fun fromException(error: Throwable): DomainError = when (error) {
is SocketTimeoutException,
is SocketException,
is IOException,
-> DomainError.Network(detail = error.message ?: error::class.java.simpleName)
else -> DomainError.Unknown(detail = error.message ?: error::class.java.simpleName)
}
}
@@ -0,0 +1,22 @@
package com.flagship.abox.manager.absdk.bridge
/**
* Marks construction of the original ABSDK 0630 client, whose base endpoint is hardcoded to HTTP.
*
* Opting in acknowledges that transport confidentiality and integrity are not provided by the
* vendor JAR. App integration must remain disabled until the deployment has an approved transport
* control and the security limitation is presented to the responsible integrator.
*/
@RequiresOptIn(
message = "ABSDK 0630 uses a hardcoded cleartext HTTP transport. " +
"Do not enable it without an approved transport control.",
level = RequiresOptIn.Level.ERROR,
)
@Retention(AnnotationRetention.BINARY)
@Target(
AnnotationTarget.CLASS,
AnnotationTarget.CONSTRUCTOR,
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY,
)
annotation class CleartextAbsdkTransport
@@ -0,0 +1,67 @@
package com.flagship.abox.manager.absdk.bridge
import com.dhc.absdk.ABRet
import com.dhc.absdk.ABSDK
import com.dhc.absdk.IrInfo
@CleartextAbsdkTransport
internal class VendorAbsdkClient private constructor(
private val sdk: ABSDK,
) : AbsdkClient {
override fun login(username: String, password: String): AbsdkResponse? =
sdk.loginWithUsername(username, password).toSnapshot()
override fun validateToken(): AbsdkResponse? = sdk.isValidateToken().toSnapshot()
override fun getSocketStatus(deviceName: String): AbsdkResponse? =
sdk.getSockStatus(deviceName).toSnapshot()
override fun setSocketPower(deviceName: String, status: String): AbsdkResponse? =
sdk.sockCtrl(deviceName, status).toSnapshot()
override fun getTemperatureHumidity(deviceName: String): AbsdkResponse? =
sdk.getThStatus(deviceName).toSnapshot()
override fun sendInfrared(deviceName: String, key: String): AbsdkResponse? =
sdk.sendIr(deviceName, key).toSnapshot()
override fun learnInfrared(deviceName: String, key: String): AbsdkResponse? =
sdk.studyIrByIrDevName(deviceName, key).toSnapshot()
override fun downloadInfrared(codes: List<AbsdkDownloadCode>): AbsdkResponse? {
val response = sdk.downloadIrCodes(
codes.map { code ->
IrInfo().apply {
irDevName = code.deviceName
key = code.key
this.code = code.code
}
},
) ?: return null
val irResults = (response.dicDatas?.get(IR_RESULTS_KEY) as? List<*>)
?.mapNotNull { (it as? IrInfo)?.toSnapshot() }
.orEmpty()
val data = response.dicDatas.orEmpty() + (IR_RESULTS_KEY to irResults)
return AbsdkResponse(response.code, response.msg, data)
}
companion object {
private const val IR_RESULTS_KEY = "ret"
fun create(): VendorAbsdkClient = VendorAbsdkClient(ABSDK.getInstance())
}
}
private fun ABRet?.toSnapshot(): AbsdkResponse? = this?.let {
AbsdkResponse(
code = code,
message = msg,
data = dicDatas.orEmpty().mapValues { entry -> entry.value },
)
}
private fun IrInfo.toSnapshot(): AbsdkIrResult = AbsdkIrResult(
deviceName = irDevName,
key = key,
status = status,
)
@@ -0,0 +1,174 @@
package com.flagship.abox.manager.absdk.bridge
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.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.Username
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AbsdkDeviceBridgeTest {
private val profile = ProviderProfileId("abox")
private val socket = DeviceName("socket-1")
@Test
fun socketControlReturnsConfirmedStatus() = runTest {
val client = ScriptedAbsdkClient().apply {
socketStatusResponses = ArrayDeque(
listOf(ScriptedAbsdkClient.success(mapOf("status" to "1"))),
)
}
val bridge = authenticatedBridge(client)
val result = bridge.setPower(profile, socket, SocketPowerState.ON)
assertEquals(SocketPowerState.ON, (result as DomainResult.Success).value.powerState)
assertEquals(
listOf("login:demo", "socket-power:socket-1:1", "socket-status:socket-1"),
client.calls,
)
}
@Test
fun socketSdkExceptionIsMapped() = runTest {
val client = ScriptedAbsdkClient()
val bridge = authenticatedBridge(client)
client.failure = IOException("socket unavailable")
val result = bridge.getStatus(profile, socket)
assertTrue((result as DomainResult.Failure).error is DomainError.Network)
}
@Test
fun socketMalformedStatusReturnsNoData() = runTest {
val client = ScriptedAbsdkClient().apply {
socketStatusResponses = ArrayDeque(
listOf(ScriptedAbsdkClient.success(mapOf("status" to "unknown"))),
)
}
val bridge = authenticatedBridge(client)
val result = bridge.getStatus(profile, socket)
assertTrue((result as DomainResult.Failure).error is DomainError.NoData)
}
@Test
fun temperatureMapsValuesAndErrors() = runTest {
val client = ScriptedAbsdkClient()
val bridge = authenticatedBridge(client)
val success = bridge.getReading(profile, DeviceName("th-1")) as DomainResult.Success
assertEquals("23.5", success.value.temperature)
assertEquals("45", success.value.humidity)
client.temperatureResponse = ScriptedAbsdkClient.success(mapOf("temperature" to "23"))
val missing = bridge.getReading(profile, DeviceName("th-1"))
assertTrue((missing as DomainResult.Failure).error is DomainError.NoData)
client.temperatureResponse = ScriptedAbsdkClient.failure("20805", "offline")
val offline = bridge.getReading(profile, DeviceName("th-1"))
assertTrue((offline as DomainResult.Failure).error is DomainError.DeviceOffline)
}
@Test
fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest {
val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1")
val codeTwo = InfraredCode(DeviceName("ir-1"), "volume", "code-2")
val client = ScriptedAbsdkClient().apply {
downloadResponse = AbsdkResponse(
code = "20204",
message = "partial failure",
data = mapOf(
"ret" to listOf(
AbsdkIrResult("ir-1", "power", "1"),
AbsdkIrResult("ir-1", "volume", "0"),
),
),
)
}
val bridge = authenticatedBridge(client)
assertEquals(DomainResult.Success(Unit), bridge.sendKey(profile, DeviceName("ir-1"), "power"))
assertEquals(DomainResult.Success(Unit), bridge.learnKey(profile, DeviceName("ir-1"), "power"))
val result = bridge.downloadCodes(profile, listOf(codeOne, codeTwo)) as DomainResult.Success
assertEquals(listOf(false, true).sorted(), result.value.map { it.successful }.sorted())
assertEquals(
listOf(
AbsdkDownloadCode("ir-1", "power", "code-1"),
AbsdkDownloadCode("ir-1", "volume", "code-2"),
),
client.downloadedCodes,
)
}
@Test
fun infraredTokenErrorCannotBeMaskedByValidResults() = runTest {
val code = InfraredCode(DeviceName("ir-1"), "power", "code-1")
val client = ScriptedAbsdkClient().apply {
downloadResponse = AbsdkResponse(
code = "10002",
message = "expired",
data = mapOf(
"ret" to listOf(AbsdkIrResult("ir-1", "power", "1")),
),
)
}
val bridge = authenticatedBridge(client)
val result = bridge.downloadCodes(profile, listOf(code))
assertTrue((result as DomainResult.Failure).error is DomainError.TokenInvalid)
assertEquals(null, bridge.observeSession(profile).first())
val second = bridge.sendKey(profile, DeviceName("ir-1"), "power")
assertTrue((second as DomainResult.Failure).error is DomainError.TokenInvalid)
assertEquals(listOf("login:demo", "ir-download"), client.calls)
}
@Test
fun infraredMalformedResultsFailTheBatch() = runTest {
val code = InfraredCode(DeviceName("ir-1"), "power", "code-1")
val client = ScriptedAbsdkClient().apply {
downloadResponse = ScriptedAbsdkClient.success(
mapOf("ret" to listOf(AbsdkIrResult("another-device", "power", "1"))),
)
}
val bridge = authenticatedBridge(client)
val result = bridge.downloadCodes(profile, listOf(code))
assertTrue((result as DomainResult.Failure).error is DomainError.OperationFailed)
}
@Test
fun ioExceptionsStayInsideTheBridge() = runTest {
val client = ScriptedAbsdkClient().apply { failure = IOException("offline") }
val bridge = AbsdkBridge(client, Dispatchers.Unconfined)
val result = bridge.login(
profile,
LoginCredentials(Username("demo"), Password("secret")),
)
assertTrue((result as DomainResult.Failure).error is DomainError.Network)
assertFalse(result.error.detail.orEmpty().contains("secret"))
}
private suspend fun authenticatedBridge(client: ScriptedAbsdkClient): AbsdkBridge =
AbsdkBridge(client, Dispatchers.Unconfined).also {
it.login(profile, LoginCredentials(Username("demo"), Password("secret")))
}
}
@@ -0,0 +1,49 @@
package com.flagship.abox.manager.absdk.bridge
import com.flagship.abox.manager.domain.DomainError
import java.io.IOException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AbsdkErrorMapperTest {
@Test
fun mapsKnownCodeGroups() {
val cases = mapOf(
"10002" to DomainError.TokenInvalid::class,
"10004" to DomainError.InvalidRequest::class,
"10006" to DomainError.Authentication::class,
"20000" to DomainError.Network::class,
"20004" to DomainError.Authentication::class,
"20101" to DomainError.InvalidRequest::class,
"20203" to DomainError.DeviceNotFound::class,
"20205" to DomainError.OperationFailed::class,
"20206" to DomainError.DeviceOffline::class,
"20501" to DomainError.InvalidRequest::class,
"20502" to DomainError.DeviceNotFound::class,
"20503" to DomainError.OperationFailed::class,
"20504" to DomainError.DeviceOffline::class,
"20803" to DomainError.NoData::class,
"20805" to DomainError.DeviceOffline::class,
)
cases.forEach { (code, expectedType) ->
val error = AbsdkErrorMapper.fromCode(code, "detail")
assertEquals(code, error.externalCode)
assertEquals("detail", error.detail)
assertEquals(expectedType, error::class)
}
}
@Test
fun mapsUnknownAndNullResponses() {
assertTrue(AbsdkErrorMapper.fromCode("99999", null) is DomainError.Unknown)
assertTrue(AbsdkErrorMapper.fromResponse(null) is DomainError.Unknown)
}
@Test
fun mapsIoExceptionsToNetworkErrors() {
assertTrue(AbsdkErrorMapper.fromException(IOException("offline")) is DomainError.Network)
assertTrue(AbsdkErrorMapper.fromException(IllegalStateException("broken")) is DomainError.Unknown)
}
}
@@ -0,0 +1,12 @@
package com.flagship.abox.manager.absdk.bridge
import org.junit.Assert.assertSame
import org.junit.Test
@OptIn(CleartextAbsdkTransport::class)
class AbsdkFactoryTest {
@Test
fun productionFactoryReturnsOneProcessInstance() {
assertSame(AbsdkBridge.create(), AbsdkBridge.create())
}
}
@@ -0,0 +1,36 @@
package com.flagship.abox.manager.absdk.bridge
import com.dhc.absdk.ABRet
import com.dhc.absdk.ABSDK
import com.dhc.absdk.IrInfo
import java.lang.reflect.ParameterizedType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class AbsdkPublicApiContractTest {
@Test
fun usesActualTemperatureHumidityMethodName() {
assertNotNull(ABSDK::class.java.getMethod("getThStatus", String::class.java))
assertNull(
ABSDK::class.java.methods.find { it.name == "getThStatusByDevName" },
)
}
@Test
fun sdkHasNoPublicLogoutApi() {
assertNull(ABSDK::class.java.methods.find { it.name.equals("logout", ignoreCase = true) })
}
@Test
fun responseAndInfraredAccessorsKeepExpectedTypes() {
assertEquals(String::class.java, ABRet::class.java.getMethod("getCode").returnType)
assertEquals(String::class.java, ABRet::class.java.getMethod("getMsg").returnType)
assertEquals(Map::class.java, ABRet::class.java.getMethod("getDicDatas").returnType)
val dataType = ABRet::class.java.getMethod("getDicDatas").genericReturnType as ParameterizedType
assertEquals(String::class.java, dataType.actualTypeArguments[0])
assertEquals(Any::class.java, dataType.actualTypeArguments[1])
assertEquals(String::class.java, IrInfo::class.java.getMethod("getStatus").returnType)
}
}
@@ -0,0 +1,51 @@
package com.flagship.abox.manager.absdk.bridge
import com.flagship.abox.manager.domain.DeviceName
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.SocketPowerState
import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Test
class AbsdkSerializationTest {
@Test
fun concurrentCallsDoNotEnterTheSdkTogether() = runBlocking {
withTimeout(5_000) {
val profile = ProviderProfileId("abox")
val client = ScriptedAbsdkClient().apply {
socketStatusResponses = ArrayDeque(
listOf(
ScriptedAbsdkClient.success(mapOf("status" to "1")),
ScriptedAbsdkClient.success(mapOf("status" to "0")),
),
)
}
val bridge = AbsdkBridge(client, Dispatchers.IO)
bridge.login(profile, LoginCredentials(Username("demo"), Password("secret")))
client.onCall = {
if (client.calls.last().startsWith("socket-power")) {
Thread.sleep(75)
}
}
val first = async {
bridge.setPower(profile, DeviceName("socket-1"), SocketPowerState.ON)
}
val second = async {
bridge.setPower(profile, DeviceName("socket-2"), SocketPowerState.OFF)
}
delay(10)
first.await()
second.await()
assertEquals(1, client.maxConcurrentCalls.get())
}
}
}
@@ -0,0 +1,115 @@
package com.flagship.abox.manager.absdk.bridge
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.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.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AbsdkSessionBridgeTest {
private val profile = ProviderProfileId("abox")
private val otherProfile = ProviderProfileId("other")
private val credentials = LoginCredentials(Username("demo"), Password("secret"))
@Test
fun loginPublishesSessionAndLogoutClearsOnlyLocalState() = runTest {
val client = ScriptedAbsdkClient()
val bridge = bridge(client)
val login = bridge.login(profile, credentials)
assertTrue(login is DomainResult.Success)
assertEquals(Username("demo"), bridge.observeSession(profile).first()?.username)
assertEquals(DomainResult.Success(Unit), bridge.logout(profile))
assertNull(bridge.observeSession(profile).first())
val deviceResult = bridge.getStatus(profile, DeviceName("socket"))
assertTrue((deviceResult as DomainResult.Failure).error is DomainError.TokenInvalid)
assertEquals(listOf("login:demo"), client.calls)
}
@Test
fun failedLoginDoesNotPublishSession() = runTest {
val client = ScriptedAbsdkClient().apply {
loginResponse = ScriptedAbsdkClient.failure("20004", "invalid")
}
val bridge = bridge(client)
val result = bridge.login(profile, credentials)
assertTrue((result as DomainResult.Failure).error is DomainError.Authentication)
assertNull(bridge.observeSession(profile).first())
}
@Test
fun rejectsSecondProfileUntilLogout() = runTest {
val client = ScriptedAbsdkClient()
val bridge = bridge(client)
bridge.login(profile, credentials)
val result = bridge.login(otherProfile, credentials)
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertEquals(listOf("login:demo"), client.calls)
}
@Test
fun validatesAndClearsInvalidSession() = runTest {
val client = ScriptedAbsdkClient().apply {
validationResponse = ScriptedAbsdkClient.success(mapOf("status" to "0"))
}
val bridge = bridge(client)
bridge.login(profile, credentials)
val result = bridge.validateSession(profile)
assertEquals(DomainResult.Success(SessionValidity.INVALID), result)
assertNull(bridge.observeSession(profile).first())
}
@Test
fun tokenErrorClearsSession() = runTest {
val client = ScriptedAbsdkClient().apply {
validationResponse = ScriptedAbsdkClient.failure("10002", "expired")
}
val bridge = bridge(client)
bridge.login(profile, credentials)
val result = bridge.validateSession(profile)
assertTrue((result as DomainResult.Failure).error is DomainError.TokenInvalid)
assertNull(bridge.observeSession(profile).first())
}
@Test
fun deviceTokenErrorClearsSessionAndBlocksFurtherCalls() = runTest {
val client = ScriptedAbsdkClient().apply {
socketStatusResponses = ArrayDeque(
listOf(ScriptedAbsdkClient.failure("10002", "expired")),
)
}
val bridge = bridge(client)
bridge.login(profile, credentials)
val first = bridge.getStatus(profile, DeviceName("socket"))
assertTrue((first as DomainResult.Failure).error is DomainError.TokenInvalid)
assertNull(bridge.observeSession(profile).first())
val second = bridge.getStatus(profile, DeviceName("socket"))
assertTrue((second as DomainResult.Failure).error is DomainError.TokenInvalid)
assertEquals(listOf("login:demo", "socket-status:socket"), client.calls)
}
private fun bridge(client: ScriptedAbsdkClient): AbsdkBridge =
AbsdkBridge(client, Dispatchers.Unconfined)
}
@@ -0,0 +1,73 @@
package com.flagship.abox.manager.absdk.bridge
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
internal class ScriptedAbsdkClient : AbsdkClient {
val calls = CopyOnWriteArrayList<String>()
val downloadedCodes = CopyOnWriteArrayList<AbsdkDownloadCode>()
val activeCalls = AtomicInteger(0)
val maxConcurrentCalls = AtomicInteger(0)
var loginResponse: AbsdkResponse? = success()
var validationResponse: AbsdkResponse? = success(mapOf("status" to "1"))
var socketStatusResponses = ArrayDeque(listOf(success(mapOf("status" to "0"))))
var socketControlResponse: AbsdkResponse? = success()
var temperatureResponse: AbsdkResponse? = success(
mapOf("temperature" to "23.5", "humidity" to "45"),
)
var sendResponse: AbsdkResponse? = success()
var learnResponse: AbsdkResponse? = success()
var downloadResponse: AbsdkResponse? = success(mapOf("ret" to emptyList<AbsdkIrResult>()))
var failure: Exception? = null
var onCall: (() -> Unit)? = null
override fun login(username: String, password: String): AbsdkResponse? =
record("login:$username") { loginResponse }
override fun validateToken(): AbsdkResponse? = record("validate") { validationResponse }
override fun getSocketStatus(deviceName: String): AbsdkResponse? =
record("socket-status:$deviceName") {
socketStatusResponses.removeFirstOrNull() ?: error("No socket status response scripted")
}
override fun setSocketPower(deviceName: String, status: String): AbsdkResponse? =
record("socket-power:$deviceName:$status") { socketControlResponse }
override fun getTemperatureHumidity(deviceName: String): AbsdkResponse? =
record("temperature:$deviceName") { temperatureResponse }
override fun sendInfrared(deviceName: String, key: String): AbsdkResponse? =
record("ir-send:$deviceName:$key") { sendResponse }
override fun learnInfrared(deviceName: String, key: String): AbsdkResponse? =
record("ir-learn:$deviceName:$key") { learnResponse }
override fun downloadInfrared(codes: List<AbsdkDownloadCode>): AbsdkResponse? =
record("ir-download") {
downloadedCodes += codes
downloadResponse
}
private fun <T> record(name: String, block: () -> T): T {
failure?.let { throw it }
calls += name
val concurrent = activeCalls.incrementAndGet()
maxConcurrentCalls.updateAndGet { previous -> maxOf(previous, concurrent) }
return try {
onCall?.invoke()
block()
} finally {
activeCalls.decrementAndGet()
}
}
companion object {
fun success(data: Map<String, Any?> = emptyMap()): AbsdkResponse =
AbsdkResponse("00000", null, data)
fun failure(code: String, message: String = "failure"): AbsdkResponse =
AbsdkResponse(code, message, emptyMap())
}
}