glasses: пассивный Accessibility-сервис (авто-запуск + живучесть) + TASKS.md с планом

This commit is contained in:
2026-09-27 05:46:14 +03:00
parent 9914488258
commit 2125788e25
5 changed files with 264 additions and 4 deletions
+171 -4
View File
@@ -1,4 +1,171 @@
1. [ ] Стартовый экран (тот когда не запущино ничего на вирутальных мониторах) отображается криво, не бинокулдярно. Он тоже должен быть бинокулярным. # 1 · Биноокулярный стартовый экран
2. [ ] Надо добавить возможность проброса звука с виртуального дисплея на телефон. Должен быть переключатель через что выводить звук: через телефон или с очков. то есть надо сделать саму механику проброса, а потом кнопку переключатиль в мобильном приложении
3. [ ] Когда приложение на виртуальном дисплее запущено отображается положение мышки (жолтая точка). Когда она долго не двигается надо её скрывать на очках Стартовый экран (тот когда не запущено ничего на виртуальных мониторах) отображается
4. [ ] Доработать функционал клавиатуры. 1. когда на самих очках поле ввода в фокусе НЕ нужно отображать клавиатуру на очках. 2. когда на телефоне тыкаю на кнопку клавиатуры нужно отобразить клавиатуру, и чтобы все что там вводится вводилось на очках криво, не бинокулярно. Он тоже должен быть бинокулярным.
**Причина:** `StatusOverlay` в `app-glasses-vm/.../ui/GlassesScreen.kt` — обычный Compose-оверлей,
центрируется по всей ширине 1280px, поэтому попадает на стык глаз и рисуется ОДИН раз, а не в обеих
половинах. Тот же приём уже используется в `CursorOverlay` (руками: `x` и `x + half`).
**Решение (согласовано 2026-09-27):**
- Перенести в **rayneo-mercury** удобную view-mate-функцию `Binocular` из
`view-mate/app-glasses/.../ui/ViewUtils.kt` (`fun Binocular`, `BinocularSize`, `BinocularScreenSide`,
`enum Side`, `ScreenSize`, `getValue`-extension). Она делает `Row` из двух `Column(weight(1f))`,
каждый прогоняет весь `content` в своей половине, а `BinocularScreenSize` сообщает композитору
размер одного глаза (W/2 × H).
- Использовать `Binocular { ... }` **только для простых экранов** (Starting / NoConnection /
NoAppSelected / Error / ShizukuMissing).
- **НЕ трогать** GL-сюрфейс (`BinocularRendererView` — он сам бинокулярит на 1280 и кладётся ВНЕ
`Binocular`) и **НЕ трогать `CursorOverlay`** — там хитрая отрисовка, оставить как есть.
**Где переносить:** модуль rayneo-mercury. Кандидаты: `:binocular-renderer` либо новый маленький
`:compose-binocular`. Решить при реализации (по зависимостям — `Binocular` не нужен GL).
---
# 2 · Проброс звука с виртуального дисплея на телефон
Добавить возможность проброса звука с виртуального дисплея на телефон. Переключатель: через телефон
или с очков. Сначала механика, потом кнопка в мобильном приложении.
**Что уже есть (но не доделано):**
- `AudioCaptureService` (очки) — НЕ объявлен в манифесте, никто не вызывает; `SetAudioMode` на
очках только логируется (`VmService.kt`). Сейчас в MIC-fallback, а нужен `AudioPlaybackCapture`.
- Серверного endpoint `/vm-audio` НЕТ (`WifiServerTransport` регистрирует только `/vm`).
- `AudioPlaybackManager` (телефон) написан, но никем не вызывается; `AudioPlaybackService` — заглушка.
- UI-переключателя нет; `controller.setAudioMode` не вызывается.
**Решение (согласовано 2026-09-27):**
- **MediaProjection ОБЯЗАТЕЛЕН** для `AudioPlaybackCapture` (`CAPTURE_AUDIO_OUTPUT` — privileged,
Shizuku не даёт; Accessibility тоже не даёт). НО диалог можно обойти:
`MediaProjection` проверяет AppOps **`PROJECT_MEDIA`** → `cmd appops set <pkg> PROJECT_MEDIA allow`
отключает диалог НАВСЕГДА. Выполнять через Shizuku (один раз, либо при каждом соединении, если
appop сбрасывается системой).
- ON_PHONE: захват `AudioPlaybackCapture` на очках → стрим на телефон → `AudioPlaybackManager`
играет. **Обязательно глушить динамик очков** (`STREAM_MUSIC` → 0 / mute), иначе двойной звук.
- ON_GLASSES: стоп захвата, вернуть звук очков.
- Захватывается ВСЁ системное аудио очков, включая звук гостя — так и надо.
- **Переключатель — на экране общего статуса очков** (`GlassesStatusScreen`), не в пульте.
**Открытые технические вопросы (проверить на девайсе):**
- Работает ли `cmd appops set ... PROJECT_MEDIA allow` от shell на RayNeo X3 Pro (SELinux)?
- Cбрасывается ли `PROJECT_MEDIA` appop при перезапуске/обновлении? Если да — выставлять при каждом
соединении.
- Глушение: `setStreamVolume(STREAM_MUSIC, 0)` глобально; вернуть прежнее значение в ON_GLASSES.
**Риск:** самая большая задача. Начинать первой.
---
# 3 · Автоскрытие курсора
Когда приложение на виртуальном дисплее запущено, отображается положение мышки (жёлтая точка).
Когда она долго не двигается — скрывать на очках.
**Механики нет вообще.** Курсор задаётся в `moveCursor` (`VmGlassesController.kt`), скрывается только
в `stopGuest`.
**Решение (согласовано 2026-09-27):**
- Watchdog: **3 секунды** после последнего действия → скрыть курсор.
- «Движением» считать **любое действие с тачпадом**: move / click / scroll (`moveCursor` /
`clickCursor` / `scrollCursor`) — все сбрасывают таймер и показывают курсор.
- После скрытия при следующем действии курсор появляется (можно на новой позиции — не принципиально).
---
# 4 · Клавиатура
1. Когда на очках поле ввода в фокусе — НЕ показывать клавиатуру на очках.
2. Когда на телефоне тыкаю кнопку клавиатуры — ввести текст, и всё, что вводится, уходит на очки.
**Текущее состояние:**
- На телефоне есть рабочий `KeyboardDialog`, но он не подключён к навигации (ZonaProfile — мёртвый
экран). В `RemoteScreen.kt` кнопка «Клавиатура» **задизейблена**.
- На очках своей клавиатуры нет. Но когда гости (Zona) получает фокус в поле ввода, он показывает
СВОЙ системный IME **поверх нашего приложения** (один экземпляр на display 0, поэтому не
бинокулярный). Это и есть проблема 4.1.
**Решение (согласовано 2026-09-27):**
- **4.1:** написать крошечный IME-заглушку (`VmNoopImeService`, 0 UI) **внутри GlassesApp** и
поставить его дефолтным IME **навсегда** через Shizuku (`settings put secure default_input_method
<наш IME>`). Обратно НЕ возвращать (пользователю не важно). Никакого диалога выбора IME.
- **4.2:** ввод с телефона — инжект Unicode в `KeyEvent` (через Shizuku binder, `injectText(String)`),
чтобы работали **русский и английский**. Новое сообщение `SetText` в `:shared` `VmMessage`.
Кнопка клавиатуры — в `RemoteScreen` (там сейчас заглушка).
- В перспективе: `onAccessibilityEvent` из #5 может помочь прятать IME гостя (см. #5).
**Открытые вопросы (проверить):**
- Пустит ли RayNeo выбрать наш IME дефолтным (`settings put secure default_input_method`) через shell?
- Нужны ли отдельные кнопки Backspace/Enter на телефонной клавиатуре (обычно да).
---
# 5 · Accessibility-сервис (авто-запуск + фоновый процесс) — НЕ ЗАКРЫТА
Рассмотреть возможность добавить сервис в режиме Accessibility: принимать наши команды, делать всё в
фоне (mDNS и т.п.), запускать основное Activity, если оно не запущено — легальный авто-запуск.
**ЦЕЛЬ (уточнено 2026-09-27):** Accessibility должен быть **включён ВСЕГДА**. В него планируется
перенести работу с **WiFi и Bluetooth** (наши каналы, не Mercury). Так гарантируем, что сервис всегда
запущен (система его сама держит/перезапускает, без foreground-нотификации).
**Уже сделано (ядро, проверено на девайсе 2026-09-27):**
- `app-glasses-vm/.../VmAccessibilityService.kt` — пассивный слушатель: НЕ читает содержимое
(`canRetrieveWindowContent=false`), НЕ инжектит. В `onServiceConnected` поднимает `MainActivity`
(авто-запуск).
- `res/xml/vm_accessibility_service.xml` — конфиг (eventTypes=`typeWindowStateChanged`).
- Манифест: `<service>` с `BIND_ACCESSIBILITY_SERVICE` + meta-data.
- Строки в `res/values/strings.xml`.
- **Проверено:** включение работает (и вручную в UI, и программно от shell);
`Enabled services` + `Bound services` подтверждены; при бинде поднимается процесс и `MainActivity`
(`I/VmAccessibilitySvc: Accessibility-сервис подключён` → `MainActivity запущена из
Accessibility-сервиса`).
**МОДЕЛЬ — КАК У SHIZUKU (согласовано 2026-09-27):** provisioning при **каждом** подключении
телефона к очкам проверяет, включён ли Accessibility, и если нет — включает:
```
settings get secure enabled_accessibility_services
# если нашего сервиса нет:
settings put secure enabled_accessibility_services \
pw.binom.rayneovm.glasses/pw.binom.rayneovm.glasses.VmAccessibilityService
settings put secure accessibility_enabled 1
```
Выполнять с телефона через уже готовый **`AdbController`** (wireless-adb → shell). Проверено: от
shell `settings put` работает.
**ДВА ПУТИ ВКЛЮЧЕНИЯ (делаем оба):**
- **(A) Основной — с телефона:** при коннекте `AccessibilityProvisioner.ensure()` (по аналогии с
`ShizukuProvisioner`) проверяет/включает через adb.
- **(B) Самовосстановление — на очках:** само приложение при старте проверяет свой сервис (локально,
через Shizuku) и включает, если выключен.
**ВАЖНЫЕ ОГРАНИЧЕНИЯ (проверено/выяснено):**
- **`force-stop` снимает accessibility-флаг.** `am force-stop`, обновление APK и т.п. выключают
сервис — поэтому provisioning **перепроверяет при каждом коннекте** (не один раз).
- **Ребут:** надо проверить, переживает ли `enabled_accessibility_services` перезагрузку очков
(обычно secure setting переживает, но проверить на RayNeo). Если нет — задействовать наш
HOME-кандидат (`MainActivity` уже с `HOME` intent-filter) и/или `BOOT_COMPLETED`.
- **`onAccessibilityEvent` — мгновенный**, никаких тяжёлых операций (ANR → система отрубит сервис).
Сетевую логику — в корутины/отдельные потоки.
- **Перенос WiFi/BLE-логики в сервис:** сейчас mDNS + WebSocket server живут в foreground-`VmService`.
План — перенести host-логику в `VmAccessibilityService` (или сделать его хостом), `VmService`
сделать тонким/убрать. Обсудить при реализации.
- **Что Accessibility НЕ даёт:** не заменяет MediaProjection/PROJECT_MEDIA appop для #2. Для ввода
(в перспективе) может дать `onAccessibilityEvent` (фокус окна) для #4.
- **Отдельно — киоск (Kiosk / Lock Task Mode):** требует deviceOwner (`dpm set-device-owner` —
одноразово, без сброса не снять) либо whitelisting. Проработать позже, отдельной задачей.
---
# Общие согласования
- Расширять `:shared` `VmMessage` новыми сообщениями (`SetText`, `SetKeyboardActive`, реальный
`SetAudioMode` и т.п.) — **ОК** (APK всё равно пересобираются).
- **Все «повышения» делаются с телефона через `AdbController` при коннекте** (проверено от shell
2026-09-27):
- Accessibility: `settings put secure enabled_accessibility_services` + `accessibility_enabled 1`;
- MediaProjection без диалога: `cmd appops set pw.binom.rayneovm.glasses PROJECT_MEDIA allow`;
- дефолтный IME: `settings put secure default_input_method <наш IME>` (проверить в #4).
- Порядок работы ночью: **#2 → #1 → #3 → #4 → #5** (по убыванию риска/размера).
- Коммиты/пуш — по завершении блоков; отдельно rayneo-vm и rayneo-mercury.
- **Отмашка на начало работ — ждём от пользователя (пока не дана).**
@@ -79,6 +79,22 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaPlayback" /> android:foregroundServiceType="mediaPlayback" />
<!-- Пассивный Accessibility-сервис (задача #5): живучесть процесса +
легальный запуск Activity из фона. Ничего не читает и не инжектит.
Активируется вручную: Настройки → Специальные возможности → RayNeo VM. -->
<service
android:name=".VmAccessibilityService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/vm_accessibility_service" />
</service>
<!-- VmShizukuService НЕ объявляется здесь как <service> — это Shizuku UserService, <!-- VmShizukuService НЕ объявляется здесь как <service> — это Shizuku UserService,
который ServiceStarter создаёт через reflection в отдельном isolated-процессе. который ServiceStarter создаёт через reflection в отдельном isolated-процессе.
Если объявить — Shizuku-сервер не сможет привести класс к IBinder Если объявить — Shizuku-сервер не сможет привести класс к IBinder
@@ -0,0 +1,54 @@
package pw.binom.rayneovm.glasses
import android.accessibilityservice.AccessibilityService
import android.content.Intent
import android.util.Log
import android.view.accessibility.AccessibilityEvent
/**
* Пассивный Accessibility-сервис GlassesApp.
*
* Зачем: Accessibility-сервис — единственный «легальный» способ для обычного
* приложения (без root/Shizuku/system-app) получить:
* - живучесть процесса: систему сама поднимает и перезапускает сервис,
* - возможность открывать Activity из фона,
* - [onAccessibilityEvent] (в будущем — для отслеживания фокуса ввода и клавиатуры, задача #4).
*
* ВАЖНО: сервис НИЧЕГО не делает с чужими окнами — не читает содержимое
* ([android:canRetrieveWindowContent] = false) и не инжектит события. Это
* сознательно: минимум привилегий, минимум риска сломать ввод в гостевых приложениях.
*
* Регистрируется в манифесте через [VM_ACCESSIBILITY_SERVICE] с конфигом
* `res/xml/vm_accessibility_service.xml`. Активируется пользователем вручную:
* Настройки → Специальные возможности → RayNeo VM.
*/
class VmAccessibilityService : AccessibilityService() {
override fun onServiceConnected() {
super.onServiceConnected()
Log.i(TAG, "Accessibility-сервис подключён")
// Авто-старт: если основное приложение ещё не поднято — поднимаем его.
// Это и есть «легальный авто-запуск» из задачи #5.
runCatching { startActivity(Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}) }
.onSuccess { Log.i(TAG, "MainActivity запущена из Accessibility-сервиса") }
.onFailure { Log.w(TAG, "Не удалось запустить MainActivity: ${it.message}") }
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// Намеренно пусто. Слушаем только для того, чтобы сервис считался живым.
// Тут позже появится логика задачи #4 (фокус поля ввода → скрыть IME гостя).
}
override fun onInterrupt() {
// Ничего: сервис не выполняет длительных операций.
}
companion object {
private const val TAG = "VmAccessibilitySvc"
/** Действие для запроса состояния сервиса/проверки активности (диагностика). */
const val ACTION_PING = "pw.binom.rayneovm.glasses.ACCESSIBILITY_PING"
}
}
@@ -1,3 +1,5 @@
<resources> <resources>
<string name="app_name">RayNeo VM</string> <string name="app_name">RayNeo VM</string>
<string name="vm_accessibility_summary">RayNeo VM</string>
<string name="vm_accessibility_description">Фоновый сервис RayNeo VM. Держит приложение работоспособным в фоне и позволяет ему самостоятельно запускаться. Не читает содержимое экрана и не управляет другими приложениями.</string>
</resources> </resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Конфиг Accessibility-сервиса RayNeo VM.
Сервис — пассивный слушатель: он НЕ управляет чужими окнами и НЕ инжектит
события. Нужен только как «легальный» живучий процесс, который система
поднимает/перезапускает сама, и через который можно открывать Activity из фона.
Никаких eventTypes/feedbackType не запрашиваем сверх необходимого:
eventTypes="typeWindowStateChanged" — минимально нужный, чтобы сервис считался
активным и `onAccessibilityEvent` вызывался. canRetrieveWindowContent=false —
содержимое окон нам не нужно, меньше привилегий.
-->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault"
android:canRetrieveWindowContent="false"
android:description="@string/vm_accessibility_description"
android:notificationTimeout="100"
android:summary="@string/vm_accessibility_summary" />