diff --git a/bin/app/Cargo.lock b/bin/app/Cargo.lock index 87b3e208a..2b391d9f1 100644 --- a/bin/app/Cargo.lock +++ b/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", diff --git a/bin/app/Cargo.toml b/bin/app/Cargo.toml index bd044e9bf..e3df4953b 100644 --- a/bin/app/Cargo.toml +++ b/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" diff --git a/bin/app/Makefile b/bin/app/Makefile index 1768931b9..8ec89beae 100644 --- a/bin/app/Makefile +++ b/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 diff --git a/bin/app/java/MainActivity.java b/bin/app/java/MainActivity.java index 31abbe92b..b57c20644 100644 --- a/bin/app/java/MainActivity.java +++ b/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 diff --git a/bin/app/java/textinput/GameTextInput.java b/bin/app/java/textinput/GameTextInput.java index a9be73ad9..1008fb356 100644 --- a/bin/app/java/textinput/GameTextInput.java +++ b/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() {} } diff --git a/bin/app/java/textinput/InputConnection.java b/bin/app/java/textinput/InputConnection.java index 92ec7f854..5f411d870 100644 --- a/bin/app/java/textinput/InputConnection.java +++ b/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; diff --git a/bin/app/java/textinput/Pair.java b/bin/app/java/textinput/Pair.java deleted file mode 100644 index bdbd01b07..000000000 --- a/bin/app/java/textinput/Pair.java +++ /dev/null @@ -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; - } -} diff --git a/bin/app/pkg/android/dl-textinput-lib.sh b/bin/app/pkg/android/dl-textinput-lib.sh deleted file mode 100755 index f35aee597..000000000 --- a/bin/app/pkg/android/dl-textinput-lib.sh +++ /dev/null @@ -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" - diff --git a/bin/app/quad.toml b/bin/app/quad.toml index 5c11f427f..975118a0f 100644 --- a/bin/app/quad.toml +++ b/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" diff --git a/bin/app/src/android/mod.rs b/bin/app/src/android/mod.rs index 12a389a9f..19e3760e0 100644 --- a/bin/app/src/android/mod.rs +++ b/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 { diff --git a/bin/app/src/android/textinput/state.rs b/bin/app/src/android/textinput/gametextinput.rs similarity index 51% rename from bin/app/src/android/textinput/state.rs rename to bin/app/src/android/textinput/gametextinput.rs index a67a5322f..5ec5f1865 100644 --- a/bin/app/src/android/textinput/state.rs +++ b/bin/app/src/android/textinput/gametextinput.rs @@ -16,17 +16,24 @@ * along with this program. If not, see . */ -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 = 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, - input_connection: Option, + state: SyncMutex>, + input_connection: RwLock>, 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> } 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"\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 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 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"\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 let Some(input_connection) = self.input_connection { - delete_global_ref(self.env, input_connection); + if self.state_class != std::ptr::null_mut() { + delete_global_ref(env, self.state_class); + } + if let Some(input_connection) = *self.input_connection.read() { + delete_global_ref(env, input_connection); } } } diff --git a/bin/app/src/android/textinput/jni.rs b/bin/app/src/android/textinput/jni.rs index ce91e112c..80f477ebd 100644 --- a/bin/app/src/android/textinput/jni.rs +++ b/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 . + */ -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); } diff --git a/bin/app/src/android/textinput/mod.rs b/bin/app/src/android/textinput/mod.rs index a26147828..c47988a36 100644 --- a/bin/app/src/android/textinput/mod.rs +++ b/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>> = - 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, +} + +impl SharedState { + fn new(sender: AsyncSender) -> Self { + Self { state: Default::default(), is_active: false, sender } } } +pub(self) type SharedStatePtr = Arc>; + pub struct AndroidTextInput { - state: AndroidTextInputState, - sender: async_channel::Sender, - is_focus: bool, + state: SharedStatePtr, } impl AndroidTextInput { - pub fn new(sender: async_channel::Sender) -> Self { - Self { state: AndroidTextInputState::new(), sender, is_focus: false } + pub fn new(sender: AsyncSender) -> 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() } } diff --git a/bin/app/src/android/util.rs b/bin/app/src/android/util.rs new file mode 100644 index 000000000..b77cc482d --- /dev/null +++ b/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 . + */ + +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); + } +} diff --git a/bin/app/src/ui/edit/mod.rs b/bin/app/src/ui/edit/mod.rs index 1483c1f9d..662b86f2d 100644 --- a/bin/app/src/ui/edit/mod.rs +++ b/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;