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
+4
View File
@@ -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)
}
+12 -1
View File
@@ -5,8 +5,19 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<application
android:name=".GlassesApp"
android:label="ViewMate Glasses"
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,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
}
}
@@ -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<Boolean> = _connected.asStateFlow()
private val _status = MutableStateFlow("не подключено")
val status: StateFlow<String> = _status.asStateFlow()
private val _messages = MutableStateFlow<List<String>>(emptyList())
val messages: StateFlow<List<String>> = _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()
}
}
@@ -0,0 +1,6 @@
package pw.binom.viewmate.glasses
/** Единый лог очков: простой println (работает и в unit-тестах). */
fun log(tag: String, message: String) {
println("[view-mate] $tag $message")
}
@@ -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)
}
}
}
}
@@ -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<DefaultWebSocketServerSession, Unit>()
@@ -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
@@ -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,14 +22,23 @@ fun main() {
val config = Config.load()
log("config", "Jellyfin: ${config.jellyfinUrl}, Mirror: ${config.mirrorUrl}")
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 = JellyfinClient(config.jellyfinUrl, config.jellyfinApiKey),
mirror = MirrorClient(config.mirrorUrl, config.mirrorApiKey),
hub = hub,
jellyfin = jellyfin,
mirror = mirror,
sender = hub,
state = state,
json = hostJson,
userId = userId,
)
val server = embeddedServer(Netty, port = HOST_PORT, host = "0.0.0.0") {
@@ -44,7 +52,6 @@ fun main() {
log("server", "остановлен")
})
runBlocking {
launch {
hub.connected.collectLatest { count ->
log("hub", "подключено очков: $count")
@@ -85,20 +92,20 @@ private suspend fun handleConsoleCommand(actions: PhoneActions, hub: GlassesHub,
"mode" -> {
val mode = parseEnum<GlassesMode>(parts.getOrNull(1))
if (mode == null) return logUsage("mode MOVIE|CHAT")
hub.setMode(mode)
out(actions.applyMode(mode))
}
"state" -> {
val state = parseEnum<AssistantState>(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<MediaCommand>(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)))
+13
View File
@@ -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)
}
+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)
}
}
}
+1
View File
@@ -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" }
+8
View File
@@ -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")
}
}
}
@@ -0,0 +1,6 @@
package pw.binom.viewmate.core
/** Единый лог общего слоя (KMP): без java.time, просто тег + сообщение. */
fun log(tag: String, message: String) {
println("[view-mate] $tag $message")
}
@@ -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,
@@ -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://<host>: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()
}
}
@@ -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)
}
@@ -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
}
@@ -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<SessionInfo> = listOf(
val sessions: MutableList<SessionInfo> = mutableListOf(
SessionInfo("glasses-movie", "Очки/кино"),
SessionInfo("work", "Работа"),
SessionInfo("general", "Общее"),
SessionInfo("current", "Текущая"),
)
/** Эмуляция скачанного: itemId → куда (очки|телефон). */
val downloaded: ConcurrentHashMap<String, String> = ConcurrentHashMap()
val downloaded: MutableMap<String, String> = mutableMapOf()
}
@@ -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
}
@@ -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<HostToGlasses>()
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<PlayVideo>(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<ChatSessionMsg>(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<pw.binom.viewmate.core.protocol.SetMode>(msg)
assertEquals(pw.binom.viewmate.core.GlassesMode.CHAT, msg.mode)
}
}
@@ -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<GlassesToHost>()
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
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<HostToGlasses>()
val connected = CompletableDeferred<Unit>()
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<Welcome>().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<Unit>()
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<Gesture>().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<HostToGlasses>()
val connected = CompletableDeferred<Unit>()
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<SetMode>().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()
}
}
}