feat: сетевая часть для app-phone (WS-сервер) и app-glasses (WS-клиент), PhoneState/PhoneActions → lib-core

lib-core:
- core/phone/: PhoneState, PhoneActions, GlassesSender (интерфейс) — перенесены из app-host
- core/net/: GlassesWsClient — KMP WS-клиент очков (реконнект, Hello, send GlassesToHost)
- GlassesWsClientTest + PhoneActionsTest (перенесён)

app-phone (Android):
- GlassesServer: Ktor WS-сервер (CIO) 0.0.0.0:8080/ws/glasses, принимает очки, Welcome из стейта
- PhoneApp (Application): клиенты, стейт, действия, сервер
- PhoneConfig: креды Jellyfin/Mirror
- MainActivity: статус сервера (порт, счётчик подключений)
- GlassesServerTest: hello→welcome, gesture

app-glasses (Android):
- GlassesApp (Application), HostConnection: WS-клиент к телефону, Hello, реконнект, обработка HostToGlasses (PlayVideo/SetMode и т.д.), StateFlow для UI
- MainActivity: статус подключения

app-host: PhoneActions/PhoneState удалены (теперь lib-core), адаптирован под GlassesSender

171 тест, 0 провалов; app-host жив (catalog/watch/status работают через lib-core)
This commit is contained in:
Hermes Agent
2026-08-11 15:06:07 +03:00
parent f0f1f71575
commit 298d8e0b69
27 changed files with 1165 additions and 118 deletions
+4
View File
@@ -50,4 +50,8 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(project(":lib-core"))
// WS-клиент очков (OkHttp — Android-движок)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.websockets)
}
+12 -1
View File
@@ -5,8 +5,19 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<application
android:name=".GlassesApp"
android:label="ViewMate Glasses"
android:theme="@style/Theme.AppCompat"
android:allowBackup="true">
android:allowBackup="true"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,32 @@
package pw.binom.viewmate.glasses
import android.app.Application
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
object GlassesConfig {
// Эмулятор — 10.0.2.2 (localhost хоста); на устройстве — LAN-адрес телефона.
const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses"
}
/**
* Application очков: создаёт и запускает HostConnection (WS-клиент к телефону).
* Синглтон-доступ через companion.
*/
class GlassesApp : Application() {
lateinit var hostConnection: HostConnection
private set
override fun onCreate() {
super.onCreate()
instance = this
hostConnection = HostConnection(GlassesConfig.HOST_URL)
hostConnection.start()
log("app", "HostConnection стартует: ${GlassesConfig.HOST_URL}")
}
companion object {
lateinit var instance: GlassesApp
private set
}
}
@@ -0,0 +1,102 @@
package pw.binom.viewmate.glasses
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import pw.binom.viewmate.core.net.GlassesWsClient
import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ChatSessionMsg
import pw.binom.viewmate.core.protocol.GlassesStatus
import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.Welcome
/**
* Обвязка над [GlassesWsClient] для UI очков:
* реконнект, Hello при подключении, периодический статус/позиция,
* обработка входящих HostToGlasses в StateFlow (пока — лог).
*/
class HostConnection(
private val url: String,
) {
private val client = GlassesWsClient(url)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _connected = MutableStateFlow(false)
val connected: StateFlow<Boolean> = _connected.asStateFlow()
private val _status = MutableStateFlow("не подключено")
val status: StateFlow<String> = _status.asStateFlow()
private val _messages = MutableStateFlow<List<String>>(emptyList())
val messages: StateFlow<List<String>> = _messages.asStateFlow()
fun start() {
scope.launch {
client.connect(
onHostMessage = { msg -> handleHostMessage(msg) },
onConnected = {
_connected.value = true
_status.value = "подключено: $url"
addMessage("соединение установлено")
client.send(Hello(appVersion = "0.1.0"))
},
onDisconnected = {
_connected.value = false
_status.value = "нет соединения (реконнект через 3с)"
},
)
}
scope.launch {
while (isActive) {
delay(5_000)
// статус/позиция — заглушки (реальные батарея/память/таймкод — позже)
client.send(GlassesStatus(batteryPercent = 80, storageUsedGb = 10.5, storageTotalGb = 32.0))
client.send(PlaybackPosition(positionMs = 0, playing = false))
}
}
}
private suspend fun handleHostMessage(msg: HostToGlasses) {
when (msg) {
is Welcome -> {
_connected.value = true
addMessage("Welcome: режим ${msg.mode}, сессия ${msg.sessionId ?: "-"}")
}
is SetMode -> addMessage("SetMode: ${msg.mode}")
is PlayVideo -> addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
is MediaCommandMsg -> addMessage("MediaCommand: ${msg.command} (seekMs=${msg.seekMs})")
is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
is ShowText -> addMessage("ShowText: ${msg.text}")
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
}
}
private fun addMessage(text: String) {
log("glasses", text)
_messages.value = (_messages.value + "» $text").takeLast(50)
}
fun stop() {
client.close()
scope.cancel()
}
}
@@ -0,0 +1,6 @@
package pw.binom.viewmate.glasses
/** Единый лог очков: простой println (работает и в unit-тестах). */
fun log(tag: String, message: String) {
println("[view-mate] $tag $message")
}
@@ -1,12 +1,53 @@
package pw.binom.viewmate.glasses
import android.app.Activity
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
class MainActivity : Activity() {
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Точка входа на очках: инициализация RayNeo SDK, режимы, связь.
// Пока — заглушка, заполняется на следующем шаге.
setContent {
MaterialTheme {
ConnectionScreen()
}
}
}
}
@Composable
fun ConnectionScreen() {
val connection = GlassesApp.instance.hostConnection
val connected by connection.connected.collectAsState()
val status by connection.status.collectAsState()
val messages by connection.messages.collectAsState()
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
Text(if (connected) "подключено к телефону" else "нет соединения")
Text(status, style = MaterialTheme.typography.bodyMedium)
Text("Последние сообщения:", style = MaterialTheme.typography.titleMedium)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(messages) { msg ->
Text(msg, style = MaterialTheme.typography.bodySmall)
}
}
}
}