diff --git a/app-glasses-vm/src/main/AndroidManifest.xml b/app-glasses-vm/src/main/AndroidManifest.xml index f840c3f..0b7322e 100644 --- a/app-glasses-vm/src/main/AndroidManifest.xml +++ b/app-glasses-vm/src/main/AndroidManifest.xml @@ -100,7 +100,8 @@ + android:label="@string/app_name" + android:permission="android.permission.BIND_INPUT_METHOD"> diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmAccessibilityService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmAccessibilityService.kt index 2159c5a..105db16 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmAccessibilityService.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmAccessibilityService.kt @@ -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" } diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmNoopImeService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmNoopImeService.kt index d4c38db..7b4dca9 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmNoopImeService.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmNoopImeService.kt @@ -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 + } + } } } diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt index 56bf92b..d6ed029 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt @@ -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, ...). */ diff --git a/app-glasses-vm/src/main/res/xml/vm_accessibility_service.xml b/app-glasses-vm/src/main/res/xml/vm_accessibility_service.xml index 33873ad..b18b92a 100644 --- a/app-glasses-vm/src/main/res/xml/vm_accessibility_service.xml +++ b/app-glasses-vm/src/main/res/xml/vm_accessibility_service.xml @@ -2,20 +2,22 @@ diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt index 374d4df..53f3bed 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt @@ -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( diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt index 20c7dab..4e9b307 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt @@ -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) } }, ) } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/ZonaProfile.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/ZonaProfile.kt index 351bb63..6cc24ba 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/ZonaProfile.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/ZonaProfile.kt @@ -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)) } }, ) }