feat: add ABSDK 0630 bridge #3
@@ -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,
|
||||||
|
)
|
||||||
+51
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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
|
||||||
+67
@@ -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,
|
||||||
|
)
|
||||||
+174
@@ -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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
@@ -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)
|
||||||
|
}
|
||||||
+73
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-3
@@ -92,12 +92,27 @@ Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证
|
|||||||
|
|
||||||
设备目录导航、插座与温湿度页面、红外页面和情景页面由固定模块负责人实现,不放入工程基础层。
|
设备目录导航、插座与温湿度页面、红外页面和情景页面由固定模块负责人实现,不放入工程基础层。
|
||||||
|
|
||||||
## 8. 后续边界
|
## 8. ABSDK Bridge
|
||||||
|
|
||||||
|
`:absdk-bridge` 是纯 Kotlin/JVM 防腐层,依赖 `domain` 并实现会话、插座、温湿度和红外 Gateway。该模块:
|
||||||
|
|
||||||
|
- 只在 `VendorAbsdkClient` 中接触 `ABSDK`、`ABRet` 和 `IrInfo`;
|
||||||
|
- 将所有同步调用移到后台并串行进入全局 SDK;
|
||||||
|
- 将返回值和错误码转换为 `DomainResult` 和领域模型;
|
||||||
|
- 不提供设备目录;
|
||||||
|
- 不调用未确认的 `setHostInfo`;
|
||||||
|
- logout 只清除 Bridge 本地会话,因为 JAR 没有公开 logout 或 Token setter;
|
||||||
|
- 生产工厂始终返回进程内同一个 Bridge,使互斥锁和本地会话与厂商单例保持一致;
|
||||||
|
- 真实工厂标记为 `@CleartextAbsdkTransport`,调用方必须显式 opt-in 承认厂商 JAR 的硬编码 HTTP 风险;
|
||||||
|
- 当前直接引用归档 Sample 目录中的唯一一份 JAR,不复制厂商二进制。
|
||||||
|
|
||||||
|
Bridge 本轮尚未加入 `android/app` 依赖,App debug 仍绑定 Fake。提供方选择、安全凭据和真实 Bridge 绑定由后续 `feat/provider-login` 完成;在获批的传输控制方案明确前,不得在 App 中启用真实 Bridge。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。
|
||||||
|
|
||||||
|
## 9. 后续边界
|
||||||
|
|
||||||
后续 KremeCN 工作保持为独立短期分支:
|
后续 KremeCN 工作保持为独立短期分支:
|
||||||
|
|
||||||
- `feat/absdk-bridge`:真实 JAR 防腐层和协议验证;
|
- `feat/provider-login`:提供方入口、安全凭据、自动登录、重新登录和真实 Bridge 绑定;
|
||||||
- `feat/provider-login`:提供方入口、安全凭据、自动登录和重新登录;
|
|
||||||
- `feat/compatible-server`:在协议与连接方式确认后建立服务端、CLI 和 Docker 基础。
|
- `feat/compatible-server`:在协议与连接方式确认后建立服务端、CLI 和 Docker 基础。
|
||||||
|
|
||||||
Room、DataStore 和 Android Keystore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。
|
Room、DataStore 和 Android Keystore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# ABSDK 0630 已确认事实与边界
|
||||||
|
|
||||||
|
## 1. 文档范围
|
||||||
|
|
||||||
|
本文记录 `libABSDK0630.jar`、厂商接口文档和仓库历史 Sample 中能够复核的事实,并明确仍待真实环境确认的内容。
|
||||||
|
|
||||||
|
证据来源:
|
||||||
|
|
||||||
|
- JAR:`samples/abox-sdk-sample/login-mac/libs/libABSDK0630.jar`;
|
||||||
|
- 厂商文档:`docs/ABSDK接口部分_0630.md`;
|
||||||
|
- 历史 Sample:`samples/abox-sdk-sample/login-mac/`;
|
||||||
|
- 公开 API 使用 `javap` 验证,协议细节通过授权字节码分析验证。
|
||||||
|
|
||||||
|
本文不是自有兼容后端的最终规范。真实连接参数、部署行为和硬件结果仍须通过授权测试环境验证。
|
||||||
|
|
||||||
|
## 2. JAR 公开类型
|
||||||
|
|
||||||
|
### `com.dhc.absdk.ABSDK`
|
||||||
|
|
||||||
|
JAR 提供 `getInstance()` 单例入口以及以下与 MVP 有关的方法:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ABRet loginWithUsername(String username, String password)
|
||||||
|
ABRet isValidateToken()
|
||||||
|
ABRet setHostInfo(String url, String hotelName, String roomNo, String apikey)
|
||||||
|
ABRet getSockStatus(String soDevName)
|
||||||
|
ABRet sockCtrl(String soDevName, String status)
|
||||||
|
ABRet getThStatus(String thDevName)
|
||||||
|
ABRet sendIr(String irDevName, String irKey)
|
||||||
|
ABRet studyIrByIrDevName(String irDevName, String irKey)
|
||||||
|
ABRet downloadIrCodes(List<IrInfo> irInfos)
|
||||||
|
```
|
||||||
|
|
||||||
|
JAR 还包含窗帘、开关、射灯、门磁、人体活动和一氧化碳相关接口,但不属于当前 MVP。
|
||||||
|
|
||||||
|
JAR 没有公开的设备列表、设备发现、logout 或 Token setter API。
|
||||||
|
|
||||||
|
### `com.dhc.absdk.ABRet`
|
||||||
|
|
||||||
|
```text
|
||||||
|
String getCode()
|
||||||
|
String getMsg()
|
||||||
|
Map<String, Object> getDicDatas()
|
||||||
|
```
|
||||||
|
|
||||||
|
`"00000"` 表示调用成功;其他字符串代码表示失败。Bridge 不向领域层泄漏 `ABRet`、Map 或厂商类型。
|
||||||
|
|
||||||
|
### `com.dhc.absdk.IrInfo`
|
||||||
|
|
||||||
|
```text
|
||||||
|
String irDevName
|
||||||
|
String key
|
||||||
|
String code
|
||||||
|
String status
|
||||||
|
```
|
||||||
|
|
||||||
|
下载结果的 `status` 是字符串:`"0"` 表示失败,`"1"` 表示成功。
|
||||||
|
|
||||||
|
## 3. 文档与 JAR 差异
|
||||||
|
|
||||||
|
厂商 Markdown 文档把温湿度方法写成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
getThStatusByDevName(String)
|
||||||
|
```
|
||||||
|
|
||||||
|
JAR 实际公开的方法是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
getThStatus(String)
|
||||||
|
```
|
||||||
|
|
||||||
|
Bridge 必须以实际 JAR 方法为准。
|
||||||
|
|
||||||
|
`setHostInfo` 是 JAR 的公开方法,但未出现在厂商 Markdown 接口清单中。
|
||||||
|
|
||||||
|
## 4. 全局状态与线程边界
|
||||||
|
|
||||||
|
JAR 使用单例 `ABSDK`,登录成功后把 Token 保存在 SDK 的静态状态中;后续请求从该全局状态读取 Token。HTTP Client 和 HTTP Context 也由 SDK 共享。
|
||||||
|
|
||||||
|
由此得到当前 Bridge 约束:
|
||||||
|
|
||||||
|
- 任一时刻只维护一个活动 ABSDK 会话;
|
||||||
|
- 所有 SDK 调用必须在后台执行;
|
||||||
|
- 所有 SDK 调用默认串行化;
|
||||||
|
- 切换提供方前必须退出 Bridge 本地会话;
|
||||||
|
- JAR 没有 logout,Bridge 的 logout 只能清除本地会话并阻止后续调用;
|
||||||
|
- Bridge 不通过反射访问或清理 SDK 私有 Token;重新登录会覆盖 SDK 中的 Token。
|
||||||
|
|
||||||
|
JAR 自身是否保证并发线程安全仍未得到厂商确认。
|
||||||
|
|
||||||
|
## 5. 传输事实
|
||||||
|
|
||||||
|
字节码确认 SDK 的基础地址硬编码为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://a-box.com.cn/abox/index.php/
|
||||||
|
```
|
||||||
|
|
||||||
|
SDK 使用 Apache HttpClient 4.x,所有已检查业务请求均为 POST。归档 Sample 同时包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
httpclient-4.3.6.jar
|
||||||
|
httpcore-4.3.3.jar
|
||||||
|
commons-codec-1.6.jar
|
||||||
|
commons-logging-1.1.3.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
JAR 内含宽松的 TLS 信任实现。正式产品不得把这一历史传输行为扩展为 App 其他网络请求的安全策略,也不得记录密码、Token、API Key 或敏感红外载荷。
|
||||||
|
|
||||||
|
真实 Bridge 工厂使用 `@CleartextAbsdkTransport` 作为编译期 opt-in 门禁,并且当前没有接入 Android App。该标记只防止误接入,不会为链路提供加密。生产集成前必须确定并批准 HTTPS、受控加密代理或其他能够提供机密性与完整性的传输控制;在此之前不得启用真实 ABox 入口。
|
||||||
|
|
||||||
|
## 6. `setHostInfo` 的已确认行为
|
||||||
|
|
||||||
|
`setHostInfo(url, hotelName, roomNo, apikey)` 向以下端点发送初始化请求:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST abjhsdk/init
|
||||||
|
```
|
||||||
|
|
||||||
|
四个参数作为表单字段提交:
|
||||||
|
|
||||||
|
```text
|
||||||
|
url
|
||||||
|
hotelName
|
||||||
|
roomNo
|
||||||
|
apikey
|
||||||
|
```
|
||||||
|
|
||||||
|
其中第一个参数只写入请求字段 `url`,不会修改 SDK 的硬编码 HTTP 基础地址。因此原始 JAR 不能通过该公开 API 直接切换到任意自有后端。
|
||||||
|
|
||||||
|
仍未确认:
|
||||||
|
|
||||||
|
- 正式业务中四个字段的具体语义;
|
||||||
|
- ABox 官方环境是否要求每次启动调用;
|
||||||
|
- hotel、room 和 API Key 的正式取值;
|
||||||
|
- 初始化失败对后续登录和设备操作的影响。
|
||||||
|
|
||||||
|
所以 `feat/absdk-bridge` 不实现或调用 `setHostInfo`。
|
||||||
|
|
||||||
|
## 7. MVP 端点与数据
|
||||||
|
|
||||||
|
| SDK 方法 | 端点 | 关键参数或返回 |
|
||||||
|
|---|---|---|
|
||||||
|
| `setHostInfo` | `abjhsdk/init` | `url`、`hotelName`、`roomNo`、`apikey` |
|
||||||
|
| `loginWithUsername` | `abjhsdk/user/login` | `username`、SDK 内部 MD5 处理后的 `password`;返回 `token` |
|
||||||
|
| `isValidateToken` | `abjhsdk/user/validate` | 返回 `status`:`0`/`1` |
|
||||||
|
| `getSockStatus` | `abjhsdk/sock/properties` | `name`;返回 `status`:`0`/`1` |
|
||||||
|
| `sockCtrl` | `abjhsdk/zigbee/sock/{device}` | `status`:`0`/`1` |
|
||||||
|
| `getThStatus` | `abjhsdk/th/properties` | `name`;返回 `temperature`、`humidity` |
|
||||||
|
| `sendIr` | `abjhsdk/ir/{device}/send` | `irKey` |
|
||||||
|
| `studyIrByIrDevName` | `abjhsdk/ir/{device}/study` | `irKey` |
|
||||||
|
| `downloadIrCodes` | `abjhsdk/ir/{device}/download` | 每项发送 `irKey`、`irCode` |
|
||||||
|
|
||||||
|
`downloadIrCodes(List<IrInfo>)` 并不是单次批量 HTTP 请求。JAR 按输入顺序逐项调用下载端点,并返回每项 `status`。只要有任一项失败,总体代码为 `20204`,同时 `dicDatas["ret"]` 仍包含逐项结果。
|
||||||
|
|
||||||
|
## 8. 请求签名
|
||||||
|
|
||||||
|
字节码显示 SDK 会:
|
||||||
|
|
||||||
|
1. 取得秒级时间戳;
|
||||||
|
2. 对业务参数键排序并组装签名原文;
|
||||||
|
3. 加入端点、时间戳和 SDK 内部 secret;
|
||||||
|
4. 使用 MD5 生成摘要;
|
||||||
|
5. 按时间戳计算的位置插入时间片段并添加固定前缀;
|
||||||
|
6. 在请求的 `system` 数据中提交签名、时间戳和 Token。
|
||||||
|
|
||||||
|
这些事实用于判断协议边界,不在本轮重新实现签名算法或对外宣称兼容性。正式兼容后端开始前,必须建立脱敏签名测试向量并复核每个字段和编码细节。
|
||||||
|
|
||||||
|
## 9. 响应结构
|
||||||
|
|
||||||
|
字节码解析的响应结构为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "00000",
|
||||||
|
"msg": "...",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`data` 被转换为 `ABRet.dicDatas`。解析或请求失败时,SDK 可返回 `20000` 和请求失败信息。
|
||||||
|
|
||||||
|
当前 Bridge 对成功数据进行防御性解析:缺失字段、未知状态或错误类型不会导致 `ClassCastException` 泄漏到调用方。
|
||||||
|
|
||||||
|
## 10. 错误码来源
|
||||||
|
|
||||||
|
完整错误码表来自厂商文档和历史 `AboxConstants.java`。Bridge 保留原始 `externalCode`,并映射为以下领域分类:
|
||||||
|
|
||||||
|
- Token 无效;
|
||||||
|
- 认证失败;
|
||||||
|
- 网络错误;
|
||||||
|
- 请求参数错误;
|
||||||
|
- 设备不存在;
|
||||||
|
- 设备离线;
|
||||||
|
- 无数据;
|
||||||
|
- 操作失败;
|
||||||
|
- 未知错误。
|
||||||
|
|
||||||
|
错误 `msg` 只作为诊断 detail,不直接作为最终 UI 文案。
|
||||||
|
|
||||||
|
## 11. 历史 Sample 观察
|
||||||
|
|
||||||
|
历史 Sample:
|
||||||
|
|
||||||
|
- 直接调用 `ABSDK.getInstance()`;
|
||||||
|
- 使用 Executor 或 AsyncTask 把同步调用移出 UI 线程;
|
||||||
|
- 没有调用 `setHostInfo`;
|
||||||
|
- logout 是空实现;
|
||||||
|
- 使用普通 SharedPreferences 明文保存密码;
|
||||||
|
- 全局允许明文网络。
|
||||||
|
|
||||||
|
正式实现只复用已确认的 SDK 调用事实,不复制 AsyncTask、明文凭据或全局明文网络配置。
|
||||||
|
|
||||||
|
## 12. 仍待确认
|
||||||
|
|
||||||
|
- ABox 官方初始化的完整流程和参数;
|
||||||
|
- 文档所称 Token 两小时有效期在真实环境中的精确表现;
|
||||||
|
- Token 失效时各设备接口实际返回的代码;
|
||||||
|
- SDK 的厂商线程安全保证;
|
||||||
|
- 真实账号、设备名称和硬件环境;
|
||||||
|
- 原始 JAR 指向自有兼容后端的最终授权方案;
|
||||||
|
- ABSDK 和附属 JAR 的生产分发许可;
|
||||||
|
- 所有端点的完整表单编码和可重复签名测试向量。
|
||||||
|
|
||||||
|
这些事项验证前不得作为产品实现承诺。
|
||||||
@@ -3,7 +3,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(17)
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ dependencyResolutionManagement {
|
|||||||
rootProject.name = "abox-manager"
|
rootProject.name = "abox-manager"
|
||||||
|
|
||||||
include(":domain")
|
include(":domain")
|
||||||
|
include(":absdk-bridge")
|
||||||
include(":testing:fakes")
|
include(":testing:fakes")
|
||||||
include(":android:core:designsystem")
|
include(":android:core:designsystem")
|
||||||
include(":android:app")
|
include(":android:app")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(17)
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|||||||
Reference in New Issue
Block a user