Ассистент Порфирий: тула времени, сессии, чат-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
|
||||
fun ConnectionScreen() {
|
||||
val connection = GlassesApp.instance.hostConnection
|
||||
val connected by connection.connected.collectAsState()
|
||||
val status by connection.status.collectAsState()
|
||||
val messages by connection.messages.collectAsState()
|
||||
val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ
|
||||
|
||||
Binocular(scale = false) {
|
||||
val screenWidthPx = BinocularScreenSize.current.width
|
||||
val screenHeightPx = BinocularScreenSize.current.height
|
||||
// масштаб через layout — как в GlassesMovieScreen
|
||||
val containerW = (screenWidthPx * scale).toInt()
|
||||
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),
|
||||
) {
|
||||
... существующий контент ...
|
||||
class PlaybackService : Service() {
|
||||
companion object {
|
||||
const val CHANNEL_ID = "viewmate_playback"
|
||||
const val NOTIFICATION_ID = 1
|
||||
// аккуратный запуск: ContextCompat.startForegroundService(context, intent)
|
||||
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()
|
||||
// канал уведомлений (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,
|
||||
оверлей распознавания — но всё это теперь в уменьшенном контейнере.
|
||||
Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть.
|
||||
### 3. Запуск/остановка сервиса в `PhoneApp.kt`
|
||||
|
||||
⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри
|
||||
`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер
|
||||
уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192.
|
||||
Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen
|
||||
(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в
|
||||
GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent,
|
||||
там есть `Side.LEFT/RIGHT` через CompositionLocal).
|
||||
- **Старт — при каждом старте аудио** (3 места):
|
||||
- `autostartAudio()` (строка ~173, перед `audioSync.play(source, positionMs)`)
|
||||
- `DetailsScreen.onWatch` — там где вызывается `app.audioSync.play(source, positionMs)`
|
||||
(строка ~127) и второй play (строка ~215)
|
||||
- ОБЩИЙ подход: раз звук — сервис. Проще всего: стартовать в
|
||||
`PhoneApp.onCreate()` сразу (сервис лёгкий, уведомление висит пока
|
||||
приложение живо) — НО только если пользователь не против постоянного
|
||||
уведомления. РЕШЕНИЕ: старт в 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`:
|
||||
`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится.
|
||||
`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит.
|
||||
|
||||
**Фикс:** добавить в `CatalogState`:
|
||||
- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция
|
||||
- `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. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок.
|
||||
1. Сборка: `./gradlew :app-phone:assembleDebug` — успех.
|
||||
2. Юнит-тесты: `./gradlew :app-phone:testDebugUnitTest` — зелёные.
|
||||
3. На железе (через 142): установить APK, запустить, начать просмотр,
|
||||
`adb -s <phone> shell input keyevent 26` (экран телефона в сон) →
|
||||
проверить: звук играет, видео на очках играет (позиция растёт в логах),
|
||||
сервис жив: `adb shell dumpsys activity services | grep -A3 PlaybackService`.
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
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
|
||||
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 хоста); на устройстве телефон
|
||||
// находится авто-обнаружением по mDNS (_viewmate._tcp).
|
||||
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.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
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.ChatSessionMsg
|
||||
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.SttPhrase
|
||||
import pw.binom.viewmate.core.protocol.Welcome
|
||||
import pw.binom.viewmate.core.protocol.protocolJson
|
||||
|
||||
/**
|
||||
* Обвязка над [GlassesWsClient] для UI очков:
|
||||
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект,
|
||||
* Hello при подключении, периодический статус/позиция,
|
||||
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
|
||||
* DownloadFiles/DeleteFiles — менеджером скачивания).
|
||||
* Обвязка над [GlassesTransport] для UI очков (TASK-transport.md п.1/п.4):
|
||||
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес, остаётся на
|
||||
* WiFi-стороне — [WifiGlassesTransport]), реконнект, Hello при подключении,
|
||||
* периодический статус/позиция, обработка входящих HostToGlasses (строкой из
|
||||
* транспорта; декод — здесь, не в транспорте). Дефолтный транспорт — WiFi;
|
||||
* Bluetooth SPP ([BtGlassesTransport]) — флагом [GlassesConfig.USE_BLUETOOTH].
|
||||
*/
|
||||
class HostConnection(
|
||||
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
|
||||
@@ -56,11 +61,14 @@ class HostConnection(
|
||||
private val discovery = MdnsClient(context)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** Активный WS-клиент (создаётся после выбора адреса mDNS/fallback). */
|
||||
@Volatile
|
||||
private var client: GlassesWsClient? = null
|
||||
/** Транспортом включается выбор [GlassesConfig.USE_BLUETOOTH]: дефолт — WiFi. */
|
||||
private val useBt: Boolean = GlassesConfig.USE_BLUETOOTH
|
||||
|
||||
/** Последний найденный адрес — реконнект переиспользует его (без нового mDNS-цикла). */
|
||||
/** Активный транспорт (создаётся после выбора адреса: mDNS/fallback или SPP-MAC). */
|
||||
@Volatile
|
||||
private var transport: GlassesTransport? = null
|
||||
|
||||
/** Последний найденный адрес — реконнект переиспользует его (WiFi: URL, BT: MAC). */
|
||||
@Volatile
|
||||
private var lastUrl: String? = null
|
||||
|
||||
@@ -81,10 +89,10 @@ class HostConnection(
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(5_000)
|
||||
client?.send(buildStatus())
|
||||
sendToHost(buildStatus())
|
||||
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
|
||||
client?.send(
|
||||
sendToHost(
|
||||
PlaybackPosition(
|
||||
positionMs = snapshot.positionMs,
|
||||
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с на
|
||||
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
||||
* Первое подключение (WiFi): mDNS-дискавери телефона (до 15с на устройстве,
|
||||
* 3с на эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
||||
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
|
||||
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
|
||||
* Внутри [GlassesWsClient.connect] живёт бесконечный реконнект с тем же
|
||||
* Внутри [GlassesTransport.connect] живёт бесконечный реконнект с тем же
|
||||
* адресом (переиспользование последнего найденного); при его отсутствии —
|
||||
* повторный mDNS-цикл.
|
||||
*/
|
||||
private suspend fun connectLoop() {
|
||||
private suspend fun connectLoopWifi() {
|
||||
val url = lastUrl ?: discoverPhoneUrl()
|
||||
if (url.isEmpty()) {
|
||||
log("app", "телефон не найден — повтор поиска через 3с")
|
||||
delay(3_000)
|
||||
connectLoop()
|
||||
connectLoopWifi()
|
||||
return
|
||||
}
|
||||
lastUrl = url
|
||||
log("app", "подключаюсь к $url")
|
||||
log("app", "подключаюсь к $url (WiFi)")
|
||||
|
||||
val c = GlassesWsClient(url)
|
||||
client = c
|
||||
c.connect(
|
||||
onHostMessage = { msg -> handleHostMessage(msg) },
|
||||
val t = WifiGlassesTransport(url)
|
||||
transport = t
|
||||
t.connect(
|
||||
onMessage = { text -> handleHostMessageText(text) },
|
||||
onConnected = {
|
||||
_connected.value = true
|
||||
_status.value = "подключено: $url"
|
||||
_status.value = "подключено: ${t.name} $url"
|
||||
addMessage("соединение установлено")
|
||||
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()) {
|
||||
withContext(Dispatchers.Main) { movieController.resume() }
|
||||
addMessage("связь восстановлена — продолжаю")
|
||||
@@ -145,7 +197,7 @@ class HostConnection(
|
||||
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
|
||||
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
|
||||
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
|
||||
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoop]).
|
||||
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoopWifi]).
|
||||
*/
|
||||
private suspend fun discoverPhoneUrl(): String {
|
||||
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
|
||||
@@ -171,18 +223,29 @@ class HostConnection(
|
||||
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) {
|
||||
client?.send(msg)
|
||||
val t = transport ?: return
|
||||
t.send(protocolJson.encodeToString(GlassesToHost.serializer(), msg))
|
||||
}
|
||||
|
||||
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
|
||||
fun sendPlaybackPositionNow() {
|
||||
val c = client ?: return
|
||||
if (transport == null) return
|
||||
scope.launch {
|
||||
val (positionMs, playing, itemId, audioIndex) =
|
||||
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() {
|
||||
client?.close()
|
||||
transport?.close()
|
||||
discovery.stop()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -116,6 +117,7 @@ class MainActivity : BaseEventActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
GlassesApp.instance.mainActivity = this
|
||||
requestBtPermissions()
|
||||
collectTempleActions()
|
||||
disableTrackpadSounds()
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
@@ -193,6 +195,16 @@ class MainActivity : BaseEventActivity() {
|
||||
* голосовой ввод (безусловный вызов), Click во время прослушивания —
|
||||
* завершить распознавание, 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() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
|
||||
@@ -7,7 +7,18 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
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.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
|
||||
android:name=".PhoneApp"
|
||||
@@ -24,5 +35,10 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".PlaybackService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
</application>
|
||||
</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
|
||||
|
||||
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 android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
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.PhoneState
|
||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||
@@ -41,14 +36,18 @@ const val GLASSES_WS_PATH = "/ws/glasses"
|
||||
|
||||
/**
|
||||
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
|
||||
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
|
||||
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза,
|
||||
* DownloadProgress → карта скачивания на очки).
|
||||
* Сессии адресуются **connId-строками** (UUID — WiFi, MAC-адрес — Bluetooth),
|
||||
* не WS-объектами (TASK-transport.md п.1): WS-мост — в [WifiServerTransport],
|
||||
* SPP-сокеты — в [BtServerTransport]. Исходящие — JSON-строкой через
|
||||
* [GlassesServerTransport.send] (декод протокола — в хабe, не в транспорте).
|
||||
*/
|
||||
class GlassesHub(
|
||||
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)
|
||||
val connected: StateFlow<Int> = _connected.asStateFlow()
|
||||
@@ -110,32 +109,29 @@ class GlassesHub(
|
||||
@Volatile
|
||||
var sttFactory: (() -> SttStreamer?)? = null
|
||||
|
||||
fun add(session: DefaultWebSocketServerSession) {
|
||||
sessions[session] = Unit
|
||||
fun add(connId: String) {
|
||||
sessions[connId] = Unit
|
||||
_connected.value = sessions.size
|
||||
log("hub", "очки подключились, всего: ${sessions.size}")
|
||||
log("hub", "очки подключились (connId $connId, всего: ${sessions.size})")
|
||||
}
|
||||
|
||||
fun remove(session: DefaultWebSocketServerSession) {
|
||||
sessions.remove(session)
|
||||
fun remove(connId: String) {
|
||||
sessions.remove(connId)
|
||||
_connected.value = sessions.size
|
||||
log("hub", "очки отключились, всего: ${sessions.size}")
|
||||
log("hub", "очки отключились (connId $connId, всего: ${sessions.size})")
|
||||
}
|
||||
|
||||
/** Отправить сообщение всем подключённым очкам. */
|
||||
/** Отправить сообщение всем подключённым очкам (JSON-строкой через [transport]). */
|
||||
suspend fun broadcast(msg: HostToGlasses) {
|
||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||||
for (session in sessions.keys) {
|
||||
try {
|
||||
session.send(text)
|
||||
} catch (e: Exception) {
|
||||
log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}")
|
||||
}
|
||||
for (connId in sessions.keys) {
|
||||
runCatching { transport.send(connId, text) }
|
||||
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Обработать входящее текстовое сообщение от очков. */
|
||||
suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
|
||||
/** Обработать входящее текстовое сообщение от очков (JSON-строкой из [transport]). */
|
||||
suspend fun handle(connId: String, text: String) {
|
||||
val msg = try {
|
||||
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
|
||||
} catch (e: Exception) {
|
||||
@@ -144,8 +140,8 @@ class GlassesHub(
|
||||
}
|
||||
when (msg) {
|
||||
is Hello -> {
|
||||
log("glasses", "hello (app=${msg.appVersion}) → welcome")
|
||||
send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId))
|
||||
log("glasses", "hello (app=${msg.appVersion}, connId $connId) → welcome")
|
||||
send(connId, Welcome(mode = state.mode, sessionId = state.activeSessionId))
|
||||
}
|
||||
|
||||
is Gesture -> {
|
||||
@@ -199,80 +195,67 @@ class GlassesHub(
|
||||
is StopStt -> {
|
||||
if (msg.cancel) {
|
||||
log("stt", "распознавание отменено (очки)")
|
||||
send(session, SttCancel())
|
||||
send(connId, SttCancel())
|
||||
stt?.reset()
|
||||
} else {
|
||||
val full = stt?.finish() ?: ""
|
||||
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
||||
send(session, SttDone(full))
|
||||
send(connId, SttDone(full))
|
||||
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)
|
||||
session.send(text)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
runCatching { transport.send(connId, text) }
|
||||
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WS-сервер телефона (CIO-движок — работает на Android, в отличие от Netty).
|
||||
* Живёт в приложении (Application), слушает 0.0.0.0:[port]/ws/glasses.
|
||||
* Сервер связи очков (запускается в PhoneApp). [transport] выбирается флагом
|
||||
* [useBluetooth] (TASK-transport.md п.4): дефолт — [WifiServerTransport]
|
||||
* (Ktor CIO, `0.0.0.0:$port/ws/glasses`), Bluetooth — [BtServerTransport]
|
||||
* (SPP). Поведение по умолчанию (WiFi) не меняется.
|
||||
*/
|
||||
class GlassesServer(
|
||||
private val port: Int = PhoneConfig.SERVER_PORT,
|
||||
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). */
|
||||
val sender: GlassesSender = object : GlassesSender {
|
||||
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() {
|
||||
if (server != null) return
|
||||
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
|
||||
glassesServerModule(hub)
|
||||
if (acceptJob != null) return
|
||||
acceptJob = scope.launch {
|
||||
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() {
|
||||
server?.stop(1_000, 3_000)
|
||||
server = null
|
||||
transport.close()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
@@ -7,6 +9,7 @@ import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.core.content.ContextCompat
|
||||
import pw.binom.viewmate.phone.stt.SttDebug
|
||||
import pw.binom.viewmate.phone.agent.LlmDebug
|
||||
import pw.binom.viewmate.phone.ui.ViewMateApp
|
||||
@@ -15,6 +18,7 @@ import java.io.File
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestBtPermissions()
|
||||
setContent {
|
||||
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]. */
|
||||
private fun runLlmDebug() {
|
||||
showOverlay("[llm] вызываю llm.binom.pw...")
|
||||
|
||||
@@ -39,8 +39,13 @@ class PhoneApp : Application() {
|
||||
|
||||
val state: PhoneState = PhoneState()
|
||||
|
||||
/** WS-сервер для очков. */
|
||||
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
|
||||
/** Сервер связи очков: транспорт по [PhoneConfig.USE_BLUETOOTH] — дефолт WiFi (WS), Bluetooth SPP — флагом. */
|
||||
val server: GlassesServer = GlassesServer(
|
||||
port = PhoneConfig.SERVER_PORT,
|
||||
state = state,
|
||||
useBluetooth = PhoneConfig.USE_BLUETOOTH,
|
||||
btContext = this,
|
||||
)
|
||||
|
||||
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
|
||||
val nsd: NsdPublisher by lazy { NsdPublisher(this) }
|
||||
@@ -103,13 +108,21 @@ class PhoneApp : Application() {
|
||||
*/
|
||||
private suspend fun assistantLoop() {
|
||||
for (phrase in assistantChannel) {
|
||||
log("assistant", "получена фраза: $phrase")
|
||||
val assistant = ensureAssistant()
|
||||
if (assistant == null) {
|
||||
log("assistant", "LLM-ключ не задан — фраза пропущена")
|
||||
continue
|
||||
}
|
||||
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}") }
|
||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||
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 —
|
||||
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
|
||||
@@ -189,6 +232,9 @@ class PhoneApp : Application() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
PlaybackService.start(this)
|
||||
log("server", "playback-сервис запущен (foreground, WAKE_LOCK)")
|
||||
|
||||
server.start()
|
||||
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
||||
nsd.publish()
|
||||
@@ -264,6 +310,7 @@ class PhoneApp : Application() {
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
PlaybackService.stop(this)
|
||||
nsd.unpublish()
|
||||
sttStreamer?.close()
|
||||
server.stop()
|
||||
|
||||
@@ -12,6 +12,13 @@ object PhoneConfig {
|
||||
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
|
||||
const val SERVER_PORT = 8080
|
||||
|
||||
/**
|
||||
* Выбор транспорта очки↔телефон (TASK-transport.md п.4):
|
||||
* `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений);
|
||||
* `true` — Bluetooth SPP ([BtServerTransport], manual-включение).
|
||||
*/
|
||||
const val USE_BLUETOOTH = true
|
||||
|
||||
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
|
||||
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-вызовы не пересекаются).
|
||||
*
|
||||
* Тулы: агент видит их в системном промпте (см. [SYSTEM_PROMPT]); если ему
|
||||
* нужна тула, он отвечает одной строкой `TOOL_CALL: <имя>` — ассистент
|
||||
* выполняет тул и возвращает результат как tool-сообщение в истории,
|
||||
* затем делает второй вызов LLM с результатом. Обычный текст (без маркера)
|
||||
* возвращается пользователю как финальный ответ.
|
||||
*/
|
||||
class Assistant(
|
||||
private val chatDao: ChatDao,
|
||||
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
|
||||
private val tools: List<AgentTool> = AgentTools.ALL,
|
||||
private val timeoutMs: Long = 30_000,
|
||||
) {
|
||||
/**
|
||||
* Обработать фразу: сохранить user-сообщение, вызвать LLM с историей
|
||||
* сессии (последние 20 сообщений), сохранить ответ. При таймауте LLM —
|
||||
* ответ с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
|
||||
* Обработать фразу в текущей (последней) сессии: сохранить user-сообщение,
|
||||
* вызвать LLM с историей сессии (последние 20 сообщений), при вызове тула —
|
||||
* выполнить и повторить вызов, сохранить ответ. При таймауте LLM — ответ
|
||||
* с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
|
||||
*/
|
||||
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)
|
||||
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 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 "Ассистент не ответил (таймаут)"
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
||||
chatDao.touchSession(sessionId)
|
||||
if (!isToolCall(answer)) {
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
||||
chatDao.touchSession(sessionId)
|
||||
}
|
||||
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 {
|
||||
// TODO(персона): промпт выберет пользователь
|
||||
const val SYSTEM_PROMPT =
|
||||
"Ты — локальный голосовой ассистент в AR-очках. Отвечай кратко (1-3 предложения), по-русски, без markdown и эмодзи. Рядом с тобой может идти фильм — вопросы могут быть про него."
|
||||
/** Простой протокол вызова тула: строка вида `TOOL_CALL: <name>`. */
|
||||
private val TOOL_CALL_RE = Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)""", RegexOption.IGNORE_CASE)
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
/** Сессия по 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) {
|
||||
db.writableDatabase.update(
|
||||
ChatDb.TABLE_SESSIONS,
|
||||
|
||||
@@ -1,64 +1,107 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import ai.koog.http.client.okhttp.OkHttpKoogHttpClient
|
||||
import ai.koog.prompt.dsl.prompt
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
|
||||
import ai.koog.prompt.llm.LLMCapability
|
||||
import ai.koog.prompt.llm.LLModel
|
||||
import ai.koog.prompt.llm.LLMProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Базовый LLM-клиент на Koog (JetBrains), OpenAI-совместимый эндпоинт
|
||||
* https://llm.binom.pw (chatCompletionsPath по умолчанию "v1/chat/completions").
|
||||
* LLM-клиент на прямом HTTP (OkHttp) к OpenAI-совместимому эндпоинту
|
||||
* 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(
|
||||
private val apiKey: String,
|
||||
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(
|
||||
apiKey = apiKey,
|
||||
settings = OpenAIClientSettings(baseUrl = baseUrl),
|
||||
httpClientFactory = OkHttpKoogHttpClient.Factory(),
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
@Serializable
|
||||
private data class ChatMessageDto(
|
||||
val role: String,
|
||||
val content: String,
|
||||
)
|
||||
|
||||
private val llmModel: LLModel = LLModel(
|
||||
provider = LLMProvider.OpenAI,
|
||||
id = model,
|
||||
capabilities = listOf(
|
||||
LLMCapability.Completion,
|
||||
LLMCapability.OpenAIEndpoint.Completions,
|
||||
),
|
||||
@Serializable
|
||||
private data class ChatRequest(
|
||||
val model: String,
|
||||
val messages: List<ChatMessageDto>,
|
||||
val max_tokens: Int = 1024,
|
||||
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 {
|
||||
val prompt = prompt("assistant-chat") {
|
||||
if (system != null) {
|
||||
this.system(system)
|
||||
}
|
||||
for (msg in messages) {
|
||||
when (msg.role) {
|
||||
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) this.system(msg.content)
|
||||
ChatRole.USER -> if (msg.content.isNotBlank()) user(msg.content)
|
||||
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) assistant(msg.content)
|
||||
// TODO(assistant): в Koog нет отдельной tool-роли — результаты инструментов
|
||||
// моделируются как MessagePart.Tool.Result внутри user-сообщения, но для этого
|
||||
// нужен tool-name, которого в ChatMessage нет. Пропускаем (базовый клиент).
|
||||
ChatRole.TOOL -> Unit
|
||||
val dto = ArrayList<ChatMessageDto>()
|
||||
if (system != null) dto += ChatMessageDto("system", system)
|
||||
for (msg in messages) {
|
||||
when (msg.role) {
|
||||
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) dto += ChatMessageDto("system", msg.content)
|
||||
ChatRole.USER -> if (msg.content.isNotBlank()) dto += ChatMessageDto("user", msg.content)
|
||||
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) dto += ChatMessageDto("assistant", msg.content)
|
||||
ChatRole.TOOL -> if (msg.content.isNotBlank()) {
|
||||
dto += ChatMessageDto("user", "Результат тула ${msg.toolCallId ?: "?"}: ${msg.content}")
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.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.TextButton
|
||||
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.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
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
|
||||
fun ChatScreen() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
|
||||
val app = PhoneApp.instance
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
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,
|
||||
color = PhoneText,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "в разработке (следующий этап)",
|
||||
color = PhoneMuted,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
.background(
|
||||
color = if (isUser) PhoneSurface else PhoneCard,
|
||||
shape = RoundedCornerShape(10.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.webSocket
|
||||
import io.ktor.server.testing.testApplication
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import io.ktor.websocket.send
|
||||
@@ -30,17 +29,34 @@ import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.fail
|
||||
|
||||
/**
|
||||
* Интеграционные тесты хаба поверх реального [WifiServerTransport] (Ktor CIO):
|
||||
* поднимаем сервер на свободном порту, ходим WS-клиентом, проверяем
|
||||
* handshake/жесты/бродкаст/статусы (TASK-transport.md п.5: «тесты зелёные»).
|
||||
*/
|
||||
class GlassesServerTest {
|
||||
|
||||
private val json = protocolJson
|
||||
|
||||
@Test
|
||||
fun helloGetsWelcome() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
/** Поднять [GlassesServer] на случайном порту и вернуть его + порт. */
|
||||
private fun startServer(): Pair<GlassesServer, Int> {
|
||||
val port = freePort()
|
||||
val server = GlassesServer(port = port, state = PhoneState(), useBluetooth = false)
|
||||
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")))
|
||||
val frame = incoming.receive() as Frame.Text
|
||||
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||
@@ -50,12 +66,9 @@ class GlassesServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gestureIsProcessed() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
fun gestureIsProcessed() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
|
||||
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
|
||||
}
|
||||
@@ -64,28 +77,22 @@ class GlassesServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectedCounterTracksSessions() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) { }
|
||||
fun connectedCounterTracksSessions() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { }
|
||||
awaitTrue { hub.connected.value == 0 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun broadcastReachesAllGlasses() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
fun broadcastReachesAllGlasses() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
coroutineScope {
|
||||
val opened = Channel<Unit>(2)
|
||||
val received = Channel<HostToGlasses>(2)
|
||||
|
||||
val jobs = (1..2).map {
|
||||
launch {
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
opened.send(Unit)
|
||||
while (true) {
|
||||
val frame = incoming.receive()
|
||||
@@ -115,12 +122,9 @@ class GlassesServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadProgressUpdatesHubPerFileMap() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
fun downloadProgressUpdatesHubPerFileMap() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
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)))
|
||||
}
|
||||
@@ -131,12 +135,9 @@ class GlassesServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extendedGlassesStatusUpdatesHubFields() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
fun extendedGlassesStatusUpdatesHubFields() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
send(
|
||||
json.encodeToString(
|
||||
GlassesToHost.serializer(),
|
||||
@@ -163,6 +164,21 @@ class GlassesServerTest {
|
||||
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) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
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.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import pw.binom.viewmate.core.log
|
||||
import pw.binom.viewmate.core.media.defaultWsClient
|
||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||
import pw.binom.viewmate.core.protocol.protocolJson
|
||||
|
||||
/**
|
||||
* WS-клиент очков: подключается к хосту (телефону), шлёт [GlassesToHost],
|
||||
* принимает [HostToGlasses]. [connect] — бесконечный цикл приёма с
|
||||
* реконнектом через 3с при обрыве.
|
||||
* WS-клиент очков: подключается к хосту (телефону). Работает **JSON-строками**
|
||||
* (TASK-transport.md п.2 — протокол не в заботах транспорта): [connect]
|
||||
* бесконечный цикл приёма с реконнектом через 3с при обрыве, [send] — строка
|
||||
* (декод/кодинг вызывающий, т.е. HostConnection поверх [GlassesTransport]).
|
||||
*/
|
||||
class GlassesWsClient(
|
||||
private val url: String, // ws://<host>:8080/ws/glasses
|
||||
private val http: HttpClient = defaultWsClient(),
|
||||
) {
|
||||
) : GlassesTransport {
|
||||
|
||||
override val name = "wifi"
|
||||
|
||||
@Volatile
|
||||
private var closed = false
|
||||
@@ -36,13 +34,13 @@ class GlassesWsClient(
|
||||
|
||||
/**
|
||||
* Подключиться и слушать. Возвращается при вызове [close].
|
||||
* [onHostMessage] — входящие от хоста; [onConnected]/[onDisconnected] —
|
||||
* [onMessage] — входящие JSON-строки от хоста; [onConnected]/[onDisconnected] —
|
||||
* события (пере)подключения (для Hello и смены статуса в UI).
|
||||
*/
|
||||
suspend fun connect(
|
||||
onHostMessage: suspend (HostToGlasses) -> Unit,
|
||||
onConnected: suspend () -> Unit = {},
|
||||
onDisconnected: suspend () -> Unit = {},
|
||||
override suspend fun connect(
|
||||
onMessage: suspend (String) -> Unit,
|
||||
onConnected: suspend () -> Unit,
|
||||
onDisconnected: suspend () -> Unit,
|
||||
) {
|
||||
while (!closed) {
|
||||
try {
|
||||
@@ -52,9 +50,7 @@ class GlassesWsClient(
|
||||
try {
|
||||
for (frame in ws.incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val text = frame.readText()
|
||||
val msg = protocolJson.decodeFromString(HostToGlasses.serializer(), text)
|
||||
onHostMessage(msg)
|
||||
onMessage(frame.readText())
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -72,14 +68,13 @@ class GlassesWsClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** Отправить сообщение хосту. Без активного соединения — no-op. */
|
||||
suspend fun send(msg: GlassesToHost) {
|
||||
val text = protocolJson.encodeToString(GlassesToHost.serializer(), msg)
|
||||
session?.send(text)
|
||||
/** Отправить JSON-строку хосту. Без активного соединения — no-op. */
|
||||
override suspend fun send(json: String) {
|
||||
session?.send(json)
|
||||
}
|
||||
|
||||
/** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
|
||||
fun close() {
|
||||
override fun close() {
|
||||
closed = true
|
||||
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 {
|
||||
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) {
|
||||
val received = ConcurrentLinkedQueue<GlassesToHost>()
|
||||
/** Входящие JSON-строки (транспорт — независим от протокола). */
|
||||
val receivedRaw = ConcurrentLinkedQueue<String>()
|
||||
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
|
||||
|
||||
private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
|
||||
@@ -54,10 +63,11 @@ class GlassesWsClientTest {
|
||||
try {
|
||||
for (frame in incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), frame.readText())
|
||||
received.add(msg)
|
||||
val text = frame.readText()
|
||||
receivedRaw.add(text)
|
||||
val msg = glassesToHost(text)
|
||||
if (msg is Hello) {
|
||||
send(protocolJson.encodeToString(
|
||||
send(json.encodeToString(
|
||||
HostToGlasses.serializer(),
|
||||
Welcome(mode = GlassesMode.MOVIE),
|
||||
))
|
||||
@@ -83,7 +93,7 @@ class GlassesWsClientTest {
|
||||
get() = sessions.size
|
||||
|
||||
suspend fun broadcast(msg: HostToGlasses) {
|
||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||
for (s in sessions) {
|
||||
try {
|
||||
s.send(text)
|
||||
@@ -114,20 +124,21 @@ class GlassesWsClientTest {
|
||||
try {
|
||||
runBlocking {
|
||||
val client = GlassesWsClient(server.url())
|
||||
val messages = mutableListOf<HostToGlasses>()
|
||||
val messages = ConcurrentLinkedQueue<String>()
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val job = launch {
|
||||
client.connect(
|
||||
onHostMessage = { messages.add(it) },
|
||||
onMessage = { messages.add(it) },
|
||||
onConnected = { connected.complete(Unit) },
|
||||
onDisconnected = {},
|
||||
)
|
||||
}
|
||||
|
||||
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 } }
|
||||
val welcome = messages.filterIsInstance<Welcome>().first()
|
||||
awaitTrue { messages.any { hostToGlasses(it) is Welcome } }
|
||||
val welcome = messages.first { hostToGlasses(it) is Welcome }.let { hostToGlasses(it) as Welcome }
|
||||
assertEquals(GlassesMode.MOVIE, welcome.mode)
|
||||
|
||||
client.close()
|
||||
@@ -147,16 +158,19 @@ class GlassesWsClientTest {
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val job = launch {
|
||||
client.connect(
|
||||
onHostMessage = {},
|
||||
onMessage = {},
|
||||
onConnected = { connected.complete(Unit) },
|
||||
onDisconnected = {},
|
||||
)
|
||||
}
|
||||
|
||||
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 } }
|
||||
val gesture = server.received.filterIsInstance<Gesture>().first()
|
||||
awaitTrue { server.receivedRaw.any { glassesToHost(it) is Gesture } }
|
||||
val gesture = server.receivedRaw.first { glassesToHost(it) is Gesture }.let {
|
||||
glassesToHost(it) as Gesture
|
||||
}
|
||||
assertEquals("DoubleClick", gesture.gesture)
|
||||
|
||||
client.close()
|
||||
@@ -173,12 +187,13 @@ class GlassesWsClientTest {
|
||||
try {
|
||||
runBlocking {
|
||||
val client = GlassesWsClient(server.url())
|
||||
val messages = mutableListOf<HostToGlasses>()
|
||||
val messages = ConcurrentLinkedQueue<String>()
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val job = launch {
|
||||
client.connect(
|
||||
onHostMessage = { messages.add(it) },
|
||||
onMessage = { messages.add(it) },
|
||||
onConnected = { connected.complete(Unit) },
|
||||
onDisconnected = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,8 +202,8 @@ class GlassesWsClientTest {
|
||||
|
||||
server.broadcast(SetMode(GlassesMode.CHAT))
|
||||
|
||||
awaitTrue { messages.any { it is SetMode } }
|
||||
val setMode = messages.filterIsInstance<SetMode>().first()
|
||||
awaitTrue { messages.any { hostToGlasses(it) is SetMode } }
|
||||
val setMode = messages.first { hostToGlasses(it) is SetMode }.let { hostToGlasses(it) as SetMode }
|
||||
assertEquals(GlassesMode.CHAT, setMode.mode)
|
||||
|
||||
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
|
||||
fun reconnectsAfterServerDrop() {
|
||||
val server = TestServer()
|
||||
@@ -209,8 +268,9 @@ class GlassesWsClientTest {
|
||||
val client = GlassesWsClient(server.url())
|
||||
val job = launch {
|
||||
client.connect(
|
||||
onHostMessage = {},
|
||||
onMessage = {},
|
||||
onConnected = { connects++ },
|
||||
onDisconnected = {},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user