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