клавиатура: подавить системный 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 -1
View File
@@ -100,7 +100,8 @@
<service <service
android:name=".VmNoopImeService" android:name=".VmNoopImeService"
android:exported="true" android:exported="true"
android:label="@string/app_name"> android:label="@string/app_name"
android:permission="android.permission.BIND_INPUT_METHOD">
<intent-filter> <intent-filter>
<action android:name="android.view.InputMethod" /> <action android:name="android.view.InputMethod" />
</intent-filter> </intent-filter>
@@ -2,30 +2,30 @@ package pw.binom.rayneovm.glasses
import android.accessibilityservice.AccessibilityService import android.accessibilityservice.AccessibilityService
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
/** /**
* Пассивный Accessibility-сервис GlassesApp. * Accessibility-сервис GlassesApp.
* *
* Зачем: Accessibility-сервис — единственный «легальный» способ для обычного * Зачем:
* приложения (без root/Shizuku/system-app) получить: * - живучесть процесса: система сама поднимает и перезапускает сервис;
* - живучесть процесса: систему сама поднимает и перезапускает сервис, * - «легальный» запуск Activity из фона;
* - возможность открывать Activity из фона, * - ввод текста в поле гостя на VirtualDisplay ([setText], ACTION_SET_TEXT) — IME на
* - [onAccessibilityEvent] (в будущем — для отслеживания фокуса ввода и клавиатуры, задача #4). * этих очках сменить нельзя (ROM жёстко держит LatinIME), поэтому системная
* клавиатура гостя отключается, а текст приходит из телефона сюда.
* *
* ВАЖНО: сервис НИЧЕГО не делает с чужими окнами — не читает содержимое * Регистрируется в манифесте с конфигом `res/xml/vm_accessibility_service.xml`.
* ([android:canRetrieveWindowContent] = false) и не инжектит события. Это * Активируется пользователем вручную: Настройки → Специальные возможности → RayNeo VM.
* сознательно: минимум привилегий, минимум риска сломать ввод в гостевых приложениях.
*
* Регистрируется в манифесте через [VM_ACCESSIBILITY_SERVICE] с конфигом
* `res/xml/vm_accessibility_service.xml`. Активируется пользователем вручную:
* Настройки → Специальные возможности → RayNeo VM.
*/ */
class VmAccessibilityService : AccessibilityService() { class VmAccessibilityService : AccessibilityService() {
override fun onServiceConnected() { override fun onServiceConnected() {
super.onServiceConnected() super.onServiceConnected()
instance = this
Log.i(TAG, "Accessibility-сервис подключён") Log.i(TAG, "Accessibility-сервис подключён")
// Авто-старт: если основное приложение ещё не поднято — поднимаем его. // Авто-старт: если основное приложение ещё не поднято — поднимаем его.
// Это и есть «легальный авто-запуск» из задачи #5. // Это и есть «легальный авто-запуск» из задачи #5.
@@ -37,17 +37,97 @@ class VmAccessibilityService : AccessibilityService() {
} }
override fun onAccessibilityEvent(event: AccessibilityEvent?) { override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// Намеренно пусто. Слушаем только для того, чтобы сервис считался живым. // Намеренно пусто: слушаем только чтобы сервис считался живым.
// Тут позже появится логика задачи #4 (фокус поля ввода → скрыть IME гостя).
} }
override fun onInterrupt() { 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 { companion object {
private const val TAG = "VmAccessibilitySvc" 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" const val ACTION_PING = "pw.binom.rayneovm.glasses.ACCESSIBILITY_PING"
} }
@@ -4,14 +4,20 @@ import android.inputmethodservice.InputMethodService
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
/** /**
* Пустой IME — глушилка системного IME гостя на нашем VirtualDisplay. * Пустой IME — глушилка системного IME гостя на нашем VirtualDisplay.
* *
* Когда это дефолтный IME очков — гость (Zona, ...) не показывает свою клавиатуру поверх * Когда это дефолтный IME очков — гость (Zona, ...) не показывает свою клавиатуру поверх
* нашего приложения: InputMethodService.showInputRequestedAndUpdated → false (нет UI). * нашего приложения: [onCreateInputView] возвращает null (нет UI).
* *
* Активируется через Shizuku/binder: `settings put secure default_input_method * Заодно сервис служит каналом ввода текста: только IME держит актуальный [InputConnection]
* сфокусированного поля гостя. Сервис живёт в том же процессе, что и [VmService], поэтому
* текст с телефона ([SetText]) доставляется напрямую через [commit] — без раскладки
* и без кросс-процессного доступа к чужому InputConnection.
*
* Активируется через Shizuku: `settings put secure default_input_method
* pw.binom.rayneovm.glasses/.VmNoopImeService`. Возврат к штатному IME не предусмотрен. * pw.binom.rayneovm.glasses/.VmNoopImeService`. Возврат к штатному IME не предусмотрен.
*/ */
class VmNoopImeService : InputMethodService() { class VmNoopImeService : InputMethodService() {
@@ -21,15 +27,44 @@ class VmNoopImeService : InputMethodService() {
return null return null
} }
override fun onStartInputView(info: EditorInfo?, restarting: Boolean) { override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
Log.i(TAG, "onStartInputView: info=$info restarting=$restarting") super.onStartInput(attribute, restarting)
// UI не показываем (onCreateInputView == null). Фокус отдаём гостю, как обычно — connection = currentInputConnection
// его кастомные EditText-ы получают inputType как обычно. Log.i(TAG, "onStartInput: conn=${connection != null} restarting=$restarting")
} }
override fun onFinishInputView(finishedInput: Boolean) {} override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
super.onStartInputView(info, restarting)
connection = currentInputConnection
Log.i(TAG, "onStartInputView: conn=${connection != null}")
}
override fun onFinishInput() {
connection = null
super.onFinishInput()
}
companion object { companion object {
private const val TAG = "VmNoopIme" private const val TAG = "VmNoopIme"
/**
* Активный [InputConnection] гостя. Наш IME — дефолтный на очках и живёт в том же
* процессе, что и контроллер, поэтому текст инжектится напрямую через этот объект.
*/
@Volatile
private var connection: InputConnection? = null
/** Доставляет [text] в текущее поле ввода гостя. false — если поля в фокусе нет. */
fun commit(text: String): Boolean {
if (text.isEmpty()) return true
val c = connection ?: return false
return try {
c.commitText(text, 1)
true
} catch (t: Throwable) {
Log.w(TAG, "commitText failed", t)
false
}
}
} }
} }
@@ -26,6 +26,8 @@ import kotlinx.coroutines.sync.withLock
import pw.binom.mercury.binocular.FrameSource import pw.binom.mercury.binocular.FrameSource
import pw.binom.mercury.shizuku.ShizukuVdBinder import pw.binom.mercury.shizuku.ShizukuVdBinder
import pw.binom.mercury.shizuku.ShizukuVdClient import pw.binom.mercury.shizuku.ShizukuVdClient
import pw.binom.rayneovm.glasses.VmAccessibilityService
import pw.binom.rayneovm.glasses.VmNoopImeService
import pw.binom.rayneovm.glasses.VmShizukuService import pw.binom.rayneovm.glasses.VmShizukuService
import pw.binom.rayneovm.glasses.input.TempleGesture import pw.binom.rayneovm.glasses.input.TempleGesture
import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource
@@ -304,20 +306,21 @@ class VmGlassesController {
* у активного гостевого дисплея. * у активного гостевого дисплея.
*/ */
fun injectText(displayId: Int, text: String): Boolean { fun injectText(displayId: Int, text: String): Boolean {
val binder = guardBinder ?: activeSource?.shizukuBinder
if (binder == null) {
Log.w(TAG, "injectText: нет Shizuku-биндера")
return false
}
if (text.isEmpty()) return true if (text.isEmpty()) return true
return try { // Основной путь: AccessibilityService + ACTION_SET_TEXT — единственный способ
val ok = ShizukuVdBinder.injectText(binder, displayId, text) // достучаться до поля гостя на VirtualDisplay без IME (сменить IME на этих очках
if (!ok) Log.w(TAG, "injectText: binder вернул false") // нельзя, а системная клавиатура гостя отключена).
ok if (VmAccessibilityService.setText(text)) {
} catch (t: Throwable) { Log.i(TAG, "injectText: доставлено через Accessibility (len=${text.length})")
Log.w(TAG, "injectText: binder transact упал", t) return true
false
} }
// Резерв: если наш IME всё-таки является активным — шлём через его InputConnection.
if (VmNoopImeService.commit(text)) {
Log.i(TAG, "injectText: доставлено через IME (len=${text.length})")
return true
}
Log.w(TAG, "injectText: нет активного поля ввода (ни Accessibility, ни IME)")
return false
} }
/** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */ /** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */
@@ -2,20 +2,22 @@
<!-- <!--
Конфиг Accessibility-сервиса RayNeo VM. Конфиг Accessibility-сервиса RayNeo VM.
Сервис — пассивный слушатель: он НЕ управляет чужими окнами и НЕ инжектит Сервис — «легальный» живучий процесс, который система поднимает/перезапускает сама,
события. Нужен только как «легальный» живучий процесс, который система через который можно открывать Activity из фона, и — главное — единственный
поднимает/перезапускает сама, и через который можно открывать Activity из фона. корректный путь ввода текста в поле гостя на VirtualDisplay без IME
(ACTION_SET_TEXT). Чужой ввод при этом НЕ инжектится.
Никаких eventTypes/feedbackType не запрашиваем сверх необходимого: eventTypes: typeWindowStateChanged|typeViewFocused — второе нужно, чтобы ловить
eventTypes="typeWindowStateChanged" — минимально нужный, чтобы сервис считался фокус поля ввода (в будущем).
активным и `onAccessibilityEvent` вызывался. canRetrieveWindowContent=false — canRetrieveWindowContent=true — иначе ACCESSIBILITY недоступен rootInActiveWindow и
содержимое окон нам не нужно, меньше привилегий. ACTION_SET_TEXT не выполнить. flagRetrieveInteractiveWindows — чтобы видеть окна
гостя на VirtualDisplay, а не только окно по умолчанию.
--> -->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android" <accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged" android:accessibilityEventTypes="typeWindowStateChanged|typeViewFocused"
android:accessibilityFeedbackType="feedbackGeneric" android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault" android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="false" android:canRetrieveWindowContent="true"
android:description="@string/vm_accessibility_description" android:description="@string/vm_accessibility_description"
android:notificationTimeout="100" android:notificationTimeout="100"
android:summary="@string/vm_accessibility_summary" /> android:summary="@string/vm_accessibility_summary" />
@@ -128,11 +128,16 @@ class ShizukuProvisioner(
Log.w(TAG, "не удалось узнать UID $GLASSES_PACKAGE — PROJECT_MEDIA per-UID не выставлен") Log.w(TAG, "не удалось узнать UID $GLASSES_PACKAGE — PROJECT_MEDIA per-UID не выставлен")
Result.failure(IllegalStateException("uid not found")) Result.failure(IllegalStateException("uid not found"))
} }
// #4.1: назначаем наш VmNoopImeService дефолтным IME навсегда (см. [VmNoopImeService]). // #4.1: глушим системную клавиатуру гостя. Наш VmNoopImeService дефолтом стать
// Подавляет системный IME, который гость (Zona) поднимает при фокусе в поле ввода. // не может — вендорский ROM жёстко держит LatinIME (allowlist; даже системный
// Sogou откатывается). Поэтому просто отключаем LatinIME: на очках не поднимается
// ни один IME, а текст с телефона доставляется через AccessibilityService
// (ACTION_SET_TEXT, см. VmAccessibilityService). На «обычных» ROM сработает и
// первый способ — назначение нашего IME.
val ime = adb.shell( val ime = adb.shell(
"settings put secure default_input_method " + "ime enable $GLASSES_PACKAGE/.VmNoopImeService; " +
"$GLASSES_PACKAGE/.VmNoopImeService", "ime set $GLASSES_PACKAGE/.VmNoopImeService; " +
"pm disable-user --user 0 com.android.inputmethod.latin",
timeoutMs = 15_000, timeoutMs = 15_000,
) )
Log.i( Log.i(
@@ -233,7 +233,6 @@ fun RemoteScreen(
scope.launch { controller.sendText(text) } scope.launch { controller.sendText(text) }
showKeyboard = false showKeyboard = false
}, },
onBackspace = { scope.launch { controller.sendKey(KEYCODE_DEL) } },
onEnter = { scope.launch { controller.sendKey(KEYCODE_ENTER) } }, onEnter = { scope.launch { controller.sendKey(KEYCODE_ENTER) } },
) )
} }
@@ -131,7 +131,6 @@ fun ZonaProfile(
scope.launch { controller.sendText(text) } scope.launch { controller.sendText(text) }
showKeyboard = false showKeyboard = false
}, },
onBackspace = { scope.launch { controller.send(VmKeyEvent(keyCode = 67, action = 1)) } },
onEnter = { scope.launch { controller.send(VmKeyEvent(keyCode = 66, action = 1)) } }, onEnter = { scope.launch { controller.send(VmKeyEvent(keyCode = 66, action = 1)) } },
) )
} }