feat: сетевая часть для app-phone (WS-сервер) и app-glasses (WS-клиент), PhoneState/PhoneActions → lib-core

lib-core:
- core/phone/: PhoneState, PhoneActions, GlassesSender (интерфейс) — перенесены из app-host
- core/net/: GlassesWsClient — KMP WS-клиент очков (реконнект, Hello, send GlassesToHost)
- GlassesWsClientTest + PhoneActionsTest (перенесён)

app-phone (Android):
- GlassesServer: Ktor WS-сервер (CIO) 0.0.0.0:8080/ws/glasses, принимает очки, Welcome из стейта
- PhoneApp (Application): клиенты, стейт, действия, сервер
- PhoneConfig: креды Jellyfin/Mirror
- MainActivity: статус сервера (порт, счётчик подключений)
- GlassesServerTest: hello→welcome, gesture

app-glasses (Android):
- GlassesApp (Application), HostConnection: WS-клиент к телефону, Hello, реконнект, обработка HostToGlasses (PlayVideo/SetMode и т.д.), StateFlow для UI
- MainActivity: статус подключения

app-host: PhoneActions/PhoneState удалены (теперь lib-core), адаптирован под GlassesSender

171 тест, 0 провалов; app-host жив (catalog/watch/status работают через lib-core)
This commit is contained in:
Hermes Agent
2026-08-11 15:06:07 +03:00
parent f0f1f71575
commit 298d8e0b69
27 changed files with 1165 additions and 118 deletions
@@ -0,0 +1,122 @@
package pw.binom.viewmate.phone
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.server.testing.testApplication
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.Gesture
import pw.binom.viewmate.core.protocol.GlassesStatus
import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.Welcome
import pw.binom.viewmate.core.protocol.protocolJson
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.fail
class GlassesServerTest {
private val json = protocolJson
@Test
fun helloGetsWelcome() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
val frame = incoming.receive() as Frame.Text
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
assertIs<Welcome>(welcome)
assertEquals(GlassesMode.MOVIE, welcome.mode)
}
}
@Test
fun gestureIsProcessed() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
}
awaitTrue { hub.gesturesReceived.value == 1 }
awaitTrue { hub.connected.value == 0 }
}
@Test
fun connectedCounterTracksSessions() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) { }
awaitTrue { hub.connected.value == 0 }
}
@Test
fun broadcastReachesAllGlasses() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
coroutineScope {
val opened = Channel<Unit>(2)
val received = Channel<HostToGlasses>(2)
val jobs = (1..2).map {
launch {
client.webSocket(GLASSES_WS_PATH) {
opened.send(Unit)
while (true) {
val frame = incoming.receive()
if (frame is Frame.Text) {
received.send(json.decodeFromString(HostToGlasses.serializer(), frame.readText()))
}
}
}
}
}
repeat(2) { opened.receive() }
awaitTrue { hub.connected.value == 2 }
hub.broadcast(SetMode(GlassesMode.CHAT))
val first = received.receive()
val second = received.receive()
listOf(first, second).forEach {
assertIs<SetMode>(it)
assertEquals(GlassesMode.CHAT, it.mode)
}
jobs.forEach { it.cancelAndJoin() }
awaitTrue { hub.connected.value == 0 }
}
}
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (!condition()) {
if (System.currentTimeMillis() > deadline) fail("условие не выполнено за ${timeoutMs}мс")
delay(20)
}
}
}