feat: establish Android project foundation

This commit is contained in:
KremeCN
2026-08-22 22:06:50 +08:00
parent e651f008e0
commit 117f87f735
39 changed files with 1653 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
kotlin {
jvmToolchain(21)
}
dependencies {
api(project(":domain"))
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,62 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceId
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.ConcurrentHashMap
class FakeDeviceCatalog : DeviceCatalog {
private val devices = ConcurrentHashMap<
ProviderProfileId,
MutableStateFlow<DomainResult<List<DeviceDescriptor>>>,
>()
override fun observeDevices(
profileId: ProviderProfileId,
): Flow<DomainResult<List<DeviceDescriptor>>> = deviceFlow(profileId)
fun setDevices(profileId: ProviderProfileId, value: List<DeviceDescriptor>) {
require(value.all { it.profileId == profileId }) {
"All devices must belong to the supplied provider profile"
}
deviceFlow(profileId).value = DomainResult.Success(value)
}
fun setError(profileId: ProviderProfileId, error: DomainError) {
deviceFlow(profileId).value = DomainResult.Failure(error)
}
private fun deviceFlow(
profileId: ProviderProfileId,
): MutableStateFlow<DomainResult<List<DeviceDescriptor>>> = devices.getOrPut(profileId) {
MutableStateFlow(DomainResult.Success(defaultDevices(profileId)))
}
private fun defaultDevices(profileId: ProviderProfileId): List<DeviceDescriptor> = listOf(
DeviceDescriptor(
id = DeviceId("${profileId.value}:socket-1"),
profileId = profileId,
name = DeviceName("socket-1"),
type = DeviceType.SOCKET,
),
DeviceDescriptor(
id = DeviceId("${profileId.value}:th-1"),
profileId = profileId,
name = DeviceName("th-1"),
type = DeviceType.TEMPERATURE_HUMIDITY,
),
DeviceDescriptor(
id = DeviceId("${profileId.value}:ir-1"),
profileId = profileId,
name = DeviceName("ir-1"),
type = DeviceType.INFRARED,
),
)
}
@@ -0,0 +1,134 @@
package com.flagship.abox.manager.testing.fakes
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.ProviderProfileId
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 java.util.Collections
import java.util.concurrent.ConcurrentHashMap
class FakeSocketGateway : SocketGateway {
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
private val errors = ConcurrentHashMap<DeviceKey, DomainError>()
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> = resultFor(profileId, deviceName)
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> {
val key = DeviceKey(profileId, deviceName)
errors[key]?.let { return DomainResult.Failure(it) }
states[key] = powerState
return DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
}
fun setError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) {
val key = DeviceKey(profileId, deviceName)
if (error == null) errors.remove(key) else errors[key] = error
}
private fun resultFor(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> {
val key = DeviceKey(profileId, deviceName)
errors[key]?.let { return DomainResult.Failure(it) }
val state = states.getOrPut(key) { SocketPowerState.OFF }
return DomainResult.Success(SocketStatus(profileId, deviceName, state))
}
}
class FakeTemperatureHumidityGateway : TemperatureHumidityGateway {
private val readings = ConcurrentHashMap<DeviceKey, DomainResult<TemperatureHumidityReading>>()
override suspend fun getReading(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<TemperatureHumidityReading> = readings.getOrPut(DeviceKey(profileId, deviceName)) {
DomainResult.Success(
TemperatureHumidityReading(
profileId = profileId,
deviceName = deviceName,
temperature = "23.5",
humidity = "45",
),
)
}
fun setReading(reading: TemperatureHumidityReading) {
readings[DeviceKey(reading.profileId, reading.deviceName)] = DomainResult.Success(reading)
}
fun setError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError) {
readings[DeviceKey(profileId, deviceName)] = DomainResult.Failure(error)
}
}
class FakeInfraredGateway : InfraredGateway {
val sentKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
val learnedKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
val downloadedCodes: MutableList<InfraredCode> = Collections.synchronizedList(mutableListOf())
@Volatile
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
@Volatile
var learnResult: DomainResult<Unit> = DomainResult.Success(Unit)
@Volatile
var downloadError: DomainError? = null
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
sentKeys += InfraredCall(profileId, deviceName, key)
return sendResult
}
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
learnedKeys += InfraredCall(profileId, deviceName, key)
return learnResult
}
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> {
require(codes.all { it.profileId == profileId }) {
"All infrared codes must belong to the supplied provider profile"
}
downloadError?.let { return DomainResult.Failure(it) }
downloadedCodes += codes
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
}
}
data class InfraredCall(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
)
private data class DeviceKey(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
)
@@ -0,0 +1,70 @@
package com.flagship.abox.manager.testing.fakes
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.ProviderProfileId
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.SessionValidity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.ConcurrentHashMap
class FakeSessionGateway : SessionGateway {
private val sessions = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<Session?>>()
private val loginResults = ConcurrentHashMap<ProviderProfileId, DomainResult<Session>>()
private val validationResults = ConcurrentHashMap<ProviderProfileId, DomainResult<SessionValidity>>()
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> = sessionFlow(profileId)
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session> {
val result = loginResults[profileId] ?: DomainResult.Success(
Session(profileId = profileId, username = credentials.username),
)
if (result is DomainResult.Success) {
sessionFlow(profileId).value = result.value
}
return result
}
override suspend fun validateSession(
profileId: ProviderProfileId,
): DomainResult<SessionValidity> = validationResults[profileId]
?: DomainResult.Success(
if (sessionFlow(profileId).value == null) {
SessionValidity.INVALID
} else {
SessionValidity.VALID
},
)
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> {
sessionFlow(profileId).value = null
return DomainResult.Success(Unit)
}
fun setLoginResult(profileId: ProviderProfileId, result: DomainResult<Session>) {
loginResults[profileId] = result
}
fun setValidationResult(
profileId: ProviderProfileId,
result: DomainResult<SessionValidity>,
) {
validationResults[profileId] = result
}
fun failLogin(
profileId: ProviderProfileId,
error: DomainError = DomainError.Authentication(),
) {
setLoginResult(profileId, DomainResult.Failure(error))
}
private fun sessionFlow(profileId: ProviderProfileId): MutableStateFlow<Session?> =
sessions.getOrPut(profileId) { MutableStateFlow(null) }
}
@@ -0,0 +1,47 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceId
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class FakeDeviceCatalogTest {
@Test
fun defaultDevicesAreIsolatedByProviderProfile() = runTest {
val gateway = FakeDeviceCatalog()
val aboxProfile = ProviderProfileId("abox")
val localProfile = ProviderProfileId("local")
val aboxDevices = (gateway.observeDevices(aboxProfile).first() as DomainResult.Success).value
val localDevices = (gateway.observeDevices(localProfile).first() as DomainResult.Success).value
assertEquals(3, aboxDevices.size)
assertEquals(3, localDevices.size)
assertNotEquals(aboxDevices.first().id, localDevices.first().id)
assertEquals(aboxProfile, aboxDevices.first().profileId)
assertEquals(localProfile, localDevices.first().profileId)
}
@Test(expected = IllegalArgumentException::class)
fun setDevicesRejectsAnotherProvidersData() {
val gateway = FakeDeviceCatalog()
gateway.setDevices(
ProviderProfileId("one"),
listOf(
DeviceDescriptor(
id = DeviceId("two:socket"),
profileId = ProviderProfileId("two"),
name = DeviceName("socket"),
type = DeviceType.SOCKET,
),
),
)
}
}
@@ -0,0 +1,57 @@
package com.flagship.abox.manager.testing.fakes
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.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class FakeDeviceGatewaysTest {
private val profileOne = ProviderProfileId("one")
private val profileTwo = ProviderProfileId("two")
private val socket = DeviceName("socket-1")
@Test
fun socketStateChangesWithoutCrossingProviderBoundary() = runTest {
val gateway = FakeSocketGateway()
gateway.setPower(profileOne, socket, SocketPowerState.ON)
val one = gateway.getStatus(profileOne, socket) as DomainResult.Success
val two = gateway.getStatus(profileTwo, socket) as DomainResult.Success
assertEquals(SocketPowerState.ON, one.value.powerState)
assertEquals(SocketPowerState.OFF, two.value.powerState)
}
@Test
fun temperatureGatewaySupportsNoDataAndErrors() = runTest {
val gateway = FakeTemperatureHumidityGateway()
gateway.setError(profileOne, DeviceName("empty"), DomainError.NoData())
gateway.setError(profileOne, DeviceName("offline"), DomainError.DeviceOffline())
assertTrue(gateway.getReading(profileOne, DeviceName("empty")) is DomainResult.Failure)
assertTrue(gateway.getReading(profileOne, DeviceName("offline")) is DomainResult.Failure)
assertTrue(gateway.getReading(profileOne, DeviceName("th-1")) is DomainResult.Success)
}
@Test
fun infraredGatewayRecordsCallsAndResults() = runTest {
val gateway = FakeInfraredGateway()
val deviceName = DeviceName("ir-1")
val code = InfraredCode(profileOne, deviceName, "power", "code-value")
gateway.sendKey(profileOne, deviceName, "power")
gateway.learnKey(profileOne, deviceName, "volume-up")
val download = gateway.downloadCodes(profileOne, listOf(code))
assertEquals(InfraredCall(profileOne, deviceName, "power"), gateway.sentKeys.single())
assertEquals("volume-up", gateway.learnedKeys.single().key)
assertEquals(listOf(code), gateway.downloadedCodes)
assertTrue(download is DomainResult.Success)
}
}
@@ -0,0 +1,58 @@
package com.flagship.abox.manager.testing.fakes
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.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 FakeSessionGatewayTest {
private val profileId = ProviderProfileId("abox")
private val credentials = LoginCredentials(Username("demo"), Password("secret"))
@Test
fun loginPublishesSessionAndLogoutClearsIt() = runTest {
val gateway = FakeSessionGateway()
val result = gateway.login(profileId, credentials)
assertTrue(result is DomainResult.Success)
assertEquals(Username("demo"), gateway.observeSession(profileId).first()?.username)
gateway.logout(profileId)
assertNull(gateway.observeSession(profileId).first())
}
@Test
fun failedLoginDoesNotCreateSession() = runTest {
val gateway = FakeSessionGateway().apply {
failLogin(profileId, DomainError.Authentication(externalCode = "20004"))
}
val result = gateway.login(profileId, credentials)
assertTrue(result is DomainResult.Failure)
assertNull(gateway.observeSession(profileId).first())
}
@Test
fun validationCanSimulateInvalidToken() = runTest {
val gateway = FakeSessionGateway().apply {
setValidationResult(profileId, DomainResult.Success(SessionValidity.INVALID))
}
assertEquals(
DomainResult.Success(SessionValidity.INVALID),
gateway.validateSession(profileId),
)
}
}