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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+44
-26
@@ -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
|
||||||
}
|
}
|
||||||
+4
-5
@@ -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
|
||||||
|
}
|
||||||
+56
-49
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user