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
+12 -1
View File
@@ -6,8 +6,19 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:name=".PhoneApp"
android:label="ViewMate"
android:theme="@style/Theme.AppCompat"
android:allowBackup="true">
android:allowBackup="true"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,169 @@
package pw.binom.viewmate.phone
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.phone.GlassesSender
import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.GlassesOff
import pw.binom.viewmate.core.protocol.GlassesStatus
import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.Gesture
import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.Welcome
import pw.binom.viewmate.core.protocol.protocolJson
import java.util.concurrent.ConcurrentHashMap
const val GLASSES_WS_PATH = "/ws/glasses"
/**
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза).
*/
class GlassesHub(
private val state: PhoneState,
) {
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>()
private val _connected = MutableStateFlow(0)
val connected: StateFlow<Int> = _connected.asStateFlow()
private val _gesturesReceived = MutableStateFlow(0)
val gesturesReceived: StateFlow<Int> = _gesturesReceived.asStateFlow()
fun add(session: DefaultWebSocketServerSession) {
sessions[session] = Unit
_connected.value = sessions.size
log("hub", "очки подключились, всего: ${sessions.size}")
}
fun remove(session: DefaultWebSocketServerSession) {
sessions.remove(session)
_connected.value = sessions.size
log("hub", "очки отключились, всего: ${sessions.size}")
}
/** Отправить сообщение всем подключённым очкам. */
suspend fun broadcast(msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
for (session in sessions.keys) {
try {
session.send(text)
} catch (e: Exception) {
log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}")
}
}
}
/** Обработать входящее текстовое сообщение от очков. */
suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), text)
when (msg) {
is Hello -> {
log("glasses", "hello (app=${msg.appVersion}) → welcome")
send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId))
}
is Gesture -> {
_gesturesReceived.value++
log("glasses", "жест с тачпада: ${msg.gesture}")
}
is GlassesStatus -> {
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ")
}
is PlaybackPosition -> {
val state = if (msg.playing) "играет" else "пауза"
log("glasses", "позиция: ${msg.positionMs} мс, $state")
}
is GlassesOff -> {
log("glasses", "очки выключились → пауза (${msg.reason})")
this.state.playing = false
}
}
}
private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
session.send(text)
}
}
/** Ktor-модуль телефона: WebSocket-роут для подключения очков. */
fun Application.glassesServerModule(hub: GlassesHub) {
install(WebSockets) {
pingPeriodMillis = 15_000
timeoutMillis = 15_000
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
webSocket(GLASSES_WS_PATH) {
hub.add(this)
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
hub.handle(this, text)
}
}
} catch (e: Exception) {
log("server", "WS-ошибка: ${e.message}")
} finally {
hub.remove(this)
}
}
}
}
/**
* WS-сервер телефона (CIO-движок — работает на Android, в отличие от Netty).
* Живёт в приложении (Application), слушает 0.0.0.0:[port]/ws/glasses.
*/
class GlassesServer(
private val port: Int = PhoneConfig.SERVER_PORT,
private val state: PhoneState = PhoneState(),
) {
val hub = GlassesHub(state)
/** GlassesSender-адаптер поверх хаба (для PhoneActions). */
val sender: GlassesSender = object : GlassesSender {
override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg)
}
private var server: EmbeddedServer<*, *>? = null
fun start() {
if (server != null) return
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
glassesServerModule(hub)
}
server!!.start(wait = false)
log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
}
fun stop() {
server?.stop(1_000, 3_000)
server = null
}
}
@@ -0,0 +1,6 @@
package pw.binom.viewmate.phone
/** Единый лог телефона: простой println (работает и в unit-тестах). */
fun log(tag: String, message: String) {
println("[view-mate] $tag $message")
}
@@ -1,11 +1,44 @@
package pw.binom.viewmate.phone
import android.app.Activity
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
class MainActivity : Activity() {
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Компаньон: дубль-управление, чат, каталог. Пока — заглушка.
setContent {
MaterialTheme {
ServerStatusScreen()
}
}
}
}
@Composable
fun ServerStatusScreen() {
val app = PhoneApp.instance
val connected by app.server.hub.connected.collectAsState()
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("ViewMate Phone", style = MaterialTheme.typography.headlineMedium)
Text("WS-сервер запущен")
Text("Порт: ${PhoneConfig.SERVER_PORT}")
Text("Путь: $GLASSES_WS_PATH")
Text("Подключено очков: $connected")
}
}
@@ -0,0 +1,60 @@
package pw.binom.viewmate.phone
import android.app.Application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.MirrorClient
import pw.binom.viewmate.core.phone.PhoneActions
import pw.binom.viewmate.core.phone.PhoneState
/**
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
* WS-сервер (GlassesServer) и PhoneActions. Синглтон-доступ через companion.
*/
class PhoneApp : Application() {
lateinit var state: PhoneState
private set
lateinit var server: GlassesServer
private set
lateinit var actions: PhoneActions
private set
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
instance = this
state = PhoneState()
server = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
val jellyfin = JellyfinClient(PhoneConfig.JELLYFIN_URL, PhoneConfig.JELLYFIN_API_KEY)
val mirror = MirrorClient(PhoneConfig.MIRROR_URL, PhoneConfig.MIRROR_API_KEY)
server.start()
// первый пользователь Jellyfin — один раз при старте
scope.launch {
val userId = runCatching { jellyfin.users().firstOrNull()?.Id }.getOrNull()
?: ""
if (userId.isBlank()) log("app", "не удалось получить пользователя Jellyfin")
actions = PhoneActions(
jellyfin = jellyfin,
mirror = mirror,
sender = server.sender,
state = state,
userId = userId,
)
log("app", "PhoneActions готов (userId=$userId)")
}
}
companion object {
lateinit var instance: PhoneApp
private set
}
}
@@ -0,0 +1,14 @@
package pw.binom.viewmate.phone
/**
* Конфигурация телефона: креды Jellyfin/media-mirror и порт WS-сервера.
* Пока — простые константы (значения те же, что в app-host application.properties),
* позже заменится на BuildConfig/строки.
*/
object PhoneConfig {
const val JELLYFIN_URL = "https://jellyfin.binom.pw"
const val JELLYFIN_API_KEY = "59e6380a96a94364ae25d2b7cf9f1359"
const val MIRROR_URL = "https://mirror.binom.pw"
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
const val SERVER_PORT = 8080
}
@@ -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)
}
}
}