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.coroutines.android)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(project(":lib-core")) 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" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<application <application
android:name=".GlassesApp"
android:label="ViewMate Glasses" android:label="ViewMate Glasses"
android:theme="@style/Theme.AppCompat" 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> </application>
</manifest> </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 package pw.binom.viewmate.glasses
import android.app.Activity
import android.os.Bundle 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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.coroutines.flow.asStateFlow
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import pw.binom.viewmate.core.GlassesMode 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.GlassesOff
import pw.binom.viewmate.core.protocol.GlassesStatus import pw.binom.viewmate.core.protocol.GlassesStatus
import pw.binom.viewmate.core.protocol.GlassesToHost import pw.binom.viewmate.core.protocol.GlassesToHost
@@ -24,7 +26,7 @@ import java.util.concurrent.ConcurrentHashMap
open class GlassesHub( open class GlassesHub(
private val json: Json, private val json: Json,
private val state: PhoneState = PhoneState(), private val state: PhoneState = PhoneState(),
) { ) : GlassesSender {
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>() private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>()
@@ -69,6 +71,9 @@ open class GlassesHub(
} }
} }
/** GlassesSender: то же, что [broadcast]. */
override suspend fun send(msg: HostToGlasses) = broadcast(msg)
/** Сменить режим очков и разослать SetMode. */ /** Сменить режим очков и разослать SetMode. */
open suspend fun setMode(mode: GlassesMode) { open suspend fun setMode(mode: GlassesMode) {
_mode.value = mode _mode.value = mode
@@ -10,12 +10,11 @@ import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.media.JellyfinClient import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.MirrorClient 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.AssistantState
import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ChatSessionMsg 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.PlayVideo
import pw.binom.viewmate.core.protocol.ShowText
const val HOST_PORT = 8080 const val HOST_PORT = 8080
@@ -23,14 +22,23 @@ fun main() {
val config = Config.load() val config = Config.load()
log("config", "Jellyfin: ${config.jellyfinUrl}, Mirror: ${config.mirrorUrl}") 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 state = PhoneState()
val hub = GlassesHub(hostJson, state) val hub = GlassesHub(hostJson, state)
val actions = PhoneActions( val actions = PhoneActions(
jellyfin = JellyfinClient(config.jellyfinUrl, config.jellyfinApiKey), jellyfin = jellyfin,
mirror = MirrorClient(config.mirrorUrl, config.mirrorApiKey), mirror = mirror,
hub = hub, sender = hub,
state = state, state = state,
json = hostJson, userId = userId,
) )
val server = embeddedServer(Netty, port = HOST_PORT, host = "0.0.0.0") { val server = embeddedServer(Netty, port = HOST_PORT, host = "0.0.0.0") {
@@ -44,7 +52,6 @@ fun main() {
log("server", "остановлен") log("server", "остановлен")
}) })
runBlocking {
launch { launch {
hub.connected.collectLatest { count -> hub.connected.collectLatest { count ->
log("hub", "подключено очков: $count") log("hub", "подключено очков: $count")
@@ -85,20 +92,20 @@ private suspend fun handleConsoleCommand(actions: PhoneActions, hub: GlassesHub,
"mode" -> { "mode" -> {
val mode = parseEnum<GlassesMode>(parts.getOrNull(1)) val mode = parseEnum<GlassesMode>(parts.getOrNull(1))
if (mode == null) return logUsage("mode MOVIE|CHAT") if (mode == null) return logUsage("mode MOVIE|CHAT")
hub.setMode(mode) out(actions.applyMode(mode))
} }
"state" -> { "state" -> {
val state = parseEnum<AssistantState>(parts.getOrNull(1)) val state = parseEnum<AssistantState>(parts.getOrNull(1))
if (state == null) return logUsage("state IDLE|LISTENING|THINKING|SPEAKING [seconds]") if (state == null) return logUsage("state IDLE|LISTENING|THINKING|SPEAKING [seconds]")
val seconds = parts.getOrNull(2)?.toIntOrNull() ?: 0 val seconds = parts.getOrNull(2)?.toIntOrNull() ?: 0
hub.broadcast(AssistantStateMsg(state = state, recordingSeconds = seconds)) out(actions.applyAssistantState(state, seconds))
} }
"text" -> { "text" -> {
val text = parts.drop(1).joinToString(" ") val text = parts.drop(1).joinToString(" ")
if (text.isEmpty()) return logUsage("text <текст>") if (text.isEmpty()) return logUsage("text <текст>")
hub.broadcast(ShowText(text = text)) out(actions.applyText(text))
} }
"session" -> { "session" -> {
@@ -117,7 +124,7 @@ private suspend fun handleConsoleCommand(actions: PhoneActions, hub: GlassesHub,
val command = parseEnum<MediaCommand>(parts.getOrNull(1)) 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]") 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 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))) "catalog" -> out(actions.catalog(parts.getOrNull(1)))
+13
View File
@@ -48,4 +48,17 @@ dependencies {
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(project(":lib-core")) 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" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<application <application
android:name=".PhoneApp"
android:label="ViewMate" android:label="ViewMate"
android:theme="@style/Theme.AppCompat" 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> </application>
</manifest> </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 package pw.binom.viewmate.phone
import android.app.Activity
import android.os.Bundle 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", 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-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-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-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" } 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.serialization.json)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.logging) implementation(libs.ktor.client.logging)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
@@ -35,6 +36,13 @@ kotlin {
jvmMain.dependencies { jvmMain.dependencies {
implementation(libs.ktor.client.cio) 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.LogLevel
import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.logging.Logger 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.HttpResponse
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import pw.binom.viewmate.core.protocol.protocolJson
/** /**
* Общий HTTP-клиент для media-бэкендов (Jellyfin, media-mirror): * Общий HTTP-клиент для media-бэкендов (Jellyfin, media-mirror):
@@ -45,6 +47,31 @@ object HttpClientDebugLogging {
var enabled: Boolean = true 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-запроса с понятным текстом. */ /** Ошибка HTTP-запроса с понятным текстом. */
class ApiClientException( class ApiClientException(
message: String, 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.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.JellyfinClient
import pw.binom.viewmate.core.media.JellyfinItem import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorClient import pw.binom.viewmate.core.media.MirrorClient
import pw.binom.viewmate.core.media.MirrorJob import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.media.MirrorStatus 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.ChatSessionMsg
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.PlayVideo import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.ShowText
/** /**
* Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки. * Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки.
* Каждый метод возвращает человекочитаемый текст для консоли * Каждый метод возвращает человекочитаемый текст для консоли/UI
* и дёргает hub для реальных команд очкам. * и дёргает [GlassesSender] для реальных команд очкам.
*/ */
class PhoneActions( class PhoneActions(
private val jellyfin: JellyfinClient, private val jellyfin: JellyfinClient,
private val mirror: MirrorClient, private val mirror: MirrorClient,
private val hub: GlassesHub, private val sender: GlassesSender,
private val state: PhoneState, 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 или содержимое папки, с бейджами статусов зеркал. */ /** Корень Jellyfin или содержимое папки, с бейджами статусов зеркал. */
suspend fun catalog(parentId: String? = null): String { suspend fun catalog(parentId: String? = null): String {
val uid = userId() val items = if (parentId == null) jellyfin.rootItems(userId) else jellyfin.items(userId, parentId)
val items = if (parentId == null) jellyfin.rootItems(uid) else jellyfin.items(uid, parentId)
val mirrors = mirror.list(limit = 500).associateBy { it.itemId } val mirrors = mirror.list(limit = 500).associateBy { it.itemId }
val rows = items.map { item -> catalogLine(item, mirrors[item.Id]) } val rows = items.map { item -> catalogLine(item, mirrors[item.Id]) }
@@ -77,8 +70,7 @@ class PhoneActions(
/** Инфо об элементе + статус зеркала + дорожки озвучки. */ /** Инфо об элементе + статус зеркала + дорожки озвучки. */
suspend fun open(itemId: String): String { suspend fun open(itemId: String): String {
val uid = userId() val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val item = jellyfin.item(uid, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId) val status = mirror.mirrorByItem(itemId)
val sb = StringBuilder() val sb = StringBuilder()
@@ -93,8 +85,7 @@ class PhoneActions(
/** Включить на очках: зеркало должно быть done → PlayVideo с audioIndex. */ /** Включить на очках: зеркало должно быть done → PlayVideo с audioIndex. */
suspend fun watch(itemId: String, audioIndex: Int = 0): String { suspend fun watch(itemId: String, audioIndex: Int = 0): String {
val uid = userId() val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val item = jellyfin.item(uid, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId) val status = mirror.mirrorByItem(itemId)
val files = status?.files val files = status?.files
if (status == null || status.status != "done" || files == null) { if (status == null || status.status != "done" || files == null) {
@@ -102,7 +93,7 @@ class PhoneActions(
} }
val title = item.Name ?: itemId 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.mode = GlassesMode.MOVIE
state.currentItem = item state.currentItem = item
state.currentAudioIndex = audioIndex state.currentAudioIndex = audioIndex
@@ -176,7 +167,7 @@ class PhoneActions(
val session = state.sessions.firstOrNull { it.id == id } val session = state.sessions.firstOrNull { it.id == id }
?: return "сессия «$id» не найдена. Доступны: ${state.sessions.joinToString(", ") { it.id }}" ?: return "сессия «$id» не найдена. Доступны: ${state.sessions.joinToString(", ") { it.id }}"
state.activeSessionId = 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})" return "выбрана сессия: ${session.title} (${session.id})"
} }
@@ -197,5 +188,32 @@ class PhoneActions(
return sb.toString().trimEnd() 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 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.GlassesMode
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.media.JellyfinItem import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorStatus import pw.binom.viewmate.core.media.MirrorStatus
import java.util.concurrent.ConcurrentHashMap
/** Сессия чата (эмуляция) — простой DTO. */ /** Сессия чата (эмуляция) — простой DTO. */
data class SessionInfo( data class SessionInfo(
@@ -60,13 +60,12 @@ class PhoneState {
} }
/** Эмуляция сессий чата: 3 фейковых + текущая. */ /** Эмуляция сессий чата: 3 фейковых + текущая. */
val sessions: List<SessionInfo> = listOf( val sessions: MutableList<SessionInfo> = mutableListOf(
SessionInfo("glasses-movie", "Очки/кино"), SessionInfo("glasses-movie", "Очки/кино"),
SessionInfo("work", "Работа"), SessionInfo("work", "Работа"),
SessionInfo("general", "Общее"), SessionInfo("general", "Общее"),
SessionInfo("current", "Текущая"),
) )
/** Эмуляция скачанного: itemId → куда (очки|телефон). */ /** Эмуляция скачанного: 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.HttpClient
import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.MockEngine
@@ -32,7 +32,6 @@ class PhoneActionsTest {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val usersJson = """[{"Id":"u1","Name":"Admin"}]"""
private val rootItemsJson = """ private val rootItemsJson = """
{"Items":[ {"Items":[
{"Id":"f1","Name":"Фильмы","Type":"CollectionFolder"}, {"Id":"f1","Name":"Фильмы","Type":"CollectionFolder"},
@@ -69,7 +68,6 @@ class PhoneActionsTest {
private fun simpleJellyfin(): JellyfinClient = jellyfin { request -> private fun simpleJellyfin(): JellyfinClient = jellyfin { request ->
when (request.url.encodedPath) { when (request.url.encodedPath) {
"/Users" -> jsonResponse(usersJson)
"/Users/u1/Items" -> jsonResponse(rootItemsJson) "/Users/u1/Items" -> jsonResponse(rootItemsJson)
else -> jsonResponse("""{"Id":"m1","Name":"Интерстеллар","Type":"Movie","Overview":"Лучший фильм","RunTimeTicks":148800000000}""") else -> jsonResponse("""{"Id":"m1","Name":"Интерстеллар","Type":"Movie","Overview":"Лучший фильм","RunTimeTicks":148800000000}""")
} }
@@ -86,10 +84,9 @@ class PhoneActionsTest {
} }
} }
private class HubStub(val phoneState: PhoneState = PhoneState()) : private class SenderStub(val phoneState: PhoneState = PhoneState()) : GlassesSender {
GlassesHub(Json { ignoreUnknownKeys = true }, phoneState) {
val sent = mutableListOf<HostToGlasses>() val sent = mutableListOf<HostToGlasses>()
override suspend fun broadcast(msg: HostToGlasses) { override suspend fun send(msg: HostToGlasses) {
sent += msg sent += msg
} }
} }
@@ -97,13 +94,13 @@ class PhoneActionsTest {
private fun actions( private fun actions(
jf: JellyfinClient, jf: JellyfinClient,
mirror: MirrorClient, mirror: MirrorClient,
hub: HubStub, sender: SenderStub,
): PhoneActions = PhoneActions( ): PhoneActions = PhoneActions(
jellyfin = jf, jellyfin = jf,
mirror = mirror, mirror = mirror,
hub = hub, sender = sender,
state = hub.phoneState, state = sender.phoneState,
json = json, userId = "u1",
) )
@Test @Test
@@ -115,7 +112,7 @@ class PhoneActionsTest {
"""{"id":"job2","itemId":"m2","status":"processing","progress":45}]""" """{"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("Каталог Jellyfin (корень)"))
assertTrue(out.contains("Фильмы (f1)")) assertTrue(out.contains("Фильмы (f1)"))
@@ -130,7 +127,6 @@ class PhoneActionsTest {
fun catalogParentShowsFolderContents() = runTest { fun catalogParentShowsFolderContents() = runTest {
val jf = jellyfin { request -> val jf = jellyfin { request ->
when (request.url.encodedPath) { when (request.url.encodedPath) {
"/Users" -> jsonResponse(usersJson)
"/Users/u1/Items" -> { "/Users/u1/Items" -> {
assertEquals("f1", request.url.parameters["ParentId"]) assertEquals("f1", request.url.parameters["ParentId"])
jsonResponse("""{"Items":[{"Id":"e1","Name":"Пилот","Type":"Episode","SeriesName":"Друзья"}]}""") jsonResponse("""{"Items":[{"Id":"e1","Name":"Пилот","Type":"Episode","SeriesName":"Друзья"}]}""")
@@ -139,7 +135,7 @@ class PhoneActionsTest {
} }
} }
val mirrorClient = mirror { request -> jsonResponse("""[]""") } 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("папка f1"))
assertTrue(out.contains("Пилот (e1)")) assertTrue(out.contains("Пилот (e1)"))
@@ -148,7 +144,7 @@ class PhoneActionsTest {
@Test @Test
fun openShowsInfoMirrorAndTracks() = runTest { 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("Интерстеллар (m1)"))
assertTrue(out.contains("Лучший фильм")) assertTrue(out.contains("Лучший фильм"))
@@ -161,49 +157,48 @@ class PhoneActionsTest {
@Test @Test
fun watchSendsPlayVideoWithUrlAndAudioIndex() = runTest { fun watchSendsPlayVideoWithUrlAndAudioIndex() = runTest {
val hub = HubStub() val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), hub).watch("m1", audioIndex = 1) val out = actions(simpleJellyfin(), doneMirror(), sender).watch("m1", audioIndex = 1)
assertTrue(out.contains("включил «Интерстеллар»")) assertTrue(out.contains("включил «Интерстеллар»"))
assertEquals(1, hub.sent.size) assertEquals(1, sender.sent.size)
val msg = hub.sent.single() val msg = sender.sent.single()
assertIs<PlayVideo>(msg) assertIs<PlayVideo>(msg)
assertEquals("m1", msg.itemId) assertEquals("m1", msg.itemId)
assertEquals("Интерстеллар", msg.title) assertEquals("Интерстеллар", msg.title)
assertEquals("https://s3/media/m1/video.mp4", msg.videoUrl) assertEquals("https://s3/media/m1/video.mp4", msg.videoUrl)
assertEquals(1, msg.audioIndex) assertEquals(1, msg.audioIndex)
assertEquals("m1", hub.phoneState.currentItem?.Id) assertEquals("m1", sender.phoneState.currentItem?.Id)
assertEquals(1, hub.phoneState.currentAudioIndex) assertEquals(1, sender.phoneState.currentAudioIndex)
assertTrue(hub.phoneState.playing) assertTrue(sender.phoneState.playing)
assertEquals("done", hub.phoneState.mirrorStatus?.status) assertEquals("done", sender.phoneState.mirrorStatus?.status)
} }
@Test @Test
fun watchWhenMirrorNotReadyReturnsHint() = runTest { fun watchWhenMirrorNotReadyReturnsHint() = runTest {
val hub = HubStub() val sender = SenderStub()
val mirrorClient = mirror { request -> val mirrorClient = mirror { request ->
respond("", HttpStatusCode.NotFound) respond("", HttpStatusCode.NotFound)
} }
val out = actions(simpleJellyfin(), mirrorClient, hub).watch("m1") val out = actions(simpleJellyfin(), mirrorClient, sender).watch("m1")
assertTrue(out.contains("не готово")) assertTrue(out.contains("не готово"))
assertTrue(hub.sent.isEmpty()) assertTrue(sender.sent.isEmpty())
} }
@Test @Test
fun watchUnknownItemReturnsNotFound() = runTest { fun watchUnknownItemReturnsNotFound() = runTest {
val hub = HubStub() val sender = SenderStub()
val jf = jellyfin { request -> val jf = jellyfin { request ->
when (request.url.encodedPath) { when (request.url.encodedPath) {
"/Users" -> jsonResponse(usersJson)
"/Users/u1/Items/missing" -> respond("", HttpStatusCode.NotFound) "/Users/u1/Items/missing" -> respond("", HttpStatusCode.NotFound)
else -> jsonResponse("""{"Items":[]}""") else -> jsonResponse("""{"Items":[]}""")
} }
} }
val out = actions(jf, doneMirror(), hub).watch("missing") val out = actions(jf, doneMirror(), sender).watch("missing")
assertTrue(out.contains("не найден")) assertTrue(out.contains("не найден"))
assertTrue(hub.sent.isEmpty()) assertTrue(sender.sent.isEmpty())
} }
@Test @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("/api/mirror", capturedPath)
assertEquals(HttpMethod.Post, capturedMethod) assertEquals(HttpMethod.Post, capturedMethod)
@@ -234,23 +229,23 @@ class PhoneActionsTest {
@Test @Test
fun mirrorStatusReturnsText() = runTest { 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("done"))
assertTrue(done.contains("✓")) assertTrue(done.contains("✓"))
val missing = mirror { request -> respond("", HttpStatusCode.NotFound) } 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("Зеркало: нет")) assertTrue(none.contains("Зеркало: нет"))
} }
@Test @Test
fun selectSessionBroadcastsChatSessionMsg() = runTest { fun selectSessionBroadcastsChatSessionMsg() = runTest {
val hub = HubStub() val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), hub).selectSession("work") val out = actions(simpleJellyfin(), doneMirror(), sender).selectSession("work")
assertTrue(out.contains("Работа")) assertTrue(out.contains("Работа"))
assertEquals("work", hub.phoneState.activeSessionId) assertEquals("work", sender.phoneState.activeSessionId)
val msg = hub.sent.single() val msg = sender.sent.single()
assertIs<ChatSessionMsg>(msg) assertIs<ChatSessionMsg>(msg)
assertEquals("work", msg.sessionId) assertEquals("work", msg.sessionId)
assertEquals("Работа", msg.title) assertEquals("Работа", msg.title)
@@ -258,18 +253,18 @@ class PhoneActionsTest {
@Test @Test
fun selectSessionUnknownReturnsError() = runTest { fun selectSessionUnknownReturnsError() = runTest {
val hub = HubStub() val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), hub).selectSession("nope") val out = actions(simpleJellyfin(), doneMirror(), sender).selectSession("nope")
assertTrue(out.contains("не найдена")) assertTrue(out.contains("не найдена"))
assertTrue(hub.sent.isEmpty()) assertTrue(sender.sent.isEmpty())
assertNull(hub.phoneState.activeSessionId) assertNull(sender.phoneState.activeSessionId)
} }
@Test @Test
fun sessionsListsAllSessions() = runTest { fun sessionsListsAllSessions() = runTest {
val hub = HubStub() val sender = SenderStub()
hub.phoneState.activeSessionId = "work" sender.phoneState.activeSessionId = "work"
val out = actions(simpleJellyfin(), doneMirror(), hub).sessions() val out = actions(simpleJellyfin(), doneMirror(), sender).sessions()
assertTrue(out.contains("glasses-movie — Очки/кино")) assertTrue(out.contains("glasses-movie — Очки/кино"))
assertTrue(out.contains("work — Работа")) assertTrue(out.contains("work — Работа"))
assertTrue(out.contains("general — Общее")) assertTrue(out.contains("general — Общее"))
@@ -278,16 +273,16 @@ class PhoneActionsTest {
@Test @Test
fun downloadAndDeleteDownloadedEmulate() { fun downloadAndDeleteDownloadedEmulate() {
val hub = HubStub() val sender = SenderStub()
val actions = actions(simpleJellyfin(), doneMirror(), hub) val actions = actions(simpleJellyfin(), doneMirror(), sender)
val dl = actions.download("m1", "очки") val dl = actions.download("m1", "очки")
assertTrue(dl.contains("скачано m1")) assertTrue(dl.contains("скачано m1"))
assertEquals("очки", hub.phoneState.downloaded["m1"]) assertEquals("очки", sender.phoneState.downloaded["m1"])
val rm = actions.deleteDownloaded("m1") val rm = actions.deleteDownloaded("m1")
assertTrue(rm.contains("удалено скачанное")) assertTrue(rm.contains("удалено скачанное"))
assertFalse(hub.phoneState.downloaded.containsKey("m1")) assertFalse(sender.phoneState.downloaded.containsKey("m1"))
val rm2 = actions.deleteDownloaded("m1") val rm2 = actions.deleteDownloaded("m1")
assertTrue(rm2.contains("не было скачано")) assertTrue(rm2.contains("не было скачано"))
@@ -295,8 +290,8 @@ class PhoneActionsTest {
@Test @Test
fun statusShowsCurrentState() = runTest { fun statusShowsCurrentState() = runTest {
val hub = HubStub() val sender = SenderStub()
val actions = actions(simpleJellyfin(), doneMirror(), hub) val actions = actions(simpleJellyfin(), doneMirror(), sender)
val empty = actions.status() val empty = actions.status()
assertTrue(empty.contains("Режим: MOVIE")) assertTrue(empty.contains("Режим: MOVIE"))
@@ -309,4 +304,16 @@ class PhoneActionsTest {
assertTrue(after.contains("играет")) assertTrue(after.contains("играет"))
assertTrue(after.contains("Озвучка: #0")) 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()
}
}
}