клавиатура: подавить системный IME на очках + ввод текста через Accessibility

This commit is contained in:
2026-10-01 17:40:28 +03:00
parent c02e126bc6
commit 65782abfdb
8 changed files with 175 additions and 51 deletions
@@ -2,30 +2,30 @@ package pw.binom.rayneovm.glasses
import android.accessibilityservice.AccessibilityService
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
/**
* Пассивный Accessibility-сервис GlassesApp.
* Accessibility-сервис GlassesApp.
*
* Зачем: Accessibility-сервис — единственный «легальный» способ для обычного
* приложения (без root/Shizuku/system-app) получить:
* - живучесть процесса: систему сама поднимает и перезапускает сервис,
* - возможность открывать Activity из фона,
* - [onAccessibilityEvent] (в будущем — для отслеживания фокуса ввода и клавиатуры, задача #4).
* Зачем:
* - живучесть процесса: система сама поднимает и перезапускает сервис;
* - «легальный» запуск Activity из фона;
* - ввод текста в поле гостя на VirtualDisplay ([setText], ACTION_SET_TEXT) — IME на
* этих очках сменить нельзя (ROM жёстко держит LatinIME), поэтому системная
* клавиатура гостя отключается, а текст приходит из телефона сюда.
*
* ВАЖНО: сервис НИЧЕГО не делает с чужими окнами — не читает содержимое
* ([android:canRetrieveWindowContent] = false) и не инжектит события. Это
* сознательно: минимум привилегий, минимум риска сломать ввод в гостевых приложениях.
*
* Регистрируется в манифесте через [VM_ACCESSIBILITY_SERVICE] с конфигом
* `res/xml/vm_accessibility_service.xml`. Активируется пользователем вручную:
* Настройки → Специальные возможности → RayNeo VM.
* Регистрируется в манифесте с конфигом `res/xml/vm_accessibility_service.xml`.
* Активируется пользователем вручную: Настройки → Специальные возможности → RayNeo VM.
*/
class VmAccessibilityService : AccessibilityService() {
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
Log.i(TAG, "Accessibility-сервис подключён")
// Авто-старт: если основное приложение ещё не поднято — поднимаем его.
// Это и есть «легальный авто-запуск» из задачи #5.
@@ -37,17 +37,97 @@ class VmAccessibilityService : AccessibilityService() {
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// Намеренно пусто. Слушаем только для того, чтобы сервис считался живым.
// Тут позже появится логика задачи #4 (фокус поля ввода → скрыть IME гостя).
// Намеренно пусто: слушаем только чтобы сервис считался живым.
}
override fun onInterrupt() {
// Ничего: сервис не выполняет длительных операций.
}
override fun onUnbind(intent: Intent?): Boolean {
if (instance === this) instance = null
return super.onUnbind(intent)
}
override fun onDestroy() {
if (instance === this) instance = null
super.onDestroy()
}
/** Заменяет текст в сфокусированном поле ввода гостя. false — если поля в фокусе нет. */
private fun applyText(text: String): Boolean {
val node = findFocusedInputNode() ?: return false
return try {
val args = Bundle().apply {
putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
}
val ok = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
if (!ok) Log.w(TAG, "ACTION_SET_TEXT вернул false")
ok
} catch (t: Throwable) {
Log.w(TAG, "ACTION_SET_TEXT упал", t)
false
}
}
/**
* Ищет сфокусированный узел ввода: сначала в активном окне, затем во всех
* интерактивных окнах (в т.ч. окно гостя на VirtualDisplay).
*/
private fun findFocusedInputNode(): AccessibilityNodeInfo? {
val active = rootInActiveWindow
val activeFocus = active?.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
Log.i(
TAG,
"findFocused: activeRoot=${active?.packageName} focus=${activeFocus?.className} " +
"editable=${activeFocus?.isEditable}",
)
if (activeFocus?.isEditable == true) return activeFocus
findEditable(active)?.let {
Log.i(TAG, "findFocused: редактируемый узел в активном окне: ${it.className}")
return it
}
val ws = windows
Log.i(TAG, "findFocused: окон=${ws?.size ?: 0}")
ws?.forEach { w ->
val root = w.root ?: return@forEach
val focus = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
Log.i(
TAG,
" окно type=${w.type} pkg=${root.packageName} focus=${focus?.className} editable=${focus?.isEditable}",
)
if (focus?.isEditable == true) return focus
findEditable(root)?.let {
Log.i(TAG, " редактируемый узел: ${it.className} pkg=${root.packageName}")
return it
}
}
return null
}
/** Первый редактируемый (EditText-подобный) потомок в дереве. */
private fun findEditable(root: AccessibilityNodeInfo?): AccessibilityNodeInfo? {
root ?: return null
if (root.isEditable) return root
for (i in 0 until root.childCount) {
findEditable(root.getChild(i))?.let { return it }
}
return null
}
companion object {
private const val TAG = "VmAccessibilitySvc"
/** Активный экземпляр сервиса (в том же процессе, что и [VmService]). */
@Volatile
private var instance: VmAccessibilityService? = null
/** Ввести [text] в текущее поле ввода гостя. false — если поля в фокусе нет. */
fun setText(text: String): Boolean {
if (text.isEmpty()) return true
return instance?.applyText(text) ?: false
}
/** Действие для запроса состояния сервиса/проверки активности (диагностика). */
const val ACTION_PING = "pw.binom.rayneovm.glasses.ACCESSIBILITY_PING"
}