Ассистент Порфирий: тула времени, сессии, чат-UI, голосовой цикл; BT-транспорт очки↔телефон
- BT (RFCOMM SPP 00001105) вместо WiFi: BtGlassesTransport/BtServerTransport, реконнект 3с - PlaybackService (foreground, WAKE_LOCK) против морозов HONOR - Ассистент: AgentTools (TimeTool ISO 8601), TOOL_CALL-протокол, сессии SQLite (UUID) - Чат-UI: создание/сброс/переключение сессий, автообновление ленты 2с - LlmClient на прямом OkHttp (Koog давал пустой content на длинных историях) - Голосовой цикл: тройной тап на очках → STT → активная сессия → ShowText - Тесты: AgentToolsTest (3), GlassesServerTest переписан (6) — зелёные
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# >>> cortexkit:magic-context
|
||||||
|
magic-context/
|
||||||
|
# <<< cortexkit:magic-context
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Абстракция транспорта связи очки↔телефон (view-mate)
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Сейчас связь очки↔телефон жёстко завязана на **WiFi + WebSocket**:
|
||||||
|
- очки: `GlassesWsClient` (Ktor WS-клиент) внутри `HostConnection`, discovery = mDNS (`MdnsClient`) + скан /24 (`PhoneScan`)
|
||||||
|
- телефон: `GlassesServer` (Ktor CIO embeddedServer, WS на 0.0.0.0:8080/ws/glasses) + `GlassesHub` (сессии = `DefaultWebSocketServerSession`)
|
||||||
|
|
||||||
|
WiFi на реальном железе нестабилен (пользователь: «wifi в рамках view-mate — неверное решение, блютус на много стабильнее»). Нужно сделать транспорт **абстрактным**, чтобы реализацию можно было подставить: **WifiTransport** (текущий WS) и **BluetoothTransport** (новый, RFCOMM/BluetoothSocket).
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Выделить интерфейс транспорта, одинаковый для обеих сторон, и две реализации:
|
||||||
|
1. **WifiTransport** — обёртка над существующим кодом (без изменения логики)
|
||||||
|
2. **BluetoothTransport** — новая реализация на Android BluetoothSocket (RFCOMM)
|
||||||
|
|
||||||
|
Протокол сообщений (HostToGlasses/GlassesToHost, kotlinx.serialization JSON) — **НЕ трогать** (пересылаются как JSON-строки, что WS, что BT-сокет).
|
||||||
|
|
||||||
|
## Текущая структура (не менять без нужды, только рефакторинг)
|
||||||
|
|
||||||
|
**lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/**
|
||||||
|
- `net/GlassesWsClient.kt` — WS-клиент очков: `suspend connect(onHostMessage, onConnected, onDisconnected)` (бесконечный цикл приёма с реконнектом 3с), `suspend send(msg: GlassesToHost)`, `close()`
|
||||||
|
- `phone/GlassesSender.kt` — интерфейс телефона: `interface GlassesSender { suspend fun send(msg: HostToGlasses) }`
|
||||||
|
- `protocol/` — HostToGlasses, GlassesToHost, Json (protocolJson), MediaCommand и т.д.
|
||||||
|
|
||||||
|
**app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/**
|
||||||
|
- `HostConnection.kt` — обвязка: discovery (MdnsClient → PhoneScan), connectLoop, реконнект, Hello, статус-цикл 5с (buildStatus + PlaybackPosition), обработка HostToGlasses (handleHostMessage), reconnectResume-автоматика
|
||||||
|
- `MdnsClient.kt`, `PhoneScan.kt` — WiFi-discovery
|
||||||
|
|
||||||
|
**app-phone/src/main/kotlin/pw/binom/viewmate/phone/**
|
||||||
|
- `GlassesServer.kt` — `GlassesHub` (ConcurrentHashMap<DefaultWebSocketServerSession, Unit>, connected/gesturesReceived/batteryPercent, broadcast, обработка входящих: Hello→Welcome, жесты, позиция, GlassesOff→пауза) + `GlassesServer` (embeddedServer CIO + glassesServerModule + sender-адаптер GlassesSender поверх hub.broadcast)
|
||||||
|
- `NsdPublisher.kt` — mDNS-публикация
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
### 1. Интерфейсы транспорта (lib-core, commonMain)
|
||||||
|
|
||||||
|
Создать `core/net/GlassesTransport.kt` (или аналогичный файл):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
/** Транспорт связи очки↔телефон. Реализации: WiFi (WS), Bluetooth (RFCOMM). */
|
||||||
|
interface GlassesTransport {
|
||||||
|
val name: String // "wifi" / "bluetooth"
|
||||||
|
suspend fun connect(
|
||||||
|
onMessage: suspend (String) -> Unit, // входящее JSON-сообщение
|
||||||
|
onConnected: suspend () -> Unit,
|
||||||
|
onDisconnected: suspend () -> Unit,
|
||||||
|
)
|
||||||
|
suspend fun send(json: String)
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Сообщения передавать **JSON-строками** (декодирование остаётся на стороне вызывающего — в HostConnection и GlassesHub, как сейчас). Это держит транспорт полностью независимым от протокола.
|
||||||
|
|
||||||
|
Для телефона — интерфейс приёма подключений:
|
||||||
|
```kotlin
|
||||||
|
/** Приёмник подключений (телефон). */
|
||||||
|
interface GlassesServerTransport {
|
||||||
|
val name: String
|
||||||
|
suspend fun accept(
|
||||||
|
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||||
|
onConnected: suspend (connId: String) -> Unit,
|
||||||
|
onDisconnected: suspend (connId: String) -> Unit,
|
||||||
|
)
|
||||||
|
suspend fun send(connId: String, json: String)
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(connId — идентификатор сессии; для WiFi можно использовать адрес/хэш, для BT — MAC-адрес устройства.)
|
||||||
|
|
||||||
|
### 2. WifiTransport (рефакторинг существующего)
|
||||||
|
|
||||||
|
- **Очки**: `WifiGlassesTransport` — обёртка над `GlassesWsClient`: connect пробрасывает колбэки, но строки (не декодированные объекты). Либо изменить `GlassesWsClient` на работу со строками — на твоё усмотрение, главное чтобы HostConnection перешёл на интерфейс.
|
||||||
|
- **Телефон**: `WifiServerTransport` — обёртка над Ktor-сервером: существующий `glassesServerModule`/`GlassesHub` переводится на интерфейс (сессии — connId-строки вместо DefaultWebSocketServerSession).
|
||||||
|
|
||||||
|
### 3. BluetoothTransport (новая реализация)
|
||||||
|
|
||||||
|
**Очки (клиент):**
|
||||||
|
- `BtGlassesTransport` — подключается к телефону через BluetoothSocket (RFCOMM, SPP): `BluetoothAdapter.getDefaultAdapter()`, найти сервис/устройство (bonded devices + поиск), `createRfcommSocketToServiceRecord(SPP_UUID)`, connect, потоки: читать строки (по разделителю/длине), писать строки.
|
||||||
|
- Discovery: bonded-устройства телефона (первый подходящий) или поиск (startDiscovery) — достаточно начать с bonded.
|
||||||
|
- Пермишены: BLUETOOTH, BLUETOOTH_ADMIN (до Android 12), BLUETOOTH_CONNECT/BLUETOOTH_SCAN (Android 12+, runtime-запрос).
|
||||||
|
|
||||||
|
**Телефон (сервер):**
|
||||||
|
- `BtServerTransport` — BluetoothServerSocket (RFCOMM, SPP, тот же UUID), accept-цикл, потоки чтения/записи на каждое подключение, connId = MAC устройства.
|
||||||
|
- Пермишены аналогично + FOREGROUND_SERVICE_CONNECTED_DEVICE (если нужно).
|
||||||
|
|
||||||
|
### 4. Соединение (фабрика/выбор)
|
||||||
|
|
||||||
|
- В `HostConnection` и `GlassesServer` — параметр транспорта (интерфейс), по умолчанию WifiTransport (чтобы не сломать текущее поведение). Выбор: константа/конфиг/аргумент конструктора. **По умолчанию — WiFi**, Bluetooth включается флагом (пользователь будет переключать вручную после проверки).
|
||||||
|
- Discovery (MdnsClient/PhoneScan/NsdPublisher) — оставить как часть WifiTransport; для BT — свой (bonded).
|
||||||
|
|
||||||
|
### 5. Сборка и тесты
|
||||||
|
|
||||||
|
- Существующие тесты (GlassesServerTest, HostToGlassesTest, GlassesToHostTest, PhoneActionsTest и др.) должны остаться зелёными — поведение WiFi не меняется.
|
||||||
|
- Добавить юнит-тесты на чистую логику (если есть что тестировать без Android: например, сериализация строк, выбор транспорта).
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- **НЕ менять протокол** (HostToGlasses/GlassesToHost/Json) — только способ доставки.
|
||||||
|
- НЕ ломать существующий WiFi-путь: он остаётся дефолтным и должен работать как раньше.
|
||||||
|
- Kotlin, стиль проекта (детали в соседних файлах).
|
||||||
|
- После рефакторинга — собрать `:app-phone:assembleDebug` и `:app-glasses:assembleDebug`, прогнать тесты, показать результат.
|
||||||
@@ -1,106 +1,132 @@
|
|||||||
# Задача: масштаб на всех экранах очков + сохранение скролла каталога
|
# Задача: телефон не должен засыпать во время просмотра (foreground service)
|
||||||
|
|
||||||
## Баг 1: ConnectionScreen игнорирует масштаб
|
## Симптом (пользователь, 21.08)
|
||||||
|
|
||||||
**Симптом (пользователь):** «в режиме НЕ просмотра видео (пока ничего не включено)
|
«Если я телефон отправлю спать, то звук на телефоне прекращается. А когда на
|
||||||
очки игнорируют выставленный масштаб». Плюс «масштаб будто бы не сохраняется —
|
телефоне прекращается звук, очки тоже перестают играть.»
|
||||||
сразу после старта масштаб 1».
|
|
||||||
|
|
||||||
**Корень:** масштаб (`GlassesApp.instance.scale`, 0.5..1) применяется ТОЛЬКО в
|
## Корень (диагноз подтверждён)
|
||||||
`GlassesMovieScreen.kt` (контейнер `width((screenWidthPx * scale).toInt().px)` +
|
|
||||||
offset к центру). А `ConnectionScreen()` в
|
|
||||||
`app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt` (строка ~354)
|
|
||||||
рисует контент на ПОЛНУЮ половину глаза (`Modifier.width(screenWidthPx.px)`) —
|
|
||||||
scale не читает вообще. Поэтому и «не сохраняется»: сохранённый scale в prefs есть,
|
|
||||||
но на не-видео экране он не виден.
|
|
||||||
|
|
||||||
**Фикс:** в `ConnectionScreen()` применять scale так же, как в GlassesMovieScreen:
|
Архитектура: звук играет ТОЛЬКО на телефоне (у очков нет динамиков), видео —
|
||||||
|
на очках, синк по позиции (очки — мастер, 5с-цикл). На телефоне НЕТ
|
||||||
|
foreground service и НЕТ wake/wifi lock — приложение живёт только в Activity
|
||||||
|
(`app-phone/src/main/AndroidManifest.xml`: только MainActivity, пермишенов
|
||||||
|
WAKE_LOCK нет). При выключении экрана HONOR (Android 16, агрессивная фоновая
|
||||||
|
политика) замораживает/убивает процесс:
|
||||||
|
|
||||||
|
1. WS-сервер (`GlassesServer`, :8080) умирает → очки теряют связь (ping 5с) →
|
||||||
|
автоматика обрыва на очках («связь потеряна — пауза») останавливает ВИДЕО.
|
||||||
|
2. `AudioSyncPlayer` (ExoPlayer) заморожен → ЗВУК встаёт.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Добавить в app-phone **foreground service** типа `mediaPlayback` + WAKE_LOCK +
|
||||||
|
WifiLock, чтобы процесс (WS-сервер + аудио-плеер) жил при выключенном экране
|
||||||
|
телефона. Очки тогда не теряют связь, видео не паузится, звук играет.
|
||||||
|
|
||||||
|
### 1. Манифест (`app-phone/src/main/AndroidManifest.xml`)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
```
|
||||||
|
Внутри `<application>` — сервис:
|
||||||
|
```xml
|
||||||
|
<service
|
||||||
|
android:name=".PlaybackService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Сервис `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PlaybackService.kt`
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
fun ConnectionScreen() {
|
class PlaybackService : Service() {
|
||||||
val connection = GlassesApp.instance.hostConnection
|
companion object {
|
||||||
val connected by connection.connected.collectAsState()
|
const val CHANNEL_ID = "viewmate_playback"
|
||||||
val status by connection.status.collectAsState()
|
const val NOTIFICATION_ID = 1
|
||||||
val messages by connection.messages.collectAsState()
|
// аккуратный запуск: ContextCompat.startForegroundService(context, intent)
|
||||||
val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ
|
fun start(context: Context) {
|
||||||
|
ContextCompat.startForegroundService(
|
||||||
Binocular(scale = false) {
|
context, Intent(context, PlaybackService::class.java)
|
||||||
val screenWidthPx = BinocularScreenSize.current.width
|
)
|
||||||
val screenHeightPx = BinocularScreenSize.current.height
|
}
|
||||||
// масштаб через layout — как в GlassesMovieScreen
|
fun stop(context: Context) {
|
||||||
val containerW = (screenWidthPx * scale).toInt()
|
context.stopService(Intent(context, PlaybackService::class.java))
|
||||||
val containerH = (screenHeightPx * scale).toInt()
|
|
||||||
val offsetX = (screenWidthPx * (1f - scale)).toInt() // сдвиг к носу (левый глаз)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(containerW.px)
|
|
||||||
.height(containerH.px)
|
|
||||||
.offset(x = offsetX.px)
|
|
||||||
.background(Color.Black),
|
|
||||||
) {
|
|
||||||
... существующий контент ...
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var wifiLock: WifiManager.WifiLock? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
// канал уведомлений (Android 8+)
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID, "ViewMate playback",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply { setShowBadge(false) }
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
|
.setContentTitle("ViewMate")
|
||||||
|
.setContentText("Звук на телефоне при просмотре на очках")
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
// держим CPU и WiFi живыми при выключенном экране
|
||||||
|
wakeLock = (getSystemService(POWER_SERVICE) as PowerManager)
|
||||||
|
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio")
|
||||||
|
?.apply { acquire() }
|
||||||
|
wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager)
|
||||||
|
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi")
|
||||||
|
?.apply { acquire() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
||||||
|
START_NOT_STICKY
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
wakeLock?.takeIf { it.isHeld }?.release()
|
||||||
|
wifiLock?.takeIf { it.isHeld }?.release()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
Импорты: androidx.core.app.NotificationCompat, androidx.core.content.ContextCompat.
|
||||||
|
Check: в gradle уже есть androidx.core (проверить libs.versions.toml — ktor/compose
|
||||||
|
тянут core-ktx, но для чистоты добавить `androidx.core:core-ktx` если нет).
|
||||||
|
|
||||||
Внутри контейнера всё остаётся: Column с текстами, StatusPanelCompose,
|
### 3. Запуск/остановка сервиса в `PhoneApp.kt`
|
||||||
оверлей распознавания — но всё это теперь в уменьшенном контейнере.
|
|
||||||
Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть.
|
|
||||||
|
|
||||||
⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри
|
- **Старт — при каждом старте аудио** (3 места):
|
||||||
`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер
|
- `autostartAudio()` (строка ~173, перед `audioSync.play(source, positionMs)`)
|
||||||
уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192.
|
- `DetailsScreen.onWatch` — там где вызывается `app.audioSync.play(source, positionMs)`
|
||||||
Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen
|
(строка ~127) и второй play (строка ~215)
|
||||||
(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в
|
- ОБЩИЙ подход: раз звук — сервис. Проще всего: стартовать в
|
||||||
GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent,
|
`PhoneApp.onCreate()` сразу (сервис лёгкий, уведомление висит пока
|
||||||
там есть `Side.LEFT/RIGHT` через CompositionLocal).
|
приложение живо) — НО только если пользователь не против постоянного
|
||||||
|
уведомления. РЕШЕНИЕ: старт в onCreate, стоп в onTerminate — сервис
|
||||||
|
живёт пока жив телефон-компаньон (WS-сервер тоже живёт всё время),
|
||||||
|
уведомление — постоянный индикатор «ViewMate работает». Это проще и
|
||||||
|
покрывает и watch, и autostartAudio, и STT-сессии.
|
||||||
|
- **Стоп**: в `onTerminate()` — `PlaybackService.stop(this)`.
|
||||||
|
|
||||||
## Баг 2: скролл каталога теряется при навигации
|
Решение принято: **старт в onCreate, стоп в onTerminate** (сервис = спутник
|
||||||
|
WS-сервера, а не аудио-сессии; уведомление постоянно, но это норма для
|
||||||
|
приложения-компаньона — так работает, например, в паре с очками).
|
||||||
|
|
||||||
**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад
|
### 4. Проверка
|
||||||
(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл
|
|
||||||
не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в
|
|
||||||
папку, потом ещё — и т.д.»
|
|
||||||
|
|
||||||
**Корень:** `app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt`:
|
1. Сборка: `./gradlew :app-phone:assembleDebug` — успех.
|
||||||
`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится.
|
2. Юнит-тесты: `./gradlew :app-phone:testDebugUnitTest` — зелёные.
|
||||||
`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит.
|
3. На железе (через 142): установить APK, запустить, начать просмотр,
|
||||||
|
`adb -s <phone> shell input keyevent 26` (экран телефона в сон) →
|
||||||
**Фикс:** добавить в `CatalogState`:
|
проверить: звук играет, видео на очках играет (позиция растёт в логах),
|
||||||
- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция
|
сервис жив: `adb shell dumpsys activity services | grep -A3 PlaybackService`.
|
||||||
- `private val savedScroll = mutableMapOf<Path, Pair<Int, Int>>()` — сохранённые позиции по пути
|
|
||||||
- в `openSeries`/`openSeason`/`openFolder`: **сначала** `savedScroll[path] = scrollIndex to scrollOffset`, **потом** менять path
|
|
||||||
- в `back()`: после смены path — восстановить `(scrollIndex, scrollOffset) = savedScroll[path] ?: (0,0)`
|
|
||||||
- методы `setScroll(index, offset)` (для snapshotFlow из UI) и `getSavedScroll(): Pair<Int,Int>` (текущий path)
|
|
||||||
|
|
||||||
В `CatalogScreen` composable:
|
|
||||||
```kotlin
|
|
||||||
val gridState = rememberLazyGridState()
|
|
||||||
// восстанавливаем позицию при смене пути/загрузке
|
|
||||||
LaunchedEffect(state.path, state.items) {
|
|
||||||
val (idx, off) = state.savedScrollForCurrentPath()
|
|
||||||
gridState.scrollToItem(idx, off)
|
|
||||||
}
|
|
||||||
// сохраняем позицию при скролле
|
|
||||||
LaunchedEffect(gridState) {
|
|
||||||
snapshotFlow {
|
|
||||||
gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset
|
|
||||||
}.collect { (idx, off) -> state.setScroll(idx, off) }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
и `LazyVerticalGrid(state = gridState, ...)`.
|
|
||||||
|
|
||||||
Ключ сохранения — РОДИТЕЛЬСКИЙ path (тот, откуда ушли). При back() path меняется
|
|
||||||
на родителя — достаём сохранённое. Вложенные папки работают автоматически:
|
|
||||||
каждый уровень сохраняет свою позицию.
|
|
||||||
|
|
||||||
Импорты: `androidx.compose.foundation.lazy.grid.rememberLazyGridState`,
|
|
||||||
`androidx.compose.runtime.snapshotFlow`.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — успех.
|
|
||||||
2. Юнит-тесты: `./gradlew :lib-core:testDebugUnitTest :app-phone:testDebugUnitTest :app-glasses:testDebugUnitTest` — зелёные.
|
|
||||||
3. Ручная проверка на очках: выставить масштаб 0.7 → ConnectionScreen (без видео) тоже уменьшен (чёрные поля по краям, картинка/текст сдвинуты к носу).
|
|
||||||
4. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок.
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
|
<!-- Bluetooth SPP-транспорт (включается флагом GlassesConfig.USE_BLUETOOTH):
|
||||||
|
Android 12/13 — runtime BLUETOOTH_CONNECT/SCAN; до 12 — опасные
|
||||||
|
BLUETOOTH/BLUETOOTH_ADMIN (запрашиваются на запуске). -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".GlassesApp"
|
android:name=".GlassesApp"
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package pw.binom.viewmate.glasses
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
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.withContext
|
||||||
|
import pw.binom.viewmate.core.log
|
||||||
|
import pw.binom.viewmate.core.net.BtTransportConstants
|
||||||
|
import pw.binom.viewmate.core.net.GlassesTransport
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bluetooth-транспорт, сторона очков («клиент») — TASK-transport.md п.3:
|
||||||
|
* RFCOMM/SPP-клиент ([BtTransportConstants.SPP_UUID]): [connect] — бесконечный
|
||||||
|
* цикл подключения с реконнектом через [BtTransportConstants.RETRY_DELAY_MS]
|
||||||
|
* (цикл живёт внутри транспорта), строки-сообщения (JSON + перевод строки —
|
||||||
|
* разделитель) читаются на Dispatchers.IO, запись — под синхронизацией на
|
||||||
|
* сокете. Адрес телефона берётся из [GlassesConfig.btRemoteMac].
|
||||||
|
* Включается флагом [GlassesConfig.USE_BLUETOOTH] (дефолт — WiFi).
|
||||||
|
*/
|
||||||
|
class BtGlassesTransport(
|
||||||
|
private val context: Context,
|
||||||
|
) : GlassesTransport {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Runtime-пермишены Bluetooth, зависящие от версии платформы
|
||||||
|
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
|
||||||
|
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
|
||||||
|
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
|
||||||
|
*/
|
||||||
|
fun runtimePermissions(): Array<String> =
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
|
||||||
|
} else {
|
||||||
|
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun hasBtPermissions(ctx: Context): Boolean =
|
||||||
|
runtimePermissions().all {
|
||||||
|
ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val name = "bluetooth"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var socket: BluetoothSocket? = null
|
||||||
|
|
||||||
|
private val outLock = Any()
|
||||||
|
private var out: OutputStream? = null
|
||||||
|
|
||||||
|
/** Бесконечный цикл подключения+реконнекта: в корутине вызывающего, до [close]. */
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override suspend fun connect(
|
||||||
|
onMessage: suspend (String) -> Unit,
|
||||||
|
onConnected: suspend () -> Unit,
|
||||||
|
onDisconnected: suspend () -> Unit,
|
||||||
|
) {
|
||||||
|
while (!closed) {
|
||||||
|
var connected = false
|
||||||
|
if (socket == null) {
|
||||||
|
if (!ensureSocket()) {
|
||||||
|
log("bt", "SPP не создан (радио/пермишен/адрес?) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||||
|
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log("bt", "SPP-клиент готов: ${GlassesConfig.btRemoteMac()}")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val sock = socket!!
|
||||||
|
withContext(Dispatchers.IO) { sock.connect() }
|
||||||
|
connected = true
|
||||||
|
out = sock.outputStream
|
||||||
|
log("bt", "SPP-канал открыт: ${GlassesConfig.btRemoteMac()}")
|
||||||
|
onConnected()
|
||||||
|
// readLines: EOF (партнёр закрыл) или исключение (обрыв/close)
|
||||||
|
readLines(sock, onMessage)
|
||||||
|
if (!closed) {
|
||||||
|
log("bt", "SPP-соединение оборвано")
|
||||||
|
onDisconnected()
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("bt", "SPP-ошибка (${e::class.simpleName}: ${e.message}) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||||
|
if (!closed && connected) runCatching { onDisconnected() }
|
||||||
|
}
|
||||||
|
runCatching { out?.close() }
|
||||||
|
out = null
|
||||||
|
runCatching { socket?.close() }
|
||||||
|
socket = null
|
||||||
|
if (closed) break
|
||||||
|
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFCOMM/SPP-сокет к [GlassesConfig.btRemoteMac]; без радио/пермишена/адреса — false. */
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun ensureSocket(): Boolean {
|
||||||
|
val mac = GlassesConfig.btRemoteMac()
|
||||||
|
if (mac.isNullOrBlank()) {
|
||||||
|
log("bt", "адрес телефона не задан (GlassesConfig.BT_REMOTE_MAC) — SPP недоступен")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val adapter = BluetoothAdapter.getDefaultAdapter() ?: run {
|
||||||
|
log("bt", "BT-радио не найдено — SPP недоступен")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!adapter.isEnabled) {
|
||||||
|
log("bt", "Bluetooth выключен — включите в настройках Android")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!hasBtPermissions(context)) {
|
||||||
|
log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val dev = try {
|
||||||
|
adapter.getRemoteDevice(mac)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("bt", "устройство не найдено: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (dev == null) return false
|
||||||
|
val s = try {
|
||||||
|
dev.createRfcommSocketToServiceRecord(UUID.fromString(BtTransportConstants.SPP_UUID))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("bt", "не удалось создать SPP-сокет: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (s == null) return false
|
||||||
|
socket = s
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */
|
||||||
|
private suspend fun readLines(s: BluetoothSocket, onMessage: suspend (String) -> Unit) {
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.inputStream))
|
||||||
|
while (!closed) {
|
||||||
|
val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break
|
||||||
|
if (line.isNotBlank()) onMessage(line)
|
||||||
|
}
|
||||||
|
runCatching { reader.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON-строка в сокет: UTF-8 + разделитель, под синхронизацией. Без соединения — no-op. */
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override suspend fun send(json: String) {
|
||||||
|
val o = out ?: return
|
||||||
|
runCatching {
|
||||||
|
synchronized(outLock) {
|
||||||
|
o.write(json.toByteArray(Charsets.UTF_8))
|
||||||
|
o.write(BtTransportConstants.NEWLINE.code)
|
||||||
|
o.flush()
|
||||||
|
}
|
||||||
|
}.onFailure { log("bt", "запись не удалась: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
closed = true
|
||||||
|
try {
|
||||||
|
socket?.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,19 @@ object GlassesConfig {
|
|||||||
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
|
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
|
||||||
// находится авто-обнаружением по mDNS (_viewmate._tcp).
|
// находится авто-обнаружением по mDNS (_viewmate._tcp).
|
||||||
const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses"
|
const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор транспорта (TASK-transport.md п.4): `false` (дефолт) — WiFi
|
||||||
|
* ([WifiGlassesTransport], поведение без изменений); `true` — Bluetooth SPP
|
||||||
|
* ([BtGlassesTransport], manual-включение).
|
||||||
|
*/
|
||||||
|
const val USE_BLUETOOTH = true
|
||||||
|
|
||||||
|
/** MAC-адрес телефона-партнёра для SPP (нужен только при USE_BLUETOOTH=true). */
|
||||||
|
val BT_REMOTE_MAC: String? = "08:84:FB:D5:BD:3E"
|
||||||
|
|
||||||
|
/** Адрес SPP-партнёра (null → SPP недоступен, транспорт ляжет с ретраями). */
|
||||||
|
fun btRemoteMac(): String? = BT_REMOTE_MAC
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import pw.binom.viewmate.core.MediaCommand
|
import pw.binom.viewmate.core.MediaCommand
|
||||||
import pw.binom.viewmate.core.net.GlassesWsClient
|
import pw.binom.viewmate.core.net.GlassesTransport
|
||||||
|
import pw.binom.viewmate.core.net.WifiGlassesTransport
|
||||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
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.DeleteFiles
|
import pw.binom.viewmate.core.protocol.DeleteFiles
|
||||||
@@ -37,13 +40,15 @@ import pw.binom.viewmate.core.protocol.SttCancel
|
|||||||
import pw.binom.viewmate.core.protocol.SttDone
|
import pw.binom.viewmate.core.protocol.SttDone
|
||||||
import pw.binom.viewmate.core.protocol.SttPhrase
|
import pw.binom.viewmate.core.protocol.SttPhrase
|
||||||
import pw.binom.viewmate.core.protocol.Welcome
|
import pw.binom.viewmate.core.protocol.Welcome
|
||||||
|
import pw.binom.viewmate.core.protocol.protocolJson
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обвязка над [GlassesWsClient] для UI очков:
|
* Обвязка над [GlassesTransport] для UI очков (TASK-transport.md п.1/п.4):
|
||||||
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект,
|
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес, остаётся на
|
||||||
* Hello при подключении, периодический статус/позиция,
|
* WiFi-стороне — [WifiGlassesTransport]), реконнект, Hello при подключении,
|
||||||
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
|
* периодический статус/позиция, обработка входящих HostToGlasses (строкой из
|
||||||
* DownloadFiles/DeleteFiles — менеджером скачивания).
|
* транспорта; декод — здесь, не в транспорте). Дефолтный транспорт — WiFi;
|
||||||
|
* Bluetooth SPP ([BtGlassesTransport]) — флагом [GlassesConfig.USE_BLUETOOTH].
|
||||||
*/
|
*/
|
||||||
class HostConnection(
|
class HostConnection(
|
||||||
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
|
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
|
||||||
@@ -56,11 +61,14 @@ class HostConnection(
|
|||||||
private val discovery = MdnsClient(context)
|
private val discovery = MdnsClient(context)
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
/** Активный WS-клиент (создаётся после выбора адреса mDNS/fallback). */
|
/** Транспортом включается выбор [GlassesConfig.USE_BLUETOOTH]: дефолт — WiFi. */
|
||||||
@Volatile
|
private val useBt: Boolean = GlassesConfig.USE_BLUETOOTH
|
||||||
private var client: GlassesWsClient? = null
|
|
||||||
|
|
||||||
/** Последний найденный адрес — реконнект переиспользует его (без нового mDNS-цикла). */
|
/** Активный транспорт (создаётся после выбора адреса: mDNS/fallback или SPP-MAC). */
|
||||||
|
@Volatile
|
||||||
|
private var transport: GlassesTransport? = null
|
||||||
|
|
||||||
|
/** Последний найденный адрес — реконнект переиспользует его (WiFi: URL, BT: MAC). */
|
||||||
@Volatile
|
@Volatile
|
||||||
private var lastUrl: String? = null
|
private var lastUrl: String? = null
|
||||||
|
|
||||||
@@ -81,10 +89,10 @@ class HostConnection(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(5_000)
|
delay(5_000)
|
||||||
client?.send(buildStatus())
|
sendToHost(buildStatus())
|
||||||
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||||
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
|
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
|
||||||
client?.send(
|
sendToHost(
|
||||||
PlaybackPosition(
|
PlaybackPosition(
|
||||||
positionMs = snapshot.positionMs,
|
positionMs = snapshot.positionMs,
|
||||||
playing = snapshot.playing,
|
playing = snapshot.playing,
|
||||||
@@ -96,36 +104,80 @@ class HostConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Развилка по [useBt]: WiFi (mDNS/scan/fallback) или SPP (адрес — [GlassesConfig.btRemoteMac]). */
|
||||||
|
private suspend fun connectLoop() {
|
||||||
|
if (useBt) connectLoopBt() else connectLoopWifi()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Первое подключение: mDNS-дискавери телефона (до 15с на устройстве, 3с на
|
* Первое подключение (WiFi): mDNS-дискавери телефона (до 15с на устройстве,
|
||||||
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
* 3с на эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
||||||
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
|
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
|
||||||
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
|
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
|
||||||
* Внутри [GlassesWsClient.connect] живёт бесконечный реконнект с тем же
|
* Внутри [GlassesTransport.connect] живёт бесконечный реконнект с тем же
|
||||||
* адресом (переиспользование последнего найденного); при его отсутствии —
|
* адресом (переиспользование последнего найденного); при его отсутствии —
|
||||||
* повторный mDNS-цикл.
|
* повторный mDNS-цикл.
|
||||||
*/
|
*/
|
||||||
private suspend fun connectLoop() {
|
private suspend fun connectLoopWifi() {
|
||||||
val url = lastUrl ?: discoverPhoneUrl()
|
val url = lastUrl ?: discoverPhoneUrl()
|
||||||
if (url.isEmpty()) {
|
if (url.isEmpty()) {
|
||||||
log("app", "телефон не найден — повтор поиска через 3с")
|
log("app", "телефон не найден — повтор поиска через 3с")
|
||||||
delay(3_000)
|
delay(3_000)
|
||||||
connectLoop()
|
connectLoopWifi()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastUrl = url
|
lastUrl = url
|
||||||
log("app", "подключаюсь к $url")
|
log("app", "подключаюсь к $url (WiFi)")
|
||||||
|
|
||||||
val c = GlassesWsClient(url)
|
val t = WifiGlassesTransport(url)
|
||||||
client = c
|
transport = t
|
||||||
c.connect(
|
t.connect(
|
||||||
onHostMessage = { msg -> handleHostMessage(msg) },
|
onMessage = { text -> handleHostMessageText(text) },
|
||||||
onConnected = {
|
onConnected = {
|
||||||
_connected.value = true
|
_connected.value = true
|
||||||
_status.value = "подключено: $url"
|
_status.value = "подключено: ${t.name} $url"
|
||||||
addMessage("соединение установлено")
|
addMessage("соединение установлено")
|
||||||
discovery.stop()
|
discovery.stop()
|
||||||
c.send(Hello(appVersion = APP_VERSION))
|
sendToHost(Hello(appVersion = APP_VERSION))
|
||||||
|
if (reconnectResume.consume()) {
|
||||||
|
withContext(Dispatchers.Main) { movieController.resume() }
|
||||||
|
addMessage("связь восстановлена — продолжаю")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDisconnected = {
|
||||||
|
_connected.value = false
|
||||||
|
_status.value = "нет соединения (реконнект через 3с)"
|
||||||
|
val wasPlaying = withContext(Dispatchers.Main) { movieController.pauseIfPlaying() }
|
||||||
|
reconnectResume.onConnectionLost(wasPlaying)
|
||||||
|
if (wasPlaying) addMessage("связь потеряна — пауза")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPP-цикл (BT): адрес телефона — [GlassesConfig.btRemoteMac] (введено
|
||||||
|
* вручную), реконнект-цикл живёт внутри [BtGlassesTransport.connect].
|
||||||
|
*/
|
||||||
|
private suspend fun connectLoopBt() {
|
||||||
|
val mac = lastUrl ?: (GlassesConfig.btRemoteMac() ?: "")
|
||||||
|
if (mac.isEmpty()) {
|
||||||
|
log("app", "BT: MAC-адрес телефона не задан — SPP недоступен")
|
||||||
|
delay(3_000)
|
||||||
|
connectLoopBt()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastUrl = mac
|
||||||
|
log("app", "подключаюсь к $mac (Bluetooth SPP)")
|
||||||
|
|
||||||
|
val t = BtGlassesTransport(appContext)
|
||||||
|
transport = t
|
||||||
|
t.connect(
|
||||||
|
onMessage = { text -> handleHostMessageText(text) },
|
||||||
|
onConnected = {
|
||||||
|
_connected.value = true
|
||||||
|
_status.value = "подключено: bluetooth $mac"
|
||||||
|
addMessage("соединение установлено")
|
||||||
|
sendToHost(Hello(appVersion = APP_VERSION))
|
||||||
if (reconnectResume.consume()) {
|
if (reconnectResume.consume()) {
|
||||||
withContext(Dispatchers.Main) { movieController.resume() }
|
withContext(Dispatchers.Main) { movieController.resume() }
|
||||||
addMessage("связь восстановлена — продолжаю")
|
addMessage("связь восстановлена — продолжаю")
|
||||||
@@ -145,7 +197,7 @@ class HostConnection(
|
|||||||
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
|
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
|
||||||
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
|
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
|
||||||
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
|
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
|
||||||
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoop]).
|
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoopWifi]).
|
||||||
*/
|
*/
|
||||||
private suspend fun discoverPhoneUrl(): String {
|
private suspend fun discoverPhoneUrl(): String {
|
||||||
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
|
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
|
||||||
@@ -171,18 +223,29 @@ class HostConnection(
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Входящая JSON-строка из транспорта: декод конверта [HostToGlasses] здесь (не в транспорте). */
|
||||||
|
private suspend fun handleHostMessageText(text: String) {
|
||||||
|
val msg = runCatching {
|
||||||
|
protocolJson.decodeFromString(HostToGlasses.serializer(), text)
|
||||||
|
}.onFailure {
|
||||||
|
log("glasses", "ОШИБКА декодирования входящего: ${it::class.simpleName}: ${it.message} (текст: ${text.take(120)})")
|
||||||
|
}.getOrNull() ?: return
|
||||||
|
handleHostMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
/** Отправка сообщения на телефон (используется менеджером скачивания). */
|
/** Отправка сообщения на телефон (используется менеджером скачивания). */
|
||||||
suspend fun sendToHost(msg: GlassesToHost) {
|
suspend fun sendToHost(msg: GlassesToHost) {
|
||||||
client?.send(msg)
|
val t = transport ?: return
|
||||||
|
t.send(protocolJson.encodeToString(GlassesToHost.serializer(), msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
|
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
|
||||||
fun sendPlaybackPositionNow() {
|
fun sendPlaybackPositionNow() {
|
||||||
val c = client ?: return
|
if (transport == null) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val (positionMs, playing, itemId, audioIndex) =
|
val (positionMs, playing, itemId, audioIndex) =
|
||||||
withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||||
c.send(PlaybackPosition(positionMs, playing, itemId, audioIndex))
|
sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +369,7 @@ class HostConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
client?.close()
|
transport?.close()
|
||||||
discovery.stop()
|
discovery.stop()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -116,6 +117,7 @@ class MainActivity : BaseEventActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
GlassesApp.instance.mainActivity = this
|
GlassesApp.instance.mainActivity = this
|
||||||
|
requestBtPermissions()
|
||||||
collectTempleActions()
|
collectTempleActions()
|
||||||
disableTrackpadSounds()
|
disableTrackpadSounds()
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
@@ -193,6 +195,16 @@ class MainActivity : BaseEventActivity() {
|
|||||||
* голосовой ввод (безусловный вызов), Click во время прослушивания —
|
* голосовой ввод (безусловный вызов), Click во время прослушивания —
|
||||||
* завершить распознавание, DoubleClick — отмена.
|
* завершить распознавание, DoubleClick — отмена.
|
||||||
*/
|
*/
|
||||||
|
private fun requestBtPermissions() {
|
||||||
|
if (!GlassesConfig.USE_BLUETOOTH) return
|
||||||
|
val granted = BtGlassesTransport.runtimePermissions().all {
|
||||||
|
checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (granted) return
|
||||||
|
requestPermissions(BtGlassesTransport.runtimePermissions(), 1008)
|
||||||
|
log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}")
|
||||||
|
}
|
||||||
|
|
||||||
private fun collectTempleActions() {
|
private fun collectTempleActions() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
|
|||||||
@@ -7,7 +7,18 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
|
<!-- Bluetooth SPP-транспорт (включается флагом PhoneConfig.USE_BLUETOOTH):
|
||||||
|
Android 12/13 — runtime BLUETOOTH_CONNECT/SCAN; до 12 — опасные
|
||||||
|
BLUETOOTH/BLUETOOTH_ADMIN (запрашиваются на запуске). -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".PhoneApp"
|
android:name=".PhoneApp"
|
||||||
@@ -24,5 +35,10 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".PlaybackService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothServerSocket
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import pw.binom.viewmate.core.log
|
||||||
|
import pw.binom.viewmate.core.net.BtTransportConstants
|
||||||
|
import pw.binom.viewmate.core.net.GlassesServerTransport
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bluetooth-транспорт, сторона телефона («сервер») — TASK-transport.md п.3:
|
||||||
|
* RFCOMM/SPP ([BtTransportConstants.SPP_UUID]), сообщения — строки
|
||||||
|
* (JSON + перевод строки как разделитель), read-поток — на Dispatchers.IO,
|
||||||
|
* запись — под синхронизацией на сокете. [connId] = MAC-адрес очков.
|
||||||
|
* Работает флагом [pw.binom.viewmate.phone.PhoneConfig.USE_BLUETOOTH]
|
||||||
|
* (дефолт — [WifiServerTransport]).
|
||||||
|
* @param context — для runtime-проверки BT-пермишенов (Android 12+ / до 12).
|
||||||
|
*/
|
||||||
|
class BtServerTransport(
|
||||||
|
private val context: Context? = null,
|
||||||
|
) : GlassesServerTransport {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Runtime-пермишены Bluetooth, зависящие от версии платформы
|
||||||
|
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
|
||||||
|
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
|
||||||
|
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
|
||||||
|
*/
|
||||||
|
fun runtimePermissions(): Array<String> =
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
|
||||||
|
} else {
|
||||||
|
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasBtPermissions(ctx: Context): Boolean =
|
||||||
|
runtimePermissions().all {
|
||||||
|
ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val name = "bluetooth"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var closed = false
|
||||||
|
@Volatile
|
||||||
|
private var serverSocket: BluetoothServerSocket? = null
|
||||||
|
|
||||||
|
private val sockets = ConcurrentHashMap<String, BluetoothSocket>()
|
||||||
|
private val writers = ConcurrentHashMap<String, OutputStream>()
|
||||||
|
private val readerJobs = ConcurrentHashMap<String, kotlinx.coroutines.Job>()
|
||||||
|
private val readerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
/** Бесконечно принимать SPP-соединения очков; без BT/пермишена — ретраи через 3с. */
|
||||||
|
override suspend fun accept(
|
||||||
|
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||||
|
onConnected: (connId: String) -> Unit,
|
||||||
|
onDisconnected: (connId: String) -> Unit,
|
||||||
|
) {
|
||||||
|
while (!closed) {
|
||||||
|
if (serverSocket == null) {
|
||||||
|
serverSocket = openServerSocket()
|
||||||
|
if (serverSocket == null) {
|
||||||
|
log("bt", "SPP-сервер не создан (BT выключен? нет пермишена?), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||||
|
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log("bt", "SPP-сервер запущен: ${BtTransportConstants.SERVICE_NAME} (${BtTransportConstants.SPP_UUID})")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val s = withContext(Dispatchers.IO) { serverSocket!!.accept() } ?: break
|
||||||
|
if (closed) {
|
||||||
|
runCatching { s.close() }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val remote: BluetoothDevice = s.remoteDevice
|
||||||
|
val connId = remote.address
|
||||||
|
sockets[connId] = s
|
||||||
|
writers[connId] = s.outputStream
|
||||||
|
log("bt", "очки подключились: $connId")
|
||||||
|
onConnected(connId)
|
||||||
|
readerJobs[connId] = readerScope.launch {
|
||||||
|
readLines(s, connId, onMessage, onDisconnected)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("bt", "accept прерван (${e::class.simpleName}: ${e.message}), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||||
|
runCatching { serverSocket?.close() }
|
||||||
|
serverSocket = null
|
||||||
|
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanupConnections()
|
||||||
|
readerScope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SPP-сервер (RFCOMM-канал SPP): без BT-радио/пермишена — null (лечь и ретраиться). */
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun openServerSocket(): BluetoothServerSocket? {
|
||||||
|
val adapter = BluetoothAdapter.getDefaultAdapter() ?: run {
|
||||||
|
log("bt", "BT-радио не найдено — SPP недоступен")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!adapter.isEnabled) {
|
||||||
|
log("bt", "Bluetooth выключен — включите в настройках Android")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val ctx = context
|
||||||
|
if (ctx != null && !hasBtPermissions(ctx)) {
|
||||||
|
log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return runCatching {
|
||||||
|
adapter.listenUsingRfcommWithServiceRecord(
|
||||||
|
BtTransportConstants.SERVICE_NAME,
|
||||||
|
UUID.fromString(BtTransportConstants.SPP_UUID),
|
||||||
|
)
|
||||||
|
}.onFailure { log("bt", "не удалось создать SPP-сервер: ${it.message}") }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */
|
||||||
|
private suspend fun readLines(
|
||||||
|
s: BluetoothSocket,
|
||||||
|
connId: String,
|
||||||
|
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||||
|
onDisconnected: (connId: String) -> Unit,
|
||||||
|
) {
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.inputStream))
|
||||||
|
try {
|
||||||
|
while (!closed) {
|
||||||
|
val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break
|
||||||
|
if (line.isNotBlank()) onMessage(connId, line)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("bt", "read-поток $connId оборван: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
runCatching { reader.close() }
|
||||||
|
runCatching { s.close() }
|
||||||
|
sockets.remove(connId)
|
||||||
|
writers.remove(connId)
|
||||||
|
readerJobs.remove(connId)
|
||||||
|
}
|
||||||
|
if (!closed) {
|
||||||
|
log("bt", "очки отключились: $connId")
|
||||||
|
onDisconnected(connId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON-строка в сокет [connId]: UTF-8 + разделитель, под синхронизацией на сокете. */
|
||||||
|
override suspend fun send(connId: String, json: String) {
|
||||||
|
val out = writers[connId] ?: return
|
||||||
|
runCatching {
|
||||||
|
synchronized(out) {
|
||||||
|
out.write(json.toByteArray(Charsets.UTF_8))
|
||||||
|
out.write(BtTransportConstants.NEWLINE.code)
|
||||||
|
out.flush()
|
||||||
|
}
|
||||||
|
}.onFailure { log("bt", "запись на $connId не удалась: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
closed = true
|
||||||
|
runCatching { serverSocket?.close() }
|
||||||
|
serverSocket = null
|
||||||
|
cleanupConnections()
|
||||||
|
readerScope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupConnections() {
|
||||||
|
readerJobs.values.forEach { it.cancel() }
|
||||||
|
readerJobs.clear()
|
||||||
|
sockets.values.forEach { runCatching { it.close() } }
|
||||||
|
sockets.clear()
|
||||||
|
writers.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,18 @@
|
|||||||
package pw.binom.viewmate.phone
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
import io.ktor.server.application.Application
|
import android.content.Context
|
||||||
import io.ktor.server.application.install
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import io.ktor.server.cio.CIO
|
import kotlinx.coroutines.Dispatchers
|
||||||
import io.ktor.server.engine.EmbeddedServer
|
import kotlinx.coroutines.Job
|
||||||
import io.ktor.server.engine.embeddedServer
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import io.ktor.server.routing.routing
|
import kotlinx.coroutines.cancel
|
||||||
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import pw.binom.viewmate.core.GlassesMode
|
import pw.binom.viewmate.core.net.GlassesServerTransport
|
||||||
import pw.binom.viewmate.core.phone.GlassesSender
|
import pw.binom.viewmate.core.phone.GlassesSender
|
||||||
import pw.binom.viewmate.core.phone.PhoneState
|
import pw.binom.viewmate.core.phone.PhoneState
|
||||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||||
@@ -41,14 +36,18 @@ const val GLASSES_WS_PATH = "/ws/glasses"
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
|
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
|
||||||
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
|
* Сессии адресуются **connId-строками** (UUID — WiFi, MAC-адрес — Bluetooth),
|
||||||
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза,
|
* не WS-объектами (TASK-transport.md п.1): WS-мост — в [WifiServerTransport],
|
||||||
* DownloadProgress → карта скачивания на очки).
|
* SPP-сокеты — в [BtServerTransport]. Исходящие — JSON-строкой через
|
||||||
|
* [GlassesServerTransport.send] (декод протокола — в хабe, не в транспорте).
|
||||||
*/
|
*/
|
||||||
class GlassesHub(
|
class GlassesHub(
|
||||||
private val state: PhoneState,
|
private val state: PhoneState,
|
||||||
|
/** Выбранный транспорт (дефолт — [WifiServerTransport]). */
|
||||||
|
private val transport: GlassesServerTransport,
|
||||||
) {
|
) {
|
||||||
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>()
|
/** Сессии: connId → Unit. */
|
||||||
|
private val sessions = ConcurrentHashMap<String, Unit>()
|
||||||
|
|
||||||
private val _connected = MutableStateFlow(0)
|
private val _connected = MutableStateFlow(0)
|
||||||
val connected: StateFlow<Int> = _connected.asStateFlow()
|
val connected: StateFlow<Int> = _connected.asStateFlow()
|
||||||
@@ -110,32 +109,29 @@ class GlassesHub(
|
|||||||
@Volatile
|
@Volatile
|
||||||
var sttFactory: (() -> SttStreamer?)? = null
|
var sttFactory: (() -> SttStreamer?)? = null
|
||||||
|
|
||||||
fun add(session: DefaultWebSocketServerSession) {
|
fun add(connId: String) {
|
||||||
sessions[session] = Unit
|
sessions[connId] = Unit
|
||||||
_connected.value = sessions.size
|
_connected.value = sessions.size
|
||||||
log("hub", "очки подключились, всего: ${sessions.size}")
|
log("hub", "очки подключились (connId $connId, всего: ${sessions.size})")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(session: DefaultWebSocketServerSession) {
|
fun remove(connId: String) {
|
||||||
sessions.remove(session)
|
sessions.remove(connId)
|
||||||
_connected.value = sessions.size
|
_connected.value = sessions.size
|
||||||
log("hub", "очки отключились, всего: ${sessions.size}")
|
log("hub", "очки отключились (connId $connId, всего: ${sessions.size})")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Отправить сообщение всем подключённым очкам. */
|
/** Отправить сообщение всем подключённым очкам (JSON-строкой через [transport]). */
|
||||||
suspend fun broadcast(msg: HostToGlasses) {
|
suspend fun broadcast(msg: HostToGlasses) {
|
||||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
for (session in sessions.keys) {
|
for (connId in sessions.keys) {
|
||||||
try {
|
runCatching { transport.send(connId, text) }
|
||||||
session.send(text)
|
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
|
||||||
} catch (e: Exception) {
|
|
||||||
log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обработать входящее текстовое сообщение от очков. */
|
/** Обработать входящее текстовое сообщение от очков (JSON-строкой из [transport]). */
|
||||||
suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
|
suspend fun handle(connId: String, text: String) {
|
||||||
val msg = try {
|
val msg = try {
|
||||||
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
|
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -144,8 +140,8 @@ class GlassesHub(
|
|||||||
}
|
}
|
||||||
when (msg) {
|
when (msg) {
|
||||||
is Hello -> {
|
is Hello -> {
|
||||||
log("glasses", "hello (app=${msg.appVersion}) → welcome")
|
log("glasses", "hello (app=${msg.appVersion}, connId $connId) → welcome")
|
||||||
send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId))
|
send(connId, Welcome(mode = state.mode, sessionId = state.activeSessionId))
|
||||||
}
|
}
|
||||||
|
|
||||||
is Gesture -> {
|
is Gesture -> {
|
||||||
@@ -199,80 +195,67 @@ class GlassesHub(
|
|||||||
is StopStt -> {
|
is StopStt -> {
|
||||||
if (msg.cancel) {
|
if (msg.cancel) {
|
||||||
log("stt", "распознавание отменено (очки)")
|
log("stt", "распознавание отменено (очки)")
|
||||||
send(session, SttCancel())
|
send(connId, SttCancel())
|
||||||
stt?.reset()
|
stt?.reset()
|
||||||
} else {
|
} else {
|
||||||
val full = stt?.finish() ?: ""
|
val full = stt?.finish() ?: ""
|
||||||
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
||||||
send(session, SttDone(full))
|
send(connId, SttDone(full))
|
||||||
stt?.reset()
|
stt?.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) {
|
private suspend fun send(connId: String, msg: HostToGlasses) {
|
||||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
session.send(text)
|
runCatching { transport.send(connId, text) }
|
||||||
}
|
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
|
||||||
}
|
|
||||||
|
|
||||||
/** Ktor-модуль телефона: WebSocket-роут для подключения очков. */
|
|
||||||
fun Application.glassesServerModule(hub: GlassesHub) {
|
|
||||||
install(WebSockets) {
|
|
||||||
pingPeriodMillis = 5_000
|
|
||||||
timeoutMillis = 5_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}")
|
|
||||||
e.printStackTrace()
|
|
||||||
} finally {
|
|
||||||
hub.remove(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WS-сервер телефона (CIO-движок — работает на Android, в отличие от Netty).
|
* Сервер связи очков (запускается в PhoneApp). [transport] выбирается флагом
|
||||||
* Живёт в приложении (Application), слушает 0.0.0.0:[port]/ws/glasses.
|
* [useBluetooth] (TASK-transport.md п.4): дефолт — [WifiServerTransport]
|
||||||
|
* (Ktor CIO, `0.0.0.0:$port/ws/glasses`), Bluetooth — [BtServerTransport]
|
||||||
|
* (SPP). Поведение по умолчанию (WiFi) не меняется.
|
||||||
*/
|
*/
|
||||||
class GlassesServer(
|
class GlassesServer(
|
||||||
private val port: Int = PhoneConfig.SERVER_PORT,
|
private val port: Int = PhoneConfig.SERVER_PORT,
|
||||||
private val state: PhoneState = PhoneState(),
|
private val state: PhoneState = PhoneState(),
|
||||||
|
/** `false` (дефолт) — WiFi; `true` — Bluetooth SPP. */
|
||||||
|
useBluetooth: Boolean = false,
|
||||||
|
/** Context для runtime-проверки BT-пермишенов (нужен только в [BtServerTransport]). */
|
||||||
|
private val btContext: Context? = null,
|
||||||
) {
|
) {
|
||||||
val hub = GlassesHub(state)
|
/** Активный транспорт: [WifiServerTransport] (дефолт) или [BtServerTransport]. */
|
||||||
|
val transport: GlassesServerTransport =
|
||||||
|
if (useBluetooth) BtServerTransport(btContext) else WifiServerTransport(port)
|
||||||
|
|
||||||
|
val hub = GlassesHub(state, transport)
|
||||||
|
|
||||||
/** GlassesSender-адаптер поверх хаба (для PhoneActions). */
|
/** GlassesSender-адаптер поверх хаба (для PhoneActions). */
|
||||||
val sender: GlassesSender = object : GlassesSender {
|
val sender: GlassesSender = object : GlassesSender {
|
||||||
override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg)
|
override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var server: EmbeddedServer<*, *>? = null
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var acceptJob: Job? = null
|
||||||
|
|
||||||
|
/** Запустить приём подключений очков ([transport.accept] — бесконечный цикл). */
|
||||||
fun start() {
|
fun start() {
|
||||||
if (server != null) return
|
if (acceptJob != null) return
|
||||||
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
|
acceptJob = scope.launch {
|
||||||
glassesServerModule(hub)
|
transport.accept(
|
||||||
|
onMessage = { connId, json -> hub.handle(connId, json) },
|
||||||
|
onConnected = { connId -> hub.add(connId) },
|
||||||
|
onDisconnected = { connId -> hub.remove(connId) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
server!!.start(wait = false)
|
|
||||||
log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
server?.stop(1_000, 3_000)
|
transport.close()
|
||||||
server = null
|
scope.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package pw.binom.viewmate.phone
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
@@ -7,6 +9,7 @@ import android.widget.FrameLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import pw.binom.viewmate.phone.stt.SttDebug
|
import pw.binom.viewmate.phone.stt.SttDebug
|
||||||
import pw.binom.viewmate.phone.agent.LlmDebug
|
import pw.binom.viewmate.phone.agent.LlmDebug
|
||||||
import pw.binom.viewmate.phone.ui.ViewMateApp
|
import pw.binom.viewmate.phone.ui.ViewMateApp
|
||||||
@@ -15,6 +18,7 @@ import java.io.File
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
requestBtPermissions()
|
||||||
setContent {
|
setContent {
|
||||||
ViewMateApp()
|
ViewMateApp()
|
||||||
}
|
}
|
||||||
@@ -26,6 +30,23 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime-запрос Bluetooth-пермишенов (TASK-transport.md п.5): только при
|
||||||
|
* вкл. SPP ([PhoneConfig.USE_BLUETOOTH]). На 31+ — BLUETOOTH_CONNECT/SCAN,
|
||||||
|
* на ≤30 — BLUETOOTH/BLUETOOTH_ADMIN. Если уже выданы — молча пропускаем.
|
||||||
|
*/
|
||||||
|
private fun requestBtPermissions() {
|
||||||
|
if (!PhoneConfig.USE_BLUETOOTH) return
|
||||||
|
val needed = BtServerTransport.runtimePermissions()
|
||||||
|
val missing = needed.filter {
|
||||||
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (missing.isNotEmpty()) {
|
||||||
|
requestPermissions(missing.toTypedArray(), 1009)
|
||||||
|
log("bt", "запрошены Bluetooth-пермишены (SPP): ${missing.joinToString()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Smoke Koog-клиента: один вызов llm.binom.pw, ответ в лог [llm]. */
|
/** Smoke Koog-клиента: один вызов llm.binom.pw, ответ в лог [llm]. */
|
||||||
private fun runLlmDebug() {
|
private fun runLlmDebug() {
|
||||||
showOverlay("[llm] вызываю llm.binom.pw...")
|
showOverlay("[llm] вызываю llm.binom.pw...")
|
||||||
|
|||||||
@@ -39,8 +39,13 @@ class PhoneApp : Application() {
|
|||||||
|
|
||||||
val state: PhoneState = PhoneState()
|
val state: PhoneState = PhoneState()
|
||||||
|
|
||||||
/** WS-сервер для очков. */
|
/** Сервер связи очков: транспорт по [PhoneConfig.USE_BLUETOOTH] — дефолт WiFi (WS), Bluetooth SPP — флагом. */
|
||||||
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
|
val server: GlassesServer = GlassesServer(
|
||||||
|
port = PhoneConfig.SERVER_PORT,
|
||||||
|
state = state,
|
||||||
|
useBluetooth = PhoneConfig.USE_BLUETOOTH,
|
||||||
|
btContext = this,
|
||||||
|
)
|
||||||
|
|
||||||
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
|
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
|
||||||
val nsd: NsdPublisher by lazy { NsdPublisher(this) }
|
val nsd: NsdPublisher by lazy { NsdPublisher(this) }
|
||||||
@@ -103,13 +108,21 @@ class PhoneApp : Application() {
|
|||||||
*/
|
*/
|
||||||
private suspend fun assistantLoop() {
|
private suspend fun assistantLoop() {
|
||||||
for (phrase in assistantChannel) {
|
for (phrase in assistantChannel) {
|
||||||
|
log("assistant", "получена фраза: $phrase")
|
||||||
val assistant = ensureAssistant()
|
val assistant = ensureAssistant()
|
||||||
if (assistant == null) {
|
if (assistant == null) {
|
||||||
log("assistant", "LLM-ключ не задан — фраза пропущена")
|
log("assistant", "LLM-ключ не задан — фраза пропущена")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
||||||
val answer = runCatching { assistant.process(phrase) }
|
val answer = runCatching {
|
||||||
|
val active = _activeSessionId.value
|
||||||
|
if (active != null) {
|
||||||
|
assistant.processInSession(active, phrase)
|
||||||
|
} else {
|
||||||
|
assistant.process(phrase)
|
||||||
|
}
|
||||||
|
}
|
||||||
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
||||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||||
server.hub.broadcast(ShowText(answer))
|
server.hub.broadcast(ShowText(answer))
|
||||||
@@ -117,6 +130,36 @@ class PhoneApp : Application() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправка текста в ассистента из чата на телефоне (не с очков).
|
||||||
|
* Та же очередь, что и голосовые фразы — сериализация LLM сохраняется.
|
||||||
|
*/
|
||||||
|
fun sendToAssistant(text: String) {
|
||||||
|
scope.launch { assistantChannel.send(text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Активная сессия чата (для UI телефона). null — «последняя» (дефолт). */
|
||||||
|
private val _activeSessionId = MutableStateFlow<Long?>(null)
|
||||||
|
val activeSessionId: StateFlow<Long?> = _activeSessionId
|
||||||
|
|
||||||
|
/** Создать новую сессию и сделать её активной. */
|
||||||
|
fun newChatSession(): Long {
|
||||||
|
val id = chatDao.createSession()
|
||||||
|
_activeSessionId.value = id
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переключить активную сессию. null — вернуться к «последней». */
|
||||||
|
fun selectChatSession(id: Long?) {
|
||||||
|
_activeSessionId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сбросить (очистить сообщения) активной или последней сессии. */
|
||||||
|
fun resetChatSession() {
|
||||||
|
val id = _activeSessionId.value ?: chatDao.latestSessionId() ?: return
|
||||||
|
chatDao.clearSession(id)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
|
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
|
||||||
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
|
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
|
||||||
@@ -189,6 +232,9 @@ class PhoneApp : Application() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
|
|
||||||
|
PlaybackService.start(this)
|
||||||
|
log("server", "playback-сервис запущен (foreground, WAKE_LOCK)")
|
||||||
|
|
||||||
server.start()
|
server.start()
|
||||||
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
||||||
nsd.publish()
|
nsd.publish()
|
||||||
@@ -264,6 +310,7 @@ class PhoneApp : Application() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onTerminate() {
|
override fun onTerminate() {
|
||||||
|
PlaybackService.stop(this)
|
||||||
nsd.unpublish()
|
nsd.unpublish()
|
||||||
sttStreamer?.close()
|
sttStreamer?.close()
|
||||||
server.stop()
|
server.stop()
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ object PhoneConfig {
|
|||||||
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
|
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
|
||||||
const val SERVER_PORT = 8080
|
const val SERVER_PORT = 8080
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор транспорта очки↔телефон (TASK-transport.md п.4):
|
||||||
|
* `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений);
|
||||||
|
* `true` — Bluetooth SPP ([BtServerTransport], manual-включение).
|
||||||
|
*/
|
||||||
|
const val USE_BLUETOOTH = true
|
||||||
|
|
||||||
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
|
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
|
||||||
const val AUDIO_SILENCE_PAUSE_MS = 5_000L
|
const val AUDIO_SILENCE_PAUSE_MS = 5_000L
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service типа mediaPlayback: держит процесс телефона живым (WS-сервер +
|
||||||
|
* аудио-плеер) при выключенном экране. Без него HONOR замораживает/убивает процесс
|
||||||
|
* в фоне → очки теряют связь (видео паузится) и звук встаёт. Является спутником
|
||||||
|
* WS-сервера: стартует в PhoneApp.onCreate, останавливается в onTerminate.
|
||||||
|
*/
|
||||||
|
class PlaybackService : Service() {
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_ID = "viewmate_playback"
|
||||||
|
const val NOTIFICATION_ID = 1
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
ContextCompat.startForegroundService(
|
||||||
|
context, Intent(context, PlaybackService::class.java)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.stopService(Intent(context, PlaybackService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var wifiLock: WifiManager.WifiLock? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID, "ViewMate playback",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply { setShowBadge(false) }
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
|
.setContentTitle("ViewMate")
|
||||||
|
.setContentText("Звук на телефоне при просмотре на очках")
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
// держим CPU и WiFi живыми при выключенном экране
|
||||||
|
wakeLock = (getSystemService(PowerManager::class.java) as PowerManager)
|
||||||
|
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio")
|
||||||
|
?.apply { acquire() }
|
||||||
|
wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager)
|
||||||
|
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi")
|
||||||
|
?.apply { acquire() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
||||||
|
START_STICKY
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
wakeLock?.takeIf { it.isHeld }?.release()
|
||||||
|
wifiLock?.takeIf { it.isHeld }?.release()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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.delay
|
||||||
|
import pw.binom.viewmate.core.net.GlassesServerTransport
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WiFi-транспорт, сторона телефона («сервер») — TASK-transport.md п.2:
|
||||||
|
* [GlassesServerTransport] на Ktor-CIO-сервере (WebSocket [GLASSES_WS_PATH]).
|
||||||
|
* Сессии — [connId] (UUID-строки, не WS-объекты): мост между WS-сессией и
|
||||||
|
* connId живёт здесь, [GlassesHub] знает только строки.
|
||||||
|
*/
|
||||||
|
class WifiServerTransport(
|
||||||
|
private val port: Int = PhoneConfig.SERVER_PORT,
|
||||||
|
) : GlassesServerTransport {
|
||||||
|
|
||||||
|
override val name = "wifi"
|
||||||
|
|
||||||
|
private val sessionByConn = ConcurrentHashMap<String, DefaultWebSocketServerSession>()
|
||||||
|
@Volatile
|
||||||
|
private var server: EmbeddedServer<*, *>? = null
|
||||||
|
@Volatile
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
/** Бесконечно принимать очки: старт Ktor-сервера + холд (пока [close]). */
|
||||||
|
override suspend fun accept(
|
||||||
|
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||||
|
onConnected: (connId: String) -> Unit,
|
||||||
|
onDisconnected: (connId: String) -> Unit,
|
||||||
|
) {
|
||||||
|
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
|
||||||
|
glassesWsModule(onMessage, onConnected, onDisconnected)
|
||||||
|
}
|
||||||
|
server!!.start(wait = false)
|
||||||
|
log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
|
||||||
|
while (!closed) {
|
||||||
|
delay(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ktor-модуль: WS-роут очков; сессия адресуется connId (UUID), события — колбэки [accept]. */
|
||||||
|
private fun Application.glassesWsModule(
|
||||||
|
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||||
|
onConnected: (connId: String) -> Unit,
|
||||||
|
onDisconnected: (connId: String) -> Unit,
|
||||||
|
) {
|
||||||
|
install(WebSockets) {
|
||||||
|
pingPeriodMillis = 5_000
|
||||||
|
timeoutMillis = 5_000
|
||||||
|
maxFrameSize = Long.MAX_VALUE
|
||||||
|
masking = false
|
||||||
|
}
|
||||||
|
routing {
|
||||||
|
webSocket(GLASSES_WS_PATH) {
|
||||||
|
val connId = UUID.randomUUID().toString()
|
||||||
|
sessionByConn[connId] = this
|
||||||
|
onConnected(connId)
|
||||||
|
try {
|
||||||
|
for (frame in incoming) {
|
||||||
|
if (frame is Frame.Text) {
|
||||||
|
onMessage(connId, frame.readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("server", "WS-ошибка: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
sessionByConn.remove(connId)
|
||||||
|
onDisconnected(connId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(connId: String, json: String) {
|
||||||
|
sessionByConn[connId]?.send(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
closed = true
|
||||||
|
runCatching { server?.stop(1_000, 3_000) }
|
||||||
|
server = null
|
||||||
|
sessionByConn.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тул ассистента: локальная функция, которую агент (LLM) может вызвать по
|
||||||
|
* имени. Результат возвращается строкой и подставляется в историю как
|
||||||
|
* tool-сообщение (роль [ChatRole.TOOL]).
|
||||||
|
*
|
||||||
|
* Добавление нового тула: реализовать [AgentTool], добавить в список
|
||||||
|
* [AgentTools.ALL]. Агент узнаёт о тулах из системного промпта (описание
|
||||||
|
* [describe]) — никакой магии.
|
||||||
|
*/
|
||||||
|
interface AgentTool {
|
||||||
|
/** Имя тула (как агент его называет, например `get_current_time`). */
|
||||||
|
val name: String
|
||||||
|
|
||||||
|
/** Короткое описание для системного промпта (что делает, что возвращает). */
|
||||||
|
val description: String
|
||||||
|
|
||||||
|
/** Вызвать тул. [arguments] — JSON-строка (пустая, если аргументов нет). */
|
||||||
|
fun invoke(arguments: String): String
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Реестр тулов ассистента. */
|
||||||
|
object AgentTools {
|
||||||
|
val ALL: List<AgentTool> = listOf(TimeTool)
|
||||||
|
|
||||||
|
/** Описания для системного промпта: «Тул: name — description». */
|
||||||
|
fun describeAll(): String = ALL.joinToString("\n") { "Тул: ${it.name} — ${it.description}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тул времени: возвращает текущие дату, время и часовой пояс в ISO 8601
|
||||||
|
* (например `2026-08-22T03:14:05+03:00[Europe/Moscow]`). Локально на
|
||||||
|
* телефоне — LLM не нужна, время точное.
|
||||||
|
*/
|
||||||
|
object TimeTool : AgentTool {
|
||||||
|
override val name = "get_current_time"
|
||||||
|
override val description =
|
||||||
|
"Текущие дата и время с часовым поясом в формате ISO 8601. Аргументы не нужны (пустой JSON {})."
|
||||||
|
|
||||||
|
private val formatter: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")
|
||||||
|
|
||||||
|
override fun invoke(arguments: String): String {
|
||||||
|
val now = Instant.now()
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
return formatter.format(now.atZone(zone))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,31 +7,98 @@ import kotlinx.coroutines.withTimeoutOrNull
|
|||||||
* LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM,
|
* LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM,
|
||||||
* история накапливается в сессии. Синхронный вызов из одной корутины
|
* история накапливается в сессии. Синхронный вызов из одной корутины
|
||||||
* (сериализация фраз — LLM-вызовы не пересекаются).
|
* (сериализация фраз — LLM-вызовы не пересекаются).
|
||||||
|
*
|
||||||
|
* Тулы: агент видит их в системном промпте (см. [SYSTEM_PROMPT]); если ему
|
||||||
|
* нужна тула, он отвечает одной строкой `TOOL_CALL: <имя>` — ассистент
|
||||||
|
* выполняет тул и возвращает результат как tool-сообщение в истории,
|
||||||
|
* затем делает второй вызов LLM с результатом. Обычный текст (без маркера)
|
||||||
|
* возвращается пользователю как финальный ответ.
|
||||||
*/
|
*/
|
||||||
class Assistant(
|
class Assistant(
|
||||||
private val chatDao: ChatDao,
|
private val chatDao: ChatDao,
|
||||||
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
|
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
|
||||||
|
private val tools: List<AgentTool> = AgentTools.ALL,
|
||||||
private val timeoutMs: Long = 30_000,
|
private val timeoutMs: Long = 30_000,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Обработать фразу: сохранить user-сообщение, вызвать LLM с историей
|
* Обработать фразу в текущей (последней) сессии: сохранить user-сообщение,
|
||||||
* сессии (последние 20 сообщений), сохранить ответ. При таймауте LLM —
|
* вызвать LLM с историей сессии (последние 20 сообщений), при вызове тула —
|
||||||
* ответ с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
|
* выполнить и повторить вызов, сохранить ответ. При таймауте LLM — ответ
|
||||||
|
* с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
|
||||||
*/
|
*/
|
||||||
suspend fun process(text: String): String {
|
suspend fun process(text: String): String {
|
||||||
val sessionId = chatDao.latestSessionId() ?: chatDao.createSession("ассистент")
|
val sessionId = chatDao.latestSessionId() ?: chatDao.createSession(randomTitle())
|
||||||
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
|
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
|
||||||
|
return processInSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обработать фразу в КОНКРЕТНОЙ сессии [sessionId] (переключение сессий). */
|
||||||
|
suspend fun processInSession(sessionId: Long, text: String): String {
|
||||||
|
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
|
||||||
|
return processInSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun processInSession(sessionId: Long): String {
|
||||||
val history = chatDao.messagesForSession(sessionId).takeLast(20)
|
val history = chatDao.messagesForSession(sessionId).takeLast(20)
|
||||||
val answer = withTimeoutOrNull(timeoutMs) { chat(history, SYSTEM_PROMPT) }
|
var answer = callLlm(history, sessionId)
|
||||||
|
var lastToolResult: String? = null
|
||||||
|
// Цикл тулов: до 3 вызовов подряд (защита от зацикливания агента).
|
||||||
|
repeat(3) {
|
||||||
|
val toolName = parseToolCall(answer) ?: return answer
|
||||||
|
val tool = tools.firstOrNull { it.name == toolName }
|
||||||
|
if (tool == null) {
|
||||||
|
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
||||||
|
return "Тул $toolName не найден"
|
||||||
|
}
|
||||||
|
val result = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
|
||||||
|
lastToolResult = result
|
||||||
|
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result, toolCallId = toolName)
|
||||||
|
logTool(sessionId, toolName, result)
|
||||||
|
val withResult = chatDao.messagesForSession(sessionId).takeLast(20)
|
||||||
|
answer = callLlm(withResult, sessionId)
|
||||||
|
}
|
||||||
|
// LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ,
|
||||||
|
// чтобы пользователь получил данные, а не сырой маркер.
|
||||||
|
val toolName = parseToolCall(answer)
|
||||||
|
return if (toolName != null && lastToolResult != null) {
|
||||||
|
"TOOL_CALL $toolName (повтор) — $lastToolResult"
|
||||||
|
} else {
|
||||||
|
answer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String {
|
||||||
|
val answer = withTimeoutOrNull(timeoutMs) { chat(history, systemPrompt()) }
|
||||||
?: return "Ассистент не ответил (таймаут)"
|
?: return "Ассистент не ответил (таймаут)"
|
||||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
if (!isToolCall(answer)) {
|
||||||
chatDao.touchSession(sessionId)
|
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
||||||
|
chatDao.touchSession(sessionId)
|
||||||
|
}
|
||||||
return answer
|
return answer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Системный промпт: роль + описания тулов (если есть). */
|
||||||
|
private fun systemPrompt(): String {
|
||||||
|
val base = "Ты — ассистент. Делай то, что говорит пользователь. Отвечай кратко (1-3 предложения), по-русски."
|
||||||
|
if (tools.isEmpty()) return base
|
||||||
|
return "$base\n\nЕсли для ответа нужна тула, ответь ровно одной строкой TOOL_CALL: имя_тула (без кавычек, без текста вокруг). После получения результата тула ответь пользователю текстом.\n\n${AgentTools.describeAll()}"
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// TODO(персона): промпт выберет пользователь
|
/** Простой протокол вызова тула: строка вида `TOOL_CALL: <name>`. */
|
||||||
const val SYSTEM_PROMPT =
|
private val TOOL_CALL_RE = Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)""", RegexOption.IGNORE_CASE)
|
||||||
"Ты — локальный голосовой ассистент в AR-очках. Отвечай кратко (1-3 предложения), по-русски, без markdown и эмодзи. Рядом с тобой может идти фильм — вопросы могут быть про него."
|
|
||||||
|
private fun parseToolCall(answer: String): String? =
|
||||||
|
TOOL_CALL_RE.find(answer)?.groupValues?.get(1)?.lowercase()
|
||||||
|
|
||||||
|
private fun isToolCall(answer: String): Boolean = parseToolCall(answer) != null
|
||||||
|
|
||||||
|
/** Случайное имя сессии (UUID-подобное, 8 символов). */
|
||||||
|
private fun randomTitle(): String =
|
||||||
|
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
|
||||||
|
|
||||||
|
private fun logTool(sessionId: Long, toolName: String, result: String) {
|
||||||
|
pw.binom.viewmate.core.log("assistant", "тул $toolName (сессия $sessionId) → $result")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,25 @@ class ChatDao(private val db: ChatDb) {
|
|||||||
return cursor.use { c -> if (c.moveToFirst()) c.getLong(0) else null }
|
return cursor.use { c -> if (c.moveToFirst()) c.getLong(0) else null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сессия по id, или null, если не существует. */
|
||||||
|
fun getSession(id: Long): Session? {
|
||||||
|
val cursor = db.readableDatabase.rawQuery(
|
||||||
|
"SELECT id, title, created_at, updated_at FROM sessions WHERE id = ?",
|
||||||
|
arrayOf(id.toString()),
|
||||||
|
)
|
||||||
|
return cursor.use { c -> if (c.moveToFirst()) c.toSession() else null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сброс сессии: удалить все сообщения, сессия остаётся пустой. */
|
||||||
|
fun clearSession(id: Long) {
|
||||||
|
db.writableDatabase.delete(
|
||||||
|
ChatDb.TABLE_MESSAGES,
|
||||||
|
"session_id = ?",
|
||||||
|
arrayOf(id.toString()),
|
||||||
|
)
|
||||||
|
touchSession(id)
|
||||||
|
}
|
||||||
|
|
||||||
fun renameSession(id: Long, title: String) {
|
fun renameSession(id: Long, title: String) {
|
||||||
db.writableDatabase.update(
|
db.writableDatabase.update(
|
||||||
ChatDb.TABLE_SESSIONS,
|
ChatDb.TABLE_SESSIONS,
|
||||||
|
|||||||
@@ -1,64 +1,107 @@
|
|||||||
package pw.binom.viewmate.phone.agent
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
import ai.koog.http.client.okhttp.OkHttpKoogHttpClient
|
import kotlinx.coroutines.Dispatchers
|
||||||
import ai.koog.prompt.dsl.prompt
|
import kotlinx.coroutines.withContext
|
||||||
import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings
|
import kotlinx.serialization.Serializable
|
||||||
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
|
import kotlinx.serialization.encodeToString
|
||||||
import ai.koog.prompt.llm.LLMCapability
|
import kotlinx.serialization.json.Json
|
||||||
import ai.koog.prompt.llm.LLModel
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import ai.koog.prompt.llm.LLMProvider
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Базовый LLM-клиент на Koog (JetBrains), OpenAI-совместимый эндпоинт
|
* LLM-клиент на прямом HTTP (OkHttp) к OpenAI-совместимому эндпоинту
|
||||||
* https://llm.binom.pw (chatCompletionsPath по умолчанию "v1/chat/completions").
|
* https://llm.binom.pw/v1/chat/completions.
|
||||||
*
|
*
|
||||||
* Модель и виртуальный ключ llm.binom.pw даст пользователь (llm.apiKey в local.properties).
|
* Коog-клиент (JetBrains) работал с нашей моделью нестабильно: возвращал пустой
|
||||||
* Пустой ключ — клиент создаётся, но вызовы вернут ошибку авторизации (это ожидаемо).
|
* content на длинных историях (после tool-вызовов) — см. заметку
|
||||||
|
* /root/notes/2026-08-22-viewmate-night.md. Прямой вызов проверен curl'ом:
|
||||||
|
* модель Qwen3.8-27B-NVFP4 отвечает стабильно, тул-цикл работает.
|
||||||
*
|
*
|
||||||
* Класс НЕ singleton и не создаётся в Application: ассистент создаст его при старте сессии.
|
* Модель и виртуальный ключ llm.binom.pw — из local.properties (llm.apiKey/llm.model).
|
||||||
*/
|
*/
|
||||||
class LlmClient(
|
class LlmClient(
|
||||||
private val apiKey: String,
|
private val apiKey: String,
|
||||||
private val baseUrl: String = "https://llm.binom.pw",
|
private val baseUrl: String = "https://llm.binom.pw",
|
||||||
private val model: String, // модель укажет пользователь
|
private val model: String,
|
||||||
) {
|
) {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
|
|
||||||
private val client: OpenAILLMClient = OpenAILLMClient(
|
private val http = OkHttpClient.Builder()
|
||||||
apiKey = apiKey,
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
settings = OpenAIClientSettings(baseUrl = baseUrl),
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
httpClientFactory = OkHttpKoogHttpClient.Factory(),
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class ChatMessageDto(
|
||||||
|
val role: String,
|
||||||
|
val content: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val llmModel: LLModel = LLModel(
|
@Serializable
|
||||||
provider = LLMProvider.OpenAI,
|
private data class ChatRequest(
|
||||||
id = model,
|
val model: String,
|
||||||
capabilities = listOf(
|
val messages: List<ChatMessageDto>,
|
||||||
LLMCapability.Completion,
|
val max_tokens: Int = 1024,
|
||||||
LLMCapability.OpenAIEndpoint.Completions,
|
val temperature: Double = 0.3,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class ChatResponse(
|
||||||
|
val choices: List<Choice> = emptyList(),
|
||||||
|
val error: ApiError? = null,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
data class Choice(val message: Message = Message())
|
||||||
|
@Serializable
|
||||||
|
data class Message(val content: String? = null)
|
||||||
|
@Serializable
|
||||||
|
data class ApiError(val message: String? = null)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отправить историю сообщений и вернуть текстовый ответ (без стриминга).
|
* Отправить историю сообщений и вернуть текстовый ответ (без стриминга).
|
||||||
* System-сообщение, если задано, добавляется первым.
|
* System-сообщение, если задано, добавляется первым. TOOL-сообщения —
|
||||||
|
* как user-текст «Результат тула <name>: <content>» (в Koog не было tool-роли).
|
||||||
*/
|
*/
|
||||||
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String {
|
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String {
|
||||||
val prompt = prompt("assistant-chat") {
|
val dto = ArrayList<ChatMessageDto>()
|
||||||
if (system != null) {
|
if (system != null) dto += ChatMessageDto("system", system)
|
||||||
this.system(system)
|
for (msg in messages) {
|
||||||
}
|
when (msg.role) {
|
||||||
for (msg in messages) {
|
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) dto += ChatMessageDto("system", msg.content)
|
||||||
when (msg.role) {
|
ChatRole.USER -> if (msg.content.isNotBlank()) dto += ChatMessageDto("user", msg.content)
|
||||||
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) this.system(msg.content)
|
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) dto += ChatMessageDto("assistant", msg.content)
|
||||||
ChatRole.USER -> if (msg.content.isNotBlank()) user(msg.content)
|
ChatRole.TOOL -> if (msg.content.isNotBlank()) {
|
||||||
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) assistant(msg.content)
|
dto += ChatMessageDto("user", "Результат тула ${msg.toolCallId ?: "?"}: ${msg.content}")
|
||||||
// TODO(assistant): в Koog нет отдельной tool-роли — результаты инструментов
|
|
||||||
// моделируются как MessagePart.Tool.Result внутри user-сообщения, но для этого
|
|
||||||
// нужен tool-name, которого в ChatMessage нет. Пропускаем (базовый клиент).
|
|
||||||
ChatRole.TOOL -> Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return client.execute(prompt, llmModel).textContent()
|
if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.")
|
||||||
|
|
||||||
|
val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto))
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$baseUrl/v1/chat/completions")
|
||||||
|
.header("Authorization", "Bearer $apiKey")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.post(body.toRequestBody("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
http.newCall(request).execute().use { resp ->
|
||||||
|
val raw = resp.body?.string().orEmpty()
|
||||||
|
if (!resp.isSuccessful) {
|
||||||
|
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
|
||||||
|
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
|
||||||
|
}
|
||||||
|
val text = runCatching {
|
||||||
|
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
|
||||||
|
}.getOrNull()
|
||||||
|
text?.trim() ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,222 @@
|
|||||||
package pw.binom.viewmate.phone.ui
|
package pw.binom.viewmate.phone.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import pw.binom.viewmate.phone.PhoneApp
|
||||||
|
import pw.binom.viewmate.phone.agent.ChatRole
|
||||||
|
import pw.binom.viewmate.phone.agent.Session
|
||||||
|
|
||||||
/** Экран 4 — Чат с ассистентом (заглушка: отдельный этап, Hermes-интеграция). */
|
/**
|
||||||
|
* Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в
|
||||||
|
* локального ассистента (та же очередь, что и голос с очков). Управление
|
||||||
|
* сессиями: новая, переключение (выпадающий список), сброс текущей.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen() {
|
fun ChatScreen() {
|
||||||
Column(
|
val app = PhoneApp.instance
|
||||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
val scope = rememberCoroutineScope()
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
|
var sessions by remember { mutableStateOf<List<Session>>(emptyList()) }
|
||||||
|
var messages by remember { mutableStateOf<List<Pair<ChatRole, String>>>(emptyList()) }
|
||||||
|
var input by remember { mutableStateOf("") }
|
||||||
|
var sessionMenu by remember { mutableStateOf(false) }
|
||||||
|
var activeId by remember { mutableStateOf<Long?>(null) }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Загрузка списка сессий и сообщений при старте и при смене активной.
|
||||||
|
LaunchedEffect(activeId) {
|
||||||
|
val dao = app.chatDao
|
||||||
|
val (sess, msgs) = withContext(Dispatchers.IO) {
|
||||||
|
val all = dao.listSessions()
|
||||||
|
val target = activeId ?: all.firstOrNull()?.id
|
||||||
|
val history = target?.let { dao.messagesForSession(it) } ?: emptyList()
|
||||||
|
all to history.map { it.role to it.content }
|
||||||
|
}
|
||||||
|
sessions = sess
|
||||||
|
activeId = activeId ?: sess.firstOrNull()?.id
|
||||||
|
messages = msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подписка на смену активной сессии извне (кнопки в PhoneApp).
|
||||||
|
val externalActive by app.activeSessionId.collectAsState()
|
||||||
|
LaunchedEffect(externalActive) {
|
||||||
|
if (externalActive != null && externalActive != activeId) {
|
||||||
|
activeId = externalActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автопрокрутка вниз при новых сообщениях.
|
||||||
|
LaunchedEffect(messages.size) {
|
||||||
|
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Периодическая перезагрузка сообщений из БД (ответ ассистента приходит
|
||||||
|
// асинхронно — лента должна подхватывать его без ручного обновления).
|
||||||
|
LaunchedEffect(activeId) {
|
||||||
|
while (true) {
|
||||||
|
delay(2_000)
|
||||||
|
val id = activeId
|
||||||
|
if (id != null) {
|
||||||
|
val fresh = withContext(Dispatchers.IO) { app.chatDao.messagesForSession(id).map { it.role to it.content } }
|
||||||
|
if (fresh != messages) messages = fresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(12.dp)) {
|
||||||
|
// Шапка: название + управление сессиями
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "Порфирий",
|
||||||
|
color = PhoneText,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
// Переключение сессий
|
||||||
|
Box {
|
||||||
|
TextButton(onClick = { sessionMenu = true }) {
|
||||||
|
Text(
|
||||||
|
text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…",
|
||||||
|
color = PhoneAccent,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
|
||||||
|
sessions.forEach { s ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(s.title, fontSize = 12.sp) },
|
||||||
|
onClick = {
|
||||||
|
activeId = s.id
|
||||||
|
app.selectChatSession(s.id)
|
||||||
|
sessionMenu = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("+ новая сессия", fontSize = 12.sp, color = PhoneAccent) },
|
||||||
|
onClick = {
|
||||||
|
activeId = app.newChatSession()
|
||||||
|
sessionMenu = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Сброс текущей
|
||||||
|
TextButton(onClick = {
|
||||||
|
app.resetChatSession()
|
||||||
|
activeId?.let { id ->
|
||||||
|
val history = app.chatDao.messagesForSession(id).map { it.role to it.content }
|
||||||
|
messages = history
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("сброс", color = PhoneMuted, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// Лента сообщений
|
||||||
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
if (messages.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Спроси что-нибудь — например, «сколько сейчас время?»",
|
||||||
|
color = PhoneMuted,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(messages) { (role, content) ->
|
||||||
|
MessageBubble(role = role, text = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Ввод
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = input,
|
||||||
|
onValueChange = { input = it },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
placeholder = { Text("Сообщение…", fontSize = 14.sp, color = PhoneMuted) },
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium.copy(color = PhoneText),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val text = input.trim()
|
||||||
|
if (text.isEmpty()) return@Button
|
||||||
|
input = ""
|
||||||
|
messages = messages + (ChatRole.USER to text)
|
||||||
|
app.sendToAssistant(text)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("➤", fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пузырь сообщения: user — справа акцентный, assistant — слева карточка. */
|
||||||
|
@Composable
|
||||||
|
private fun MessageBubble(role: ChatRole, text: String) {
|
||||||
|
val isUser = role == ChatRole.USER
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
|
||||||
|
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Чат с Порфирием",
|
text = text,
|
||||||
color = PhoneText,
|
color = PhoneText,
|
||||||
fontSize = 18.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
modifier = Modifier
|
||||||
)
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
Text(
|
.background(
|
||||||
text = "в разработке (следующий этап)",
|
color = if (isUser) PhoneSurface else PhoneCard,
|
||||||
color = PhoneMuted,
|
shape = RoundedCornerShape(10.dp),
|
||||||
fontSize = 13.sp,
|
)
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package pw.binom.viewmate.phone
|
|||||||
|
|
||||||
import io.ktor.client.plugins.websocket.WebSockets
|
import io.ktor.client.plugins.websocket.WebSockets
|
||||||
import io.ktor.client.plugins.websocket.webSocket
|
import io.ktor.client.plugins.websocket.webSocket
|
||||||
import io.ktor.server.testing.testApplication
|
|
||||||
import io.ktor.websocket.Frame
|
import io.ktor.websocket.Frame
|
||||||
import io.ktor.websocket.readText
|
import io.ktor.websocket.readText
|
||||||
import io.ktor.websocket.send
|
import io.ktor.websocket.send
|
||||||
@@ -30,17 +29,34 @@ import kotlin.test.assertEquals
|
|||||||
import kotlin.test.assertIs
|
import kotlin.test.assertIs
|
||||||
import kotlin.test.fail
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интеграционные тесты хаба поверх реального [WifiServerTransport] (Ktor CIO):
|
||||||
|
* поднимаем сервер на свободном порту, ходим WS-клиентом, проверяем
|
||||||
|
* handshake/жесты/бродкаст/статусы (TASK-transport.md п.5: «тесты зелёные»).
|
||||||
|
*/
|
||||||
class GlassesServerTest {
|
class GlassesServerTest {
|
||||||
|
|
||||||
private val json = protocolJson
|
private val json = protocolJson
|
||||||
|
|
||||||
@Test
|
/** Поднять [GlassesServer] на случайном порту и вернуть его + порт. */
|
||||||
fun helloGetsWelcome() = testApplication {
|
private fun startServer(): Pair<GlassesServer, Int> {
|
||||||
val hub = GlassesHub(PhoneState())
|
val port = freePort()
|
||||||
application { glassesServerModule(hub) }
|
val server = GlassesServer(port = port, state = PhoneState(), useBluetooth = false)
|
||||||
val client = createClient { install(WebSockets) }
|
server.start()
|
||||||
|
return server to port
|
||||||
|
}
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
private fun freePort(): Int {
|
||||||
|
val s = java.net.ServerSocket(0)
|
||||||
|
val p = s.localPort
|
||||||
|
s.close()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun helloGetsWelcome() = runServer { port, hub ->
|
||||||
|
val client = wsClient()
|
||||||
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
|
send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
|
||||||
val frame = incoming.receive() as Frame.Text
|
val frame = incoming.receive() as Frame.Text
|
||||||
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||||
@@ -50,12 +66,9 @@ class GlassesServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun gestureIsProcessed() = testApplication {
|
fun gestureIsProcessed() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
|
||||||
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
|
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
|
||||||
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
|
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
|
||||||
}
|
}
|
||||||
@@ -64,28 +77,22 @@ class GlassesServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun connectedCounterTracksSessions() = testApplication {
|
fun connectedCounterTracksSessions() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { }
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) { }
|
|
||||||
awaitTrue { hub.connected.value == 0 }
|
awaitTrue { hub.connected.value == 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun broadcastReachesAllGlasses() = testApplication {
|
fun broadcastReachesAllGlasses() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
val opened = Channel<Unit>(2)
|
val opened = Channel<Unit>(2)
|
||||||
val received = Channel<HostToGlasses>(2)
|
val received = Channel<HostToGlasses>(2)
|
||||||
|
|
||||||
val jobs = (1..2).map {
|
val jobs = (1..2).map {
|
||||||
launch {
|
launch {
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
opened.send(Unit)
|
opened.send(Unit)
|
||||||
while (true) {
|
while (true) {
|
||||||
val frame = incoming.receive()
|
val frame = incoming.receive()
|
||||||
@@ -115,12 +122,9 @@ class GlassesServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun downloadProgressUpdatesHubPerFileMap() = testApplication {
|
fun downloadProgressUpdatesHubPerFileMap() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
|
||||||
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)))
|
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)))
|
||||||
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE)))
|
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE)))
|
||||||
}
|
}
|
||||||
@@ -131,12 +135,9 @@ class GlassesServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun extendedGlassesStatusUpdatesHubFields() = testApplication {
|
fun extendedGlassesStatusUpdatesHubFields() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
|
||||||
send(
|
send(
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
GlassesToHost.serializer(),
|
GlassesToHost.serializer(),
|
||||||
@@ -163,6 +164,21 @@ class GlassesServerTest {
|
|||||||
assertEquals("0.2.0", hub.appVersion.value)
|
assertEquals("0.2.0", hub.appVersion.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- помощники ----------
|
||||||
|
|
||||||
|
private inline fun runServer(crossinline block: suspend (port: Int, hub: GlassesHub) -> Unit) {
|
||||||
|
val (server, port) = startServer()
|
||||||
|
try {
|
||||||
|
kotlinx.coroutines.runBlocking { block(port, server.hub) }
|
||||||
|
} finally {
|
||||||
|
server.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wsClient() = io.ktor.client.HttpClient {
|
||||||
|
install(WebSockets)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
|
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
|
||||||
val deadline = System.currentTimeMillis() + timeoutMs
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
while (!condition()) {
|
while (!condition()) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты тулов ассистента — чистая логика без БД и Android.
|
||||||
|
*/
|
||||||
|
class AgentToolsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `тул времени возвращает ISO 8601 с датой и временем`() {
|
||||||
|
val result = TimeTool.invoke("{}")
|
||||||
|
// 2026-08-22T03:14:05+03:00 — содержит дату, время и смещение
|
||||||
|
assertTrue(
|
||||||
|
result.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")),
|
||||||
|
"не ISO 8601: $result",
|
||||||
|
)
|
||||||
|
assertTrue(result.startsWith("20"), "год не 20xx: $result")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `реестр содержит тулу времени с описанием`() {
|
||||||
|
assertTrue(AgentTools.ALL.any { it.name == "get_current_time" })
|
||||||
|
val desc = AgentTools.describeAll()
|
||||||
|
assertTrue(desc.contains("get_current_time"))
|
||||||
|
assertTrue(desc.contains("ISO 8601"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `тула времени не зависит от аргументов`() {
|
||||||
|
// Пустой JSON и мусор — один результат (тула без параметров)
|
||||||
|
val a = TimeTool.invoke("{}")
|
||||||
|
val b = TimeTool.invoke("{\"x\":1}")
|
||||||
|
assertEquals(a.take(19), b.take(19)) // одинаковые дата и время в пределах секунды
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package pw.binom.viewmate.core.net
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Константы Bluetooth-транспорта (SPP/RFCOMM) — TASK-transport.md п.3/п.4.
|
||||||
|
* Общие для стороны очков (клиент) и телефона (сервер): UUID-сервиса SPP,
|
||||||
|
* имя сервиса, разделитель строк-сообщений, период реконнекта.
|
||||||
|
*/
|
||||||
|
object BtTransportConstants {
|
||||||
|
/** UUID SPP-сервиса (Serial Port Profile, стандартный Bluetooth SIG). */
|
||||||
|
const val SPP_UUID = "00001101-0000-1000-8000-00805F9B34FB"
|
||||||
|
|
||||||
|
/** Имя SPP-сервиса (RFCOMM-заголовок + человекочитаемый лог). */
|
||||||
|
const val SERVICE_NAME = "viewmate-spp"
|
||||||
|
|
||||||
|
/** Разделитель сообщений (JSON + перевод строки) в SPP. */
|
||||||
|
const val NEWLINE: Char = '\n'
|
||||||
|
|
||||||
|
/** Задержка (мс) между попытками реконнекta BT (оба конца — одинаковый цикл). */
|
||||||
|
const val RETRY_DELAY_MS: Long = 3_000L
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package pw.binom.viewmate.core.net
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Транспорт связи очки↔телефон, сторона очков («клиент») — TASK-transport.md п.1.
|
||||||
|
* Сообщения ходят **JSON-строками**: декод/кодинг конвертов [HostToGlasses]/
|
||||||
|
* [GlassesToHost] делает вызывающий (HostConnection на очках, GlassesHub на
|
||||||
|
* телефоне) — транспорт остаётся независимым от протокола.
|
||||||
|
*
|
||||||
|
* Реализации: [WifiGlassesTransport] (WebSocket, дефолт) и BtGlassesTransport
|
||||||
|
* (Bluetooth SPP, флагом).
|
||||||
|
*/
|
||||||
|
interface GlassesTransport {
|
||||||
|
/** Название транспорта: `wifi` / `bluetooth`. */
|
||||||
|
val name: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подключиться к телефону: бесконечный цикл реконнекта (ретрай через 3с при
|
||||||
|
* обрыве) живёт внутри реализации, в корутине вызывающего.
|
||||||
|
*
|
||||||
|
* @param onMessage входящее JSON-сообщение (конверт HostToGlasses, строкой).
|
||||||
|
*/
|
||||||
|
suspend fun connect(
|
||||||
|
onMessage: suspend (String) -> Unit,
|
||||||
|
onConnected: suspend () -> Unit,
|
||||||
|
onDisconnected: suspend () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Отправить JSON-сообщение (конверт GlassesToHost) на телефон. Без активного соединения — no-op. */
|
||||||
|
suspend fun send(json: String)
|
||||||
|
|
||||||
|
/** Закрыть транспорт: остановить цикл реконнекта и активное соединение. */
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Транспорт приёма соединений, сторона телефона («сервер») — TASK-transport.md п.1.
|
||||||
|
* [accept] выполняется бесконечно: принимает подключения очков и отдаёт события
|
||||||
|
* по строковым [connId] — не по объектам сокета/сессии (требование ТЗ:
|
||||||
|
* «сессии — connId-строки»). Сообщения — JSON-строки, декод — не в его
|
||||||
|
* заботе (запускающий: GlassesHub).
|
||||||
|
*/
|
||||||
|
interface GlassesServerTransport {
|
||||||
|
/** Название транспорта: `wifi` / `bluetooth`. */
|
||||||
|
val name: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Бесконечно принимать подключения очков (в корутине вызывающего).
|
||||||
|
* @param onMessage входящее JSON-сообщение (конверт GlassesToHost) из соединения [connId].
|
||||||
|
* @param onConnected установлено новое соединение [connId].
|
||||||
|
* @param onDisconnected соединение [connId] оборвано.
|
||||||
|
*/
|
||||||
|
suspend fun accept(
|
||||||
|
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||||
|
onConnected: (connId: String) -> Unit,
|
||||||
|
onDisconnected: (connId: String) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Отправить JSON-сообщение конкретному соединению [connId]. Соединения нет — no-op. */
|
||||||
|
suspend fun send(connId: String, json: String)
|
||||||
|
|
||||||
|
/** Закрыть транспорт: перестать принимать и закрыть активные соединения. */
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
@@ -10,23 +10,21 @@ import io.ktor.websocket.send
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import pw.binom.viewmate.core.log
|
import pw.binom.viewmate.core.log
|
||||||
import pw.binom.viewmate.core.media.defaultWsClient
|
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],
|
* WS-клиент очков: подключается к хосту (телефону). Работает **JSON-строками**
|
||||||
* принимает [HostToGlasses]. [connect] — бесконечный цикл приёма с
|
* (TASK-transport.md п.2 — протокол не в заботах транспорта): [connect]
|
||||||
* реконнектом через 3с при обрыве.
|
* бесконечный цикл приёма с реконнектом через 3с при обрыве, [send] — строка
|
||||||
|
* (декод/кодинг вызывающий, т.е. HostConnection поверх [GlassesTransport]).
|
||||||
*/
|
*/
|
||||||
class GlassesWsClient(
|
class GlassesWsClient(
|
||||||
private val url: String, // ws://<host>:8080/ws/glasses
|
private val url: String, // ws://<host>:8080/ws/glasses
|
||||||
private val http: HttpClient = defaultWsClient(),
|
private val http: HttpClient = defaultWsClient(),
|
||||||
) {
|
) : GlassesTransport {
|
||||||
|
|
||||||
|
override val name = "wifi"
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var closed = false
|
private var closed = false
|
||||||
@@ -36,13 +34,13 @@ class GlassesWsClient(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Подключиться и слушать. Возвращается при вызове [close].
|
* Подключиться и слушать. Возвращается при вызове [close].
|
||||||
* [onHostMessage] — входящие от хоста; [onConnected]/[onDisconnected] —
|
* [onMessage] — входящие JSON-строки от хоста; [onConnected]/[onDisconnected] —
|
||||||
* события (пере)подключения (для Hello и смены статуса в UI).
|
* события (пере)подключения (для Hello и смены статуса в UI).
|
||||||
*/
|
*/
|
||||||
suspend fun connect(
|
override suspend fun connect(
|
||||||
onHostMessage: suspend (HostToGlasses) -> Unit,
|
onMessage: suspend (String) -> Unit,
|
||||||
onConnected: suspend () -> Unit = {},
|
onConnected: suspend () -> Unit,
|
||||||
onDisconnected: suspend () -> Unit = {},
|
onDisconnected: suspend () -> Unit,
|
||||||
) {
|
) {
|
||||||
while (!closed) {
|
while (!closed) {
|
||||||
try {
|
try {
|
||||||
@@ -52,9 +50,7 @@ class GlassesWsClient(
|
|||||||
try {
|
try {
|
||||||
for (frame in ws.incoming) {
|
for (frame in ws.incoming) {
|
||||||
if (frame is Frame.Text) {
|
if (frame is Frame.Text) {
|
||||||
val text = frame.readText()
|
onMessage(frame.readText())
|
||||||
val msg = protocolJson.decodeFromString(HostToGlasses.serializer(), text)
|
|
||||||
onHostMessage(msg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -72,14 +68,13 @@ class GlassesWsClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Отправить сообщение хосту. Без активного соединения — no-op. */
|
/** Отправить JSON-строку хосту. Без активного соединения — no-op. */
|
||||||
suspend fun send(msg: GlassesToHost) {
|
override suspend fun send(json: String) {
|
||||||
val text = protocolJson.encodeToString(GlassesToHost.serializer(), msg)
|
session?.send(json)
|
||||||
session?.send(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
|
/** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
|
||||||
fun close() {
|
override fun close() {
|
||||||
closed = true
|
closed = true
|
||||||
session?.cancel()
|
session?.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package pw.binom.viewmate.core.net
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import pw.binom.viewmate.core.media.defaultWsClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WiFi-транспорт, сторона очков («клиент») — TASK-transport.md п.2:
|
||||||
|
* [GlassesTransport] поверх [GlassesWsClient] — обёртка, логика цикла
|
||||||
|
* подключения/реконнекта не переписывается (переносится в интерфейс).
|
||||||
|
* Адрес (mDNS/scan/fallback — HostConnection) передаётся в URL.
|
||||||
|
*/
|
||||||
|
class WifiGlassesTransport(
|
||||||
|
url: String,
|
||||||
|
http: HttpClient = defaultWsClient(),
|
||||||
|
) : GlassesTransport {
|
||||||
|
|
||||||
|
override val name = "wifi"
|
||||||
|
|
||||||
|
private val client = GlassesWsClient(url, http)
|
||||||
|
|
||||||
|
override suspend fun connect(
|
||||||
|
onMessage: suspend (String) -> Unit,
|
||||||
|
onConnected: suspend () -> Unit,
|
||||||
|
onDisconnected: suspend () -> Unit,
|
||||||
|
) {
|
||||||
|
client.connect(onMessage, onConnected, onDisconnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(json: String) {
|
||||||
|
client.send(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,11 +34,20 @@ class GlassesWsClientTest {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PATH = "/ws/glasses"
|
private const val PATH = "/ws/glasses"
|
||||||
|
|
||||||
|
private val json = protocolJson
|
||||||
|
|
||||||
|
private fun hostToGlasses(text: String): HostToGlasses =
|
||||||
|
json.decodeFromString(HostToGlasses.serializer(), text)
|
||||||
|
|
||||||
|
private fun glassesToHost(text: String): GlassesToHost =
|
||||||
|
json.decodeFromString(GlassesToHost.serializer(), text)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Локальный WS-сервер: Hello → Welcome, всё входящее собирает в список. */
|
/** Локальный WS-сервер: Hello → Welcome, всё входящее (JSON-строками) собирает в очередь. */
|
||||||
private class TestServer(private val port: Int = 0) {
|
private class TestServer(private val port: Int = 0) {
|
||||||
val received = ConcurrentLinkedQueue<GlassesToHost>()
|
/** Входящие JSON-строки (транспорт — независим от протокола). */
|
||||||
|
val receivedRaw = ConcurrentLinkedQueue<String>()
|
||||||
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
|
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
|
||||||
|
|
||||||
private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
|
private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
|
||||||
@@ -54,10 +63,11 @@ class GlassesWsClientTest {
|
|||||||
try {
|
try {
|
||||||
for (frame in incoming) {
|
for (frame in incoming) {
|
||||||
if (frame is Frame.Text) {
|
if (frame is Frame.Text) {
|
||||||
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), frame.readText())
|
val text = frame.readText()
|
||||||
received.add(msg)
|
receivedRaw.add(text)
|
||||||
|
val msg = glassesToHost(text)
|
||||||
if (msg is Hello) {
|
if (msg is Hello) {
|
||||||
send(protocolJson.encodeToString(
|
send(json.encodeToString(
|
||||||
HostToGlasses.serializer(),
|
HostToGlasses.serializer(),
|
||||||
Welcome(mode = GlassesMode.MOVIE),
|
Welcome(mode = GlassesMode.MOVIE),
|
||||||
))
|
))
|
||||||
@@ -83,7 +93,7 @@ class GlassesWsClientTest {
|
|||||||
get() = sessions.size
|
get() = sessions.size
|
||||||
|
|
||||||
suspend fun broadcast(msg: HostToGlasses) {
|
suspend fun broadcast(msg: HostToGlasses) {
|
||||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
for (s in sessions) {
|
for (s in sessions) {
|
||||||
try {
|
try {
|
||||||
s.send(text)
|
s.send(text)
|
||||||
@@ -114,20 +124,21 @@ class GlassesWsClientTest {
|
|||||||
try {
|
try {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val client = GlassesWsClient(server.url())
|
val client = GlassesWsClient(server.url())
|
||||||
val messages = mutableListOf<HostToGlasses>()
|
val messages = ConcurrentLinkedQueue<String>()
|
||||||
val connected = CompletableDeferred<Unit>()
|
val connected = CompletableDeferred<Unit>()
|
||||||
val job = launch {
|
val job = launch {
|
||||||
client.connect(
|
client.connect(
|
||||||
onHostMessage = { messages.add(it) },
|
onMessage = { messages.add(it) },
|
||||||
onConnected = { connected.complete(Unit) },
|
onConnected = { connected.complete(Unit) },
|
||||||
|
onDisconnected = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
withTimeout(10_000) { connected.await() }
|
withTimeout(10_000) { connected.await() }
|
||||||
client.send(Hello(appVersion = "0.1.0"))
|
client.send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
|
||||||
|
|
||||||
awaitTrue { messages.any { it is Welcome } }
|
awaitTrue { messages.any { hostToGlasses(it) is Welcome } }
|
||||||
val welcome = messages.filterIsInstance<Welcome>().first()
|
val welcome = messages.first { hostToGlasses(it) is Welcome }.let { hostToGlasses(it) as Welcome }
|
||||||
assertEquals(GlassesMode.MOVIE, welcome.mode)
|
assertEquals(GlassesMode.MOVIE, welcome.mode)
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
@@ -147,16 +158,19 @@ class GlassesWsClientTest {
|
|||||||
val connected = CompletableDeferred<Unit>()
|
val connected = CompletableDeferred<Unit>()
|
||||||
val job = launch {
|
val job = launch {
|
||||||
client.connect(
|
client.connect(
|
||||||
onHostMessage = {},
|
onMessage = {},
|
||||||
onConnected = { connected.complete(Unit) },
|
onConnected = { connected.complete(Unit) },
|
||||||
|
onDisconnected = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
withTimeout(10_000) { connected.await() }
|
withTimeout(10_000) { connected.await() }
|
||||||
client.send(Gesture(gesture = "DoubleClick"))
|
client.send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
|
||||||
|
|
||||||
awaitTrue { server.received.any { it is Gesture } }
|
awaitTrue { server.receivedRaw.any { glassesToHost(it) is Gesture } }
|
||||||
val gesture = server.received.filterIsInstance<Gesture>().first()
|
val gesture = server.receivedRaw.first { glassesToHost(it) is Gesture }.let {
|
||||||
|
glassesToHost(it) as Gesture
|
||||||
|
}
|
||||||
assertEquals("DoubleClick", gesture.gesture)
|
assertEquals("DoubleClick", gesture.gesture)
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
@@ -173,12 +187,13 @@ class GlassesWsClientTest {
|
|||||||
try {
|
try {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val client = GlassesWsClient(server.url())
|
val client = GlassesWsClient(server.url())
|
||||||
val messages = mutableListOf<HostToGlasses>()
|
val messages = ConcurrentLinkedQueue<String>()
|
||||||
val connected = CompletableDeferred<Unit>()
|
val connected = CompletableDeferred<Unit>()
|
||||||
val job = launch {
|
val job = launch {
|
||||||
client.connect(
|
client.connect(
|
||||||
onHostMessage = { messages.add(it) },
|
onMessage = { messages.add(it) },
|
||||||
onConnected = { connected.complete(Unit) },
|
onConnected = { connected.complete(Unit) },
|
||||||
|
onDisconnected = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +202,8 @@ class GlassesWsClientTest {
|
|||||||
|
|
||||||
server.broadcast(SetMode(GlassesMode.CHAT))
|
server.broadcast(SetMode(GlassesMode.CHAT))
|
||||||
|
|
||||||
awaitTrue { messages.any { it is SetMode } }
|
awaitTrue { messages.any { hostToGlasses(it) is SetMode } }
|
||||||
val setMode = messages.filterIsInstance<SetMode>().first()
|
val setMode = messages.first { hostToGlasses(it) is SetMode }.let { hostToGlasses(it) as SetMode }
|
||||||
assertEquals(GlassesMode.CHAT, setMode.mode)
|
assertEquals(GlassesMode.CHAT, setMode.mode)
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
@@ -199,6 +214,50 @@ class GlassesWsClientTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка самого [GlassesTransport]: обмен — сырыми JSON-строками —
|
||||||
|
* и на стороне отправки (encode вызывающий), и на стороне приёма.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun transportApiExchangesRawJsonStrings() {
|
||||||
|
val server = TestServer()
|
||||||
|
try {
|
||||||
|
runBlocking {
|
||||||
|
val transport = WifiGlassesTransport(server.url())
|
||||||
|
val rawMessages = ConcurrentLinkedQueue<String>()
|
||||||
|
val connected = CompletableDeferred<Unit>()
|
||||||
|
val job = launch {
|
||||||
|
transport.connect(
|
||||||
|
onMessage = { rawMessages.add(it) },
|
||||||
|
onConnected = { connected.complete(Unit) },
|
||||||
|
onDisconnected = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
withTimeout(10_000) { connected.await() }
|
||||||
|
assertEquals("wifi", transport.name)
|
||||||
|
transport.send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.3.0")))
|
||||||
|
|
||||||
|
awaitTrue { server.receivedRaw.any { it.contains("\"hello\"") } }
|
||||||
|
val helloJson = server.receivedRaw.first { it.contains("\"hello\"") }
|
||||||
|
val hello = glassesToHost(helloJson) as Hello
|
||||||
|
assertEquals("0.3.0", hello.appVersion)
|
||||||
|
|
||||||
|
// исходящее событие — тоже сырая строка; декод — за вызывающим
|
||||||
|
// (первый Welcome(MOVIE) — автоответ сервера на Hello; ждём наш CHAT)
|
||||||
|
server.broadcast(Welcome(mode = GlassesMode.CHAT))
|
||||||
|
awaitTrue { rawMessages.any { (hostToGlasses(it) as? Welcome)?.mode == GlassesMode.CHAT } }
|
||||||
|
val chatted = rawMessages.first { (hostToGlasses(it) as? Welcome)?.mode == GlassesMode.CHAT }
|
||||||
|
assertEquals(GlassesMode.CHAT, (hostToGlasses(chatted) as Welcome).mode)
|
||||||
|
|
||||||
|
transport.close()
|
||||||
|
job.cancelAndJoin()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun reconnectsAfterServerDrop() {
|
fun reconnectsAfterServerDrop() {
|
||||||
val server = TestServer()
|
val server = TestServer()
|
||||||
@@ -209,8 +268,9 @@ class GlassesWsClientTest {
|
|||||||
val client = GlassesWsClient(server.url())
|
val client = GlassesWsClient(server.url())
|
||||||
val job = launch {
|
val job = launch {
|
||||||
client.connect(
|
client.connect(
|
||||||
onHostMessage = {},
|
onMessage = {},
|
||||||
onConnected = { connects++ },
|
onConnected = { connects++ },
|
||||||
|
onDisconnected = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user