Ассистент Порфирий: тула времени, сессии, чат-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:
2026-08-22 16:31:04 +03:00
parent adfe376dd4
commit bbf7a020d4
28 changed files with 1802 additions and 342 deletions
@@ -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) {