Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@
import android.content.Intent;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.SystemClock;
import android.text.Editable;
import android.text.InputType;
import android.text.Selection;
import android.util.DisplayMetrics;
import android.util.Log;

Expand Down Expand Up @@ -228,25 +228,29 @@ public void run() {
}

/**
* External call that passes the JavaFX text control that is currently active, so composing text
* can be tagged with the correct id.
* External call that passes the id of the JavaFX text control that is currently active,
* so the map of composedTexts can keep track of the current content of each editor per id.
*/
static void setActiveNodeId(String id) {
Log.d(TAG, "setActiveNodeId: " + currentActiveNodeId);
currentActiveNodeId = (id != null) ? id : "default";
}

/**
* External call that sets the Android InputType keyboard. Triggers a restartInput so the change takes
* effect immediately if the keyboard is already visible.
* External call that sets the Android InputType keyboard. Triggers a
* {@code restartInput} so the change takes effect immediately if the
* keyboard is already visible.
*/
static void setKeyboardType(int keyboardTypeValue) {
currentInputType = mapKeyboardTypeToInputType(keyboardTypeValue);
Log.d(TAG, "setKeyboardType: keyboardTypeValue=" + keyboardTypeValue + " -> inputType=" + currentInputType);
int newInputType = mapKeyboardTypeToInputType(keyboardTypeValue);
if (newInputType == currentInputType) {
Log.d(TAG, "setKeyboardType: unchanged inputType=" + newInputType);
return;
}
currentInputType = newInputType;
Log.d(TAG, "setKeyboardType: keyboardTypeValue=" + keyboardTypeValue + " -> inputType=" + newInputType);
if (imm != null && mView != null) {
instance.runOnUiThread(() -> {
imm.restartInput(mView);
});
instance.runOnUiThread(() -> imm.restartInput(mView));
}
}

Expand Down Expand Up @@ -282,18 +286,6 @@ private static int mapKeyboardTypeToInputType(int keyboardTypeValue) {
private native void nativeGotTouchEvent(int pcount, int[] actions, int[] ids, int[] touchXs, int[] touchYs);
private native void nativeDispatchKeyEvent(int type, int key, char[] chars, int charCount, int modifiers);

/**
* Forwards the final composed text to the JavaFX layer via attach_adapter.c,
* without the internals of the BasicInputConnection (like deleting chars and typing
* them all over again while composing the resulting text).
* <p>Note that listeners attached to the JavaFX text input control {@code textProperty()} will still
* catch all those internals, so this is a convenient method to export to the JavaFX layer
* only the final composed text instead</p>
*
* @param id the JavaFX Node id of the active text control
* @param text the full text content for that control
*/
private native void nativeNotifyComposingText(String id, String text);

private native void nativeDispatchLifecycleEvent(String event);
private native void nativeDispatchActivityResult(int requestCode, int resultCode, Intent intent);
Expand All @@ -307,13 +299,16 @@ class InternalSurfaceView extends SurfaceView {
private final KeyEvent ENTER_DOWN_EVENT = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER);
private final KeyEvent ENTER_UP_EVENT = new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER);

private String currentComposingText = "";

/**
* Map of currently composed text per Text Input control (identifyed by its id).
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

Javadoc typo: "identifyed" should be "identified".

Suggested change
* Map of currently composed text per Text Input control (identifyed by its id).
* Map of currently composed text per Text Input control (identified by its id).

Copilot uses AI. Check for mistakes.
* It is used to restart the IME {@link Editable} across keyboard switches / focus
* changes. The map is only accurate as long as the JavaFX control is not modified
* outside of the IME.</p>
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

The Javadoc comment ends with a stray </p> without an opening <p>, which can trigger doclint warnings and renders incorrectly. Remove the closing tag or wrap the paragraph properly.

Suggested change
* outside of the IME.</p>
* outside of the IME.

Copilot uses AI. Check for mistakes.
*/
private final HashMap<String, String> composedTexts = new HashMap<>();

private BaseInputConnection inputConnection;

public InternalSurfaceView(Context context) {
super(context);
setFocusableInTouchMode(true);
Expand Down Expand Up @@ -382,83 +377,92 @@ public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
outAttrs.inputType = currentInputType;
// Remove top textfield editor on landscape
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI;
// A new IME session is starting (keyboard shown, field focused, etc.).
// Reset the composing text and the simulated text for the active node
currentComposingText = "";
composedTexts.putIfAbsent(currentActiveNodeId, "");
final String initialText = composedTexts.get(currentActiveNodeId);
outAttrs.initialSelStart = initialText.length();
outAttrs.initialSelEnd = initialText.length();

return new BaseInputConnection(this, true) {
// for every override that follows, we take the content before and after
// calling super, and we sync the native editor with the JavaFX control
inputConnection = new BaseInputConnection(this, true) {

@Override
public boolean setComposingText(CharSequence text, int newCursorPosition) {
int deleteCount = currentComposingText.length();
currentComposingText = text.toString();
Editable content = getEditable();
String before = content.toString();
boolean result = super.setComposingText(text, newCursorPosition);
// Send individual key events (backspaces + typed text).
resetText(deleteCount);
processAndroidKeyEvent(new KeyEvent(SystemClock.uptimeMillis(), text.toString(), -1, 0));
// Update composed text with one call
updateComposedText(deleteCount, text.toString());
syncEditor(before, content.toString());
return result;
}

@Override
public boolean commitText(CharSequence text, int newCursorPosition) {
int deleteCount = currentComposingText.length();
currentComposingText = "";
Editable content = getEditable();
String before = content.toString();
boolean isEnter = ENTER_STRING.equals(text == null ? "" : text.toString());
boolean result = super.commitText(text, newCursorPosition);
if (ENTER_STRING.equals(text.toString())) {
// Clear composing region, then fire Enter.
resetText(deleteCount);
// Composed text is gone before the Enter key.
updateComposedText(deleteCount, "");
if (isEnter) {
// strip '\n' and fire Enter as a key event.
int last = content.length() - 1;
if (last >= 0 && content.charAt(last) == '\n') {
content.delete(last, last + 1);
}
syncEditor(before, content.toString());
processAndroidKeyEvent(ENTER_DOWN_EVENT);
processAndroidKeyEvent(ENTER_UP_EVENT);
} else {
// backspaces to erase composing text, then insert committed word.
resetText(deleteCount);
processAndroidKeyEvent(new KeyEvent(SystemClock.uptimeMillis(), text.toString(), -1, 0));
// committed word replaces the composing region at once.
updateComposedText(deleteCount, text.toString());
}
return result;
}

@Override
public boolean finishComposingText() {
currentComposingText = "";
boolean result = super.finishComposingText();
Editable content = getEditable();
if (content != null) {
content.clear();
return result;
}
syncEditor(before, content.toString());
return result;
}

@Override
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
// Explicit user deletion (e.g. hardware backspace).
Editable content = getEditable();
String before = content.toString();
boolean result = super.deleteSurroundingText(beforeLength, afterLength);
int length = beforeLength - afterLength;
// individual backspace key events.
resetText(length);
// sync with the explicit deletion at once.
updateComposedText(length, "");
syncEditor(before, content.toString());
return result;
}

private void resetText(int length) {
for (int i = 0; i < length; i++) {
processAndroidKeyEvent(BACK_DOWN_EVENT);
processAndroidKeyEvent(BACK_UP_EVENT);
}
}
};

// Set cached text and place the cursor at its end.
Editable editable = inputConnection.getEditable();
if (editable != null) {
editable.replace(0, editable.length(), initialText);
Selection.setSelection(editable, initialText.length());
}
Log.d(TAG, "onCreateInputConnection Editable set with '" + initialText + "'");
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

This debug log prints the full cached editor content, which may include sensitive user input (e.g., passwords). Consider removing it or logging only non-sensitive metadata (like length) behind a dedicated debug flag.

Suggested change
Log.d(TAG, "onCreateInputConnection Editable set with '" + initialText + "'");
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "onCreateInputConnection Editable initialized; cached text length=" + initialText.length());
}

Copilot uses AI. Check for mistakes.
return inputConnection;
}

/**
* Send the key events (backspaces + typed characters) needed to transform the editor
* from {@code before} to {@code after}.
* Also update the per-node cache for future IME sessions.
*/
private void syncEditor(String before, String after) {
if (before.equals(after)) {
return;
}
int common = 0;
int min = Math.min(before.length(), after.length());
while (common < min && before.charAt(common) == after.charAt(common)) {
common++;
}
for (int i = before.length() - common; i > 0; i--) {
processAndroidKeyEvent(BACK_DOWN_EVENT);
processAndroidKeyEvent(BACK_UP_EVENT);
}
if (common < after.length()) {
processAndroidKeyEvent(new KeyEvent(SystemClock.uptimeMillis(), after.substring(common), -1, 0));
}
composedTexts.put(currentActiveNodeId, after);
}

@Override
public boolean dispatchKeyEvent(final KeyEvent event) {
Log.v(TAG, "Activity, process get key event, action = "+event);
Log.v(TAG, "Activity, process get key event, action = " + event);
boolean consume = true;
if (event.getAction() == KeyEvent.ACTION_DOWN &&
(event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP ||
Expand All @@ -467,23 +471,19 @@ public boolean dispatchKeyEvent(final KeyEvent event) {
// let Android OS handle volume events
consume = false;
}
if (event.getAction() == KeyEvent.ACTION_DOWN && event.getKeyCode() == KeyEvent.KEYCODE_DEL) {
if (event.getAction() == KeyEvent.ACTION_DOWN && event.getKeyCode() == KeyEvent.KEYCODE_DEL
&& inputConnection != null) {
// sync after direct backspace key presses.
updateComposedText(1, "");
Editable content = inputConnection.getEditable();
if (content != null && content.length() > 0) {
content.delete(content.length() - 1, content.length());
composedTexts.put(currentActiveNodeId, content.toString());
}
Comment on lines +474 to +481
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

Cache update on hardware backspace deletes the last character unconditionally. Since DPAD keys are supported (caret can move), this can desync composedTexts from the actual JavaFX editor content and restore incorrect text on the next IME session. Prefer updating the cache based on the current selection (e.g., via Selection.getSelectionStart/End on the Editable) and deleting at that position, or avoid mutating the cache here and instead refresh it from the editor state when possible.

Copilot uses AI. Check for mistakes.
}
processAndroidKeyEvent(event);
return consume;
}

private void updateComposedText(int deleteCount, String text) {
String id = currentActiveNodeId;
String current = composedTexts.getOrDefault(id, "");
String updated = current.substring(0, current.length() - Math.min(deleteCount, current.length())) + text;
composedTexts.put(id, updated);
Log.d(TAG, "updateComposedText [" + id + "]: TextInputControl shows: '" + updated + "'");
nativeNotifyComposingText(id, updated);
}

private final Handler handler = new Handler();
private final LongPress longPress = new LongPress();

Expand Down
10 changes: 0 additions & 10 deletions src/main/resources/native/android/c/attach_adapter.c
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,3 @@ JNIEXPORT void JNICALL Java_com_gluonhq_helloandroid_MainActivity_nativeDispatch
attach_setActivityResult(requestCode, resultCode, intent);
}

// keyboard: Composing text (IME predictive text for node with id)
JNIEXPORT void JNICALL Java_com_gluonhq_helloandroid_MainActivity_nativeNotifyComposingText(JNIEnv *env, jobject activity, jstring id, jstring text)
{
const char *idChars = (*env)->GetStringUTFChars(env, id, NULL);
const char *textChars = (*env)->GetStringUTFChars(env, text, NULL);
LOGE(stderr, "Dispatching composing text from native Dalvik layer: id=%s, text=%s", idChars, textChars);
attach_setComposingText(idChars, textChars);
(*env)->ReleaseStringUTFChars(env, text, textChars);
(*env)->ReleaseStringUTFChars(env, id, idChars);
}
2 changes: 0 additions & 2 deletions src/main/resources/native/android/c/grandroid_ext.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,9 @@ jobject substrateGetActivity();
#ifdef SUBSTRATE
void __attribute__((weak)) attach_setActivityResult(jint requestCode, jint resultCode, jobject intent) {}
void __attribute__((weak)) attach_setLifecycleEvent(const char *event) {}
void __attribute__((weak)) attach_setComposingText(const char *id, const char *text) {}
#else
void attach_setActivityResult(jint requestCode, jint resultCode, jobject intent);
void attach_setLifecycleEvent(const char *event);
void attach_setComposingText(const char *id, const char *text);
#endif

#define ATTACH_GRAAL() \
Expand Down
Loading