diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 211ffa1a..a7010998 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,9 +14,9 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-22.04, macos-latest, windows-latest] include: - - os: ubuntu-latest + - os: ubuntu-22.04 ARCH: "x86_64" - os: macos-latest ARCH: "aarch64" @@ -56,11 +56,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup Java 11 + - name: Setup Java 17 uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: 11 + java-version: 17 - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee35776c..0f489437 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,11 +16,11 @@ jobs: fetch-depth: 5 persist-credentials: false - - name: Setup Java 11 + - name: Setup Java 17 uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: 11 + java-version: 17 - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/src/main/resources/native/android/android_project/app/src/main/java/com/gluonhq/helloandroid/MainActivity.java b/src/main/resources/native/android/android_project/app/src/main/java/com/gluonhq/helloandroid/MainActivity.java index e9ff1bef..f89ac45e 100644 --- a/src/main/resources/native/android/android_project/app/src/main/java/com/gluonhq/helloandroid/MainActivity.java +++ b/src/main/resources/native/android/android_project/app/src/main/java/com/gluonhq/helloandroid/MainActivity.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2025, Gluon + * Copyright (c) 2019, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -38,7 +38,6 @@ import android.os.SystemClock; import android.text.Editable; import android.text.InputType; -import android.text.Selection; import android.util.DisplayMetrics; import android.util.Log; @@ -60,6 +59,7 @@ import androidx.core.view.WindowCompat; +import java.util.HashMap; import java.util.TimeZone; import javafx.scene.input.KeyCode; @@ -78,6 +78,12 @@ public class MainActivity extends Activity implements SurfaceHolder.Callback, private static InputMethodManager imm; + /** Android InputType to use the next time the IME connects. Defaults to plain text. */ + private static int currentInputType = InputType.TYPE_CLASS_TEXT; + + /** The id of the JavaFX Node that currently has focus. Set from Attach via keyboard.c. */ + private static String currentActiveNodeId = "default"; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -221,6 +227,53 @@ 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. + */ + 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. + */ + static void setKeyboardType(int keyboardTypeValue) { + currentInputType = mapKeyboardTypeToInputType(keyboardTypeValue); + Log.d(TAG, "setKeyboardType: keyboardTypeValue=" + keyboardTypeValue + " -> inputType=" + currentInputType); + if (imm != null && mView != null) { + instance.runOnUiThread(() -> { + imm.restartInput(mView); + }); + } + } + + private static int mapKeyboardTypeToInputType(int keyboardTypeValue) { + switch (keyboardTypeValue) { + case 3: // URL + return InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI; + case 4: // NUMBER_PAD + case 11: // ASCII_NUMBER_PAD + return InputType.TYPE_CLASS_NUMBER; + case 5: // PHONE_PAD + return InputType.TYPE_CLASS_PHONE; + case 6: // NAME_PHONE_PAD + return InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PERSON_NAME; + case 7: // EMAIL + return InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; + case 8: // DECIMAL_PAD + return InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL; + case 0: // DEFAULT + case 1: // ASCII + case 2: // NUMBERS_AND_PUNCTUATION + case 9: // TWITTER + case 10: // WEB_SEARCH + default: + return InputType.TYPE_CLASS_TEXT; + } + } private native void startGraalApp(String[] launchArgs); private native long surfaceReady(Surface surface, float density); @@ -228,6 +281,20 @@ public void run() { private native void nativeSurfaceRedrawNeeded(); 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). + *

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

+ * + * @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); private native void nativeNotifyMenu(int x, int y, int xAbs, int yAbs, boolean isKeyboardTrigger); @@ -240,6 +307,13 @@ 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). + */ + private final HashMap composedTexts = new HashMap<>(); + public InternalSurfaceView(Context context) { super(context); setFocusableInTouchMode(true); @@ -303,33 +377,56 @@ public boolean dispatchTouchEvent(MotionEvent event) { @Override public InputConnection onCreateInputConnection(EditorInfo outAttrs) { Log.d(TAG, "onCreateInputConnection"); - // Allows predictive text - outAttrs.inputType = InputType.TYPE_CLASS_TEXT; + // Use the keyboard type set from the JavaFX layer. The types that include TYPE_CLASS_TEXT have + // predictive text features, and a custom BasicInputConnection implementation is needed. + 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, ""); return new BaseInputConnection(this, true) { @Override public boolean setComposingText(CharSequence text, int newCursorPosition) { - // remove old text - replaceText(); + int deleteCount = currentComposingText.length(); + currentComposingText = text.toString(); boolean result = super.setComposingText(text, newCursorPosition); - processText(text.toString()); + // 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()); return result; } @Override public boolean commitText(CharSequence text, int newCursorPosition) { - // remove old text - replaceText(); + int deleteCount = currentComposingText.length(); + currentComposingText = ""; boolean result = super.commitText(text, newCursorPosition); - processText(text.toString()); + if (ENTER_STRING.equals(text.toString())) { + // Clear composing region, then fire Enter. + resetText(deleteCount); + // Composed text is gone before the Enter key. + updateComposedText(deleteCount, ""); + 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) { @@ -340,52 +437,17 @@ public boolean finishComposingText() { @Override public boolean deleteSurroundingText(int beforeLength, int afterLength) { + // Explicit user deletion (e.g. hardware backspace). boolean result = super.deleteSurroundingText(beforeLength, afterLength); - resetText(beforeLength - afterLength); + int length = beforeLength - afterLength; + // individual backspace key events. + resetText(length); + // sync with the explicit deletion at once. + updateComposedText(length, ""); return result; } - private void processText(String text) { - if (ENTER_STRING.equals(text)) { - // send enter - processAndroidKeyEvent(ENTER_DOWN_EVENT); - processAndroidKeyEvent(ENTER_UP_EVENT); - } else { - // send action_multiple with new text - processAndroidKeyEvent(new KeyEvent(SystemClock.uptimeMillis(), text, -1, 0)); - } - } - - private void replaceText() { - Editable content = getEditable(); - if (content == null) { - return; - } - - int a = getComposingSpanStart(content); - int b = getComposingSpanEnd(content); - if (b < a) { - int tmp = a; - a = b; - b = tmp; - } - - if (a == -1 || b == -1) { - a = Selection.getSelectionStart(content); - b = Selection.getSelectionEnd(content); - if (a < 0) a = 0; - if (b < 0) b = 0; - if (b < a) { - int tmp = a; - a = b; - b = tmp; - } - } - resetText(b - a); - } - private void resetText(int length) { - // clear the old text for (int i = 0; i < length; i++) { processAndroidKeyEvent(BACK_DOWN_EVENT); processAndroidKeyEvent(BACK_UP_EVENT); @@ -405,10 +467,23 @@ public boolean dispatchKeyEvent(final KeyEvent event) { // let Android OS handle volume events consume = false; } - processAndroidKeyEvent (event); + if (event.getAction() == KeyEvent.ACTION_DOWN && event.getKeyCode() == KeyEvent.KEYCODE_DEL) { + // sync after direct backspace key presses. + updateComposedText(1, ""); + } + 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(); diff --git a/src/main/resources/native/android/c/attach_adapter.c b/src/main/resources/native/android/c/attach_adapter.c index 82b09b16..918fc5a4 100644 --- a/src/main/resources/native/android/c/attach_adapter.c +++ b/src/main/resources/native/android/c/attach_adapter.c @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, Gluon + * Copyright (c) 2020, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -42,3 +42,14 @@ JNIEXPORT void JNICALL Java_com_gluonhq_helloandroid_MainActivity_nativeDispatch LOGE(stderr, "Dispatching activity result from native Dalvik layer: %d %d", requestCode, resultCode); 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); +} diff --git a/src/main/resources/native/android/c/grandroid.h b/src/main/resources/native/android/c/grandroid.h index dccc47bc..f06c427e 100644 --- a/src/main/resources/native/android/c/grandroid.h +++ b/src/main/resources/native/android/c/grandroid.h @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2021, Gluon + * Copyright (c) 2020, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -41,6 +41,7 @@ extern jmethodID activity_showIME; extern jmethodID activity_hideIME; +extern jmethodID activity_setActiveNodeId; extern ANativeWindow *window; extern jfloat density; diff --git a/src/main/resources/native/android/c/grandroid_ext.h b/src/main/resources/native/android/c/grandroid_ext.h index 2d3c639d..d6f81b17 100644 --- a/src/main/resources/native/android/c/grandroid_ext.h +++ b/src/main/resources/native/android/c/grandroid_ext.h @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, Gluon + * Copyright (c) 2020, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -58,9 +58,11 @@ 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() \ diff --git a/src/main/resources/native/android/c/launcher.c b/src/main/resources/native/android/c/launcher.c index 2605494e..8d61a5ec 100644 --- a/src/main/resources/native/android/c/launcher.c +++ b/src/main/resources/native/android/c/launcher.c @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2022, Gluon + * Copyright (c) 2020, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -38,6 +38,7 @@ jclass permissionActivityClass; jobject activity; jmethodID activity_showIME; jmethodID activity_hideIME; +jmethodID activity_setActiveNodeId; JavaVM *androidVM; JNIEnv *androidEnv; @@ -77,6 +78,7 @@ void registerMethodHandles(JNIEnv *aenv) permissionActivityClass = (*aenv)->NewGlobalRef(aenv, (*aenv)->FindClass(aenv, "com/gluonhq/helloandroid/PermissionRequestActivity")); activity_showIME = (*aenv)->GetStaticMethodID(aenv, activityClass, "showIME", "()V"); activity_hideIME = (*aenv)->GetStaticMethodID(aenv, activityClass, "hideIME", "()V"); + activity_setActiveNodeId = (*aenv)->GetStaticMethodID(aenv, activityClass, "setActiveNodeId", "(Ljava/lang/String;)V"); registerJavaFXMethodHandles(aenv); }