diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java index 5d214508a3..23adb94763 100644 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -486,6 +486,11 @@ public void notifyCrash(Exception e) { new ComputerDetails.AddressTuple(host, port), httpsPort, uniqueId, config, PlatformBinding.getCryptoProvider(this), serverCert); + + // Give StreamView a reference to conn so its InputConnection + // can route IME commitText() (voice dictation, swipe typing) to the host + streamView.setNvConnection(conn); + controllerHandler = new ControllerHandler(this, conn, this, prefConfig); keyboardTranslator = new KeyboardTranslator(); diff --git a/app/src/main/java/com/limelight/binding/input/StreamInputConnection.java b/app/src/main/java/com/limelight/binding/input/StreamInputConnection.java new file mode 100644 index 0000000000..37132adf78 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/StreamInputConnection.java @@ -0,0 +1,87 @@ +package com.limelight.binding.input; + +import android.view.View; +import android.view.inputmethod.BaseInputConnection; + +import com.limelight.nvstream.NvConnection; +import com.limelight.nvstream.input.KeyboardPacket; + +/** + * Custom InputConnection that intercepts commitText() from the Android IME + * (voice dictation, autocomplete, swipe typing) and routes the committed text + * to the remote host via the Moonlight streaming protocol. + * + * This is an additive fix — individual KeyEvent handling via onKeyDown/onKeyUp + * continues to work unchanged through StreamView.onKeyPreIme(). + * + * Copyright (C) 2024 Jon Gutierrez / MN Compute + * License: GPL-3.0-or-later (same as upstream moonlight-android) + */ +public class StreamInputConnection extends BaseInputConnection { + private final NvConnection conn; + + // VK key codes in Moonlight's wire format: (KEY_PREFIX << 8) | windowsVkCode + // KEY_PREFIX = 0x80 (see KeyboardTranslator.KEY_PREFIX) + // VK_BACK = 0x08 (backspace), VK_DELETE = 0x2E (forward delete) + private static final short VK_BACKSPACE = (short) ((0x80 << 8) | 0x08); + private static final short VK_DELETE = (short) ((0x80 << 8) | 0x2E); + + public StreamInputConnection(View targetView, boolean fullEditor, NvConnection conn) { + super(targetView, fullEditor); + this.conn = conn; + } + + /** + * Called when the IME finalizes text input — voice dictation result, + * autocomplete selection, swipe-typed word, etc. + * Route the full text string to the remote host. + */ + @Override + public boolean commitText(CharSequence text, int newCursorPosition) { + if (text != null && text.length() > 0 && conn != null) { + // sendUtf8Text accepts full strings — no need for char-by-char loop + conn.sendUtf8Text(text.toString()); + } + return true; + } + + /** + * Called with in-progress (composing) text while the user is still + * dictating or typing. Don't send this — only send on commitText() + * when the text is finalized. + */ + @Override + public boolean setComposingText(CharSequence text, int newCursorPosition) { + // Intentionally do nothing with composing text + return true; + } + + /** + * Called when the IME finishes composition. Nothing to do here since + * we handle everything in commitText(). + */ + @Override + public boolean finishComposingText() { + return true; + } + + /** + * Called by the IME to delete surrounding text (e.g., voice correction + * "delete that", or backspace from swipe keyboard). + * Send the appropriate number of backspace or delete key events. + */ + @Override + public boolean deleteSurroundingText(int beforeLength, int afterLength) { + if (conn != null) { + for (int i = 0; i < beforeLength; i++) { + conn.sendKeyboardInput(VK_BACKSPACE, KeyboardPacket.KEY_DOWN, (byte) 0, (byte) 0); + conn.sendKeyboardInput(VK_BACKSPACE, KeyboardPacket.KEY_UP, (byte) 0, (byte) 0); + } + for (int i = 0; i < afterLength; i++) { + conn.sendKeyboardInput(VK_DELETE, KeyboardPacket.KEY_DOWN, (byte) 0, (byte) 0); + conn.sendKeyboardInput(VK_DELETE, KeyboardPacket.KEY_UP, (byte) 0, (byte) 0); + } + } + return true; + } +} diff --git a/app/src/main/java/com/limelight/ui/StreamView.java b/app/src/main/java/com/limelight/ui/StreamView.java index a11b416684..058e8de3eb 100644 --- a/app/src/main/java/com/limelight/ui/StreamView.java +++ b/app/src/main/java/com/limelight/ui/StreamView.java @@ -2,14 +2,27 @@ import android.annotation.TargetApi; import android.content.Context; +import android.text.InputType; import android.util.AttributeSet; import android.view.KeyEvent; import android.view.SurfaceView; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +import com.limelight.binding.input.StreamInputConnection; +import com.limelight.nvstream.NvConnection; public class StreamView extends SurfaceView { private double desiredAspectRatio; private InputCallbacks inputCallbacks; + // Connection reference for IME text commit routing + private NvConnection nvConn; + + public void setNvConnection(NvConnection conn) { + this.nvConn = conn; + } + public void setDesiredAspectRatio(double aspectRatio) { this.desiredAspectRatio = aspectRatio; } @@ -78,6 +91,18 @@ else if (event.getAction() == KeyEvent.ACTION_UP) { return super.onKeyPreIme(keyCode, event); } + /** + * Provide a custom InputConnection so that IME commitText() events + * (voice dictation, swipe typing, autocomplete) are routed to the remote host. + * Individual key events continue to flow through onKeyPreIme() as before. + */ + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_AUTO_CORRECT; + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_ACTION_NONE; + return new StreamInputConnection(this, false, nvConn); + } + public interface InputCallbacks { boolean handleKeyUp(KeyEvent event); boolean handleKeyDown(KeyEvent event);