CICD: сборка APK на release → F-Droid; versionCode из тега; фикс скролла каталога и масштаба
build / Android APK to F-Droid (release) Failing after 36s

This commit is contained in:
2026-08-21 19:27:02 +03:00
parent ee86b9dc50
commit ee9700c558
11 changed files with 267 additions and 7 deletions
+50
View File
@@ -0,0 +1,50 @@
name: build
on:
release:
types: [published]
jobs:
android:
name: Android APK to F-Droid
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Setup Java 21
uses: actions/setup-java@v4
with:
java-version: 21
distribution: adopt
- name: Setup Android SDK
shell: bash
run: |
set -euo pipefail
export ANDROID_HOME=$HOME/android-sdk
mkdir -p "$ANDROID_HOME/cmdline-tools"
if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
curl -sSL -o /tmp/cmdtools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q -o /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
fi
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;36.0.0" > /dev/null
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
- name: Build APKs
shell: bash
run: |
chmod +x gradlew
./gradlew :app-phone:assembleRelease :app-glasses:assembleRelease \
-PversionCode=${{ gitea.ref_name }} \
--no-daemon --stacktrace
- name: Publish phone to F-Droid
uses: https://git.binom.pw/subochev/devops/fdroid-publish@main
with:
apk_path: "app-phone/build/outputs/apk/release/app-phone-release-unsigned.apk"
- name: Publish glasses to F-Droid
uses: https://git.binom.pw/subochev/devops/fdroid-publish@main
with:
apk_path: "app-glasses/build/outputs/apk/release/app-glasses-release-unsigned.apk"
+106
View File
@@ -0,0 +1,106 @@
# Задача: масштаб на всех экранах очков + сохранение скролла каталога
## Баг 1: ConnectionScreen игнорирует масштаб
**Симптом (пользователь):** «в режиме НЕ просмотра видео (пока ничего не включено)
очки игнорируют выставленный масштаб». Плюс «масштаб будто бы не сохраняется —
сразу после старта масштаб 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:
```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),
) {
... существующий контент ...
}
}
}
```
Внутри контейнера всё остаётся: Column с текстами, StatusPanelCompose,
оверлей распознавания — но всё это теперь в уменьшенном контейнере.
Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть.
⚠️ ВАЖНО: `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).
## Баг 2: скролл каталога теряется при навигации
**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад
(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл
не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в
папку, потом ещё — и т.д.»
**Корень:** `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. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок.
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "pw.binom.viewmate.glasses" applicationId = "pw.binom.viewmate.glasses"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
versionName = "0.1.0" versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
} }
buildTypes { buildTypes {
@@ -45,6 +45,9 @@ class GlassesApp : Application() {
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */ /** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("") val assistantText = MutableStateFlow("")
/** Текущая Activity (для закрытия приложения по команде с телефона). */
var mainActivity: MainActivity? = null
/** Применить и сохранить масштаб. */ /** Применить и сохранить масштаб. */
fun applyScale(value: Float) { fun applyScale(value: Float) {
val v = value.coerceIn(0.5f, 1f) val v = value.coerceIn(0.5f, 1f)
@@ -31,6 +31,7 @@ import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.PlaybackPosition import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SetMode import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale 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.ShowText
import pw.binom.viewmate.core.protocol.SttCancel import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone import pw.binom.viewmate.core.protocol.SttDone
@@ -288,6 +289,14 @@ class HostConnection(
log("stt", "отменено") log("stt", "отменено")
GlassesApp.instance.listening.value = false GlassesApp.instance.listening.value = false
} }
is Shutdown -> {
addMessage("Shutdown: закрываю приложение")
// как в pair-view: finish() Activity очков
GlassesApp.instance.mainActivity?.finishAffinity()
// страховка: процесс завершится сам, но kill убирает зависшие потоки (RayNeo)
android.os.Process.killProcess(android.os.Process.myPid())
}
} }
} }
@@ -19,10 +19,10 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -56,9 +56,12 @@ import pw.binom.viewmate.glasses.gestures.templeActionToCommand
import pw.binom.viewmate.glasses.status.StatusPanelCompose import pw.binom.viewmate.glasses.status.StatusPanelCompose
import pw.binom.viewmate.glasses.stt.SttMicStream import pw.binom.viewmate.glasses.stt.SttMicStream
import pw.binom.viewmate.glasses.ui.Binocular import pw.binom.viewmate.glasses.ui.Binocular
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
import pw.binom.viewmate.glasses.ui.BinocularScreenSize import pw.binom.viewmate.glasses.ui.BinocularScreenSize
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.px import pw.binom.viewmate.glasses.ui.px
import pw.binom.viewmate.glasses.ui.scaleOffsetX
class MainActivity : BaseEventActivity() { class MainActivity : BaseEventActivity() {
private var resumeAfterScreenOn = false private var resumeAfterScreenOn = false
@@ -112,6 +115,7 @@ class MainActivity : BaseEventActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
GlassesApp.instance.mainActivity = this
collectTempleActions() collectTempleActions()
disableTrackpadSounds() disableTrackpadSounds()
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -311,6 +315,7 @@ class MainActivity : BaseEventActivity() {
} }
override fun onDestroy() { override fun onDestroy() {
GlassesApp.instance.mainActivity = null
super.onDestroy() super.onDestroy()
unregisterReceiver(screenReceiver) unregisterReceiver(screenReceiver)
unregisterReceiver(debugReceiver) unregisterReceiver(debugReceiver)
@@ -354,12 +359,27 @@ fun ConnectionScreen() {
val connected by connection.connected.collectAsState() val connected by connection.connected.collectAsState()
val status by connection.status.collectAsState() val status by connection.status.collectAsState()
val messages by connection.messages.collectAsState() val messages by connection.messages.collectAsState()
val scale by GlassesApp.instance.scale.collectAsState()
// Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой // Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой
// половины (явная ширина половины, иначе fillMaxSize растянет на 1280). // половины (явная ширина половины, иначе fillMaxSize растянет на 1280).
Binocular(scale = false) { Binocular(scale = false) {
val screenWidthPx = BinocularScreenSize.current.width val screenWidthPx = BinocularScreenSize.current.width
Box(modifier = Modifier.width(screenWidthPx.px).fillMaxHeight().background(Color.Black)) { val screenHeightPx = BinocularScreenSize.current.height
val side = BinocularScreenSide.current
// Масштаб через layout: контейнер каждого глаза — scale от половины экрана,
// сдвинут к центру (к носу) на (1 - scale) своей ширины.
val containerW = (screenWidthPx * scale).toInt()
val containerH = (screenHeightPx * scale).toInt()
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
.background(Color.Black),
) {
Column( Column(
modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp), modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -377,6 +397,7 @@ fun ConnectionScreen() {
StatusPanelCompose( StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
scale = scale,
) )
// Оверлей распознавания: затемнение + накопленный текст (поверх всего). // Оверлей распознавания: затемнение + накопленный текст (поверх всего).
+2 -2
View File
@@ -37,8 +37,8 @@ android {
applicationId = "pw.binom.viewmate.phone" applicationId = "pw.binom.viewmate.phone"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
versionName = "0.1.0" versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// LLM-ключ из local.properties (llm.apiKey) — вне git; пустой = ещё не задан // LLM-ключ из local.properties (llm.apiKey) — вне git; пустой = ещё не задан
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -26,6 +27,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -77,6 +79,13 @@ class CatalogState(
private set private set
var path by mutableStateOf<Path>(Path.Root) var path by mutableStateOf<Path>(Path.Root)
private set private set
var scrollIndex by mutableStateOf(0)
private set
var scrollOffset by mutableStateOf(0)
private set
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
private var searchJob: Job? = null private var searchJob: Job? = null
private var loadJob: Job? = null private var loadJob: Job? = null
@@ -102,16 +111,19 @@ class CatalogState(
} }
fun openSeries(item: JellyfinItem) { fun openSeries(item: JellyfinItem) {
savedScroll[path] = scrollIndex to scrollOffset
path = Path.Series(item) path = Path.Series(item)
refresh() refresh()
} }
fun openSeason(season: JellyfinItem, series: JellyfinItem) { fun openSeason(season: JellyfinItem, series: JellyfinItem) {
savedScroll[path] = scrollIndex to scrollOffset
path = Path.Season(season, series) path = Path.Season(season, series)
refresh() refresh()
} }
fun openFolder(item: JellyfinItem) { fun openFolder(item: JellyfinItem) {
savedScroll[path] = scrollIndex to scrollOffset
path = Path.Folder(item) path = Path.Folder(item)
refresh() refresh()
} }
@@ -123,9 +135,27 @@ class CatalogState(
is Path.Season -> Path.Series(p.series) is Path.Season -> Path.Series(p.series)
is Path.Folder -> Path.Root is Path.Folder -> Path.Root
} }
val (idx, off) = savedScroll[path] ?: Pair(0, 0)
scrollIndex = idx
scrollOffset = off
refresh() refresh()
} }
/** Сохранить позицию для текущего пути (перед уходом на детали и т.п.). */
fun saveScrollNow() {
savedScroll[path] = scrollIndex to scrollOffset
}
/** Сохранить текущую позицию (вызывается при скролле из UI). */
fun setScroll(index: Int, offset: Int) {
scrollIndex = index
scrollOffset = offset
}
/** Позиция скролла для текущего пути (восстановление после навигации). */
fun savedScrollForCurrentPath(): Pair<Int, Int> =
savedScroll[path] ?: Pair(0, 0)
/** Заголовок под шапкой: текущая вкладка или хлебная крошка внутри сериала/папки. */ /** Заголовок под шапкой: текущая вкладка или хлебная крошка внутри сериала/папки. */
fun subtitle(): String = when (val p = path) { fun subtitle(): String = when (val p = path) {
Path.Root -> when (tab) { Path.Root -> when (tab) {
@@ -200,6 +230,21 @@ fun CatalogScreen(
) { ) {
val app = PhoneApp.instance val app = PhoneApp.instance
val userId by app.userId.collectAsState() val userId by app.userId.collectAsState()
// Позиция скролла каталога: сохраняется/восстанавливается между навигациями.
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) }
}
val downloaded by app.state.downloadedFlow.collectAsState() val downloaded by app.state.downloadedFlow.collectAsState()
val localDownloaded by app.downloadManager.downloadedIds.collectAsState() val localDownloaded by app.downloadManager.downloadedIds.collectAsState()
@@ -218,6 +263,7 @@ fun CatalogScreen(
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто") ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто")
} else { } else {
LazyVerticalGrid( LazyVerticalGrid(
state = gridState,
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
contentPadding = PaddingValues(14.dp), contentPadding = PaddingValues(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -246,7 +292,11 @@ fun CatalogScreen(
state.openSeason(item, series) state.openSeason(item, series)
} }
item.Type in CatalogState.CONTAINER_TYPES -> state.openFolder(item) item.Type in CatalogState.CONTAINER_TYPES -> state.openFolder(item)
else -> onItemClick(item) else -> {
// уходим на детали — сохранить позицию, чтобы назад вернул скролл
state.saveScrollNow()
onItemClick(item)
}
} }
}, },
) )
@@ -213,6 +213,15 @@ fun GlassesScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
RemoteButton(
text = "⏻ Выключить очки",
big = true,
onClick = {
scope.launch { app.actions?.shutdownGlasses() }
},
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
)
RemoteButton( RemoteButton(
text = "Скачанное на очках →", text = "Скачанное на очках →",
big = true, big = true,
@@ -18,6 +18,7 @@ import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.SetMode import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.Shutdown
/** /**
* Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки. * Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки.
@@ -267,5 +268,11 @@ class PhoneActions(
return "масштаб → $clamped" return "масштаб → $clamped"
} }
/** Закрыть приложение на очках (телефон остаётся открытым). */
suspend fun shutdownGlasses(): String {
sender.send(Shutdown())
return "выключение очков"
}
private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60 private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60
} }
@@ -95,3 +95,8 @@ data class SttDone(val full: String) : HostToGlasses
@Serializable @Serializable
@SerialName("stt_cancel") @SerialName("stt_cancel")
class SttCancel : HostToGlasses class SttCancel : HostToGlasses
/** Полное выключение приложения на очках (по команде с телефона). */
@Serializable
@SerialName("shutdown")
class Shutdown : HostToGlasses