f75e672819
- AssistantMode: NONE/LISTENING/THINKING/ANSWER + чистая gestureDecision (матрица жестов) - Клик в «ответ виден» = новая фраза; DoubleClick = выход (кино на паузе); свайпы = скролл - Плавный скролл ответа: SlideContinuous delta → answerScrollDelta → scrollOffset (зажим 0..max) - Индикатор «думаю...» (thinking, сброс на ShowText) - AssistantOverlay: затемнение 72%, по глазам, скролл-аккумулятор - Тесты: 22 (AssistantGesturesTest) — glasses 91 зелёных
384 lines
17 KiB
Kotlin
384 lines
17 KiB
Kotlin
package pw.binom.viewmate.glasses
|
||
|
||
import android.content.Context
|
||
import android.os.BatteryManager
|
||
import android.os.Build
|
||
import android.os.Environment
|
||
import android.os.StatFs
|
||
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.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.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
|
||
import pw.binom.viewmate.core.protocol.DownloadFiles
|
||
import pw.binom.viewmate.core.protocol.GlassesStatus
|
||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||
import pw.binom.viewmate.core.protocol.Hello
|
||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||
import pw.binom.viewmate.core.protocol.PlaybackPosition
|
||
import pw.binom.viewmate.core.protocol.SetMode
|
||
import pw.binom.viewmate.core.protocol.SetScale
|
||
import pw.binom.viewmate.core.protocol.Shutdown
|
||
import pw.binom.viewmate.core.protocol.ShowText
|
||
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
|
||
|
||
/**
|
||
* Обвязка над [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-поиск в приоритете. */
|
||
private val fallbackUrl: String,
|
||
private val movieController: MovieController,
|
||
private val downloadManager: GlassesDownloadManager,
|
||
context: Context,
|
||
) {
|
||
private val appContext = context.applicationContext
|
||
private val discovery = MdnsClient(context)
|
||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||
|
||
/** Транспортом включается выбор [GlassesConfig.USE_BLUETOOTH]: дефолт — WiFi. */
|
||
private val useBt: Boolean = GlassesConfig.USE_BLUETOOTH
|
||
|
||
/** Активный транспорт (создаётся после выбора адреса: mDNS/fallback или SPP-MAC). */
|
||
@Volatile
|
||
private var transport: GlassesTransport? = null
|
||
|
||
/** Последний найденный адрес — реконнект переиспользует его (WiFi: URL, BT: MAC). */
|
||
@Volatile
|
||
private var lastUrl: String? = null
|
||
|
||
private val _connected = MutableStateFlow(false)
|
||
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||
|
||
private val _status = MutableStateFlow("не подключено")
|
||
val status: StateFlow<String> = _status.asStateFlow()
|
||
|
||
private val _messages = MutableStateFlow<List<String>>(emptyList())
|
||
val messages: StateFlow<List<String>> = _messages.asStateFlow()
|
||
|
||
/** Флаг «плеер играл при обрыве» — продолжим по восстановлении связи. */
|
||
private val reconnectResume = ReconnectResumeFlag()
|
||
|
||
fun start() {
|
||
scope.launch { connectLoop() }
|
||
scope.launch {
|
||
while (isActive) {
|
||
delay(5_000)
|
||
sendToHost(buildStatus())
|
||
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
|
||
sendToHost(
|
||
PlaybackPosition(
|
||
positionMs = snapshot.positionMs,
|
||
playing = snapshot.playing,
|
||
itemId = snapshot.itemId,
|
||
audioIndex = snapshot.audioIndex,
|
||
),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Развилка по [useBt]: WiFi (mDNS/scan/fallback) или SPP (адрес — [GlassesConfig.btRemoteMac]). */
|
||
private suspend fun connectLoop() {
|
||
if (useBt) connectLoopBt() else connectLoopWifi()
|
||
}
|
||
|
||
/**
|
||
* Первое подключение (WiFi): mDNS-дискавери телефона (до 15с на устройстве,
|
||
* 3с на эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
||
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
|
||
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
|
||
* Внутри [GlassesTransport.connect] живёт бесконечный реконнект с тем же
|
||
* адресом (переиспользование последнего найденного); при его отсутствии —
|
||
* повторный mDNS-цикл.
|
||
*/
|
||
private suspend fun connectLoopWifi() {
|
||
val url = lastUrl ?: discoverPhoneUrl()
|
||
if (url.isEmpty()) {
|
||
log("app", "телефон не найден — повтор поиска через 3с")
|
||
delay(3_000)
|
||
connectLoopWifi()
|
||
return
|
||
}
|
||
lastUrl = url
|
||
log("app", "подключаюсь к $url (WiFi)")
|
||
|
||
val t = WifiGlassesTransport(url)
|
||
transport = t
|
||
t.connect(
|
||
onMessage = { text -> handleHostMessageText(text) },
|
||
onConnected = {
|
||
_connected.value = true
|
||
_status.value = "подключено: ${t.name} $url"
|
||
addMessage("соединение установлено")
|
||
discovery.stop()
|
||
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("связь восстановлена — продолжаю")
|
||
}
|
||
},
|
||
onDisconnected = {
|
||
_connected.value = false
|
||
_status.value = "нет соединения (реконнект через 3с)"
|
||
val wasPlaying = withContext(Dispatchers.Main) { movieController.pauseIfPlaying() }
|
||
reconnectResume.onConnectionLost(wasPlaying)
|
||
if (wasPlaying) addMessage("связь потеряна — пауза")
|
||
},
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
|
||
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
|
||
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
|
||
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoopWifi]).
|
||
*/
|
||
private suspend fun discoverPhoneUrl(): String {
|
||
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
|
||
if (found != null) {
|
||
val url = MdnsClient.phoneWsUrl(found)
|
||
log("app", "mDNS нашёл телефон: $url")
|
||
return url
|
||
}
|
||
if (!isEmulator()) {
|
||
val localIp = MdnsClient.localIpv4()
|
||
if (localIp != null) {
|
||
val scanned = PhoneScan.findPhoneWsUrl(localIp)
|
||
if (scanned != null) return scanned
|
||
} else {
|
||
log("app", "нет локального IPv4 — scan /24 пропущен")
|
||
}
|
||
}
|
||
if (isEmulator()) {
|
||
log("app", "эмулятор — fallback на $fallbackUrl")
|
||
return fallbackUrl
|
||
}
|
||
log("app", "mDNS и scan не нашли телефон — повтор поиска через 3с")
|
||
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) {
|
||
val t = transport ?: return
|
||
t.send(protocolJson.encodeToString(GlassesToHost.serializer(), msg))
|
||
}
|
||
|
||
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
|
||
fun sendPlaybackPositionNow() {
|
||
if (transport == null) return
|
||
scope.launch {
|
||
val (positionMs, playing, itemId, audioIndex) =
|
||
withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||
sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex))
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Эмулятор (AVD/Genymotion): mDNS отсутствует, долгий поиск бессмыслен —
|
||
* таймаут сокращается до [MdnsClient.EMULATOR_TIMEOUT_MS] и быстрее
|
||
* наступает fallback на 10.0.2.2. Детект по [Build.FINGERPRINT] (префикс
|
||
* `generic`/`sdk`) и свойству ro.kernel.qemu (скрытый API — через отражение).
|
||
*/
|
||
private fun isEmulator(): Boolean {
|
||
val fingerprint = Build.FINGERPRINT
|
||
if (fingerprint.startsWith("generic") || fingerprint.startsWith("sdk")) return true
|
||
return runCatching {
|
||
val method = Class.forName("android.os.SystemProperties")
|
||
.getMethod("get", String::class.java)
|
||
(method.invoke(null, "ro.kernel.qemu") as? String) == "1"
|
||
}.getOrDefault(false)
|
||
}
|
||
|
||
private fun buildStatus(): GlassesStatus {
|
||
val (totalBytes, freeBytes) = diskBytes()
|
||
val usedBytes = (totalBytes - freeBytes).coerceAtLeast(0L)
|
||
val media = downloadManager.mediaBytesNow()
|
||
return GlassesStatus(
|
||
batteryPercent = batteryPercent(),
|
||
storageUsedGb = usedBytes / 1_000_000_000.0,
|
||
storageTotalGb = totalBytes / 1_000_000_000.0,
|
||
storageTotalBytes = totalBytes,
|
||
storageFreeBytes = freeBytes,
|
||
mediaBytes = media,
|
||
downloadedItemIds = downloadManager.downloadedIds.value.toList(),
|
||
mediaDir = downloadManager.baseDir.absolutePath,
|
||
appVersion = APP_VERSION,
|
||
)
|
||
}
|
||
|
||
private fun batteryPercent(): Int {
|
||
val bm = appContext.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
|
||
val level = bm?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||
return if (level != null && level in 0..100) level else 80
|
||
}
|
||
|
||
private fun diskBytes(): Pair<Long, Long> = runCatching {
|
||
val stat = StatFs(Environment.getDataDirectory().absolutePath)
|
||
stat.totalBytes to stat.availableBytes
|
||
}.getOrDefault(0L to 0L)
|
||
|
||
private suspend fun handleHostMessage(msg: HostToGlasses) {
|
||
when (msg) {
|
||
is Welcome -> {
|
||
_connected.value = true
|
||
addMessage("Welcome: режим ${msg.mode}, сессия ${msg.sessionId ?: "-"}")
|
||
}
|
||
|
||
is SetMode -> addMessage("SetMode: ${msg.mode}")
|
||
|
||
is SetScale -> {
|
||
GlassesApp.instance.applyScale(msg.scale)
|
||
log("app", "scale → ${msg.scale}")
|
||
}
|
||
|
||
is PlayVideo -> {
|
||
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
|
||
reconnectResume.reset()
|
||
movieController.playVideo(msg)
|
||
}
|
||
|
||
is MediaCommandMsg -> {
|
||
addMessage("MediaCommand: ${msg.command} (seekMs=${msg.seekMs})")
|
||
if (msg.command == MediaCommand.CLOSE) reconnectResume.reset()
|
||
movieController.applyMediaCommand(msg.command, msg.seekMs)
|
||
}
|
||
|
||
is DownloadFiles -> {
|
||
addMessage("скачивание на очки: «${msg.title}» (${msg.files.size} файлов)")
|
||
downloadManager.handleDownloadFiles(msg)
|
||
}
|
||
|
||
is DeleteFiles -> {
|
||
addMessage("удаление с очков: ${msg.itemId}")
|
||
downloadManager.handleDeleteFiles(msg.itemId)
|
||
}
|
||
|
||
is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
|
||
|
||
is ShowText -> {
|
||
addMessage("ShowText: ${msg.text}")
|
||
GlassesApp.instance.thinking.value = false
|
||
GlassesApp.instance.answerScrollDelta.value = 0f
|
||
GlassesApp.instance.assistantText.value = msg.text
|
||
}
|
||
|
||
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
|
||
|
||
is SttPhrase -> {
|
||
GlassesApp.instance.sttText.value = msg.full
|
||
log("stt", "фраза: ${msg.phrase}")
|
||
}
|
||
|
||
is SttDone -> {
|
||
log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}")
|
||
GlassesApp.instance.listening.value = false
|
||
}
|
||
|
||
is SttCancel -> {
|
||
log("stt", "отменено")
|
||
GlassesApp.instance.listening.value = false
|
||
GlassesApp.instance.thinking.value = false
|
||
}
|
||
|
||
is Shutdown -> {
|
||
addMessage("Shutdown: закрываю приложение")
|
||
// как в pair-view: finish() Activity очков
|
||
GlassesApp.instance.mainActivity?.finishAffinity()
|
||
// страховка: процесс завершится сам, но kill убирает зависшие потоки (RayNeo)
|
||
android.os.Process.killProcess(android.os.Process.myPid())
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun addMessage(text: String) {
|
||
log("glasses", text)
|
||
_messages.value = (_messages.value + "» $text").takeLast(50)
|
||
}
|
||
|
||
fun stop() {
|
||
transport?.close()
|
||
discovery.stop()
|
||
scope.cancel()
|
||
}
|
||
|
||
companion object {
|
||
private const val APP_VERSION = "0.1.0"
|
||
}
|
||
}
|