Эх сурвалжийг харах

app: do a big cleanup of java GameTextInput code and make everything have a sane API.

jkds 7 сар өмнө
parent
commit
6835e16ff2

+ 1 - 1
bin/app/Cargo.lock

@@ -3708,7 +3708,7 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
 [[package]]
 name = "miniquad"
 version = "0.4.8"
-source = "git+https://github.com/narodnik/miniquad#f9bf3a41e40f8b15032c159e24dec8e40c7adfa5"
+source = "git+https://github.com/not-fl3/miniquad#dd44380a7ace19462bb63e53dce6093d28219aa2"
 dependencies = [
  "libc",
  "ndk-sys",

+ 1 - 2
bin/app/Cargo.toml

@@ -9,8 +9,7 @@ homepage = "https://dark.fi"
 repository = "https://codeberg.org/darkrenaissance/darkfi"
 
 [dependencies]
-#miniquad = { git = "https://github.com/not-fl3/miniquad" }
-miniquad = { git = "https://github.com/narodnik/miniquad" }
+miniquad = { git = "https://github.com/not-fl3/miniquad" }
 
 # Currently latest version links to freetype-sys 0.19 but we use 0.21
 #harfbuzz-sys = "0.6.1"

+ 6 - 15
bin/app/Makefile

@@ -45,10 +45,10 @@ win-release: $(SRC) fonts
 win-debug: $(SRC) fonts
 	$(CARGO) build $(DEBUG_FEATURES)
 	-mv target/debug/darkfi-app.exe .
-android-release: $(SRC) fonts assets/forest_720x1280.mp4 gametextinput
+android-release: $(SRC) fonts assets/forest_720x1280.mp4
 	podman run -v $(shell pwd)/../../:/root/darkfi -w /root/darkfi/bin/app/ -t apk cargo quad-apk build --release $(RELEASE_FEATURES)
 	-mv $(RELEASE_APK) darkfi-app.apk
-android-debug: $(SRC) fonts assets/forest_720x1280.mp4 gametextinput
+android-debug: $(SRC) fonts assets/forest_720x1280.mp4
 	podman run -v $(shell pwd)/../../:/root/darkfi -w /root/darkfi/bin/app/ -t apk cargo quad-apk build $(DEBUG_FEATURES)
 	-mv $(DEBUG_APK) darkfi-app_debug.apk
 
@@ -69,18 +69,6 @@ ibm-plex-mono-regular.otf:
 NotoColorEmoji.ttf:
 	wget -c https://dark.fi/assets/NotoColorEmoji.ttf
 
-# Download Android GameTextInput library
-
-GAMETEXTINPUT_INCLUDE = src/android/textinput/include/game-text-input/game-text-input/gametextinput.h
-GAMETEXTINPUT_LIBS = pkg/android/libs/android.arm64-v8a/libgame-text-input.a
-
-gametextinput: $(GAMETEXTINPUT_INCLUDE) $(GAMETEXTINPUT_LIBS)
-
-$(GAMETEXTINPUT_INCLUDE):
-	pkg/android/dl-textinput-lib.sh
-$(GAMETEXTINPUT_LIBS):
-	pkg/android/dl-textinput-lib.sh
-
 # App data
 
 assets/forest_1920x1080.ivf:
@@ -99,7 +87,7 @@ dev: $(SRC) fonts assets/forest_1920x1080.ivf
 	./darkfi-app
 
 # Users should use the android-release and android-debug targets instead.
-apk: $(SRC) fonts assets/forest_720x1280.mp4 gametextinput
+apk: $(SRC) fonts assets/forest_720x1280.mp4
 	cargo quad-apk build $(DEV_FEATURES)
 	$(MAKE) install-apk
 
@@ -107,6 +95,9 @@ install-apk:
 	-mv $(DEBUG_APK) .
 	-adb $(ADB_DEVICE) uninstall darkfi.darkfi_app
 	adb $(ADB_DEVICE) install -r darkfi-app.apk
+	$(MAKE) log-apk
+
+log-apk:
 	reset
 	adb $(ADB_DEVICE) logcat -c
 	adb $(ADB_DEVICE) shell monkey -p darkfi.darkfi_app -c android.intent.category.LAUNCHER 1

+ 63 - 82
bin/app/java/MainActivity.java

@@ -4,10 +4,10 @@ import android.view.ViewGroup;
 import android.view.WindowInsets.Type;
 import android.view.inputmethod.EditorInfo;
 import android.text.InputType;
+import android.util.Log;
 import java.util.HashMap;
 
 import videodecode.VideoDecoder;
-import textinput.InputConnection;
 import textinput.Settings;
 import textinput.Listener;
 import textinput.State;
@@ -15,6 +15,67 @@ import textinput.GameTextInput;
 
 //% END
 
+//% QUAD_SURFACE_ON_CREATE_INPUT_CONNECTION
+
+MainActivity main = (MainActivity)getContext();
+// Create InputConnection if it doesn't exist yet
+if (main.inpcon == null) {
+    EditorInfo editorInfo = new EditorInfo();
+    editorInfo.inputType = InputType.TYPE_CLASS_TEXT |
+                           InputType.TYPE_TEXT_FLAG_AUTO_CORRECT;
+    editorInfo.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN;
+
+    main.inpcon = new textinput.InputConnection(
+        getContext(),
+        this,
+        new Settings(editorInfo, true)
+    );
+
+    // Pass the InputConnection to native GameTextInput library
+    main.setInputConnectionNative(main.inpcon);
+}
+
+// Set the listener to receive IME state changes
+main.inpcon.setListener(new Listener() {
+    @Override
+    public void stateChanged(State newState, boolean dismissed) {
+        // Called when the IME sends new text state
+        // Forward to native code which triggers Rust callback
+        Log.d("darkfi", "stateChanged: text=" + newState.toString());
+        main.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
+    }
+
+    @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) {
+    GameTextInput.copyEditorInfo(
+        main.inpcon.getEditorInfo(),
+        outAttrs
+    );
+}
+
+// Return the GameTextInput InputConnection to IME
+if (true) return main.inpcon;
+
+//% END
+
 //% RESIZING_LAYOUT_BODY
 
 native static void onApplyInsets(
@@ -53,7 +114,7 @@ native void setInputConnectionNative(textinput.InputConnection c);
 native void onTextInputEventNative(textinput.State softKeyboardEvent);
 
 // GameTextInput InputConnection reference (public for QuadSurface access)
-public textinput.InputConnection gameTextInputInputConnection;
+public textinput.InputConnection inpcon;
 
 public String getAppDataPath() {
     return getApplicationContext().getDataDir().getAbsolutePath();
@@ -95,89 +156,9 @@ public VideoDecoder createVideoDecoder() {
 
 //% MAIN_ACTIVITY_ON_CREATE
 
-//view.setFocusable(false);
-//view.setFocusableInTouchMode(false);
-//view.clearFocus();
-
 // Start a foreground service so the app stays awake
 Intent serviceIntent = new Intent(this, ForegroundService.class);
 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

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

@@ -48,5 +48,14 @@ public final class GameTextInput {
     to.initialSelEnd = from.initialSelEnd;
   }
 
+  public static final class Pair {
+    int first, second;
+
+    Pair(int f, int s) {
+      first = f;
+      second = s;
+    }
+  }
+
   private GameTextInput() {}
 }

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

@@ -42,6 +42,8 @@ import androidx.core.view.ViewCompat;
 import androidx.core.view.WindowCompat;
 import androidx.core.view.WindowInsetsCompat;
 
+import textinput.GameTextInput.Pair;
+
 public class InputConnection extends BaseInputConnection implements View.OnKeyListener {
   private static final String TAG = "gti.InputConnection";
   private final InputMethodManager imm;

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

@@ -1,25 +0,0 @@
-/*
- * 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;
-  }
-}

+ 0 - 41
bin/app/pkg/android/dl-textinput-lib.sh

@@ -1,41 +0,0 @@
-#!/bin/bash
-# Download GameTextInput library from Android Maven repository
-# This script downloads and extracts the GameTextInput headers and libraries
-
-set -e
-
-SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
-PROJECT_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd)
-
-LIBS_DIR=$SCRIPT_DIR/libs
-INCLUDE_DIR=$PROJECT_ROOT/src/android/textinput/include
-
-VERSION=4.0.0
-AAR=games-text-input-$VERSION.aar
-URL=https://dl.google.com/android/maven2/androidx/games/games-text-input/$VERSION/$AAR
-TMPDIR=/tmp/games-text-input-$VERSION
-
-cleanup() {
-    rm -rf $TMPDIR
-}
-trap cleanup EXIT
-
-# Clean existing files
-rm -rf $LIBS_DIR
-rm -rf $INCLUDE_DIR/game-text-input
-
-# Download AAR
-mkdir -p $TMPDIR
-cd $TMPDIR
-wget $URL
-unzip $AAR
-# Copy libs
-mv prefab/modules/game-text-input/libs $LIBS_DIR/
-# Copy headers
-mkdir -p $INCLUDE_DIR/game-text-input
-mv prefab/modules/game-text-input/include/* $INCLUDE_DIR/game-text-input/
-
-echo "GameTextInput ${GAMETEXTINPUT_VERSION} installation complete!"
-echo "  Libraries: $LIBS_DIR"
-echo "  Headers:  $INCLUDE_DIR"
-

+ 1 - 2
bin/app/quad.toml

@@ -6,8 +6,7 @@ java_files = [
     "java/textinput/State.java",
     "java/textinput/Listener.java",
     "java/textinput/Settings.java",
-    "java/textinput/GameTextInput.java",
-    "java/textinput/Pair.java"
+    "java/textinput/GameTextInput.java"
 ]
 comptime_jar_files = [
     "android-libs/androidx/core-1.9.0.jar"

+ 1 - 0
bin/app/src/android/mod.rs

@@ -22,6 +22,7 @@ use std::{collections::HashMap, path::PathBuf, sync::LazyLock};
 
 pub mod insets;
 pub mod textinput;
+mod util;
 pub mod vid;
 
 macro_rules! call_mainactivity_int_method {

+ 149 - 114
bin/app/src/android/textinput/state.rs → bin/app/src/android/textinput/gametextinput.rs

@@ -16,17 +16,24 @@
  * 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 miniquad::native::android::{self, ndk_sys, ndk_utils::*};
+use parking_lot::{Mutex as SyncMutex, RwLock};
+use std::{
+    ffi::CString,
+    sync::{Arc, OnceLock},
+};
 
-use super::AndroidTextInputState;
+use super::{AndroidTextInputState, SharedStatePtr};
 
-const DEFAULT_MAX_STRING_SIZE: usize = 1 << 16;
+macro_rules! w { ($($arg:tt)*) => { warn!(target: "android::textinput::gametextinput", $($arg)*); } }
 
 pub const SPAN_UNDEFINED: i32 = -1;
 
+/// Global GameTextInput instance for JNI bridge
+///
+/// Single global instance since only ONE editor is active at a time.
+pub static GAME_TEXT_INPUT: OnceLock<GameTextInput> = OnceLock::new();
+
 struct StateClassInfo {
     text: ndk_sys::jfieldID,
     selection_start: ndk_sys::jfieldID,
@@ -36,19 +43,21 @@ struct StateClassInfo {
 }
 
 pub struct GameTextInput {
-    env: *mut ndk_sys::JNIEnv,
-    state: SyncMutex<AndroidTextInputState>,
-    input_connection: Option<ndk_sys::jobject>,
+    state: SyncMutex<Option<SharedStatePtr>>,
+    input_connection: RwLock<Option<ndk_sys::jobject>>,
     input_connection_class: ndk_sys::jclass,
+    state_class: ndk_sys::jclass,
     set_soft_keyboard_active_method: ndk_sys::jmethodID,
     restart_input_method: ndk_sys::jmethodID,
+    state_constructor: 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 {
+    pub fn new() -> Self {
         unsafe {
+            let env = android::attach_jni_env();
+
             let find_class = (**env).FindClass.unwrap();
 
             let state_class_name = b"textinput/State\0";
@@ -86,41 +95,49 @@ impl GameTextInput {
                 restart_input_sig.as_ptr() as _,
             );
 
-            let state_java_class = new_global_ref!(env, state_java_class);
+            let state_class = new_global_ref!(env, state_java_class) as ndk_sys::jclass;
 
             let get_field_id = (**env).GetFieldID.unwrap();
 
             let text_field = get_field_id(
                 env,
-                state_java_class,
+                state_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,
+                state_class,
                 b"selectionStart\0".as_ptr() as _,
                 b"I\0".as_ptr() as _,
             );
             let selection_end_field = get_field_id(
                 env,
-                state_java_class,
+                state_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,
+                state_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,
+                state_class,
                 b"composingRegionEnd\0".as_ptr() as _,
                 b"I\0".as_ptr() as _,
             );
 
+            let constructor_sig = b"(Ljava/lang/String;IIII)V\0";
+            let state_constructor = get_method_id(
+                env,
+                state_class,
+                b"<init>\0".as_ptr() as _,
+                constructor_sig.as_ptr() as _,
+            );
+
             let state_class_info = StateClassInfo {
                 text: text_field,
                 selection_start: selection_start_field,
@@ -130,124 +147,139 @@ impl GameTextInput {
             };
 
             Self {
-                env,
-                state: SyncMutex::new(AndroidTextInputState::new()),
-                input_connection: None,
+                state: SyncMutex::new(None),
+                input_connection: RwLock::new(None),
                 input_connection_class,
+                state_class,
                 set_soft_keyboard_active_method,
                 restart_input_method,
+                state_constructor,
                 state_class_info,
-                event_sender: None,
             }
         }
     }
 
-    pub fn set_state(&mut self, state: &AndroidTextInputState) {
-        if let Some(input_connection) = self.input_connection {
+    pub fn focus(&self, state: SharedStatePtr) {
+        // Replace old focus
+        let mut active_focus = self.state.lock();
+        if let Some(old_focus) = &mut *active_focus {
+            // Mark old focused state as no longer active
+            old_focus.lock().is_active = false;
+        }
+        *active_focus = Some(state.clone());
+        drop(active_focus);
+
+        let mut new_focus = state.lock();
+        // Mark new state as active
+        new_focus.is_active = true;
+        // Push changes to the Java side
+        self.push_update(&new_focus.state);
+    }
+
+    pub fn push_update(&self, state: &AndroidTextInputState) {
+        let Some(input_connection) = *self.input_connection.read() else {
+            w!("push_update() - no input_connection set");
+            return
+        };
+        if let Some(input_connection) = *self.input_connection.read() {
             unsafe {
+                let env = android::attach_jni_env();
                 let jstate = self.state_to_java(state);
                 call_void_method!(
-                    self.env,
+                    env,
                     input_connection,
                     "setState",
                     "(Ltextinput/State;)V",
                     jstate
                 );
-                let delete_local_ref = (**self.env).DeleteLocalRef.unwrap();
-                delete_local_ref(self.env, jstate);
+
+                let delete_local_ref = (**env).DeleteLocalRef.unwrap();
+                delete_local_ref(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) {
+    pub fn set_input_connection(&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);
+            let env = android::attach_jni_env();
+            let mut ic = self.input_connection.write();
+            if let Some(old_ref) = *ic {
+                let delete_global_ref = (**env).DeleteGlobalRef.unwrap();
+                delete_global_ref(env, old_ref);
             }
-            self.input_connection = Some(new_global_ref!(self.env, input_connection));
+            *ic = Some(new_global_ref!(env, input_connection));
         }
     }
 
-    pub fn process_event(&mut self, event_state: ndk_sys::jobject) {
+    pub fn process_event(&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);
+
+        let Some(shared) = &*self.state.lock() else {
+            w!("process_event() - no shared state set");
+            return
+        };
+
+        let mut inner = shared.lock();
+        inner.state = state.clone();
+        let _ = inner.sender.try_send(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,
-                );
-            }
+        let Some(input_connection) = *self.input_connection.read() else {
+            w!("show_ime() - no input_connection set");
+            return
+        };
+        unsafe {
+            let env = android::attach_jni_env();
+            let call_void_method = (**env).CallVoidMethod.unwrap();
+            call_void_method(
+                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,
-                );
-            }
+        let Some(input_connection) = *self.input_connection.read() else {
+            w!("hide_ime() - no input_connection set");
+            return
+        };
+        unsafe {
+            let env = android::attach_jni_env();
+            let call_void_method = (**env).CallVoidMethod.unwrap();
+            call_void_method(
+                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);
-            }
+        let Some(input_connection) = *self.input_connection.read() else {
+            w!("restart_input() - no input_connection set");
+            return
+        };
+        unsafe {
+            let env = android::attach_jni_env();
+            let call_void_method = (**env).CallVoidMethod.unwrap();
+            call_void_method(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 env = android::attach_jni_env();
+            let new_string_utf = (**env).NewStringUTF.unwrap();
+            let text_str = CString::new(state.text.as_str()).unwrap();
+            let jtext = new_string_utf(env, text_str.as_ptr());
 
-            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 new_object = (**env).NewObject.unwrap();
 
             let (compose_start, compose_end) = match state.compose {
                 Some((start, end)) => (start as i32, end as i32),
@@ -255,9 +287,9 @@ impl GameTextInput {
             };
 
             let jobj = new_object(
-                self.env,
-                state_java_class,
-                constructor,
+                env,
+                self.state_class,
+                self.state_constructor,
                 jtext,
                 state.select.0 as i32,
                 state.select.1 as i32,
@@ -265,33 +297,32 @@ impl GameTextInput {
                 compose_end,
             );
 
-            let delete_local_ref = (**self.env).DeleteLocalRef.unwrap();
-            delete_local_ref(self.env, jtext);
-            delete_local_ref(self.env, state_java_class);
+            let delete_local_ref = (**env).DeleteLocalRef.unwrap();
+            delete_local_ref(env, jtext);
             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 env = android::attach_jni_env();
+            let get_object_field = (**env).GetObjectField.unwrap();
+            let jtext =
+                get_object_field(env, event_state, self.state_class_info.text) as ndk_sys::jstring;
 
-            let text = get_utf_str!(self.env, jtext);
+            let text = get_utf_str!(env, jtext);
 
-            let get_int_field = (**self.env).GetIntField.unwrap();
+            let get_int_field = (**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);
+                get_int_field(env, event_state, self.state_class_info.selection_start);
+            let select_end = get_int_field(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);
+                get_int_field(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);
+                get_int_field(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 delete_local_ref = (**env).DeleteLocalRef.unwrap();
+            delete_local_ref(env, jtext);
 
             let compose = if compose_start >= 0 {
                 Some((compose_start as usize, compose_end as usize))
@@ -312,12 +343,16 @@ impl GameTextInput {
 impl Drop for GameTextInput {
     fn drop(&mut self) {
         unsafe {
-            let delete_global_ref = (**self.env).DeleteGlobalRef.unwrap();
+            let env = android::attach_jni_env();
+            let delete_global_ref = (**env).DeleteGlobalRef.unwrap();
             if self.input_connection_class != std::ptr::null_mut() {
-                delete_global_ref(self.env, self.input_connection_class);
+                delete_global_ref(env, self.input_connection_class);
+            }
+            if self.state_class != std::ptr::null_mut() {
+                delete_global_ref(env, self.state_class);
             }
-            if let Some(input_connection) = self.input_connection {
-                delete_global_ref(self.env, input_connection);
+            if let Some(input_connection) = *self.input_connection.read() {
+                delete_global_ref(env, input_connection);
             }
         }
     }

+ 25 - 22
bin/app/src/android/textinput/jni.rs

@@ -1,8 +1,25 @@
-/* GameTextInput JNI bridge functions */
+/* 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 crate::android::textinput::{init_game_text_input, GAME_TEXT_INPUT};
 use miniquad::native::android::ndk_sys;
 
+use super::gametextinput::{GameTextInput, GAME_TEXT_INPUT};
+
 /// Set the InputConnection for GameTextInput (called from Java)
 ///
 /// This follows the official Android GameTextInput integration pattern:
@@ -10,24 +27,16 @@ use miniquad::native::android::ndk_sys;
 ///
 /// 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);
-    }
+    debug!(target: "android::textinput::jni", "Setting input connection");
+    // Initialize GameTextInput on first call
+    let gti = GAME_TEXT_INPUT.get_or_init(|| GameTextInput::new());
+    gti.set_input_connection(input_connection);
 }
 
 /// Process IME state event from Java Listener.stateChanged()
@@ -35,18 +44,12 @@ pub extern "C" fn Java_darkfi_darkfi_1app_MainActivity_setInputConnectionNative(
 /// 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);
-    }
+    let gti = GAME_TEXT_INPUT.get().unwrap();
+    gti.process_event(soft_keyboard_event);
 }

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

@@ -17,92 +17,76 @@
  */
 
 use async_channel::Sender as AsyncSender;
-use miniquad::native::android::attach_jni_env;
-use parking_lot::RwLock;
-use std::sync::LazyLock;
+use parking_lot::Mutex as SyncMutex;
+use std::sync::Arc;
 
+mod gametextinput;
 mod jni;
-mod state;
 
-use state::GameTextInput;
+use gametextinput::{GameTextInput, GAME_TEXT_INPUT};
 
-/// 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()
-}
+macro_rules! t { ($($arg:tt)*) => { trace!(target: "android::textinput", $($arg)*); } }
 
 // Text input state exposed to the rest of the app
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Default)]
 pub struct AndroidTextInputState {
     pub text: String,
     pub select: (usize, usize),
     pub compose: Option<(usize, usize)>,
 }
 
-impl AndroidTextInputState {
-    fn new() -> Self {
-        Self { text: String::new(), select: (0, 0), compose: None }
+struct SharedState {
+    state: AndroidTextInputState,
+    /// Used so we know whether to also update the GameTextInput in Android.
+    /// We should only do so when active.
+    is_active: bool,
+    sender: AsyncSender<AndroidTextInputState>,
+}
+
+impl SharedState {
+    fn new(sender: AsyncSender<AndroidTextInputState>) -> Self {
+        Self { state: Default::default(), is_active: false, sender }
     }
 }
 
+pub(self) type SharedStatePtr = Arc<SyncMutex<SharedState>>;
+
 pub struct AndroidTextInput {
-    state: AndroidTextInputState,
-    sender: async_channel::Sender<AndroidTextInputState>,
-    is_focus: bool,
+    state: SharedStatePtr,
 }
 
 impl AndroidTextInput {
-    pub fn new(sender: async_channel::Sender<AndroidTextInputState>) -> Self {
-        Self { state: AndroidTextInputState::new(), sender, is_focus: false }
+    pub fn new(sender: AsyncSender<AndroidTextInputState>) -> Self {
+        Self { state: Arc::new(SyncMutex::new(SharedState::new(sender))) }
     }
 
-    pub fn show(&mut self) {
-        if !is_init() {
-            return;
-        }
-        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 show(&self) {
+        t!("show IME");
+        let gti = GAME_TEXT_INPUT.get().unwrap();
+        gti.focus(self.state.clone());
+        gti.show_ime(0);
     }
 
-    pub fn hide(&mut self) {
-        if !is_init() {
-            return;
-        }
-        if let Some(gti) = &mut *GAME_TEXT_INPUT.write() {
-            gti.event_sender = None;
-            gti.hide_ime(0);
-        }
-        self.is_focus = false;
+    pub fn hide(&self) {
+        t!("hide IME");
+        let gti = GAME_TEXT_INPUT.get().unwrap();
+        gti.hide_ime(0);
     }
 
-    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);
+    pub fn set_state(&self, state: AndroidTextInputState) {
+        t!("set_state({state:?})");
+        // Always update our own state.
+        let mut ours = self.state.lock();
+        ours.state = state.clone();
+
+        // Only update java state when this input is active
+        if ours.is_active {
+            let gti = GAME_TEXT_INPUT.get().unwrap();
+            gti.push_update(&state);
         }
     }
 
-    pub fn get_state(&self) -> &AndroidTextInputState {
-        &self.state
+    pub fn get_state(&self) -> AndroidTextInputState {
+        self.state.lock().state.clone()
     }
 }

+ 36 - 0
bin/app/src/android/util.rs

@@ -0,0 +1,36 @@
+/* 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;
+
+/// Check for pending Java exceptions and log them
+///
+/// This function should be called after any JNI call that might throw an exception.
+/// It will use ExceptionDescribe to print the exception to logcat, then clear it.
+unsafe fn check_except(env: *mut ndk_sys::JNIEnv, context: &str) {
+    let exception_check = (**env).ExceptionCheck.unwrap();
+    if exception_check(env) != 0 {
+        // Use ExceptionDescribe to print the exception stack trace to logcat
+        // This is safe to call even with a pending exception and handles StackOverflowError gracefully
+        let exception_describe = (**env).ExceptionDescribe.unwrap();
+        exception_describe(env);
+
+        let exception_clear = (**env).ExceptionClear.unwrap();
+        exception_clear(env);
+    }
+}

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

@@ -1311,8 +1311,8 @@ impl BaseEdit {
 
         let mut editor = self.lock_editor().await;
         editor.on_buffer_changed(atom).await;
-
         drop(editor);
+
         self.eval_rect().await;
         self.behave.apply_cursor_scroll().await;