fix: add IMEInputReceiver to capture keyboard input from external dis…#549
fix: add IMEInputReceiver to capture keyboard input from external dis…#549xXJSONDeruloXx wants to merge 1 commit intoutkarshdalal:masterfrom
Conversation
📝 WalkthroughWalkthroughA new Changes
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/externaldisplay/IMEInputReceiver.kt (1)
25-39: Redundant focus configuration ininitblock.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.
| 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 | ||
| } |
There was a problem hiding this comment.
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.
| 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") | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -name "IMEInputReceiver.kt" -type fRepository: utkarshdalal/GameNative
Length of output: 137
🏁 Script executed:
find . -name "*Keyboard*.kt" -o -name "*Keyboard*.java" | head -20Repository: 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.ktRepository: utkarshdalal/GameNative
Length of output: 7709
🏁 Script executed:
cat -n ./app/src/main/java/com/winlator/xserver/Keyboard.javaRepository: utkarshdalal/GameNative
Length of output: 21624
Uppercase characters and shifted punctuation are silently mishandled or dropped.
Two correctness problems:
-
Uppercase letters produce lowercase output:
'A'maps toKEYCODE_Ajust like'a', but theKeyEventis created without theMETA_SHIFT_ONflag. TheKeyboard.onKeyEvent()method checksevent.isShiftPressed(), which returns false when metaState is not set, so no shift key is injected and uppercase input arrives as lowercase. -
Shifted punctuation is unmapped: Characters like
!,@,#,$,%,^,&,*,(,),_,+,{,},|,:,",<,>,?,~are not in thecharToKeyCodemapping and fall through tonull, 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.
|
|
||
| // 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) | ||
|
|
There was a problem hiding this comment.
❓ Verification inconclusive
Script executed:
#!/bin/bash
# Search for IMEInputReceiver and imeReceiver references
rg -n "IMEInputReceiver|imeReceiver" --type=kotlinRepository: 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 2Repository: 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 -5Repository: 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.ktRepository: 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.ktRepository: 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.ktRepository: 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=kotlinRepository: 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.ktRepository: 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 -20Repository: 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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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>
…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.
Written for commit 8a9f9bf. Summary will update on new commits.
Summary by CodeRabbit
Release Notes
New Features
Improvements