клавиатура: подавить системный IME на очках + ввод текста через Accessibility
This commit is contained in:
@@ -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>
|
||||
|
||||
+95
-15
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-12
@@ -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" />
|
||||
|
||||
+9
-4
@@ -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)) } },
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user