From 298d8e0b69f6c833818d304ae27674b629523d7f Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 11 Aug 2026 15:06:07 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D0=B5=D1=82=D0=B5=D0=B2=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=87=D0=B0=D1=81=D1=82=D1=8C=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?app-phone=20(WS-=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80)=20=D0=B8?= =?UTF-8?q?=20app-glasses=20(WS-=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82),=20P?= =?UTF-8?q?honeState/PhoneActions=20=E2=86=92=20lib-core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app-glasses/build.gradle.kts | 4 + app-glasses/src/main/AndroidManifest.xml | 13 +- .../pw/binom/viewmate/glasses/GlassesApp.kt | 32 +++ .../binom/viewmate/glasses/HostConnection.kt | 102 ++++++++ .../kotlin/pw/binom/viewmate/glasses/Log.kt | 6 + .../pw/binom/viewmate/glasses/MainActivity.kt | 49 +++- .../pw/binom/viewmate/host/GlassesHub.kt | 7 +- .../kotlin/pw/binom/viewmate/host/Main.kt | 63 ++--- app-phone/build.gradle.kts | 13 + app-phone/src/main/AndroidManifest.xml | 13 +- .../pw/binom/viewmate/phone/GlassesServer.kt | 169 +++++++++++++ .../kotlin/pw/binom/viewmate/phone/Log.kt | 6 + .../pw/binom/viewmate/phone/MainActivity.kt | 39 ++- .../pw/binom/viewmate/phone/PhoneApp.kt | 60 +++++ .../pw/binom/viewmate/phone/PhoneConfig.kt | 14 ++ .../binom/viewmate/phone/GlassesServerTest.kt | 122 +++++++++ gradle/libs.versions.toml | 1 + lib-core/build.gradle.kts | 8 + .../kotlin/pw/binom/viewmate/core/Log.kt | 6 + .../binom/viewmate/core/media/HttpClient.kt | 27 ++ .../viewmate/core/net/GlassesWsClient.kt | 86 +++++++ .../viewmate/core/phone/GlassesSender.kt | 8 + .../viewmate/core/phone}/PhoneActions.kt | 70 ++++-- .../binom/viewmate/core/phone}/PhoneState.kt | 9 +- .../pw/binom/viewmate/core/protocol/Json.kt | 13 + .../viewmate/core/phone}/PhoneActionsTest.kt | 105 ++++---- .../viewmate/core/net/GlassesWsClientTest.kt | 238 ++++++++++++++++++ 27 files changed, 1165 insertions(+), 118 deletions(-) create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt create mode 100644 app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt create mode 100644 lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt create mode 100644 lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesWsClient.kt create mode 100644 lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesSender.kt rename {app-host/src/main/kotlin/pw/binom/viewmate/host => lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone}/PhoneActions.kt (76%) rename {app-host/src/main/kotlin/pw/binom/viewmate/host => lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone}/PhoneState.kt (88%) create mode 100644 lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Json.kt rename {app-host/src/test/kotlin/pw/binom/viewmate/host => lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone}/PhoneActionsTest.kt (75%) create mode 100644 lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/net/GlassesWsClientTest.kt diff --git a/app-glasses/build.gradle.kts b/app-glasses/build.gradle.kts index d5f5615..9433d71 100644 --- a/app-glasses/build.gradle.kts +++ b/app-glasses/build.gradle.kts @@ -50,4 +50,8 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization.json) implementation(project(":lib-core")) + + // WS-клиент очков (OkHttp — Android-движок) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.websockets) } diff --git a/app-glasses/src/main/AndroidManifest.xml b/app-glasses/src/main/AndroidManifest.xml index 0aa9b1e..4ea4cfa 100644 --- a/app-glasses/src/main/AndroidManifest.xml +++ b/app-glasses/src/main/AndroidManifest.xml @@ -5,8 +5,19 @@ + android:allowBackup="true" + android:usesCleartextTraffic="true"> + + + + + + + diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt new file mode 100644 index 0000000..2093fad --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -0,0 +1,32 @@ +package pw.binom.viewmate.glasses + +import android.app.Application + +/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */ +object GlassesConfig { + // Эмулятор — 10.0.2.2 (localhost хоста); на устройстве — LAN-адрес телефона. + const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses" +} + +/** + * Application очков: создаёт и запускает HostConnection (WS-клиент к телефону). + * Синглтон-доступ через companion. + */ +class GlassesApp : Application() { + + lateinit var hostConnection: HostConnection + private set + + override fun onCreate() { + super.onCreate() + instance = this + hostConnection = HostConnection(GlassesConfig.HOST_URL) + hostConnection.start() + log("app", "HostConnection стартует: ${GlassesConfig.HOST_URL}") + } + + companion object { + lateinit var instance: GlassesApp + private set + } +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt new file mode 100644 index 0000000..2ff99c6 --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -0,0 +1,102 @@ +package pw.binom.viewmate.glasses + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import pw.binom.viewmate.core.net.GlassesWsClient +import pw.binom.viewmate.core.protocol.AssistantStateMsg +import pw.binom.viewmate.core.protocol.ChatSessionMsg +import pw.binom.viewmate.core.protocol.GlassesStatus +import pw.binom.viewmate.core.protocol.Hello +import pw.binom.viewmate.core.protocol.HostToGlasses +import pw.binom.viewmate.core.protocol.MediaCommandMsg +import pw.binom.viewmate.core.protocol.PlayVideo +import pw.binom.viewmate.core.protocol.PlaybackPosition +import pw.binom.viewmate.core.protocol.SetMode +import pw.binom.viewmate.core.protocol.ShowText +import pw.binom.viewmate.core.protocol.Welcome + +/** + * Обвязка над [GlassesWsClient] для UI очков: + * реконнект, Hello при подключении, периодический статус/позиция, + * обработка входящих HostToGlasses в StateFlow (пока — лог). + */ +class HostConnection( + private val url: String, +) { + private val client = GlassesWsClient(url) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val _connected = MutableStateFlow(false) + val connected: StateFlow = _connected.asStateFlow() + + private val _status = MutableStateFlow("не подключено") + val status: StateFlow = _status.asStateFlow() + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + fun start() { + scope.launch { + client.connect( + onHostMessage = { msg -> handleHostMessage(msg) }, + onConnected = { + _connected.value = true + _status.value = "подключено: $url" + addMessage("соединение установлено") + client.send(Hello(appVersion = "0.1.0")) + }, + onDisconnected = { + _connected.value = false + _status.value = "нет соединения (реконнект через 3с)" + }, + ) + } + scope.launch { + while (isActive) { + delay(5_000) + // статус/позиция — заглушки (реальные батарея/память/таймкод — позже) + client.send(GlassesStatus(batteryPercent = 80, storageUsedGb = 10.5, storageTotalGb = 32.0)) + client.send(PlaybackPosition(positionMs = 0, playing = false)) + } + } + } + + private suspend fun handleHostMessage(msg: HostToGlasses) { + when (msg) { + is Welcome -> { + _connected.value = true + addMessage("Welcome: режим ${msg.mode}, сессия ${msg.sessionId ?: "-"}") + } + + is SetMode -> addMessage("SetMode: ${msg.mode}") + + is PlayVideo -> addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}") + + is MediaCommandMsg -> addMessage("MediaCommand: ${msg.command} (seekMs=${msg.seekMs})") + + is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)") + + is ShowText -> addMessage("ShowText: ${msg.text}") + + is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}") + } + } + + private fun addMessage(text: String) { + log("glasses", text) + _messages.value = (_messages.value + "» $text").takeLast(50) + } + + fun stop() { + client.close() + scope.cancel() + } +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt new file mode 100644 index 0000000..a5fe211 --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt @@ -0,0 +1,6 @@ +package pw.binom.viewmate.glasses + +/** Единый лог очков: простой println (работает и в unit-тестах). */ +fun log(tag: String, message: String) { + println("[view-mate] $tag $message") +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index b23b1a3..b8d2555 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -1,12 +1,53 @@ package pw.binom.viewmate.glasses -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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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) - // Точка входа на очках: инициализация RayNeo SDK, режимы, связь. - // Пока — заглушка, заполняется на следующем шаге. + setContent { + MaterialTheme { + ConnectionScreen() + } + } + } +} + +@Composable +fun ConnectionScreen() { + val connection = GlassesApp.instance.hostConnection + val connected by connection.connected.collectAsState() + val status by connection.status.collectAsState() + val messages by connection.messages.collectAsState() + + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium) + Text(if (connected) "подключено к телефону" else "нет соединения") + Text(status, style = MaterialTheme.typography.bodyMedium) + Text("Последние сообщения:", style = MaterialTheme.typography.titleMedium) + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(messages) { msg -> + Text(msg, style = MaterialTheme.typography.bodySmall) + } + } } } diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt index e838010..e78b8aa 100644 --- a/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.json.Json 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 @@ -24,7 +26,7 @@ import java.util.concurrent.ConcurrentHashMap open class GlassesHub( private val json: Json, private val state: PhoneState = PhoneState(), -) { +) : GlassesSender { private val sessions = ConcurrentHashMap() @@ -69,6 +71,9 @@ open class GlassesHub( } } + /** GlassesSender: то же, что [broadcast]. */ + override suspend fun send(msg: HostToGlasses) = broadcast(msg) + /** Сменить режим очков и разослать SetMode. */ open suspend fun setMode(mode: GlassesMode) { _mode.value = mode diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt index 9b19d27..d8a8710 100644 --- a/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt @@ -10,12 +10,11 @@ import pw.binom.viewmate.core.GlassesMode import pw.binom.viewmate.core.MediaCommand 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 import pw.binom.viewmate.core.protocol.AssistantState -import pw.binom.viewmate.core.protocol.AssistantStateMsg import pw.binom.viewmate.core.protocol.ChatSessionMsg -import pw.binom.viewmate.core.protocol.MediaCommandMsg import pw.binom.viewmate.core.protocol.PlayVideo -import pw.binom.viewmate.core.protocol.ShowText const val HOST_PORT = 8080 @@ -23,28 +22,36 @@ fun main() { val config = Config.load() log("config", "Jellyfin: ${config.jellyfinUrl}, Mirror: ${config.mirrorUrl}") - val state = PhoneState() - val hub = GlassesHub(hostJson, state) - val actions = PhoneActions( - jellyfin = JellyfinClient(config.jellyfinUrl, config.jellyfinApiKey), - mirror = MirrorClient(config.mirrorUrl, config.mirrorApiKey), - hub = hub, - state = state, - json = hostJson, - ) - - val server = embeddedServer(Netty, port = HOST_PORT, host = "0.0.0.0") { - hostModule(hub) - } - server.start(wait = false) - log("server", "ViewMate Host слушает ws://0.0.0.0:$HOST_PORT$GLASSES_WS_PATH") - - Runtime.getRuntime().addShutdownHook(Thread { - server.stop(1_000, 3_000) - log("server", "остановлен") - }) - runBlocking { + val jellyfin = JellyfinClient(config.jellyfinUrl, config.jellyfinApiKey) + val mirror = MirrorClient(config.mirrorUrl, config.mirrorApiKey) + + // первый пользователь Jellyfin — один раз при старте + val userId = jellyfin.users().firstOrNull()?.Id + ?: throw IllegalStateException("нет пользователей в Jellyfin") + log("config", "пользователь Jellyfin: $userId") + + val state = PhoneState() + val hub = GlassesHub(hostJson, state) + val actions = PhoneActions( + jellyfin = jellyfin, + mirror = mirror, + sender = hub, + state = state, + userId = userId, + ) + + val server = embeddedServer(Netty, port = HOST_PORT, host = "0.0.0.0") { + hostModule(hub) + } + server.start(wait = false) + log("server", "ViewMate Host слушает ws://0.0.0.0:$HOST_PORT$GLASSES_WS_PATH") + + Runtime.getRuntime().addShutdownHook(Thread { + server.stop(1_000, 3_000) + log("server", "остановлен") + }) + launch { hub.connected.collectLatest { count -> log("hub", "подключено очков: $count") @@ -85,20 +92,20 @@ private suspend fun handleConsoleCommand(actions: PhoneActions, hub: GlassesHub, "mode" -> { val mode = parseEnum(parts.getOrNull(1)) if (mode == null) return logUsage("mode MOVIE|CHAT") - hub.setMode(mode) + out(actions.applyMode(mode)) } "state" -> { val state = parseEnum(parts.getOrNull(1)) if (state == null) return logUsage("state IDLE|LISTENING|THINKING|SPEAKING [seconds]") val seconds = parts.getOrNull(2)?.toIntOrNull() ?: 0 - hub.broadcast(AssistantStateMsg(state = state, recordingSeconds = seconds)) + out(actions.applyAssistantState(state, seconds)) } "text" -> { val text = parts.drop(1).joinToString(" ") if (text.isEmpty()) return logUsage("text <текст>") - hub.broadcast(ShowText(text = text)) + out(actions.applyText(text)) } "session" -> { @@ -117,7 +124,7 @@ private suspend fun handleConsoleCommand(actions: PhoneActions, hub: GlassesHub, val command = parseEnum(parts.getOrNull(1)) if (command == null) return logUsage("cmd PLAY|PAUSE|TOGGLE|SEEK_FORWARD|SEEK_BACKWARD|NEXT_AUDIO|PREV_AUDIO|CLOSE [seekMs]") val seekMs = parts.getOrNull(2)?.toLongOrNull() ?: 0 - hub.broadcast(MediaCommandMsg(command = command, seekMs = seekMs)) + out(actions.applyMediaCommand(command, seekMs)) } "catalog" -> out(actions.catalog(parts.getOrNull(1))) diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts index 96f130c..321613d 100644 --- a/app-phone/build.gradle.kts +++ b/app-phone/build.gradle.kts @@ -48,4 +48,17 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization.json) implementation(project(":lib-core")) + + // WS-сервер телефона (CIO — работает на Android) + implementation("io.ktor:ktor-server-core:3.3.0") + implementation("io.ktor:ktor-server-cio:3.3.0") + implementation("io.ktor:ktor-server-websockets:3.3.0") + implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0") + + testImplementation(kotlin("test")) + testImplementation("io.ktor:ktor-server-test-host:3.3.0") + testImplementation("io.ktor:ktor-client-websockets:3.3.0") + testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0") + testImplementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0") + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/app-phone/src/main/AndroidManifest.xml b/app-phone/src/main/AndroidManifest.xml index 718e3ae..6751873 100644 --- a/app-phone/src/main/AndroidManifest.xml +++ b/app-phone/src/main/AndroidManifest.xml @@ -6,8 +6,19 @@ + android:allowBackup="true" + android:usesCleartextTraffic="true"> + + + + + + + diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt new file mode 100644 index 0000000..dc685f9 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt @@ -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() + + private val _connected = MutableStateFlow(0) + val connected: StateFlow = _connected.asStateFlow() + + private val _gesturesReceived = MutableStateFlow(0) + val gesturesReceived: StateFlow = _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 + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt new file mode 100644 index 0000000..93d7601 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt @@ -0,0 +1,6 @@ +package pw.binom.viewmate.phone + +/** Единый лог телефона: простой println (работает и в unit-тестах). */ +fun log(tag: String, message: String) { + println("[view-mate] $tag $message") +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt index 8dc5b4d..f62d43d 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt @@ -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") } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt new file mode 100644 index 0000000..a844e8f --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -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 + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt new file mode 100644 index 0000000..ef49cd0 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt @@ -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 +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt new file mode 100644 index 0000000..55dd20e --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt @@ -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) + 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(2) + val received = Channel(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(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) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4f885b..bc467c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = " ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } diff --git a/lib-core/build.gradle.kts b/lib-core/build.gradle.kts index b764a4b..76d9234 100644 --- a/lib-core/build.gradle.kts +++ b/lib-core/build.gradle.kts @@ -19,6 +19,7 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.core) implementation(libs.ktor.client.core) + implementation(libs.ktor.client.websockets) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.logging) implementation(libs.ktor.serialization.kotlinx.json) @@ -35,6 +36,13 @@ kotlin { jvmMain.dependencies { implementation(libs.ktor.client.cio) } + jvmTest.dependencies { + implementation(libs.ktor.client.websockets) + implementation("io.ktor:ktor-server-core:3.3.0") + implementation("io.ktor:ktor-server-cio:3.3.0") + implementation("io.ktor:ktor-server-websockets:3.3.0") + implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0") + } } } diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt new file mode 100644 index 0000000..8fa7b1f --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt @@ -0,0 +1,6 @@ +package pw.binom.viewmate.core + +/** Единый лог общего слоя (KMP): без java.time, просто тег + сообщение. */ +fun log(tag: String, message: String) { + println("[view-mate] $tag $message") +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/HttpClient.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/HttpClient.kt index 8a30b81..ab44c0b 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/HttpClient.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/HttpClient.kt @@ -6,11 +6,13 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json +import pw.binom.viewmate.core.protocol.protocolJson /** * Общий HTTP-клиент для media-бэкендов (Jellyfin, media-mirror): @@ -45,6 +47,31 @@ object HttpClientDebugLogging { var enabled: Boolean = true } +/** + * HTTP-клиент для WS-протокола очков (GlassesWsClient): + * JSON-сериализация kotlinx с дискриминатором "type", таймауты, поддержка WS. + */ +fun defaultWsClient(): HttpClient = HttpClient { + install(ContentNegotiation) { + json(protocolJson) + } + install(HttpTimeout) { + connectTimeoutMillis = 5_000 + requestTimeoutMillis = 30_000 + } + install(WebSockets) { + pingIntervalMillis = 15_000 + } + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + if (HttpClientDebugLogging.enabled) println("[view-mate] $message") + } + } + level = LogLevel.INFO + } +} + /** Ошибка HTTP-запроса с понятным текстом. */ class ApiClientException( message: String, diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesWsClient.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesWsClient.kt new file mode 100644 index 0000000..a5551e1 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesWsClient.kt @@ -0,0 +1,86 @@ +package pw.binom.viewmate.core.net + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import io.ktor.websocket.send +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import pw.binom.viewmate.core.log +import pw.binom.viewmate.core.media.defaultWsClient +import pw.binom.viewmate.core.protocol.GlassesToHost +import pw.binom.viewmate.core.protocol.HostToGlasses +import pw.binom.viewmate.core.protocol.protocolJson + +/** + * WS-клиент очков: подключается к хосту (телефону), шлёт [GlassesToHost], + * принимает [HostToGlasses]. [connect] — бесконечный цикл приёма с + * реконнектом через 3с при обрыве. + */ +class GlassesWsClient( + private val url: String, // ws://:8080/ws/glasses + private val http: HttpClient = defaultWsClient(), +) { + + @Volatile + private var closed = false + + @Volatile + private var session: DefaultClientWebSocketSession? = null + + /** + * Подключиться и слушать. Возвращается при вызове [close]. + * [onHostMessage] — входящие от хоста; [onConnected]/[onDisconnected] — + * события (пере)подключения (для Hello и смены статуса в UI). + */ + suspend fun connect( + onHostMessage: suspend (HostToGlasses) -> Unit, + onConnected: suspend () -> Unit = {}, + onDisconnected: suspend () -> Unit = {}, + ) { + while (!closed) { + try { + val ws = http.webSocketSession(url) + session = ws + onConnected() + try { + for (frame in ws.incoming) { + if (frame is Frame.Text) { + val text = frame.readText() + val msg = protocolJson.decodeFromString(HostToGlasses.serializer(), text) + onHostMessage(msg) + } + } + } finally { + session = null + onDisconnected() + ws.close() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log("ws-client", "соединение оборвано (${e.message}), реконнект через 3с") + } + if (closed) break + delay(3_000) + } + } + + /** Отправить сообщение хосту. Без активного соединения — no-op. */ + suspend fun send(msg: GlassesToHost) { + val text = protocolJson.encodeToString(GlassesToHost.serializer(), msg) + session?.send(text) + } + + /** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */ + fun close() { + closed = true + session?.cancel() + } +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesSender.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesSender.kt new file mode 100644 index 0000000..45786f2 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesSender.kt @@ -0,0 +1,8 @@ +package pw.binom.viewmate.core.phone + +import pw.binom.viewmate.core.protocol.HostToGlasses + +/** Куда слать сообщения очкам (реализуется WS-хабом телефона/хоста). */ +interface GlassesSender { + suspend fun send(msg: HostToGlasses) +} diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneActions.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt similarity index 76% rename from app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneActions.kt rename to lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt index 3dc91f4..1aa11eb 100644 --- a/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneActions.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt @@ -1,46 +1,39 @@ -package pw.binom.viewmate.host +package pw.binom.viewmate.core.phone -import kotlinx.serialization.json.Json import pw.binom.viewmate.core.GlassesMode -import pw.binom.viewmate.core.media.ApiClientException +import pw.binom.viewmate.core.MediaCommand +import pw.binom.viewmate.core.log import pw.binom.viewmate.core.media.JellyfinClient import pw.binom.viewmate.core.media.JellyfinItem import pw.binom.viewmate.core.media.MirrorClient import pw.binom.viewmate.core.media.MirrorJob import pw.binom.viewmate.core.media.MirrorStatus +import pw.binom.viewmate.core.protocol.AssistantState +import pw.binom.viewmate.core.protocol.AssistantStateMsg import pw.binom.viewmate.core.protocol.ChatSessionMsg +import pw.binom.viewmate.core.protocol.MediaCommandMsg import pw.binom.viewmate.core.protocol.PlayVideo +import pw.binom.viewmate.core.protocol.SetMode +import pw.binom.viewmate.core.protocol.ShowText /** * Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки. - * Каждый метод возвращает человекочитаемый текст для консоли - * и дёргает hub для реальных команд очкам. + * Каждый метод возвращает человекочитаемый текст для консоли/UI + * и дёргает [GlassesSender] для реальных команд очкам. */ class PhoneActions( private val jellyfin: JellyfinClient, private val mirror: MirrorClient, - private val hub: GlassesHub, + private val sender: GlassesSender, private val state: PhoneState, - private val json: Json, + private val userId: String, // первый пользователь Jellyfin ) { - private var cachedUserId: String? = null - - private suspend fun userId(): String { - cachedUserId?.let { return it } - val users = jellyfin.users() - val id = users.firstOrNull()?.Id - ?: throw ApiClientException("нет пользователей в Jellyfin") - cachedUserId = id - return id - } - // --- каталог --- /** Корень Jellyfin или содержимое папки, с бейджами статусов зеркал. */ suspend fun catalog(parentId: String? = null): String { - val uid = userId() - val items = if (parentId == null) jellyfin.rootItems(uid) else jellyfin.items(uid, parentId) + val items = if (parentId == null) jellyfin.rootItems(userId) else jellyfin.items(userId, parentId) val mirrors = mirror.list(limit = 500).associateBy { it.itemId } val rows = items.map { item -> catalogLine(item, mirrors[item.Id]) } @@ -77,8 +70,7 @@ class PhoneActions( /** Инфо об элементе + статус зеркала + дорожки озвучки. */ suspend fun open(itemId: String): String { - val uid = userId() - val item = jellyfin.item(uid, itemId) ?: return "элемент $itemId не найден" + val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден" val status = mirror.mirrorByItem(itemId) val sb = StringBuilder() @@ -93,8 +85,7 @@ class PhoneActions( /** Включить на очках: зеркало должно быть done → PlayVideo с audioIndex. */ suspend fun watch(itemId: String, audioIndex: Int = 0): String { - val uid = userId() - val item = jellyfin.item(uid, itemId) ?: return "элемент $itemId не найден" + val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден" val status = mirror.mirrorByItem(itemId) val files = status?.files if (status == null || status.status != "done" || files == null) { @@ -102,7 +93,7 @@ class PhoneActions( } val title = item.Name ?: itemId - hub.broadcast(PlayVideo(itemId = itemId, title = title, videoUrl = files.video.url, audioIndex = audioIndex)) + sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = files.video.url, audioIndex = audioIndex)) state.mode = GlassesMode.MOVIE state.currentItem = item state.currentAudioIndex = audioIndex @@ -176,7 +167,7 @@ class PhoneActions( val session = state.sessions.firstOrNull { it.id == id } ?: return "сессия «$id» не найдена. Доступны: ${state.sessions.joinToString(", ") { it.id }}" state.activeSessionId = id - hub.broadcast(ChatSessionMsg(sessionId = id, title = session.title)) + sender.send(ChatSessionMsg(sessionId = id, title = session.title)) return "выбрана сессия: ${session.title} (${session.id})" } @@ -197,5 +188,32 @@ class PhoneActions( return sb.toString().trimEnd() } + // --- прямые команды очкам (шлются через GlassesSender) --- + + /** Переключить режим очков: SetMode + обновить стейт. */ + suspend fun applyMode(mode: GlassesMode): String { + state.mode = mode + sender.send(SetMode(mode)) + return "режим → $mode" + } + + /** Состояние ассистента: AssistantStateMsg на очки. */ + suspend fun applyAssistantState(assistantState: AssistantState, sec: Int): String { + sender.send(AssistantStateMsg(state = assistantState, recordingSeconds = sec)) + return "ассистент → $assistantState (${sec}с)" + } + + /** Показать текст на очках: ShowText. */ + suspend fun applyText(text: String): String { + sender.send(ShowText(text = text)) + return "показан текст: $text" + } + + /** Команда медиа: MediaCommandMsg на очки. */ + suspend fun applyMediaCommand(cmd: MediaCommand, seekMs: Long = 0): String { + sender.send(MediaCommandMsg(command = cmd, seekMs = seekMs)) + return "команда медиа: $cmd${if (seekMs != 0L) " ($seekMs мс)" else ""}" + } + private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60 } diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneState.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt similarity index 88% rename from app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneState.kt rename to lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt index 48a304d..5aaf803 100644 --- a/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneState.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt @@ -1,9 +1,9 @@ -package pw.binom.viewmate.host +package pw.binom.viewmate.core.phone import pw.binom.viewmate.core.GlassesMode +import pw.binom.viewmate.core.log import pw.binom.viewmate.core.media.JellyfinItem import pw.binom.viewmate.core.media.MirrorStatus -import java.util.concurrent.ConcurrentHashMap /** Сессия чата (эмуляция) — простой DTO. */ data class SessionInfo( @@ -60,13 +60,12 @@ class PhoneState { } /** Эмуляция сессий чата: 3 фейковых + текущая. */ - val sessions: List = listOf( + val sessions: MutableList = mutableListOf( SessionInfo("glasses-movie", "Очки/кино"), SessionInfo("work", "Работа"), SessionInfo("general", "Общее"), - SessionInfo("current", "Текущая"), ) /** Эмуляция скачанного: itemId → куда (очки|телефон). */ - val downloaded: ConcurrentHashMap = ConcurrentHashMap() + val downloaded: MutableMap = mutableMapOf() } diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Json.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Json.kt new file mode 100644 index 0000000..97c3972 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Json.kt @@ -0,0 +1,13 @@ +package pw.binom.viewmate.core.protocol + +import kotlinx.serialization.json.Json + +/** + * Общий Json для WS-протокола: полиморфизм через дискриминатор "type" + * (как описано в TASK.md и используется в app-host). + */ +val protocolJson: Json = Json { + classDiscriminator = "type" + ignoreUnknownKeys = true + encodeDefaults = true +} diff --git a/app-host/src/test/kotlin/pw/binom/viewmate/host/PhoneActionsTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt similarity index 75% rename from app-host/src/test/kotlin/pw/binom/viewmate/host/PhoneActionsTest.kt rename to lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt index b66f2fb..7e44e27 100644 --- a/app-host/src/test/kotlin/pw/binom/viewmate/host/PhoneActionsTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt @@ -1,4 +1,4 @@ -package pw.binom.viewmate.host +package pw.binom.viewmate.core.phone import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine @@ -32,7 +32,6 @@ class PhoneActionsTest { private val json = Json { ignoreUnknownKeys = true } - private val usersJson = """[{"Id":"u1","Name":"Admin"}]""" private val rootItemsJson = """ {"Items":[ {"Id":"f1","Name":"Фильмы","Type":"CollectionFolder"}, @@ -69,7 +68,6 @@ class PhoneActionsTest { private fun simpleJellyfin(): JellyfinClient = jellyfin { request -> when (request.url.encodedPath) { - "/Users" -> jsonResponse(usersJson) "/Users/u1/Items" -> jsonResponse(rootItemsJson) else -> jsonResponse("""{"Id":"m1","Name":"Интерстеллар","Type":"Movie","Overview":"Лучший фильм","RunTimeTicks":148800000000}""") } @@ -86,10 +84,9 @@ class PhoneActionsTest { } } - private class HubStub(val phoneState: PhoneState = PhoneState()) : - GlassesHub(Json { ignoreUnknownKeys = true }, phoneState) { + private class SenderStub(val phoneState: PhoneState = PhoneState()) : GlassesSender { val sent = mutableListOf() - override suspend fun broadcast(msg: HostToGlasses) { + override suspend fun send(msg: HostToGlasses) { sent += msg } } @@ -97,13 +94,13 @@ class PhoneActionsTest { private fun actions( jf: JellyfinClient, mirror: MirrorClient, - hub: HubStub, + sender: SenderStub, ): PhoneActions = PhoneActions( jellyfin = jf, mirror = mirror, - hub = hub, - state = hub.phoneState, - json = json, + sender = sender, + state = sender.phoneState, + userId = "u1", ) @Test @@ -115,7 +112,7 @@ class PhoneActionsTest { """{"id":"job2","itemId":"m2","status":"processing","progress":45}]""" ) } - val out = actions(jf, mirrorClient, HubStub()).catalog() + val out = actions(jf, mirrorClient, SenderStub()).catalog() assertTrue(out.contains("Каталог Jellyfin (корень)")) assertTrue(out.contains("Фильмы (f1)")) @@ -130,7 +127,6 @@ class PhoneActionsTest { fun catalogParentShowsFolderContents() = runTest { val jf = jellyfin { request -> when (request.url.encodedPath) { - "/Users" -> jsonResponse(usersJson) "/Users/u1/Items" -> { assertEquals("f1", request.url.parameters["ParentId"]) jsonResponse("""{"Items":[{"Id":"e1","Name":"Пилот","Type":"Episode","SeriesName":"Друзья"}]}""") @@ -139,7 +135,7 @@ class PhoneActionsTest { } } val mirrorClient = mirror { request -> jsonResponse("""[]""") } - val out = actions(jf, mirrorClient, HubStub()).catalog("f1") + val out = actions(jf, mirrorClient, SenderStub()).catalog("f1") assertTrue(out.contains("папка f1")) assertTrue(out.contains("Пилот (e1)")) @@ -148,7 +144,7 @@ class PhoneActionsTest { @Test fun openShowsInfoMirrorAndTracks() = runTest { - val out = actions(simpleJellyfin(), doneMirror(), HubStub()).open("m1") + val out = actions(simpleJellyfin(), doneMirror(), SenderStub()).open("m1") assertTrue(out.contains("Интерстеллар (m1)")) assertTrue(out.contains("Лучший фильм")) @@ -161,49 +157,48 @@ class PhoneActionsTest { @Test fun watchSendsPlayVideoWithUrlAndAudioIndex() = runTest { - val hub = HubStub() - val out = actions(simpleJellyfin(), doneMirror(), hub).watch("m1", audioIndex = 1) + val sender = SenderStub() + val out = actions(simpleJellyfin(), doneMirror(), sender).watch("m1", audioIndex = 1) assertTrue(out.contains("включил «Интерстеллар»")) - assertEquals(1, hub.sent.size) - val msg = hub.sent.single() + assertEquals(1, sender.sent.size) + val msg = sender.sent.single() assertIs(msg) assertEquals("m1", msg.itemId) assertEquals("Интерстеллар", msg.title) assertEquals("https://s3/media/m1/video.mp4", msg.videoUrl) assertEquals(1, msg.audioIndex) - assertEquals("m1", hub.phoneState.currentItem?.Id) - assertEquals(1, hub.phoneState.currentAudioIndex) - assertTrue(hub.phoneState.playing) - assertEquals("done", hub.phoneState.mirrorStatus?.status) + assertEquals("m1", sender.phoneState.currentItem?.Id) + assertEquals(1, sender.phoneState.currentAudioIndex) + assertTrue(sender.phoneState.playing) + assertEquals("done", sender.phoneState.mirrorStatus?.status) } @Test fun watchWhenMirrorNotReadyReturnsHint() = runTest { - val hub = HubStub() + val sender = SenderStub() val mirrorClient = mirror { request -> respond("", HttpStatusCode.NotFound) } - val out = actions(simpleJellyfin(), mirrorClient, hub).watch("m1") + val out = actions(simpleJellyfin(), mirrorClient, sender).watch("m1") assertTrue(out.contains("не готово")) - assertTrue(hub.sent.isEmpty()) + assertTrue(sender.sent.isEmpty()) } @Test fun watchUnknownItemReturnsNotFound() = runTest { - val hub = HubStub() + val sender = SenderStub() val jf = jellyfin { request -> when (request.url.encodedPath) { - "/Users" -> jsonResponse(usersJson) "/Users/u1/Items/missing" -> respond("", HttpStatusCode.NotFound) else -> jsonResponse("""{"Items":[]}""") } } - val out = actions(jf, doneMirror(), hub).watch("missing") + val out = actions(jf, doneMirror(), sender).watch("missing") assertTrue(out.contains("не найден")) - assertTrue(hub.sent.isEmpty()) + assertTrue(sender.sent.isEmpty()) } @Test @@ -222,7 +217,7 @@ class PhoneActionsTest { ) } - val out = actions(simpleJellyfin(), mirrorClient, HubStub()).makeMirror("m1") + val out = actions(simpleJellyfin(), mirrorClient, SenderStub()).makeMirror("m1") assertEquals("/api/mirror", capturedPath) assertEquals(HttpMethod.Post, capturedMethod) @@ -234,23 +229,23 @@ class PhoneActionsTest { @Test fun mirrorStatusReturnsText() = runTest { - val done = actions(simpleJellyfin(), doneMirror(), HubStub()).mirrorStatus("m1") + val done = actions(simpleJellyfin(), doneMirror(), SenderStub()).mirrorStatus("m1") assertTrue(done.contains("done")) assertTrue(done.contains("✓")) val missing = mirror { request -> respond("", HttpStatusCode.NotFound) } - val none = actions(simpleJellyfin(), missing, HubStub()).mirrorStatus("m1") + val none = actions(simpleJellyfin(), missing, SenderStub()).mirrorStatus("m1") assertTrue(none.contains("Зеркало: нет")) } @Test fun selectSessionBroadcastsChatSessionMsg() = runTest { - val hub = HubStub() - val out = actions(simpleJellyfin(), doneMirror(), hub).selectSession("work") + val sender = SenderStub() + val out = actions(simpleJellyfin(), doneMirror(), sender).selectSession("work") assertTrue(out.contains("Работа")) - assertEquals("work", hub.phoneState.activeSessionId) - val msg = hub.sent.single() + assertEquals("work", sender.phoneState.activeSessionId) + val msg = sender.sent.single() assertIs(msg) assertEquals("work", msg.sessionId) assertEquals("Работа", msg.title) @@ -258,18 +253,18 @@ class PhoneActionsTest { @Test fun selectSessionUnknownReturnsError() = runTest { - val hub = HubStub() - val out = actions(simpleJellyfin(), doneMirror(), hub).selectSession("nope") + val sender = SenderStub() + val out = actions(simpleJellyfin(), doneMirror(), sender).selectSession("nope") assertTrue(out.contains("не найдена")) - assertTrue(hub.sent.isEmpty()) - assertNull(hub.phoneState.activeSessionId) + assertTrue(sender.sent.isEmpty()) + assertNull(sender.phoneState.activeSessionId) } @Test fun sessionsListsAllSessions() = runTest { - val hub = HubStub() - hub.phoneState.activeSessionId = "work" - val out = actions(simpleJellyfin(), doneMirror(), hub).sessions() + val sender = SenderStub() + sender.phoneState.activeSessionId = "work" + val out = actions(simpleJellyfin(), doneMirror(), sender).sessions() assertTrue(out.contains("glasses-movie — Очки/кино")) assertTrue(out.contains("work — Работа")) assertTrue(out.contains("general — Общее")) @@ -278,16 +273,16 @@ class PhoneActionsTest { @Test fun downloadAndDeleteDownloadedEmulate() { - val hub = HubStub() - val actions = actions(simpleJellyfin(), doneMirror(), hub) + val sender = SenderStub() + val actions = actions(simpleJellyfin(), doneMirror(), sender) val dl = actions.download("m1", "очки") assertTrue(dl.contains("скачано m1")) - assertEquals("очки", hub.phoneState.downloaded["m1"]) + assertEquals("очки", sender.phoneState.downloaded["m1"]) val rm = actions.deleteDownloaded("m1") assertTrue(rm.contains("удалено скачанное")) - assertFalse(hub.phoneState.downloaded.containsKey("m1")) + assertFalse(sender.phoneState.downloaded.containsKey("m1")) val rm2 = actions.deleteDownloaded("m1") assertTrue(rm2.contains("не было скачано")) @@ -295,8 +290,8 @@ class PhoneActionsTest { @Test fun statusShowsCurrentState() = runTest { - val hub = HubStub() - val actions = actions(simpleJellyfin(), doneMirror(), hub) + val sender = SenderStub() + val actions = actions(simpleJellyfin(), doneMirror(), sender) val empty = actions.status() assertTrue(empty.contains("Режим: MOVIE")) @@ -309,4 +304,16 @@ class PhoneActionsTest { assertTrue(after.contains("играет")) assertTrue(after.contains("Озвучка: #0")) } + + @Test + fun applyModeSendsSetModeAndUpdatesState() = runTest { + val sender = SenderStub() + val out = actions(simpleJellyfin(), doneMirror(), sender).applyMode(pw.binom.viewmate.core.GlassesMode.CHAT) + + assertTrue(out.contains("CHAT")) + assertEquals(pw.binom.viewmate.core.GlassesMode.CHAT, sender.phoneState.mode) + val msg = sender.sent.single() + assertIs(msg) + assertEquals(pw.binom.viewmate.core.GlassesMode.CHAT, msg.mode) + } } diff --git a/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/net/GlassesWsClientTest.kt b/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/net/GlassesWsClientTest.kt new file mode 100644 index 0000000..0b5eff2 --- /dev/null +++ b/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/net/GlassesWsClientTest.kt @@ -0,0 +1,238 @@ +package pw.binom.viewmate.core.net + +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.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import pw.binom.viewmate.core.GlassesMode +import pw.binom.viewmate.core.protocol.Gesture +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 java.util.concurrent.ConcurrentLinkedQueue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GlassesWsClientTest { + + companion object { + private const val PATH = "/ws/glasses" + } + + /** Локальный WS-сервер: Hello → Welcome, всё входящее собирает в список. */ + private class TestServer(private val port: Int = 0) { + val received = ConcurrentLinkedQueue() + private val sessions = ConcurrentLinkedQueue() + + private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") { + install(WebSockets) { + pingPeriodMillis = 5_000 + timeoutMillis = 5_000 + maxFrameSize = Long.MAX_VALUE + masking = false + } + routing { + webSocket(PATH) { + sessions.add(this) + try { + for (frame in incoming) { + if (frame is Frame.Text) { + val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), frame.readText()) + received.add(msg) + if (msg is Hello) { + send(protocolJson.encodeToString( + HostToGlasses.serializer(), + Welcome(mode = GlassesMode.MOVIE), + )) + } + } + } + } finally { + sessions.remove(this) + } + } + } + } + + init { + server.start(wait = false) + } + + suspend fun boundPort(): Int = server.engine.resolvedConnectors().first().port + + suspend fun url(): String = "ws://127.0.0.1:${boundPort()}$PATH" + + val sessionCount: Int + get() = sessions.size + + suspend fun broadcast(msg: HostToGlasses) { + val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg) + for (s in sessions) { + try { + s.send(text) + } catch (_: Exception) { + // клиент мог отвалиться + } + } + } + + fun stop() { + server.stop(500, 1_000) + } + } + + private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMs + while (!condition()) { + if (System.currentTimeMillis() > deadline) { + error("условие не выполнено за ${timeoutMs}мс") + } + delay(50) + } + } + + @Test + fun helloGetsWelcomeRoundTrip() { + val server = TestServer() + try { + runBlocking { + val client = GlassesWsClient(server.url()) + val messages = mutableListOf() + val connected = CompletableDeferred() + val job = launch { + client.connect( + onHostMessage = { messages.add(it) }, + onConnected = { connected.complete(Unit) }, + ) + } + + withTimeout(10_000) { connected.await() } + client.send(Hello(appVersion = "0.1.0")) + + awaitTrue { messages.any { it is Welcome } } + val welcome = messages.filterIsInstance().first() + assertEquals(GlassesMode.MOVIE, welcome.mode) + + client.close() + job.cancelAndJoin() + } + } finally { + server.stop() + } + } + + @Test + fun sendGestureReachesServer() { + val server = TestServer() + try { + runBlocking { + val client = GlassesWsClient(server.url()) + val connected = CompletableDeferred() + val job = launch { + client.connect( + onHostMessage = {}, + onConnected = { connected.complete(Unit) }, + ) + } + + withTimeout(10_000) { connected.await() } + client.send(Gesture(gesture = "DoubleClick")) + + awaitTrue { server.received.any { it is Gesture } } + val gesture = server.received.filterIsInstance().first() + assertEquals("DoubleClick", gesture.gesture) + + client.close() + job.cancelAndJoin() + } + } finally { + server.stop() + } + } + + @Test + fun receivesHostMessage() { + val server = TestServer() + try { + runBlocking { + val client = GlassesWsClient(server.url()) + val messages = mutableListOf() + val connected = CompletableDeferred() + val job = launch { + client.connect( + onHostMessage = { messages.add(it) }, + onConnected = { connected.complete(Unit) }, + ) + } + + withTimeout(10_000) { connected.await() } + awaitTrue { server.sessionCount == 1 } + + server.broadcast(SetMode(GlassesMode.CHAT)) + + awaitTrue { messages.any { it is SetMode } } + val setMode = messages.filterIsInstance().first() + assertEquals(GlassesMode.CHAT, setMode.mode) + + client.close() + job.cancelAndJoin() + } + } finally { + server.stop() + } + } + + @Test + fun reconnectsAfterServerDrop() { + val server = TestServer() + try { + runBlocking { + val port = server.boundPort() + var connects = 0 + val client = GlassesWsClient(server.url()) + val job = launch { + client.connect( + onHostMessage = {}, + onConnected = { connects++ }, + ) + } + + awaitTrue { connects >= 1 } + server.stop() + + // сервер упал → клиент должен продолжать цикл (не выходить) и реконнектиться + delay(4_000) + kotlin.test.assertTrue(job.isActive, "клиент должен пережить обрыв сервера") + + // поднимаем сервер на том же порту → клиент подключается заново + val server2 = TestServer(port) + try { + awaitTrue { connects >= 2 } + client.close() + job.cancelAndJoin() + } finally { + server2.stop() + } + } + } finally { + server.stop() + } + } +}