клавиатура: подавить системный 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
android:name=".VmNoopImeService"
android:exported="true"
android:label="@string/app_name">
android:label="@string/app_name"
android:permission="android.permission.BIND_INPUT_METHOD">
<intent-filter>
<action android:name="android.view.InputMethod" />
</intent-filter>
@@ -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"
}
@@ -4,14 +4,20 @@ import android.inputmethodservice.InputMethodService
import android.util.Log
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
/**
* Пустой IME — глушилка системного IME гостя на нашем VirtualDisplay.
*
* Когда это дефолтный 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 не предусмотрен.
*/
class VmNoopImeService : InputMethodService() {
@@ -21,15 +27,44 @@ class VmNoopImeService : InputMethodService() {
return null
}
override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
Log.i(TAG, "onStartInputView: info=$info restarting=$restarting")
// UI не показываем (onCreateInputView == null). Фокус отдаём гостю, как обычно —
// его кастомные EditText-ы получают inputType как обычно.
override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
super.onStartInput(attribute, restarting)
connection = currentInputConnection
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 {
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.shizuku.ShizukuVdBinder
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.input.TempleGesture
import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource
@@ -304,20 +306,21 @@ class VmGlassesController {
* у активного гостевого дисплея.
*/
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
return try {
val ok = ShizukuVdBinder.injectText(binder, displayId, text)
if (!ok) Log.w(TAG, "injectText: binder вернул false")
ok
} catch (t: Throwable) {
Log.w(TAG, "injectText: binder transact упал", t)
false
// Основной путь: AccessibilityService + ACTION_SET_TEXT — единственный способ
// достучаться до поля гостя на VirtualDisplay без IME (сменить IME на этих очках
// нельзя, а системная клавиатура гостя отключена).
if (VmAccessibilityService.setText(text)) {
Log.i(TAG, "injectText: доставлено через Accessibility (len=${text.length})")
return true
}
// Резерв: если наш 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, ...). */
@@ -2,20 +2,22 @@
<!--
Конфиг Accessibility-сервиса RayNeo VM.
Сервис — пассивный слушатель: он НЕ управляет чужими окнами и НЕ инжектит
события. Нужен только как «легальный» живучий процесс, который система
поднимает/перезапускает сама, и через который можно открывать Activity из фона.
Сервис — «легальный» живучий процесс, который система поднимает/перезапускает сама,
через который можно открывать Activity из фона, и — главное — единственный
корректный путь ввода текста в поле гостя на VirtualDisplay без IME
(ACTION_SET_TEXT). Чужой ввод при этом НЕ инжектится.
Никаких eventTypes/feedbackType не запрашиваем сверх необходимого:
eventTypes="typeWindowStateChanged" — минимально нужный, чтобы сервис считался
активным и `onAccessibilityEvent` вызывался. canRetrieveWindowContent=false —
содержимое окон нам не нужно, меньше привилегий.
eventTypes: typeWindowStateChanged|typeViewFocused — второе нужно, чтобы ловить
фокус поля ввода (в будущем).
canRetrieveWindowContent=true — иначе ACCESSIBILITY недоступен rootInActiveWindow и
ACTION_SET_TEXT не выполнить. flagRetrieveInteractiveWindows — чтобы видеть окна
гостя на VirtualDisplay, а не только окно по умолчанию.
-->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityEventTypes="typeWindowStateChanged|typeViewFocused"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault"
android:canRetrieveWindowContent="false"
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:description="@string/vm_accessibility_description"
android:notificationTimeout="100"
android:summary="@string/vm_accessibility_summary" />
@@ -128,11 +128,16 @@ class ShizukuProvisioner(
Log.w(TAG, "не удалось узнать UID $GLASSES_PACKAGE — PROJECT_MEDIA per-UID не выставлен")
Result.failure(IllegalStateException("uid not found"))
}
// #4.1: назначаем наш VmNoopImeService дефолтным IME навсегда (см. [VmNoopImeService]).
// Подавляет системный IME, который гость (Zona) поднимает при фокусе в поле ввода.
// #4.1: глушим системную клавиатуру гостя. Наш VmNoopImeService дефолтом стать
// не может — вендорский ROM жёстко держит LatinIME (allowlist; даже системный
// Sogou откатывается). Поэтому просто отключаем LatinIME: на очках не поднимается
// ни один IME, а текст с телефона доставляется через AccessibilityService
// (ACTION_SET_TEXT, см. VmAccessibilityService). На «обычных» ROM сработает и
// первый способ — назначение нашего IME.
val ime = adb.shell(
"settings put secure default_input_method " +
"$GLASSES_PACKAGE/.VmNoopImeService",
"ime enable $GLASSES_PACKAGE/.VmNoopImeService; " +
"ime set $GLASSES_PACKAGE/.VmNoopImeService; " +
"pm disable-user --user 0 com.android.inputmethod.latin",
timeoutMs = 15_000,
)
Log.i(
@@ -233,7 +233,6 @@ fun RemoteScreen(
scope.launch { controller.sendText(text) }
showKeyboard = false
},
onBackspace = { scope.launch { controller.sendKey(KEYCODE_DEL) } },
onEnter = { scope.launch { controller.sendKey(KEYCODE_ENTER) } },
)
}
@@ -131,7 +131,6 @@ fun ZonaProfile(
scope.launch { controller.sendText(text) }
showKeyboard = false
},
onBackspace = { scope.launch { controller.send(VmKeyEvent(keyCode = 67, action = 1)) } },
onEnter = { scope.launch { controller.send(VmKeyEvent(keyCode = 66, action = 1)) } },
)
}