Files
subochev bbf7a020d4 Ассистент Порфирий: тула времени, сессии, чат-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) — зелёные
2026-08-22 16:31:04 +03:00

133 lines
7.1 KiB
Markdown

# Задача: телефон не должен засыпать во время просмотра (foreground service)
## Симптом (пользователь, 21.08)
«Если я телефон отправлю спать, то звук на телефоне прекращается. А когда на
телефоне прекращается звук, очки тоже перестают играть.»
## Корень (диагноз подтверждён)
Архитектура: звук играет ТОЛЬКО на телефоне (у очков нет динамиков), видео —
на очках, синк по позиции (очки — мастер, 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
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` если нет).
### 3. Запуск/остановка сервиса в `PhoneApp.kt`
- **Старт — при каждом старте аудио** (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)`.
Решение принято: **старт в onCreate, стоп в onTerminate** (сервис = спутник
WS-сервера, а не аудио-сессии; уведомление постоянно, но это норма для
приложения-компаньона — так работает, например, в паре с очками).
### 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`.