Skip to content

fix: add IMEInputReceiver to capture keyboard input from external dis…#549

Open
xXJSONDeruloXx wants to merge 1 commit intoutkarshdalal:masterfrom
xXJSONDeruloXx:fix/ext-keyboard-inputs
Open

fix: add IMEInputReceiver to capture keyboard input from external dis…#549
xXJSONDeruloXx wants to merge 1 commit intoutkarshdalal:masterfrom
xXJSONDeruloXx:fix/ext-keyboard-inputs

Conversation

@xXJSONDeruloXx
Copy link
Contributor

@xXJSONDeruloXx xXJSONDeruloXx commented Feb 17, 2026

…play


Summary by cubic

Adds an invisible IMEInputReceiver to capture system keyboard input on external displays and send keystrokes to XServer. Fixes missing keyboard input when the Android keyboard is pinned to the external screen.

  • New Features
    • Focusable IME view with a BaseInputConnection that forwards commitText and backspace to XServer as key events.
    • Character-to-keycode mapping for letters, digits, space, Enter, and common symbols.
    • IME suggestions and extract UI disabled for immediate key dispatch.
    • Integrated into XServerScreen as a full-screen, 0-alpha overlay, with helpers to show/hide the keyboard.

Written for commit 8a9f9bf. Summary will update on new commits.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added soft keyboard input support for external displays
    • Game now receives keyboard input from system IME
  • Improvements

    • Enhanced character support: letters, numbers, punctuation, and special keys

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 17, 2026

📝 Walkthrough

Walkthrough

A new IMEInputReceiver view is added to capture soft keyboard input on external displays and forward it to the XServer game input system via KeyEvent mapping. The view is integrated into XServerScreen as an invisible, non-interactive component that intercepts IME input without visible UI elements.

Changes

Cohort / File(s) Summary
IME Input Receiver Implementation
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt
New FrameLayout-based view that extends keyboard input support by implementing a custom InputConnection. Routes soft keyboard input to XServer via character-to-KeyCode mapping for ASCII characters (a-z, A-Z, 0-9, space, newline, punctuation) and handles backspace via deleteSurroundingText. Includes lifecycle hooks for focus management and public methods to show/hide the soft keyboard.
XServerScreen Integration
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
Integrates IMEInputReceiver as an invisible, non-clickable overlay (alpha 0, isClickable false) attached to the external display frameLayout prior to WinHandler initialization, enabling keyboard input capture without visible UI impact.

Sequence Diagram

sequenceDiagram
    actor User
    participant IME as System IME
    participant IMEInputReceiver
    participant InputConnection
    participant XServer as XServer Keyboard
    
    User->>IME: Type character or backspace
    IME->>IMEInputReceiver: commitText() / deleteSurroundingText()
    activate IMEInputReceiver
    IMEInputReceiver->>InputConnection: Process IME event
    activate InputConnection
    
    alt Character Input
        InputConnection->>InputConnection: Map char to KeyCode
        InputConnection->>XServer: Send KEY_DOWN
        InputConnection->>XServer: Send KEY_UP
    else Backspace
        InputConnection->>XServer: Send KEY_BKSP (DOWN)
        InputConnection->>XServer: Send KEY_BKSP (UP)
    end
    
    deactivate InputConnection
    deactivate IMEInputReceiver
    XServer->>XServer: Update game input state
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PR #116: Modifies XServerScreen's on-screen keyboard show/hide and visibility logic, complementing this PR's soft keyboard input capture mechanism for external display keyboard handling.

Poem

🐰 A keyboard appears, invisible and swift,
Capturing whispers from IME's gift,
Each keystroke mapped to XServer's delight,
The game now listens through external light! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding IMEInputReceiver to capture keyboard input from external displays.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt (1)

25-39: Redundant focus configuration in init block.

Lines 26-27 (isFocusable = true / isFocusableInTouchMode = true) and lines 30-31 (setFocusable(View.FOCUSABLE) / setFocusableInTouchMode(true)) are doing the same thing. The Kotlin property setters call the same underlying methods.

♻️ Remove duplicate calls
     init {
         isFocusable = true
         isFocusableInTouchMode = true
         
-        // Make this view visible to the IME system
-        setFocusable(View.FOCUSABLE)
-        setFocusableInTouchMode(true)
-        
         post {
             if (requestFocus()) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt` around
lines 25 - 39, The init block in IMEInputReceiver sets focusability twice: via
the Kotlin properties isFocusable/isFocusableInTouchMode and again with
setFocusable/setFocusableInTouchMode; remove the redundant calls so focusability
is configured only once. Edit the init of the IMEInputReceiver class to keep
either the property setters (isFocusable/isFocusableInTouchMode) or the method
calls (setFocusable/setFocusableInTouchMode) and delete the duplicates, ensuring
the post { requestFocus() ... } logic (and Timber logs) remains unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt`:
- Around line 66-74: In IMEInputReceiver.deleteSurroundingText the code only
sends one backspace regardless of beforeLength; change it to send a backspace
press+release for each character to delete by looping from 0 until beforeLength
and calling xServer.injectKeyPress(XKeycode.KEY_BKSP, 0) followed by
xServer.injectKeyRelease(XKeycode.KEY_BKSP) on each iteration (use the existing
xServer methods and XKeycode.KEY_BKSP symbol); keep the existing Timber.v log
but consider logging per-iteration or a single summary after the loop.
- Around line 92-105: sendCharacterToGame is dropping or lowercasing shifted
characters because KeyEvent is created without shift meta and charToKeyCode
lacks entries for shifted punctuation; fix by detecting when a char requires
shift (uppercase A-Z or shifted symbols) and include the META_SHIFT_ON metaState
when constructing KeyEvent (use KeyEvent(action, keyCode, /*repeat*/0,
/*deviceId*/0, /*scanCode*/0, metaState) or equivalent constructor), and extend
charToKeyCode to map shifted punctuation characters to their base key codes
(e.g., '!' -> KEYCODE_1 with shift) so xServer.keyboard.onKeyEvent receives the
correct keyCode + shift state; update logging in sendCharacterToGame to reflect
when shift is applied.

In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt`:
- Around line 711-722: The local imeReceiver instance created from
app.gamenative.externaldisplay.IMEInputReceiver in XServerScreen.kt is never
stored, so its public showKeyboard()/hideKeyboard() APIs are unreachable;
persist the reference (e.g., a private property on the XServerScreen hosting
class or a remember state if using Compose, or a static accessor on PluviaApp)
so callers (navigation dialog) can call
IMEInputReceiver.showKeyboard()/hideKeyboard(), or alternatively mark those
methods internal/remove them if not intended for external use; also add a short
comment near the IMEInputReceiver initialization explaining that aggressive
focus requests in init/onAttachedToWindow/onWindowFocusChanged are intentional
to route IME input and do not affect physical keyboard event delivery.

---

Nitpick comments:
In `@app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt`:
- Around line 25-39: The init block in IMEInputReceiver sets focusability twice:
via the Kotlin properties isFocusable/isFocusableInTouchMode and again with
setFocusable/setFocusableInTouchMode; remove the redundant calls so focusability
is configured only once. Edit the init of the IMEInputReceiver class to keep
either the property setters (isFocusable/isFocusableInTouchMode) or the method
calls (setFocusable/setFocusableInTouchMode) and delete the duplicates, ensuring
the post { requestFocus() ... } logic (and Timber logs) remains unchanged.

Comment on lines +66 to +74
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
Timber.d("IMEInputReceiver: deleteSurroundingText called")
if (beforeLength > 0) {
xServer.injectKeyPress(com.winlator.xserver.XKeycode.KEY_BKSP, 0)
xServer.injectKeyRelease(com.winlator.xserver.XKeycode.KEY_BKSP)
Timber.v("IMEInputReceiver: Sent backspace")
}
return true
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

deleteSurroundingText only sends one backspace regardless of beforeLength.

When the IME requests deletion of multiple characters (e.g., beforeLength = 3), only a single backspace event is sent. This will cause incomplete deletion.

🐛 Proposed fix: loop for beforeLength
             override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
                 Timber.d("IMEInputReceiver: deleteSurroundingText called")
                 if (beforeLength > 0) {
-                    xServer.injectKeyPress(com.winlator.xserver.XKeycode.KEY_BKSP, 0)
-                    xServer.injectKeyRelease(com.winlator.xserver.XKeycode.KEY_BKSP)
-                    Timber.v("IMEInputReceiver: Sent backspace")
+                    repeat(beforeLength) {
+                        xServer.injectKeyPress(com.winlator.xserver.XKeycode.KEY_BKSP, 0)
+                        xServer.injectKeyRelease(com.winlator.xserver.XKeycode.KEY_BKSP)
+                    }
+                    Timber.v("IMEInputReceiver: Sent $beforeLength backspace(s)")
                 }
                 return true
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt` around
lines 66 - 74, In IMEInputReceiver.deleteSurroundingText the code only sends one
backspace regardless of beforeLength; change it to send a backspace
press+release for each character to delete by looping from 0 until beforeLength
and calling xServer.injectKeyPress(XKeycode.KEY_BKSP, 0) followed by
xServer.injectKeyRelease(XKeycode.KEY_BKSP) on each iteration (use the existing
xServer methods and XKeycode.KEY_BKSP symbol); keep the existing Timber.v log
but consider logging per-iteration or a single summary after the loop.

Comment on lines +92 to +105
private fun sendCharacterToGame(char: Char) {
val keyCode = charToKeyCode(char)
if (keyCode != null) {
val event = KeyEvent(KeyEvent.ACTION_DOWN, keyCode)
xServer.keyboard.onKeyEvent(event)

val upEvent = KeyEvent(KeyEvent.ACTION_UP, keyCode)
xServer.keyboard.onKeyEvent(upEvent)

Timber.v("IMEInputReceiver: Sent char '$char' as keyCode $keyCode")
} else {
Timber.w("IMEInputReceiver: Could not map character '$char' to keyCode")
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -name "IMEInputReceiver.kt" -type f

Repository: utkarshdalal/GameNative

Length of output: 137


🏁 Script executed:

find . -name "*Keyboard*.kt" -o -name "*Keyboard*.java" | head -20

Repository: utkarshdalal/GameNative

Length of output: 276


🏁 Script executed:

fd -t f "IMEInputReceiver"

Repository: utkarshdalal/GameNative

Length of output: 135


🏁 Script executed:

cat -n ./app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt

Repository: utkarshdalal/GameNative

Length of output: 7709


🏁 Script executed:

cat -n ./app/src/main/java/com/winlator/xserver/Keyboard.java

Repository: utkarshdalal/GameNative

Length of output: 21624


Uppercase characters and shifted punctuation are silently mishandled or dropped.

Two correctness problems:

  1. Uppercase letters produce lowercase output: 'A' maps to KEYCODE_A just like 'a', but the KeyEvent is created without the META_SHIFT_ON flag. The Keyboard.onKeyEvent() method checks event.isShiftPressed(), which returns false when metaState is not set, so no shift key is injected and uppercase input arrives as lowercase.

  2. Shifted punctuation is unmapped: Characters like !, @, #, $, %, ^, &, *, (, ), _, +, {, }, |, :, ", <, >, ?, ~ are not in the charToKeyCode mapping and fall through to null, being silently dropped with a warning log.

Proposed fix: send shift modifier for uppercase/shifted chars
     private fun sendCharacterToGame(char: Char) {
-        val keyCode = charToKeyCode(char)
-        if (keyCode != null) {
-            val event = KeyEvent(KeyEvent.ACTION_DOWN, keyCode)
-            xServer.keyboard.onKeyEvent(event)
-            
-            val upEvent = KeyEvent(KeyEvent.ACTION_UP, keyCode)
-            xServer.keyboard.onKeyEvent(upEvent)
+        val mapping = charToKeyCode(char)
+        if (mapping != null) {
+            val (keyCode, needsShift) = mapping
+            val metaState = if (needsShift) KeyEvent.META_SHIFT_ON else 0
+            val downEvent = KeyEvent(0, 0, KeyEvent.ACTION_DOWN, keyCode, 0, metaState)
+            xServer.keyboard.onKeyEvent(downEvent)
+            val upEvent = KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, metaState)
+            xServer.keyboard.onKeyEvent(upEvent)
             
             Timber.v("IMEInputReceiver: Sent char '$char' as keyCode $keyCode")
         } else {
             Timber.w("IMEInputReceiver: Could not map character '$char' to keyCode")
         }
     }
 
-    private fun charToKeyCode(char: Char): Int? = when (char) {
-        'a', 'A' -> KeyEvent.KEYCODE_A
+    private fun charToKeyCode(char: Char): Pair<Int, Boolean>? = when (char) {
+        'a' -> KeyEvent.KEYCODE_A to false
+        'A' -> KeyEvent.KEYCODE_A to true
         // ... same pattern for b-z ...
+        '!' -> KeyEvent.KEYCODE_1 to true
+        '@' -> KeyEvent.KEYCODE_2 to true
+        '#' -> KeyEvent.KEYCODE_3 to true
+        // ... etc for other shifted punctuation ...

Also applies to: 107-158

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt` around
lines 92 - 105, sendCharacterToGame is dropping or lowercasing shifted
characters because KeyEvent is created without shift meta and charToKeyCode
lacks entries for shifted punctuation; fix by detecting when a char requires
shift (uppercase A-Z or shifted symbols) and include the META_SHIFT_ON metaState
when constructing KeyEvent (use KeyEvent(action, keyCode, /*repeat*/0,
/*deviceId*/0, /*scanCode*/0, metaState) or equivalent constructor), and extend
charToKeyCode to map shifted punctuation characters to their base key codes
(e.g., '!' -> KEYCODE_1 with shift) so xServer.keyboard.onKeyEvent receives the
correct keyCode + shift state; update logging in sendCharacterToGame to reflect
when shift is applied.

Comment on lines +711 to +722

// Add invisible IME receiver to capture system keyboard input when keyboard is on external display
val imeReceiver = app.gamenative.externaldisplay.IMEInputReceiver(context, getxServer()).apply {
layoutParams = android.widget.FrameLayout.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
)
alpha = 0f
isClickable = false
}
frameLayout.addView(imeReceiver)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

❓ Verification inconclusive

Script executed:

#!/bin/bash
# Search for IMEInputReceiver and imeReceiver references
rg -n "IMEInputReceiver|imeReceiver" --type=kotlin

Repository: utkarshdalal/GameNative


Repository: utkarshdalal/GameNative
Exit code: 0

stdout:

app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt:713:                val imeReceiver = app.gamenative.externaldisplay.IMEInputReceiver(context, getxServer()).apply {
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt:721:                frameLayout.addView(imeReceiver)
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:20:class IMEInputReceiver(
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:35:                Timber.d("IMEInputReceiver: Successfully got focus")
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:37:                Timber.w("IMEInputReceiver: Failed to get focus")
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:43:        Timber.d("IMEInputReceiver: onCheckIsTextEditor called - returning true")
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:48:        Timber.d("IMEInputReceiver: onCreateInputConnection called!")
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:59:                Timber.d("IMEInputReceiver: commitText called with: '$text'")
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:67:                Timber.d("IMEInputReceiver: deleteSurroundingText called")
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:71:                    Timber.v("IMEInputReceiver: Sent backspace")
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:80:        Timber.d("IMEInputReceiver: onWindowFocusChanged: $hasWindowFocus")
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:88:        Timber.d("IMEInputReceiver: onAttachedToWindow - requesting focus")
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:101:            Timber.v("IMEInputReceiver: Sent char '$char' as keyCode $keyCode")
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:103:            Timber.w("IMEInputReceiver: Could not map character '$char' to keyCode")
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:165:            Timber.d("IMEInputReceiver: Requested to show keyboard")

Script executed:

#!/bin/bash
# Search for showKeyboard and hideKeyboard usage
rg -n "showKeyboard|hideKeyboard" --type=kotlin -A 2 -B 2

Repository: utkarshdalal/GameNative


Repository: utkarshdalal/GameNative
Exit code: 0

stdout:

app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt-158-    }
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt-159-
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:160:    fun showKeyboard() {
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt-161-        post {
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt-162-            requestFocus()
--
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt-167-    }
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt-168-
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:169:    fun hideKeyboard() {
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt-170-        val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt-171-        imm?.hideSoftInputFromWindow(windowToken, 0)

Script executed:

#!/bin/bash
# Find and read the IMEInputReceiver class definition
find . -name "IMEInputReceiver.kt" -o -name "IMEInputReceiver.java" | head -5

Repository: utkarshdalal/GameNative


Repository: utkarshdalal/GameNative
Exit code: 0

stdout:

./app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt

Script executed:

#!/bin/bash
# Read the IMEInputReceiver class to understand focus and keyboard behavior
cat -n app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt

Repository: utkarshdalal/GameNative


Repository: utkarshdalal/GameNative
Exit code: 0

stdout:

     1	package app.gamenative.externaldisplay
     2	
     3	import android.content.Context
     4	import android.view.KeyEvent
     5	import android.view.View
     6	import android.view.inputmethod.BaseInputConnection
     7	import android.view.inputmethod.EditorInfo
     8	import android.view.inputmethod.InputConnection
     9	import android.view.inputmethod.InputMethodManager
    10	import android.widget.FrameLayout
    11	import com.winlator.xserver.XServer
    12	import timber.log.Timber
    13	
    14	/**
    15	 * Invisible view that receives IME (soft keyboard) input from Android system keyboard
    16	 * and forwards it to XServer as keyboard events for the game.
    17	 * 
    18	 * This is needed when the system keyboard is pinned to the external display
    19	 */
    20	class IMEInputReceiver(
    21	    context: Context,
    22	    private val xServer: XServer,
    23	) : FrameLayout(context) {
    24	
    25	    init {
    26	        isFocusable = true
    27	        isFocusableInTouchMode = true
    28	        
    29	        // Make this view visible to the IME system
    30	        setFocusable(View.FOCUSABLE)
    31	        setFocusableInTouchMode(true)
    32	        
    33	        post {
    34	            if (requestFocus()) {
    35	                Timber.d("IMEInputReceiver: Successfully got focus")
    36	            } else {
    37	                Timber.w("IMEInputReceiver: Failed to get focus")
    38	            }
    39	        }
    40	    }
    41	
    42	    override fun onCheckIsTextEditor(): Boolean {
    43	        Timber.d("IMEInputReceiver: onCheckIsTextEditor called - returning true")
    44	        return true
    45	    }
    46	
    47	    override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
    48	        Timber.d("IMEInputReceiver: onCreateInputConnection called!")
    49	        // Disable autocomplete/suggestions so each key commits immediately
    50	        outAttrs.inputType = EditorInfo.TYPE_CLASS_TEXT or 
    51	                             EditorInfo.TYPE_TEXT_FLAG_NO_SUGGESTIONS or
    52	                             EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
    53	        outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or 
    54	                              EditorInfo.IME_FLAG_NO_EXTRACT_UI or
    55	                              EditorInfo.IME_ACTION_NONE
    56	        
    57	        return object : BaseInputConnection(this, false) {
    58	            override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
    59	                Timber.d("IMEInputReceiver: commitText called with: '$text'")
    60	                text?.forEach { char ->
    61	                    sendCharacterToGame(char)
    62	                }
    63	                return true
    64	            }
    65	
    66	            override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
    67	                Timber.d("IMEInputReceiver: deleteSurroundingText called")
    68	                if (beforeLength > 0) {
    69	                    xServer.injectKeyPress(com.winlator.xserver.XKeycode.KEY_BKSP, 0)
    70	                    xServer.injectKeyRelease(com.winlator.xserver.XKeycode.KEY_BKSP)
    71	                    Timber.v("IMEInputReceiver: Sent backspace")
    72	                }
    73	                return true
    74	            }
    75	        }
    76	    }
    77	
    78	    override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
    79	        super.onWindowFocusChanged(hasWindowFocus)
    80	        Timber.d("IMEInputReceiver: onWindowFocusChanged: $hasWindowFocus")
    81	        if (hasWindowFocus) {
    82	            post { requestFocus() }
    83	        }
    84	    }
    85	
    86	    override fun onAttachedToWindow() {
    87	        super.onAttachedToWindow()
    88	        Timber.d("IMEInputReceiver: onAttachedToWindow - requesting focus")
    89	        post { requestFocus() }
    90	    }
    91	
    92	    private fun sendCharacterToGame(char: Char) {
    93	        val keyCode = charToKeyCode(char)
    94	        if (keyCode != null) {
    95	            val event = KeyEvent(KeyEvent.ACTION_DOWN, keyCode)
    96	            xServer.keyboard.onKeyEvent(event)
    97	            
    98	            val upEvent = KeyEvent(KeyEvent.ACTION_UP, keyCode)
    99	            xServer.keyboard.onKeyEvent(upEvent)
   100	            
   101	            Timber.v("IMEInputReceiver: Sent char '$char' as keyCode $keyCode")
   102	        } else {
   103	            Timber.w("IMEInputReceiver: Could not map character '$char' to keyCode")
   104	        }
   105	    }
   106	
   107	    private fun charToKeyCode(char: Char): Int? = when (char) {
   108	        'a', 'A' -> KeyEvent.KEYCODE_A
   109	        'b', 'B' -> KeyEvent.KEYCODE_B
   110	        'c', 'C' -> KeyEvent.KEYCODE_C
   111	        'd', 'D' -> KeyEvent.KEYCODE_D
   112	        'e', 'E' -> KeyEvent.KEYCODE_E
   113	        'f', 'F' -> KeyEvent.KEYCODE_F
   114	        'g', 'G' -> KeyEvent.KEYCODE_G
   115	        'h', 'H' -> KeyEvent.KEYCODE_H
   116	        'i', 'I' -> KeyEvent.KEYCODE_I
   117	        'j', 'J' -> KeyEvent.KEYCODE_J
   118	        'k', 'K' -> KeyEvent.KEYCODE_K
   119	        'l', 'L' -> KeyEvent.KEYCODE_L
   120	        'm', 'M' -> KeyEvent.KEYCODE_M
   121	        'n', 'N' -> KeyEvent.KEYCODE_N
   122	        'o', 'O' -> KeyEvent.KEYCODE_O
   123	        'p', 'P' -> KeyEvent.KEYCODE_P
   124	        'q', 'Q' -> KeyEvent.KEYCODE_Q
   125	        'r', 'R' -> KeyEvent.KEYCODE_R
   126	        's', 'S' -> KeyEvent.KEYCODE_S
   127	        't', 'T' -> KeyEvent.KEYCODE_T
   128	        'u', 'U' -> KeyEvent.KEYCODE_U
   129	        'v', 'V' -> KeyEvent.KEYCODE_V
   130	        'w', 'W' -> KeyEvent.KEYCODE_W
   131	        'x', 'X' -> KeyEvent.KEYCODE_X
   132	        'y', 'Y' -> KeyEvent.KEYCODE_Y
   133	        'z', 'Z' -> KeyEvent.KEYCODE_Z
   134	        '0' -> KeyEvent.KEYCODE_0
   135	        '1' -> KeyEvent.KEYCODE_1
   136	        '2' -> KeyEvent.KEYCODE_2
   137	        '3' -> KeyEvent.KEYCODE_3
   138	        '4' -> KeyEvent.KEYCODE_4
   139	        '5' -> KeyEvent.KEYCODE_5
   140	        '6' -> KeyEvent.KEYCODE_6
   141	        '7' -> KeyEvent.KEYCODE_7
   142	        '8' -> KeyEvent.KEYCODE_8
   143	        '9' -> KeyEvent.KEYCODE_9
   144	        ' ' -> KeyEvent.KEYCODE_SPACE
   145	        '\n' -> KeyEvent.KEYCODE_ENTER
   146	        '-' -> KeyEvent.KEYCODE_MINUS
   147	        '=' -> KeyEvent.KEYCODE_EQUALS
   148	        '[' -> KeyEvent.KEYCODE_LEFT_BRACKET
   149	        ']' -> KeyEvent.KEYCODE_RIGHT_BRACKET
   150	        '\\' -> KeyEvent.KEYCODE_BACKSLASH
   151	        ';' -> KeyEvent.KEYCODE_SEMICOLON
   152	        '\'' -> KeyEvent.KEYCODE_APOSTROPHE
   153	        ',' -> KeyEvent.KEYCODE_COMMA
   154	        '.' -> KeyEvent.KEYCODE_PERIOD
   155	        '/' -> KeyEvent.KEYCODE_SLASH
   156	        '`' -> KeyEvent.KEYCODE_GRAVE
   157	        else -> null
   158	    }
   159	
   160	    fun showKeyboard() {
   161	        post {
   162	            requestFocus()
   163	            val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
   164	            imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
   165	            Timber.d("IMEInputReceiver: Requested to show keyboard")
   166	        }
   167	    }
   168	
   169	    fun hideKeyboard() {
   170	        val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
   171	        imm?.hideSoftInputFromWindow(windowToken, 0)
   172	    }
   173	}

Script executed:

#!/bin/bash
# Check the context around lines 711-722 in XServerScreen.kt to see variable scope
sed -n '700,730p' app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt

Repository: utkarshdalal/GameNative


Repository: utkarshdalal/GameNative
Exit code: 0

stdout:

            val xServerView = XServerView(
                context,
                xServerToUse,
            ).apply {
                xServerView = this
                val renderer = this.renderer
                renderer.isCursorVisible = false
                getxServer().renderer = renderer
                PluviaApp.touchpadView = TouchpadView(context, getxServer(), PrefManager.getBoolean("capture_pointer_on_external_mouse", true))
                frameLayout.addView(PluviaApp.touchpadView)
                PluviaApp.touchpadView?.setMoveCursorToTouchpoint(PrefManager.getBoolean("move_cursor_to_touchpoint", false))
                
                // Add invisible IME receiver to capture system keyboard input when keyboard is on external display
                val imeReceiver = app.gamenative.externaldisplay.IMEInputReceiver(context, getxServer()).apply {
                    layoutParams = android.widget.FrameLayout.LayoutParams(
                        android.view.ViewGroup.LayoutParams.MATCH_PARENT,
                        android.view.ViewGroup.LayoutParams.MATCH_PARENT,
                    )
                    alpha = 0f
                    isClickable = false
                }
                frameLayout.addView(imeReceiver)
                
                getxServer().winHandler = WinHandler(getxServer(), this)
                win32AppWorkarounds = Win32AppWorkarounds(
                    getxServer(),
                    taskAffinityMask,
                    taskAffinityMaskWoW64,
                )
                touchMouse = TouchMouse(getxServer())
                keyboard = Keyboard(getxServer())

Script executed:

#!/bin/bash
# Check if there are any fields in the screen that store references to IMEInputReceiver or similar
rg -n "var imeReceiver|val imeReceiver" app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt

Repository: utkarshdalal/GameNative


Repository: utkarshdalal/GameNative
Exit code: 0

stdout:

713:                val imeReceiver = app.gamenative.externaldisplay.IMEInputReceiver(context, getxServer()).apply {

Script executed:

#!/bin/bash
# Check if PluviaApp stores any IMEInputReceiver reference
rg -n "IMEInputReceiver|imeReceiver" app/src/main/java/app/gamenative/PluviaApp.kt 2>/dev/null || echo "PluviaApp.kt does not contain these references"

Repository: utkarshdalal/GameNative


Repository: utkarshdalal/GameNative
Exit code: 0

stdout:

PluviaApp.kt does not contain these references

Script executed:

#!/bin/bash
# Search for any calls to showKeyboard or hideKeyboard in the entire codebase
rg -n "\.showKeyboard\(\)|\.hideKeyboard\(\)" --type=kotlin

Repository: utkarshdalal/GameNative


Repository: utkarshdalal/GameNative
Exit code: 1


Script executed:

#!/bin/bash
# Check broader context around xServerView to understand the view hierarchy and input handling
sed -n '695,750p' app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt

Repository: utkarshdalal/GameNative


Repository: utkarshdalal/GameNative
Exit code: 0

stdout:

            val existingXServer =
                PluviaApp.xEnvironment
                    ?.getComponent<XServerComponent>(XServerComponent::class.java)
                    ?.xServer
            val xServerToUse = existingXServer ?: XServer(ScreenInfo(xServerState.value.screenSize))
            val xServerView = XServerView(
                context,
                xServerToUse,
            ).apply {
                xServerView = this
                val renderer = this.renderer
                renderer.isCursorVisible = false
                getxServer().renderer = renderer
                PluviaApp.touchpadView = TouchpadView(context, getxServer(), PrefManager.getBoolean("capture_pointer_on_external_mouse", true))
                frameLayout.addView(PluviaApp.touchpadView)
                PluviaApp.touchpadView?.setMoveCursorToTouchpoint(PrefManager.getBoolean("move_cursor_to_touchpoint", false))
                
                // Add invisible IME receiver to capture system keyboard input when keyboard is on external display
                val imeReceiver = app.gamenative.externaldisplay.IMEInputReceiver(context, getxServer()).apply {
                    layoutParams = android.widget.FrameLayout.LayoutParams(
                        android.view.ViewGroup.LayoutParams.MATCH_PARENT,
                        android.view.ViewGroup.LayoutParams.MATCH_PARENT,
                    )
                    alpha = 0f
                    isClickable = false
                }
                frameLayout.addView(imeReceiver)
                
                getxServer().winHandler = WinHandler(getxServer(), this)
                win32AppWorkarounds = Win32AppWorkarounds(
                    getxServer(),
                    taskAffinityMask,
                    taskAffinityMaskWoW64,
                )
                touchMouse = TouchMouse(getxServer())
                keyboard = Keyboard(getxServer())
                if (!bootToContainer) {
                    renderer.setUnviewableWMClasses("explorer.exe")
                    // TODO: make 'force fullscreen' be an option of the app being launched
                    if (container.executablePath.isNotBlank()) {
                        renderer.forceFullscreenWMClass = Paths.get(container.executablePath).name
                    }
                }
                getxServer().windowManager.addOnWindowModificationListener(
                    object : WindowManager.OnWindowModificationListener {
                        private fun changeFrameRatingVisibility(window: Window, property: Property?) {
                            if (frameRating == null) return
                            if (property != null) {
                                if (frameRatingWindowId == -1 && (
                                            property.nameAsString().contains("_UTIL_LAYER") ||
                                            property.nameAsString().contains("_MESA_DRV") ||
                                            container.containerVariant.equals(Container.GLIBC) && property.nameAsString().contains("_NET_WM_SURFACE"))) {
                                    frameRatingWindowId = window.id
                                    (context as? Activity)?.runOnUiThread {
                                        frameRating?.visibility = View.VISIBLE
                                    }

Script executed:

#!/bin/bash
# Check if there are any other keyboard input handlers that might be affected by focus
rg -n "keyboard\?.onKeyEvent|onKeyEvent" app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt | head -20

Repository: utkarshdalal/GameNative


Repository: utkarshdalal/GameNative
Exit code: 0

stdout:

586:        val onKeyEvent: (AndroidEvent.KeyEvent) -> Boolean = {
589:            // logD("onKeyEvent(${it.event.device.sources})\n\tisGamepad: $isGamepad\n\tisKeyboard: $isKeyboard\n\t${it.event}")
593:                handled = physicalControllerHandler?.onKeyEvent(it.event) == true
594:                if (!handled) handled = PluviaApp.inputControlsView?.onKeyEvent(it.event) == true
596:                if (!handled) handled = xServerView!!.getxServer().winHandler.onKeyEvent(it.event)
599:                handled = keyboard?.onKeyEvent(it.event) == true
631:        PluviaApp.events.on<AndroidEvent.KeyEvent, Boolean>(onKeyEvent)
639:            PluviaApp.events.off<AndroidEvent.KeyEvent, Boolean>(onKeyEvent)

Unreachable keyboard control API; focus behavior is by design but worth documenting.

The imeReceiver reference is local and never stored anywhere accessible, making the public showKeyboard() / hideKeyboard() API on IMEInputReceiver unreachable from outside this scope. If these methods are intended for external use (e.g., from the navigation dialog), the reference needs to be stored in a field or companion object.

The aggressive focus requests in init, onAttachedToWindow, and onWindowFocusChanged are necessary for the IME system to route soft keyboard input to this view—this is intentional, not a bug. However, this behavior could be worth documenting in a comment, as it's not obvious that these focus requests won't interfere with physical keyboard events (which arrive through a separate AndroidEvent.KeyEvent system and don't depend on which view has focus).

Suggested: store the reference for keyboard control

Consider storing the imeReceiver in a remember state or a PluviaApp static field so that showKeyboard()/hideKeyboard() can be called when the user needs to show/hide the soft keyboard. If these methods are not intended for external use, they should be made internal or removed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt` around
lines 711 - 722, The local imeReceiver instance created from
app.gamenative.externaldisplay.IMEInputReceiver in XServerScreen.kt is never
stored, so its public showKeyboard()/hideKeyboard() APIs are unreachable;
persist the reference (e.g., a private property on the XServerScreen hosting
class or a remember state if using Compose, or a static accessor on PluviaApp)
so callers (navigation dialog) can call
IMEInputReceiver.showKeyboard()/hideKeyboard(), or alternatively mark those
methods internal/remove them if not intended for external use; also add a short
comment near the IMEInputReceiver initialization explaining that aggressive
focus requests in init/onAttachedToWindow/onWindowFocusChanged are intentional
to route IME input and do not affect physical keyboard event delivery.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 2 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt">

<violation number="1" location="app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:68">
P2: deleteSurroundingText ignores the requested deletion length and always sends a single backspace, so multi-character deletions from the IME are truncated.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt:95">
P2: IMEInputReceiver sends KeyEvent without SHIFT meta state and does not map shifted symbols, so uppercase letters are lowercased and many punctuation characters are dropped when forwarded to XServer.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

private fun sendCharacterToGame(char: Char) {
val keyCode = charToKeyCode(char)
if (keyCode != null) {
val event = KeyEvent(KeyEvent.ACTION_DOWN, keyCode)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: IMEInputReceiver sends KeyEvent without SHIFT meta state and does not map shifted symbols, so uppercase letters are lowercased and many punctuation characters are dropped when forwarded to XServer.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt, line 95:

<comment>IMEInputReceiver sends KeyEvent without SHIFT meta state and does not map shifted symbols, so uppercase letters are lowercased and many punctuation characters are dropped when forwarded to XServer.</comment>

<file context>
@@ -0,0 +1,173 @@
+    private fun sendCharacterToGame(char: Char) {
+        val keyCode = charToKeyCode(char)
+        if (keyCode != null) {
+            val event = KeyEvent(KeyEvent.ACTION_DOWN, keyCode)
+            xServer.keyboard.onKeyEvent(event)
+            
</file context>


override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
Timber.d("IMEInputReceiver: deleteSurroundingText called")
if (beforeLength > 0) {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: deleteSurroundingText ignores the requested deletion length and always sends a single backspace, so multi-character deletions from the IME are truncated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt, line 68:

<comment>deleteSurroundingText ignores the requested deletion length and always sends a single backspace, so multi-character deletions from the IME are truncated.</comment>

<file context>
@@ -0,0 +1,173 @@
+
+            override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
+                Timber.d("IMEInputReceiver: deleteSurroundingText called")
+                if (beforeLength > 0) {
+                    xServer.injectKeyPress(com.winlator.xserver.XKeycode.KEY_BKSP, 0)
+                    xServer.injectKeyRelease(com.winlator.xserver.XKeycode.KEY_BKSP)
</file context>
Fix with Cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant