Ver código fonte

app: copy GameTextInput java code into app

jkds 7 meses atrás
pai
commit
64e38580b2

+ 0 - 17
bin/app/build.rs

@@ -64,22 +64,5 @@ fn main() {
                 "cargo:warning='leaving linker args for {target_os}:{target_arch} unchanged"
             ),
         }
-
-        // Link GameTextInput static library
-        let gametextinput_lib_dir = match target_arch.as_str() {
-            "aarch64" => "pkg/android/libs/android.arm64-v8a",
-            "arm" => "pkg/android/libs/android.armeabi-v7a",
-            "i686" => "pkg/android/libs/android.x86",
-            "x86_64" => "pkg/android/libs/android.x86_64",
-            _ => {
-                println!("cargo:warning=Unknown target arch for GameTextInput: {target_arch}");
-                "pkg/android/libs"
-            }
-        };
-        println!("cargo:rustc-link-search=native={gametextinput_lib_dir}");
-        println!("cargo:rustc-link-lib=static=game-text-input");
-
-        // Rebuild if headers change
-        println!("cargo:rerun-if-changed=src/android/textinput/include");
     }
 }

+ 93 - 3
bin/app/java/MainActivity.java

@@ -2,9 +2,16 @@
 
 import android.view.ViewGroup;
 import android.view.WindowInsets.Type;
+import android.view.inputmethod.EditorInfo;
+import android.text.InputType;
 import java.util.HashMap;
 
 import videodecode.VideoDecoder;
+import textinput.InputConnection;
+import textinput.Settings;
+import textinput.Listener;
+import textinput.State;
+import textinput.GameTextInput;
 
 //% END
 
@@ -41,6 +48,13 @@ if (true)
 
 //% MAIN_ACTIVITY_BODY
 
+// GameTextInput native bridge functions (following official Android pattern)
+native void setInputConnectionNative(textinput.InputConnection c);
+native void onTextInputEventNative(textinput.State softKeyboardEvent);
+
+// GameTextInput InputConnection reference (public for QuadSurface access)
+public textinput.InputConnection gameTextInputInputConnection;
+
 public String getAppDataPath() {
     return getApplicationContext().getDataDir().getAbsolutePath();
 }
@@ -81,9 +95,9 @@ public VideoDecoder createVideoDecoder() {
 
 //% MAIN_ACTIVITY_ON_CREATE
 
-view.setFocusable(false);
-view.setFocusableInTouchMode(false);
-view.clearFocus();
+//view.setFocusable(false);
+//view.setFocusableInTouchMode(false);
+//view.clearFocus();
 
 // Start a foreground service so the app stays awake
 Intent serviceIntent = new Intent(this, ForegroundService.class);
@@ -91,3 +105,79 @@ startForegroundService(serviceIntent);
 
 //% END
 
+
+//% QUAD_SURFACE_ON_CREATE_INPUT_CONNECTION
+
+// Get reference to MainActivity
+        if (getContext() == null)
+            Log.i("darkfi", "getCTX (on creat) is nulllll!!!!!!!!!!!!!!!!!!");
+MainActivity mainActivity = (MainActivity)getContext();
+
+android.util.Log.d("darkfi", "onCreateInputConnection called");
+
+// Create InputConnection if it doesn't exist yet
+if (mainActivity.gameTextInputInputConnection == null) {
+    android.util.Log.d("darkfi", "Creating new InputConnection");
+    // Create InputConnection with Context (from QuadSurface)
+    android.view.inputmethod.EditorInfo editorInfo = new android.view.inputmethod.EditorInfo();
+    editorInfo.inputType = android.text.InputType.TYPE_CLASS_TEXT |
+                           android.text.InputType.TYPE_TEXT_FLAG_AUTO_CORRECT;
+    editorInfo.imeOptions = android.view.inputmethod.EditorInfo.IME_FLAG_NO_FULLSCREEN;
+
+    if (mainActivity == null)
+        Log.i("darkfi", "mainact is NULLLL");
+    mainActivity.gameTextInputInputConnection = new textinput.InputConnection(
+        getContext(),
+        this,
+        new textinput.Settings(editorInfo, true)
+    );
+
+    // Pass the InputConnection to native GameTextInput library
+    android.util.Log.d("darkfi", "InputConnection created and passed to native");
+    mainActivity.setInputConnectionNative(mainActivity.gameTextInputInputConnection);
+} else {
+    android.util.Log.d("darkfi", "Reusing existing InputConnection");
+}
+
+// Set the listener to receive IME state changes
+mainActivity.gameTextInputInputConnection.setListener(new textinput.Listener() {
+    @Override
+    public void stateChanged(textinput.State newState, boolean dismissed) {
+        // Called when the IME sends new text state
+        // Forward to native code which triggers Rust callback
+        android.util.Log.d("darkfi", "stateChanged: text=" + newState.toString());
+        mainActivity.onTextInputEventNative(newState);
+    }
+
+    @Override
+    public void onImeInsetsChanged(androidx.core.graphics.Insets insets) {
+        // Called when IME insets change (e.g., keyboard height changes)
+        // Optional: can be used for dynamic layout adjustment
+    }
+
+    @Override
+    public void onSoftwareKeyboardVisibilityChanged(boolean visible) {
+        // Called when keyboard is shown or hidden
+        android.util.Log.d("darkfi", "onSoftwareKeyboardVisibilityChanged: " + visible);
+    }
+
+    @Override
+    public void onEditorAction(int actionCode) {
+        // Called when user presses action button (Done, Next, etc.)
+        // Optional: handle specific editor actions
+    }
+});
+
+// Copy EditorInfo from GameTextInput to configure IME
+if (outAttrs != null) {
+    textinput.GameTextInput.copyEditorInfo(
+        mainActivity.gameTextInputInputConnection.getEditorInfo(),
+        outAttrs
+    );
+}
+
+// Return the GameTextInput InputConnection to IME
+if (true) return mainActivity.gameTextInputInputConnection;
+return mainActivity.gameTextInputInputConnection;
+
+//% END

+ 52 - 0
bin/app/java/textinput/GameTextInput.java

@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package textinput;
+
+import android.text.Editable;
+import android.text.Spanned;
+import android.view.inputmethod.EditorInfo;
+
+/*
+ * Singleton GameTextInput class with helper methods.
+ */
+public final class GameTextInput {
+  public final static void copyEditorInfo(EditorInfo from, EditorInfo to) {
+    if (from == null || to == null)
+      return;
+    if (from.hintText != null) {
+      to.hintText = from.hintText;
+    }
+
+    to.inputType = from.inputType;
+    to.imeOptions = from.imeOptions;
+    to.label = from.label;
+    to.initialCapsMode = from.initialCapsMode;
+    to.privateImeOptions = from.privateImeOptions;
+    if (from.packageName != null) {
+      to.packageName = from.packageName;
+    }
+
+    to.fieldId = from.fieldId;
+    if (from.fieldName != null) {
+      to.fieldName = from.fieldName;
+    }
+
+    to.initialSelStart = from.initialSelStart;
+    to.initialSelEnd = from.initialSelEnd;
+  }
+
+  private GameTextInput() {}
+}

+ 711 - 0
bin/app/java/textinput/InputConnection.java

@@ -0,0 +1,711 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package textinput;
+
+import static android.view.inputmethod.EditorInfo.IME_ACTION_UNSPECIFIED;
+
+import android.app.Activity;
+import android.content.Context;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.Selection;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.BaseInputConnection;
+import android.view.inputmethod.CompletionInfo;
+import android.view.inputmethod.CorrectionInfo;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.ExtractedText;
+import android.view.inputmethod.ExtractedTextRequest;
+import android.view.inputmethod.InputMethodManager;
+import androidx.core.graphics.Insets;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowCompat;
+import androidx.core.view.WindowInsetsCompat;
+
+public class InputConnection extends BaseInputConnection implements View.OnKeyListener {
+  private static final String TAG = "gti.InputConnection";
+  private final InputMethodManager imm;
+  private final View targetView;
+  private final Settings settings;
+  private final Editable mEditable;
+  private Listener listener;
+  private boolean mSoftKeyboardActive;
+
+  /*
+   * This class filters EOL characters from the input. For details of how InputFilter.filter
+   * function works, refer to its documentation. If the suggested change is accepted without
+   * modifications, filter() should return null.
+   */
+  private class SingeLineFilter implements InputFilter {
+    public CharSequence filter(
+        CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
+      boolean keepOriginal = true;
+      StringBuilder builder = new StringBuilder(end - start);
+
+      for (int i = start; i < end; i++) {
+        char c = source.charAt(i);
+
+        if (c == '\n') {
+          keepOriginal = false;
+        } else {
+          builder.append(c);
+        }
+      }
+
+      if (keepOriginal) {
+        return null;
+      }
+
+      if (source instanceof Spanned) {
+        SpannableString s = new SpannableString(builder);
+        TextUtils.copySpansFrom((Spanned) source, start, builder.length(), null, s, 0);
+        return s;
+      } else {
+        return builder;
+      }
+    }
+  }
+
+  private static final int MAX_LENGTH_FOR_SINGLE_LINE_EDIT_TEXT = 5000;
+
+  /**
+   * Constructor
+   *
+   * @param ctx        The app's context
+   * @param targetView The view created this input connection
+   * @param settings   EditorInfo and other settings needed by this class
+   *                   InputConnection.
+   */
+  public InputConnection(Context ctx, View targetView, Settings settings) {
+    super(targetView, settings.mEditorInfo.inputType != 0);
+    Log.d(TAG, "InputConnection created");
+
+    this.targetView = targetView;
+    this.settings = settings;
+    Object imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE);
+    if (imm == null) {
+      throw new java.lang.RuntimeException("Can't get IMM");
+    } else {
+      this.imm = (InputMethodManager) imm;
+      this.mEditable = (Editable) (new SpannableStringBuilder());
+    }
+    // Listen for insets changes
+    WindowCompat.setDecorFitsSystemWindows(((Activity) targetView.getContext()).getWindow(), false);
+    targetView.setOnKeyListener(this);
+    // Apply EditorInfo settings
+    this.setEditorInfo(settings.mEditorInfo);
+  }
+
+  /**
+   * Restart the input method manager. This is useful to apply changes to the keyboard
+   * after calling setEditorInfo.
+   */
+  public void restartInput() {
+    imm.restartInput(targetView);
+  }
+
+  /**
+   * Get whether the soft keyboard is visible.
+   *
+   * @return true if the soft keyboard is visible, false otherwise
+   */
+  public final boolean getSoftKeyboardActive() {
+    return this.mSoftKeyboardActive;
+  }
+
+  /**
+   * Request the soft keyboard to become visible or invisible.
+   *
+   * @param active True if the soft keyboard should be made visible, otherwise false.
+   * @param flags  See
+   *     https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#showSoftInput(android.view.View,%20int)
+   */
+  public final void setSoftKeyboardActive(boolean active, int flags) {
+    Log.d(TAG, "setSoftKeyboardActive, active: " + active);
+
+    this.mSoftKeyboardActive = active;
+    if (active) {
+      this.targetView.setFocusableInTouchMode(true);
+      this.targetView.requestFocus();
+      this.imm.showSoftInput(this.targetView, flags);
+    } else {
+      this.imm.hideSoftInputFromWindow(this.targetView.getWindowToken(), flags);
+    }
+    restartInput();
+  }
+
+  /**
+   * Get the current EditorInfo used to configure the InputConnection's behaviour.
+   *
+   * @return The current EditorInfo.
+   */
+  public final EditorInfo getEditorInfo() {
+    return this.settings.mEditorInfo;
+  }
+
+  /**
+   * Set the current EditorInfo used to configure the InputConnection's behaviour.
+   *
+   * @param editorInfo The EditorInfo to use
+   */
+  public final void setEditorInfo(EditorInfo editorInfo) {
+    Log.d(TAG, "setEditorInfo");
+    settings.mEditorInfo = editorInfo;
+
+    // Depending on the multiline state, we might need a different set of filters.
+    // Filters are being used to filter specific characters for hardware keyboards
+    // (software input methods already support TYPE_TEXT_FLAG_MULTI_LINE).
+    if ((settings.mEditorInfo.inputType & EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE) == 0) {
+      mEditable.setFilters(
+          new InputFilter[] {new InputFilter.LengthFilter(MAX_LENGTH_FOR_SINGLE_LINE_EDIT_TEXT),
+              new SingeLineFilter()});
+    } else {
+      mEditable.setFilters(new InputFilter[] {});
+    }
+  }
+
+  /**
+   * Set the text, selection and composing region state.
+   *
+   * @param state The state to be used by the IME.
+   *              This replaces any text, selections and composing regions currently active.
+   */
+  public final void setState(State state) {
+    if (state == null)
+      return;
+    Log.d(TAG,
+        "setState: '" + state.text + "', selection=(" + state.selectionStart + ","
+            + state.selectionEnd + "), composing region=(" + state.composingRegionStart + ","
+            + state.composingRegionEnd + ")");
+    mEditable.clear();
+    mEditable.clearSpans();
+    mEditable.insert(0, (CharSequence) state.text);
+    setSelection(state.selectionStart, state.selectionEnd);
+    if (state.composingRegionStart != state.composingRegionEnd) {
+      setComposingRegion(state.composingRegionStart, state.composingRegionEnd);
+    }
+    restartInput();
+  }
+
+  /**
+   * Get the current listener for state changes.
+   *
+   * @return The current Listener
+   */
+  public final Listener getListener() {
+    return listener;
+  }
+
+  /**
+   * Set a listener for state changes.
+   *
+   * @param listener
+   * @return This InputConnection, for setter chaining.
+   */
+  public final InputConnection setListener(Listener listener) {
+    this.listener = listener;
+    return this;
+  }
+
+  // From View.OnKeyListener
+  @Override
+  public boolean onKey(View view, int i, KeyEvent keyEvent) {
+    Log.d(TAG, "onKey: " + keyEvent);
+    if (!getSoftKeyboardActive()) {
+      return false;
+    }
+    // Don't call sendKeyEvent as it might produce an infinite loop.
+    if (processKeyEvent(keyEvent)) {
+      // IMM seems to cache the content of Editable, so we update it with restartInput
+      // Also it caches selection and composing region, so let's notify it about updates.
+      stateUpdated();
+      immUpdateSelection();
+      restartInput();
+      return true;
+    }
+    return false;
+  }
+
+  // From BaseInputConnection
+  @Override
+  public Editable getEditable() {
+    Log.d(TAG, "getEditable");
+    return mEditable;
+  }
+
+  // From BaseInputConnection
+  @Override
+  public boolean setSelection(int start, int end) {
+    Log.d(TAG, "setSelection: " + start + ":" + end);
+    return super.setSelection(start, end);
+  }
+
+  // From BaseInputConnection
+  @Override
+  public boolean setComposingText(CharSequence text, int newCursorPosition) {
+    Log.d(
+        TAG, String.format("setComposingText='%s' newCursorPosition=%d", text, newCursorPosition));
+    if (text == null) {
+      return false;
+    }
+    return super.setComposingText(text, newCursorPosition);
+  }
+
+  @Override
+  public boolean setComposingRegion(int start, int end) {
+    Log.d(TAG, "setComposingRegion: " + start + ":" + end);
+    return super.setComposingRegion(start, end);
+  }
+
+  // From BaseInputConnection
+  @Override
+  public boolean finishComposingText() {
+    Log.d(TAG, "finishComposingText");
+    return super.finishComposingText();
+  }
+
+  @Override
+  public boolean endBatchEdit() {
+    Log.d(TAG, "endBatchEdit");
+    stateUpdated();
+    return super.endBatchEdit();
+  }
+
+  @Override
+  public boolean commitCompletion(CompletionInfo text) {
+    Log.d(TAG, "commitCompletion");
+    return super.commitCompletion(text);
+  }
+
+  @Override
+  public boolean commitCorrection(CorrectionInfo text) {
+    Log.d(TAG, "commitCompletion");
+    return super.commitCorrection(text);
+  }
+
+  // From BaseInputConnection
+  @Override
+  public boolean commitText(CharSequence text, int newCursorPosition) {
+    Log.d(TAG,
+        (new StringBuilder())
+            .append("commitText: ")
+            .append(text)
+            .append(", new pos = ")
+            .append(newCursorPosition)
+            .toString());
+    return super.commitText(text, newCursorPosition);
+  }
+
+  // From BaseInputConnection
+  @Override
+  public boolean deleteSurroundingText(int beforeLength, int afterLength) {
+    Log.d(TAG, "deleteSurroundingText: " + beforeLength + ":" + afterLength);
+    return super.deleteSurroundingText(beforeLength, afterLength);
+  }
+
+  // From BaseInputConnection
+  @Override
+  public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) {
+    Log.d(TAG, "deleteSurroundingTextInCodePoints: " + beforeLength + ":" + afterLength);
+    return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength);
+  }
+
+  // From BaseInputConnection
+  @Override
+  public boolean sendKeyEvent(KeyEvent event) {
+    Log.d(TAG, "sendKeyEvent: " + event);
+    return super.sendKeyEvent(event);
+  }
+
+  // From BaseInputConnection
+  @Override
+  public CharSequence getSelectedText(int flags) {
+    CharSequence result = super.getSelectedText(flags);
+    if (result == null) {
+      result = "";
+    }
+    Log.d(TAG, "getSelectedText: " + flags + ", result: " + result);
+    return result;
+  }
+
+  // From BaseInputConnection
+  @Override
+  public CharSequence getTextAfterCursor(int length, int flags) {
+    Log.d(TAG, "getTextAfterCursor: " + length + ":" + flags);
+    if (length < 0) {
+      Log.i(TAG, "getTextAfterCursor: returning null to due to an invalid length=" + length);
+      return null;
+    }
+    return super.getTextAfterCursor(length, flags);
+  }
+
+  // From BaseInputConnection
+  @Override
+  public CharSequence getTextBeforeCursor(int length, int flags) {
+    Log.d(TAG, "getTextBeforeCursor: " + length + ", flags=" + flags);
+    if (length < 0) {
+      Log.i(TAG, "getTextBeforeCursor: returning null to due to an invalid length=" + length);
+      return null;
+    }
+    return super.getTextBeforeCursor(length, flags);
+  }
+
+  // From BaseInputConnection
+  @Override
+  public boolean requestCursorUpdates(int cursorUpdateMode) {
+    Log.d(TAG, "Request cursor updates: " + cursorUpdateMode);
+    return super.requestCursorUpdates(cursorUpdateMode);
+  }
+
+  // From BaseInputConnection
+  @Override
+  public void closeConnection() {
+    Log.d(TAG, "closeConnection");
+    super.closeConnection();
+  }
+
+  @Override
+  public boolean setImeConsumesInput(boolean imeConsumesInput) {
+    Log.d(TAG, "setImeConsumesInput: " + imeConsumesInput);
+    return super.setImeConsumesInput(imeConsumesInput);
+  }
+
+  @Override
+  public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
+    Log.d(TAG, "getExtractedText");
+    return super.getExtractedText(request, flags);
+  }
+
+  @Override
+  public boolean performPrivateCommand(String action, Bundle data) {
+    Log.d(TAG, "performPrivateCommand");
+    return super.performPrivateCommand(action, data);
+  }
+
+  private void immUpdateSelection() {
+    Pair selection = this.getSelection();
+    Pair cr = this.getComposingRegion();
+    Log.d(TAG,
+        "immUpdateSelection: " + selection.first + "," + selection.second + ". " + cr.first + ","
+            + cr.second);
+    settings.mEditorInfo.initialSelStart = selection.first;
+    settings.mEditorInfo.initialSelEnd = selection.second;
+    imm.updateSelection(targetView, selection.first, selection.second, cr.first, cr.second);
+  }
+
+  private Pair getSelection() {
+    return new Pair(Selection.getSelectionStart(mEditable), Selection.getSelectionEnd(mEditable));
+  }
+
+  private Pair getComposingRegion() {
+    return new Pair(getComposingSpanStart(mEditable), getComposingSpanEnd(mEditable));
+  }
+
+  private boolean processKeyEvent(KeyEvent event) {
+    if (event == null) {
+      return false;
+    }
+    int keyCode = event.getKeyCode();
+    Log.d(
+        TAG, String.format("processKeyEvent(key=%d) text=%s", keyCode, this.mEditable.toString()));
+    // Filter out Enter keys if multi-line mode is disabled.
+    if ((settings.mEditorInfo.inputType & EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE) == 0
+        && (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER)
+        && event.hasNoModifiers()) {
+      sendEditorAction(settings.mEditorInfo.actionId);
+      return true;
+    }
+    if (event.getAction() != KeyEvent.ACTION_DOWN) {
+      return false;
+    }
+    // If no selection is set, move the selection to the end.
+    // This is the case when first typing on keys when the selection is not set.
+    // Note that for InputType.TYPE_CLASS_TEXT, this is not be needed because the
+    // selection is set in setComposingText.
+    Pair selection = this.getSelection();
+    if (selection.first == -1) {
+      selection.first = this.mEditable.length();
+      selection.second = this.mEditable.length();
+    }
+
+    if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
+      if (selection.first == selection.second) {
+        int newIndex = findIndexBackward(mEditable, selection.first, 1);
+        setSelection(newIndex, newIndex);
+      } else {
+        setSelection(selection.first, selection.first);
+      }
+      return true;
+    }
+    if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
+      if (selection.first == selection.second) {
+        int newIndex = findIndexForward(mEditable, selection.second, 1);
+        setSelection(newIndex, newIndex);
+      } else {
+        setSelection(selection.second, selection.second);
+      }
+      return true;
+    }
+    if (keyCode == KeyEvent.KEYCODE_MOVE_HOME) {
+      setSelection(0, 0);
+      return true;
+    }
+    if (keyCode == KeyEvent.KEYCODE_MOVE_END) {
+      setSelection(this.mEditable.length(), this.mEditable.length());
+      return true;
+    }
+    if (keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
+      if (selection.first != selection.second) {
+        this.mEditable.delete(selection.first, selection.second);
+        return true;
+      }
+      if (keyCode == KeyEvent.KEYCODE_DEL) {
+        if (selection.first > 0) {
+          finishComposingText();
+          deleteSurroundingTextInCodePoints(1, 0);
+          return true;
+        }
+      }
+      if (keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
+        if (selection.first < this.mEditable.length()) {
+          finishComposingText();
+          deleteSurroundingTextInCodePoints(0, 1);
+          return true;
+        }
+      }
+      return false;
+    }
+
+    if (event.getUnicodeChar() == 0) {
+      return false;
+    }
+
+    if (selection.first != selection.second) {
+      Log.d(TAG, String.format("processKeyEvent: deleting selection"));
+      this.mEditable.delete(selection.first, selection.second);
+    }
+
+    String charsToInsert = Character.toString((char) event.getUnicodeChar());
+    this.mEditable.insert(selection.first, (CharSequence) charsToInsert);
+    int length = this.mEditable.length();
+
+    // Same logic as in setComposingText(): we must update composing region,
+    // so make sure it points to a valid range.
+    Pair composingRegion = this.getComposingRegion();
+    if (composingRegion.first == -1) {
+      composingRegion = this.getSelection();
+      if (composingRegion.first == -1) {
+        composingRegion = new Pair(0, 0);
+      }
+    }
+
+    composingRegion.second = composingRegion.first + length;
+    this.setComposingRegion(composingRegion.first, composingRegion.second);
+    int new_cursor = selection.first + charsToInsert.length();
+    setSelection(new_cursor, new_cursor);
+    Log.d(TAG, String.format("processKeyEvent: exit, text=%s", this.mEditable.toString()));
+    return true;
+  }
+
+  private final void stateUpdated() {
+    Pair selection = this.getSelection();
+    Pair cr = this.getComposingRegion();
+    State state = new State(
+        this.mEditable.toString(), selection.first, selection.second, cr.first, cr.second);
+    settings.mEditorInfo.initialSelStart = selection.first;
+    settings.mEditorInfo.initialSelEnd = selection.second;
+
+    // Keep a reference to the listener to avoid a race condition when setting the listener.
+    Listener listener = this.listener;
+
+    // We always propagate state change events because unfortunately keyboard visibility functions
+    // are unreliable, and text editor logic should not depend on them.
+    if (listener != null) {
+      listener.stateChanged(state, /*dismissed=*/false);
+    }
+  }
+
+  /**
+   * Get the current IME insets.
+   *
+   * @return The current IME insets
+   */
+  public Insets getImeInsets() {
+    if (this.targetView == null) {
+      return Insets.NONE;
+    }
+
+    WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(this.targetView);
+
+    if (insets == null) {
+      return Insets.NONE;
+    }
+
+    return insets.getInsets(WindowInsetsCompat.Type.ime());
+  }
+
+  /**
+   * Returns true if software keyboard is visible, false otherwise.
+   *
+   * @return whether software IME is visible or not.
+   */
+  public boolean isSoftwareKeyboardVisible() {
+    if (this.targetView == null) {
+      return false;
+    }
+
+    WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(this.targetView);
+
+    if (insets == null) {
+      return false;
+    }
+
+    return insets.isVisible(WindowInsetsCompat.Type.ime());
+  }
+
+  /**
+   * This is an event handler from InputConnection interface.
+   * It's called when action button is triggered (typically this means Enter was pressed).
+   *
+   * @param action Action code, either one from EditorInfo.imeOptions or a custom one.
+   * @return Returns true on success, false if the input connection is no longer valid.
+   */
+  @Override
+  public boolean performEditorAction(int action) {
+    Log.d(TAG, "performEditorAction, action=" + action);
+    if (action == IME_ACTION_UNSPECIFIED) {
+      // Super emulates Enter key press/release
+      return super.performEditorAction(action);
+    }
+    return sendEditorAction(action);
+  }
+
+  /**
+   * Delivers editor action to listener
+   *
+   * @param action Action code, either one from EditorInfo.imeOptions or a custom one.
+   * @return Returns true on success, false if the input connection is no longer valid.
+   */
+  private boolean sendEditorAction(int action) {
+    Listener listener = this.listener;
+    if (listener != null) {
+      listener.onEditorAction(action);
+      return true;
+    }
+    return false;
+  }
+
+  private static int INVALID_INDEX = -1;
+  // Implementation copy from BaseInputConnection
+  private static int findIndexBackward(
+      final CharSequence cs, final int from, final int numCodePoints) {
+    int currentIndex = from;
+    boolean waitingHighSurrogate = false;
+    final int N = cs.length();
+    if (currentIndex < 0 || N < currentIndex) {
+      return INVALID_INDEX; // The starting point is out of range.
+    }
+    if (numCodePoints < 0) {
+      return INVALID_INDEX; // Basically this should not happen.
+    }
+    int remainingCodePoints = numCodePoints;
+    while (true) {
+      if (remainingCodePoints == 0) {
+        return currentIndex; // Reached to the requested length in code points.
+      }
+
+      --currentIndex;
+      if (currentIndex < 0) {
+        if (waitingHighSurrogate) {
+          return INVALID_INDEX; // An invalid surrogate pair is found.
+        }
+        return 0; // Reached to the beginning of the text w/o any invalid surrogate pair.
+      }
+      final char c = cs.charAt(currentIndex);
+      if (waitingHighSurrogate) {
+        if (!java.lang.Character.isHighSurrogate(c)) {
+          return INVALID_INDEX; // An invalid surrogate pair is found.
+        }
+        waitingHighSurrogate = false;
+        --remainingCodePoints;
+        continue;
+      }
+      if (!java.lang.Character.isSurrogate(c)) {
+        --remainingCodePoints;
+        continue;
+      }
+      if (java.lang.Character.isHighSurrogate(c)) {
+        return INVALID_INDEX; // A invalid surrogate pair is found.
+      }
+      waitingHighSurrogate = true;
+    }
+  }
+
+  // Implementation copy from BaseInputConnection
+  private static int findIndexForward(
+      final CharSequence cs, final int from, final int numCodePoints) {
+    int currentIndex = from;
+    boolean waitingLowSurrogate = false;
+    final int N = cs.length();
+    if (currentIndex < 0 || N < currentIndex) {
+      return INVALID_INDEX; // The starting point is out of range.
+    }
+    if (numCodePoints < 0) {
+      return INVALID_INDEX; // Basically this should not happen.
+    }
+    int remainingCodePoints = numCodePoints;
+
+    while (true) {
+      if (remainingCodePoints == 0) {
+        return currentIndex; // Reached to the requested length in code points.
+      }
+
+      if (currentIndex >= N) {
+        if (waitingLowSurrogate) {
+          return INVALID_INDEX; // An invalid surrogate pair is found.
+        }
+        return N; // Reached to the end of the text w/o any invalid surrogate pair.
+      }
+      final char c = cs.charAt(currentIndex);
+      if (waitingLowSurrogate) {
+        if (!java.lang.Character.isLowSurrogate(c)) {
+          return INVALID_INDEX; // An invalid surrogate pair is found.
+        }
+        --remainingCodePoints;
+        waitingLowSurrogate = false;
+        ++currentIndex;
+        continue;
+      }
+      if (!java.lang.Character.isSurrogate(c)) {
+        --remainingCodePoints;
+        ++currentIndex;
+        continue;
+      }
+      if (java.lang.Character.isLowSurrogate(c)) {
+        return INVALID_INDEX; // A invalid surrogate pair is found.
+      }
+      waitingLowSurrogate = true;
+      ++currentIndex;
+    }
+  }
+}

+ 55 - 0
bin/app/java/textinput/Listener.java

@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package textinput;
+
+import androidx.core.graphics.Insets;
+
+/**
+ * Listener interface for text, selection and composing region changes.
+ * Also a listener for window insets changes.
+ */
+public interface Listener {
+  /*
+   * Called when the IME text, selection or composing region has changed.
+   *
+   * @param newState The updated state
+   * @param dismmissed Deprecated, don't use
+   */
+  void stateChanged(State newState, boolean dismissed);
+
+  /*
+   * Called when the IME window insets change, i.e. the IME moves into or out of view.
+   *
+   * @param insets The new window insets, i.e. the offsets of top, bottom, left and right
+   * relative to the window
+   */
+  void onImeInsetsChanged(Insets insets);
+
+  /*
+   * Called when the IME window is shown or hidden.
+   *
+   * @param insets True is IME is visible, false otherwise.
+   */
+  void onSoftwareKeyboardVisibilityChanged(boolean visible);
+
+  /*
+   * Called when any editor action is performed. Typically this means that
+   * the Enter button has been pressed.
+   *
+   * @param action Code of the action. A default action is IME_ACTION_DONE.
+   */
+  void onEditorAction(int action);
+}

+ 25 - 0
bin/app/java/textinput/Pair.java

@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package textinput;
+
+public final class Pair {
+  public int first, second;
+
+  public Pair(int f, int s) {
+    first = f;
+    second = s;
+  }
+}

+ 29 - 0
bin/app/java/textinput/Settings.java

@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package textinput;
+
+import android.view.inputmethod.EditorInfo;
+
+// Settings for InputConnection
+public final class Settings {
+  EditorInfo mEditorInfo;
+  boolean mForwardKeyEvents;
+
+  public Settings(EditorInfo editorInfo, boolean forwardKeyEvents) {
+    mEditorInfo = editorInfo;
+    mForwardKeyEvents = forwardKeyEvents;
+  }
+}

+ 34 - 0
bin/app/java/textinput/State.java

@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package textinput;
+
+// The state of an editable text region.
+public final class State {
+  public State(String text_in, int selectionStart_in, int selectionEnd_in,
+      int composingRegionStart_in, int composingRegionEnd_in) {
+    text = text_in;
+    selectionStart = selectionStart_in;
+    selectionEnd = selectionEnd_in;
+    composingRegionStart = composingRegionStart_in;
+    composingRegionEnd = composingRegionEnd_in;
+  }
+
+  public String text;
+  public int selectionStart;
+  public int selectionEnd;
+  public int composingRegionStart;
+  public int composingRegionEnd;
+}

+ 13 - 1
bin/app/quad.toml

@@ -1,6 +1,18 @@
 main_activity_inject = "java/MainActivity.java"
 java_files = [
     "java/ForegroundService.java",
-    "java/videodecode/VideoDecoder.java"
+    "java/videodecode/VideoDecoder.java",
+    "java/textinput/InputConnection.java",
+    "java/textinput/State.java",
+    "java/textinput/Listener.java",
+    "java/textinput/Settings.java",
+    "java/textinput/GameTextInput.java",
+    "java/textinput/Pair.java"
+]
+comptime_jar_files = [
+    "android-libs/androidx/core-1.9.0.jar"
+]
+runtime_jar_files = [
+    "android-libs/androidx/core-1.9.0.jar"
 ]
 

+ 0 - 111
bin/app/src/android/textinput/ffi.rs

@@ -1,111 +0,0 @@
-/* This file is part of DarkFi (https://dark.fi)
- *
- * Copyright (C) 2020-2025 Dyne.org foundation
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-use miniquad::native::android::ndk_sys;
-use std::ffi::{c_char, c_void, CStr};
-
-use super::AndroidTextInputState;
-
-// Opaque type from GameTextInput C API
-#[repr(C)]
-pub struct GameTextInput(c_void);
-
-// Span type used by GameTextInput
-#[repr(C)]
-#[derive(Clone, Copy, Debug)]
-pub struct GameTextInputSpan {
-    pub start: i32,
-    pub end: i32,
-}
-
-// State structure matching the C header (gametextinput.h:67-85)
-#[repr(C)]
-pub struct GameTextInputState {
-    pub text_utf8: *const c_char,
-    pub text_length: i32,
-    pub select: GameTextInputSpan,
-    pub compose: GameTextInputSpan,
-}
-
-impl GameTextInputState {
-    pub fn to_owned(&self) -> AndroidTextInputState {
-        let text = unsafe { CStr::from_ptr(self.text_utf8) }.to_str().unwrap().to_string();
-
-        let select = (self.select.start as usize, self.select.end as usize);
-
-        let compose = if self.compose.start >= 0 {
-            assert!(self.compose.end >= 0);
-            Some((self.compose.start as usize, self.compose.end as usize))
-        } else {
-            assert!(self.compose.end < 0);
-            None
-        };
-
-        AndroidTextInputState { text, select, compose }
-    }
-}
-
-// Callback type definitions (gametextinput.h:93-94, 221-222)
-pub type GameTextInputGetStateCallback =
-    unsafe extern "C" fn(*mut c_void, *const GameTextInputState);
-
-pub type GameTextInputEventCallback = unsafe extern "C" fn(*mut c_void, *const GameTextInputState);
-
-// FFI bindings to GameTextInput C library
-extern "C" {
-    // gametextinput.h:111
-    pub fn GameTextInput_init(
-        env: *mut ndk_sys::JNIEnv,
-        max_string_size: u32,
-    ) -> *mut GameTextInput;
-
-    // gametextinput.h:140
-    pub fn GameTextInput_destroy(state: *mut GameTextInput);
-
-    // gametextinput.h:235-237
-    pub fn GameTextInput_setEventCallback(
-        state: *mut GameTextInput,
-        callback: Option<GameTextInputEventCallback>,
-        context: *mut c_void,
-    );
-
-    // gametextinput.h:161
-    pub fn GameTextInput_showIme(state: *mut GameTextInput, flags: u32);
-
-    // gametextinput.h:182
-    pub fn GameTextInput_hideIme(state: *mut GameTextInput, flags: u32);
-
-    // gametextinput.h:211-212
-    pub fn GameTextInput_setState(state: *mut GameTextInput, state: *const GameTextInputState);
-
-    // gametextinput.h:200-202
-    pub fn GameTextInput_getState(
-        state: *const GameTextInput,
-        callback: GameTextInputGetStateCallback,
-        context: *mut c_void,
-    );
-
-    // gametextinput.h:121-122
-    pub fn GameTextInput_setInputConnection(
-        state: *mut GameTextInput,
-        input_connection: *mut c_void,
-    );
-
-    // gametextinput.h:132
-    pub fn GameTextInput_processEvent(state: *mut GameTextInput, event_state: *mut c_void);
-}

+ 52 - 0
bin/app/src/android/textinput/jni.rs

@@ -0,0 +1,52 @@
+/* GameTextInput JNI bridge functions */
+
+use crate::android::textinput::{init_game_text_input, GAME_TEXT_INPUT};
+use miniquad::native::android::ndk_sys;
+
+/// Set the InputConnection for GameTextInput (called from Java)
+///
+/// This follows the official Android GameTextInput integration pattern:
+/// https://developer.android.com/games/agdk/add-support-for-text-input
+///
+/// Called from MainActivity when the InputConnection is created. It passes
+/// the Java InputConnection object to the native GameTextInput library.
+///
+/// # Arguments
+/// * `env` - JNI environment pointer
+/// * `_class` - JNI class reference (unused)
+/// * `input_connection` - Java InputConnection object from textinput.InputConnection
+#[no_mangle]
+pub extern "C" fn Java_darkfi_darkfi_1app_MainActivity_setInputConnectionNative(
+    _env: *mut ndk_sys::JNIEnv,
+    _class: ndk_sys::jclass,
+    input_connection: ndk_sys::jobject,
+) {
+    // Initialize GameTextInput first
+    init_game_text_input();
+
+    // Now set the InputConnection
+    if let Some(gti) = &mut *GAME_TEXT_INPUT.write() {
+        gti.set_input_connection(input_connection);
+    }
+}
+
+/// Process IME state event from Java Listener.stateChanged()
+///
+/// This follows the official Android GameTextInput integration pattern.
+/// Called from the Java InputConnection's Listener whenever the IME sends
+/// a state change (text typed, cursor moved, etc.).
+///
+/// # Arguments
+/// * `env` - JNI environment pointer
+/// * `_class` - JNI class reference (unused)
+/// * `soft_keyboard_event` - Java State object from textinput.State
+#[no_mangle]
+pub extern "C" fn Java_darkfi_darkfi_1app_MainActivity_onTextInputEventNative(
+    _env: *mut ndk_sys::JNIEnv,
+    _class: ndk_sys::jclass,
+    soft_keyboard_event: ndk_sys::jobject,
+) {
+    if let Some(gti) = &mut *GAME_TEXT_INPUT.write() {
+        gti.process_event(soft_keyboard_event);
+    }
+}

+ 59 - 116
bin/app/src/android/textinput/mod.rs

@@ -16,20 +16,36 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-use miniquad::native::android::{self, ndk_sys};
-use parking_lot::Mutex as SyncMutex;
-use std::{
-    collections::HashMap,
-    ffi::{c_char, c_void, CString},
-    sync::LazyLock,
-};
-
-mod ffi;
-use ffi::{
-    GameTextInput, GameTextInputSpan, GameTextInputState, GameTextInput_destroy,
-    GameTextInput_getState, GameTextInput_hideIme, GameTextInput_init,
-    GameTextInput_setEventCallback, GameTextInput_setState, GameTextInput_showIme,
-};
+use async_channel::Sender as AsyncSender;
+use miniquad::native::android::attach_jni_env;
+use parking_lot::RwLock;
+use std::sync::LazyLock;
+
+mod jni;
+mod state;
+
+use state::GameTextInput;
+
+/// Global GameTextInput instance for JNI bridge
+///
+/// Single global instance since only ONE editor is active at a time.
+pub(self) static GAME_TEXT_INPUT: LazyLock<RwLock<Option<GameTextInput>>> =
+    LazyLock::new(|| RwLock::new(None));
+
+pub(self) fn init_game_text_input() {
+    debug!("AndroidTextInput: Initializing GameTextInput");
+
+    let env = unsafe { attach_jni_env() };
+    let mut gti = GameTextInput::new(env, 0);
+    // Store globally for JNI bridge access
+    *GAME_TEXT_INPUT.write() = Some(gti);
+
+    debug!("AndroidTextInput: GameTextInput initialized");
+}
+
+fn is_init() -> bool {
+    GAME_TEXT_INPUT.read().is_some()
+}
 
 // Text input state exposed to the rest of the app
 #[derive(Debug, Clone)]
@@ -39,127 +55,54 @@ pub struct AndroidTextInputState {
     pub compose: Option<(usize, usize)>,
 }
 
-struct Globals {
-    next_id: usize,
-    senders: HashMap<usize, async_channel::Sender<AndroidTextInputState>>,
-}
-
-static GLOBALS: LazyLock<SyncMutex<Globals>> =
-    LazyLock::new(|| SyncMutex::new(Globals { next_id: 0, senders: HashMap::new() }));
-
-// Callback implementation - sends state update event
-extern "C" fn game_text_input_callback(ctx: *mut c_void, state: *const GameTextInputState) {
-    // Ensures we can use the void* pointer to store a usize
-    assert_eq!(std::mem::size_of::<usize>(), std::mem::size_of::<*mut c_void>());
-    // ctx is the usize id we passed as void* pointer
-    let id = ctx as usize;
-    let text_state = unsafe { &(*state) }.to_owned();
-
-    let globals = GLOBALS.lock();
-    if let Some(sender) = globals.senders.get(&id) {
-        let _ = sender.try_send(text_state);
+impl AndroidTextInputState {
+    fn new() -> Self {
+        Self { text: String::new(), select: (0, 0), compose: None }
     }
 }
 
 pub struct AndroidTextInput {
-    id: usize,
-    state: *mut GameTextInput,
+    state: AndroidTextInputState,
+    sender: async_channel::Sender<AndroidTextInputState>,
+    is_focus: bool,
 }
 
-// SAFETY: GameTextInput is accessed synchronously through show_ime/hide_ime/set_state/get_state
-// The pointer is valid for the lifetime of the AndroidTextInput instance and is only
-// accessed from the thread that owns the AndroidTextInput.
-unsafe impl Send for AndroidTextInput {}
-unsafe impl Sync for AndroidTextInput {}
-
 impl AndroidTextInput {
     pub fn new(sender: async_channel::Sender<AndroidTextInputState>) -> Self {
-        let id = {
-            let mut globals = GLOBALS.lock();
-            let id = globals.next_id;
-            globals.next_id += 1;
-            globals.senders.insert(id, sender);
-            id
-        };
-
-        let state = unsafe {
-            let env = android::attach_jni_env();
-            let state = GameTextInput_init(env, 0);
-            // Ensures we can use the void* pointer to store a usize
-            assert_eq!(std::mem::size_of::<usize>(), std::mem::size_of::<*mut c_void>());
-            GameTextInput_setEventCallback(
-                state,
-                Some(game_text_input_callback),
-                id as *mut c_void,
-            );
-            state
-        };
-
-        Self { id, state }
+        Self { state: AndroidTextInputState::new(), sender, is_focus: false }
     }
 
-    pub fn show_ime(&self) {
-        unsafe {
-            GameTextInput_showIme(self.state, 0);
+    pub fn show(&mut self) {
+        if !is_init() {
+            return;
         }
-    }
-
-    pub fn hide_ime(&self) {
-        unsafe {
-            GameTextInput_hideIme(self.state, 0);
+        if let Some(gti) = &mut *GAME_TEXT_INPUT.write() {
+            gti.event_sender = Some(self.sender.clone());
+            gti.set_state(&self.state);
+            gti.show_ime(0);
         }
+        self.is_focus = true;
     }
 
-    pub fn set_state(&self, state: &AndroidTextInputState) {
-        let ctext = CString::new(state.text.as_str()).unwrap();
-
-        let select = GameTextInputSpan { start: state.select.0 as i32, end: state.select.1 as i32 };
-
-        let compose = match state.compose {
-            Some((start, end)) => GameTextInputSpan { start: start as i32, end: end as i32 },
-            None => GameTextInputSpan { start: -1, end: -1 },
-        };
-
-        let gt_state = GameTextInputState {
-            text_utf8: ctext.as_ptr(),
-            text_length: state.text.len() as i32,
-            select,
-            compose,
-        };
-
-        unsafe {
-            GameTextInput_setState(self.state, &gt_state);
+    pub fn hide(&mut self) {
+        if !is_init() {
+            return;
         }
-    }
-
-    pub fn get_state(&self) -> AndroidTextInputState {
-        let mut state =
-            AndroidTextInputState { text: String::new(), select: (0, 0), compose: None };
-
-        // This is guaranteed by GameTextInput_getState() to be called sync
-        // so what we are doing is legit here.
-        extern "C" fn callback(ctx: *mut c_void, game_state: *const GameTextInputState) {
-            let state = unsafe { &mut *(ctx as *mut AndroidTextInputState) };
-            *state = unsafe { &(*game_state) }.to_owned();
+        if let Some(gti) = &mut *GAME_TEXT_INPUT.write() {
+            gti.event_sender = None;
+            gti.hide_ime(0);
         }
+        self.is_focus = false;
+    }
 
-        unsafe {
-            GameTextInput_getState(
-                self.state,
-                callback,
-                &mut state as *mut AndroidTextInputState as *mut c_void,
-            );
+    pub fn set_state(&mut self, state: AndroidTextInputState) {
+        self.state = state;
+        if let Some(gti) = &mut *GAME_TEXT_INPUT.write() {
+            gti.set_state(&self.state);
         }
-
-        state
     }
-}
 
-impl Drop for AndroidTextInput {
-    fn drop(&mut self) {
-        unsafe {
-            GameTextInput_destroy(self.state);
-        }
-        GLOBALS.lock().senders.remove(&self.id);
+    pub fn get_state(&self) -> &AndroidTextInputState {
+        &self.state
     }
 }

+ 327 - 0
bin/app/src/android/textinput/state.rs

@@ -0,0 +1,327 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2025 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use async_channel::Sender as AsyncSender;
+use miniquad::native::android::{ndk_sys, ndk_utils::*};
+use parking_lot::Mutex as SyncMutex;
+use std::ffi::CString;
+
+use super::AndroidTextInputState;
+
+const DEFAULT_MAX_STRING_SIZE: usize = 1 << 16;
+
+pub const SPAN_UNDEFINED: i32 = -1;
+
+struct StateClassInfo {
+    text: ndk_sys::jfieldID,
+    selection_start: ndk_sys::jfieldID,
+    selection_end: ndk_sys::jfieldID,
+    composing_region_start: ndk_sys::jfieldID,
+    composing_region_end: ndk_sys::jfieldID,
+}
+
+pub struct GameTextInput {
+    env: *mut ndk_sys::JNIEnv,
+    state: SyncMutex<AndroidTextInputState>,
+    input_connection: Option<ndk_sys::jobject>,
+    input_connection_class: ndk_sys::jclass,
+    set_soft_keyboard_active_method: ndk_sys::jmethodID,
+    restart_input_method: ndk_sys::jmethodID,
+    state_class_info: StateClassInfo,
+    pub event_sender: Option<AsyncSender<AndroidTextInputState>>
+}
+
+impl GameTextInput {
+    pub fn new(env: *mut ndk_sys::JNIEnv, max_string_size: u32) -> Self {
+        unsafe {
+            let find_class = (**env).FindClass.unwrap();
+
+            let state_class_name = b"textinput/State\0";
+            let input_connection_class_name = b"textinput/InputConnection\0";
+
+            let state_java_class = find_class(env, state_class_name.as_ptr() as _);
+            let input_connection_class = find_class(env, input_connection_class_name.as_ptr() as _);
+
+            let input_connection_class =
+                new_global_ref!(env, input_connection_class) as ndk_sys::jclass;
+
+            let get_method_id = (**env).GetMethodID.unwrap();
+
+            let set_state_sig = b"(Ltextinput/State;)V\0";
+            let _input_connection_set_state_method = get_method_id(
+                env,
+                input_connection_class,
+                b"setState\0".as_ptr() as _,
+                set_state_sig.as_ptr() as _,
+            );
+
+            let set_soft_keyboard_active_sig = b"(ZI)V\0";
+            let set_soft_keyboard_active_method = get_method_id(
+                env,
+                input_connection_class,
+                b"setSoftKeyboardActive\0".as_ptr() as _,
+                set_soft_keyboard_active_sig.as_ptr() as _,
+            );
+
+            let restart_input_sig = b"()V\0";
+            let restart_input_method = get_method_id(
+                env,
+                input_connection_class,
+                b"restartInput\0".as_ptr() as _,
+                restart_input_sig.as_ptr() as _,
+            );
+
+            let state_java_class = new_global_ref!(env, state_java_class);
+
+            let get_field_id = (**env).GetFieldID.unwrap();
+
+            let text_field = get_field_id(
+                env,
+                state_java_class,
+                b"text\0".as_ptr() as _,
+                b"Ljava/lang/String;\0".as_ptr() as _,
+            );
+            let selection_start_field = get_field_id(
+                env,
+                state_java_class,
+                b"selectionStart\0".as_ptr() as _,
+                b"I\0".as_ptr() as _,
+            );
+            let selection_end_field = get_field_id(
+                env,
+                state_java_class,
+                b"selectionEnd\0".as_ptr() as _,
+                b"I\0".as_ptr() as _,
+            );
+            let composing_region_start_field = get_field_id(
+                env,
+                state_java_class,
+                b"composingRegionStart\0".as_ptr() as _,
+                b"I\0".as_ptr() as _,
+            );
+            let composing_region_end_field = get_field_id(
+                env,
+                state_java_class,
+                b"composingRegionEnd\0".as_ptr() as _,
+                b"I\0".as_ptr() as _,
+            );
+
+            let state_class_info = StateClassInfo {
+                text: text_field,
+                selection_start: selection_start_field,
+                selection_end: selection_end_field,
+                composing_region_start: composing_region_start_field,
+                composing_region_end: composing_region_end_field,
+            };
+
+            Self {
+                env,
+                state: SyncMutex::new(AndroidTextInputState::new()),
+                input_connection: None,
+                input_connection_class,
+                set_soft_keyboard_active_method,
+                restart_input_method,
+                state_class_info,
+                event_sender: None,
+            }
+        }
+    }
+
+    pub fn set_state(&mut self, state: &AndroidTextInputState) {
+        if let Some(input_connection) = self.input_connection {
+            unsafe {
+                let jstate = self.state_to_java(state);
+                call_void_method!(
+                    self.env,
+                    input_connection,
+                    "setState",
+                    "(Ltextinput/State;)V",
+                    jstate
+                );
+                let delete_local_ref = (**self.env).DeleteLocalRef.unwrap();
+                delete_local_ref(self.env, jstate);
+            }
+        }
+        *self.state.lock() = state.clone();
+    }
+
+    fn set_state_inner(&mut self, state: AndroidTextInputState) {
+        *self.state.lock() = state;
+    }
+
+    pub fn get_state(&self) -> AndroidTextInputState {
+        self.state.lock().clone()
+    }
+
+    pub fn set_input_connection(&mut self, input_connection: ndk_sys::jobject) {
+        unsafe {
+            if let Some(old_ref) = self.input_connection {
+                let delete_global_ref = (**self.env).DeleteGlobalRef.unwrap();
+                delete_global_ref(self.env, old_ref);
+            }
+            self.input_connection = Some(new_global_ref!(self.env, input_connection));
+        }
+    }
+
+    pub fn process_event(&mut self, event_state: ndk_sys::jobject) {
+        let state = self.state_from_java(event_state);
+            if let Some(sender) = &self.event_sender {
+                let _ = sender.try_send(state.clone());
+            }
+            self.set_state_inner(state);
+    }
+
+    pub fn show_ime(&self, flags: u32) {
+        if let Some(input_connection) = self.input_connection {
+            unsafe {
+                let call_void_method = (**self.env).CallVoidMethod.unwrap();
+                call_void_method(
+                    self.env,
+                    input_connection,
+                    self.set_soft_keyboard_active_method,
+                    1, // active: true
+                    flags as ndk_sys::jint,
+                );
+            }
+        }
+    }
+
+    pub fn hide_ime(&self, flags: u32) {
+        if let Some(input_connection) = self.input_connection {
+            unsafe {
+                let call_void_method = (**self.env).CallVoidMethod.unwrap();
+                call_void_method(
+                    self.env,
+                    input_connection,
+                    self.set_soft_keyboard_active_method,
+                    0, // active: false
+                    flags as ndk_sys::jint,
+                );
+            }
+        }
+    }
+
+    pub fn restart_input(&self) {
+        if let Some(input_connection) = self.input_connection {
+            unsafe {
+                let call_void_method = (**self.env).CallVoidMethod.unwrap();
+                call_void_method(self.env, input_connection, self.restart_input_method);
+            }
+        }
+    }
+
+    fn state_to_java(&self, state: &AndroidTextInputState) -> ndk_sys::jobject {
+        unsafe {
+            let new_string_utf = (**self.env).NewStringUTF.unwrap();
+            let text_str = CString::new(state.text.as_str()).unwrap_or_else(|_| {
+                tracing::error!("Failed to convert text to CString");
+                CString::new("").unwrap()
+            });
+            let jtext = new_string_utf(self.env, text_str.as_ptr());
+
+            let new_object = (**self.env).NewObject.unwrap();
+            let get_method_id = (**self.env).GetMethodID.unwrap();
+            let find_class = (**self.env).FindClass.unwrap();
+
+            let state_class_name = b"textinput/State\0";
+            let state_java_class = find_class(self.env, state_class_name.as_ptr() as _);
+
+            let constructor_sig = b"(Ljava/lang/String;IIII)V\0";
+            let constructor = get_method_id(
+                self.env,
+                state_java_class,
+                b"<init>\0".as_ptr() as _,
+                constructor_sig.as_ptr() as _,
+            );
+
+            let (compose_start, compose_end) = match state.compose {
+                Some((start, end)) => (start as i32, end as i32),
+                None => (SPAN_UNDEFINED, SPAN_UNDEFINED),
+            };
+
+            let jobj = new_object(
+                self.env,
+                state_java_class,
+                constructor,
+                jtext,
+                state.select.0 as i32,
+                state.select.1 as i32,
+                compose_start,
+                compose_end,
+            );
+
+            let delete_local_ref = (**self.env).DeleteLocalRef.unwrap();
+            delete_local_ref(self.env, jtext);
+            delete_local_ref(self.env, state_java_class);
+            jobj
+        }
+    }
+
+    fn state_from_java(&self, event_state: ndk_sys::jobject) -> AndroidTextInputState {
+        unsafe {
+            let get_object_field = (**self.env).GetObjectField.unwrap();
+            let jtext = get_object_field(self.env, event_state, self.state_class_info.text)
+                as ndk_sys::jstring;
+
+            let text = get_utf_str!(self.env, jtext);
+
+            let get_int_field = (**self.env).GetIntField.unwrap();
+            let select_start =
+                get_int_field(self.env, event_state, self.state_class_info.selection_start);
+            let select_end =
+                get_int_field(self.env, event_state, self.state_class_info.selection_end);
+            let compose_start =
+                get_int_field(self.env, event_state, self.state_class_info.composing_region_start);
+            let compose_end =
+                get_int_field(self.env, event_state, self.state_class_info.composing_region_end);
+
+            let delete_local_ref = (**self.env).DeleteLocalRef.unwrap();
+            delete_local_ref(self.env, jtext);
+
+            let compose = if compose_start >= 0 {
+                Some((compose_start as usize, compose_end as usize))
+            } else {
+                assert!(compose_end < 0);
+                None
+            };
+
+            AndroidTextInputState {
+                text,
+                select: (select_start as usize, select_end as usize),
+                compose,
+            }
+        }
+    }
+}
+
+impl Drop for GameTextInput {
+    fn drop(&mut self) {
+        unsafe {
+            let delete_global_ref = (**self.env).DeleteGlobalRef.unwrap();
+            if self.input_connection_class != std::ptr::null_mut() {
+                delete_global_ref(self.env, self.input_connection_class);
+            }
+            if let Some(input_connection) = self.input_connection {
+                delete_global_ref(self.env, input_connection);
+            }
+        }
+    }
+}
+
+unsafe impl Send for GameTextInput {}
+unsafe impl Sync for GameTextInput {}

+ 6 - 14
bin/app/src/app/schema/mod.rs

@@ -529,23 +529,15 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
 
     let chatdb_path = get_chatdb_path();
     let db = sled::open(chatdb_path).expect("cannot open sleddb");
-    for channel in CHANNELS {
-        chat::make(
-            app,
-            content.clone(),
-            channel,
-            &db,
-            i18n_fish,
-            emoji_meshes.clone(),
-            is_first_time,
-        )
+    //for channel in CHANNELS {
+    chat::make(app, content.clone(), "dev", &db, i18n_fish, emoji_meshes.clone(), is_first_time)
         .await;
-    }
-    menu::make(app, content.clone(), i18n_fish).await;
+    //}
+    //menu::make(app, content.clone(), i18n_fish).await;
 
     // @@@ Debug stuff @@@
-    //let chatview_node = app.sg_root.lookup_node("/window/content/dev_chat_layer").unwrap();
-    //chatview_node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+    let chatview_node = app.sg_root.lookup_node("/window/content/dev_chat_layer").unwrap();
+    chatview_node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
     //let menu_node = app.sg_root.lookup_node("/window/content/menu_layer").unwrap();
     //menu_node.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
 }

+ 26 - 23
bin/app/src/text2/editor/android.rs

@@ -53,8 +53,10 @@ impl Editor {
         lineheight: PropertyFloat32,
     ) -> Self {
         let (sender, recvr) = async_channel::unbounded();
+        let input = AndroidTextInput::new(sender);
+
         Self {
-            input: AndroidTextInput::new(sender),
+            input,
             recvr,
 
             layout: Default::default(),
@@ -70,8 +72,11 @@ impl Editor {
 
     pub async fn on_text_prop_changed(&mut self) {
         // Update GameTextInput state
-        let state = AndroidTextInputState { text: self.text.get(), select: (0, 0), compose: None };
-        self.input.set_state(&state);
+        let mut state = self.input.get_state().clone();
+        state.text = self.text.get();
+        state.select = (0, 0);
+        state.compose = None;
+        self.input.set_state(state);
         // Refresh our layout
         self.refresh().await;
     }
@@ -84,11 +89,11 @@ impl Editor {
         self.text.set(atom, &state.text);
     }
 
-    pub fn focus(&self) {
-        self.input.show_ime();
+    pub fn focus(&mut self) {
+        self.input.show();
     }
-    pub fn unfocus(&self) {
-        self.input.hide_ime();
+    pub fn unfocus(&mut self) {
+        self.input.hide();
     }
 
     pub async fn refresh(&mut self) {
@@ -120,16 +125,15 @@ impl Editor {
         &self.layout
     }
 
-    pub fn move_to_pos(&self, pos: Point) {
+    pub fn move_to_pos(&mut self, pos: Point) {
         let cursor = parley::Cursor::from_point(&self.layout, pos.x, pos.y);
         let cursor_idx = cursor.index();
         t!("  move_to_pos: {cursor_idx}");
-        let state = AndroidTextInputState {
-            text: self.text.get(),
-            select: (cursor_idx, cursor_idx),
-            compose: None,
-        };
-        self.input.set_state(&state);
+        let mut state = self.input.get_state().clone();
+        state.text = self.text.get();
+        state.select = (cursor_idx, cursor_idx);
+        state.compose = None;
+        self.input.set_state(state);
     }
 
     pub async fn select_word_at_point(&mut self, pos: Point) {
@@ -161,11 +165,11 @@ impl Editor {
     pub async fn insert(&mut self, txt: &str, atom: &mut PropertyAtomicGuard) {
         // TODO: need to verify this is correct
         // Insert text by updating the state
-        let mut current_state = self.input.get_state();
+        let mut current_state = self.input.get_state().clone();
         current_state.text.push_str(txt);
         current_state.select = (current_state.text.len(), current_state.text.len());
         current_state.compose = None;
-        self.input.set_state(&current_state);
+        self.input.set_state(current_state);
         self.on_buffer_changed(atom).await;
     }
 
@@ -213,16 +217,15 @@ impl Editor {
         parley::Selection::new(anchor, focus)
     }
     pub async fn set_selection(&mut self, select_start: usize, select_end: usize) {
-        let state = AndroidTextInputState {
-            text: self.text.get(),
-            select: (select_start, select_end),
-            compose: None,
-        };
-        self.input.set_state(&state);
+        let mut state = self.input.get_state().clone();
+        state.text = self.text.get();
+        state.select = (select_start, select_end);
+        state.compose = None;
+        self.input.set_state(state);
     }
 
     #[allow(dead_code)]
     pub fn buffer(&self) -> String {
-        self.input.get_state().text
+        self.input.get_state().text.clone()
     }
 }

+ 2 - 2
bin/app/src/ui/edit/mod.rs

@@ -1271,7 +1271,7 @@ impl BaseEdit {
             panic!("self destroyed before insert_text_method_task was stopped!");
         };
 
-        let editor = self_.lock_editor().await;
+        let mut editor = self_.lock_editor().await;
         editor.focus();
         true
     }
@@ -1290,7 +1290,7 @@ impl BaseEdit {
             panic!("self destroyed before insert_text_method_task was stopped!");
         };
 
-        let editor = self_.lock_editor().await;
+        let mut editor = self_.lock_editor().await;
         editor.unfocus();
         true
     }