From 0a5bc2772cee00b9b7b57ae3071039dcbdc674e9 Mon Sep 17 00:00:00 2001 From: jkds Date: Thu, 1 Jan 2026 11:39:58 +0100 Subject: [PATCH 01/12] app/android: integrate GameTextInput into build and provide an interface --- bin/app/Makefile | 18 ++- bin/app/build.rs | 17 +++ bin/app/pkg/android/dl-textinput-lib.sh | 41 ++++++ bin/app/src/android/mod.rs | 12 +- bin/app/src/android/textinput/ffi.rs | 111 +++++++++++++++++ bin/app/src/android/textinput/mod.rs | 159 ++++++++++++++++++++++++ bin/app/src/main.rs | 10 -- bin/app/src/text2/editor/android.rs | 3 +- bin/app/src/ui/edit/mod.rs | 2 +- 9 files changed, 356 insertions(+), 17 deletions(-) create mode 100755 bin/app/pkg/android/dl-textinput-lib.sh create mode 100644 bin/app/src/android/textinput/ffi.rs create mode 100644 bin/app/src/android/textinput/mod.rs diff --git a/bin/app/Makefile b/bin/app/Makefile index 928eb50aa..1768931b9 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 +android-release: $(SRC) fonts assets/forest_720x1280.mp4 gametextinput 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 +android-debug: $(SRC) fonts assets/forest_720x1280.mp4 gametextinput 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,6 +69,18 @@ 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: @@ -87,7 +99,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 +apk: $(SRC) fonts assets/forest_720x1280.mp4 gametextinput cargo quad-apk build $(DEV_FEATURES) $(MAKE) install-apk diff --git a/bin/app/build.rs b/bin/app/build.rs index a399394f6..b0148a896 100644 --- a/bin/app/build.rs +++ b/bin/app/build.rs @@ -64,5 +64,22 @@ 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"); } } diff --git a/bin/app/pkg/android/dl-textinput-lib.sh b/bin/app/pkg/android/dl-textinput-lib.sh new file mode 100755 index 000000000..f35aee597 --- /dev/null +++ b/bin/app/pkg/android/dl-textinput-lib.sh @@ -0,0 +1,41 @@ +#!/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/src/android/mod.rs b/bin/app/src/android/mod.rs index 5ab6ee36a..77998b621 100644 --- a/bin/app/src/android/mod.rs +++ b/bin/app/src/android/mod.rs @@ -20,9 +20,19 @@ use miniquad::native::android::{self, ndk_sys, ndk_utils}; use parking_lot::Mutex as SyncMutex; use std::{collections::HashMap, path::PathBuf, sync::LazyLock}; -use crate::AndroidSuggestEvent; +// TODO: Remove this enum after migration to GameTextInput is complete +#[derive(Debug)] +pub enum AndroidSuggestEvent { + Init, + CreateInputConnect, + Compose { text: String, cursor_pos: i32, is_commit: bool }, + ComposeRegion { start: usize, end: usize }, + FinishCompose, + DeleteSurroundingText { left: usize, right: usize }, +} pub mod insets; +pub mod textinput; pub mod vid; macro_rules! call_mainactivity_int_method { diff --git a/bin/app/src/android/textinput/ffi.rs b/bin/app/src/android/textinput/ffi.rs new file mode 100644 index 000000000..7c769d8e2 --- /dev/null +++ b/bin/app/src/android/textinput/ffi.rs @@ -0,0 +1,111 @@ +/* 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; +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, + 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); +} diff --git a/bin/app/src/android/textinput/mod.rs b/bin/app/src/android/textinput/mod.rs new file mode 100644 index 000000000..734d8b447 --- /dev/null +++ b/bin/app/src/android/textinput/mod.rs @@ -0,0 +1,159 @@ +/* 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::{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, +}; + +// Text input state exposed to the rest of the app +#[derive(Debug, Clone)] +pub struct AndroidTextInputState { + pub text: String, + pub select: (usize, usize), + pub compose: Option<(usize, usize)>, +} + +struct Globals { + next_id: usize, + senders: HashMap>, +} + +static GLOBALS: LazyLock> = + 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::(), 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); + } +} + +pub struct AndroidTextInput { + id: usize, + state: *mut GameTextInput, +} + +impl AndroidTextInput { + pub fn new(sender: async_channel::Sender) -> 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::(), std::mem::size_of::<*mut c_void>()); + GameTextInput_setEventCallback( + state, + Some(game_text_input_callback), + id as *mut c_void, + ); + state + }; + + Self { id, state } + } + + pub fn show_ime(&self) { + unsafe { + GameTextInput_showIme(self.state, 0); + } + } + + pub fn hide_ime(&self) { + unsafe { + GameTextInput_hideIme(self.state, 0); + } + } + + 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, >_state); + } + } + + 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(); + } + + unsafe { + GameTextInput_getState( + self.state, + callback, + &mut state as *mut AndroidTextInputState as *mut c_void, + ); + } + + state + } +} + +impl Drop for AndroidTextInput { + fn drop(&mut self) { + unsafe { + GameTextInput_destroy(self.state); + } + GLOBALS.lock().senders.remove(&self.id); + } +} diff --git a/bin/app/src/main.rs b/bin/app/src/main.rs index 8601063c0..74a8fbc7a 100644 --- a/bin/app/src/main.rs +++ b/bin/app/src/main.rs @@ -27,16 +27,6 @@ use std::sync::{Arc, OnceLock}; #[macro_use] extern crate tracing; -#[derive(Debug)] -pub enum AndroidSuggestEvent { - Init, - CreateInputConnect, - Compose { text: String, cursor_pos: i32, is_commit: bool }, - ComposeRegion { start: usize, end: usize }, - FinishCompose, - DeleteSurroundingText { left: usize, right: usize }, -} - #[cfg(target_os = "android")] mod android; mod app; diff --git a/bin/app/src/text2/editor/android.rs b/bin/app/src/text2/editor/android.rs index 18435ccad..a86efee71 100644 --- a/bin/app/src/text2/editor/android.rs +++ b/bin/app/src/text2/editor/android.rs @@ -17,12 +17,11 @@ */ use crate::{ - android, + android::{self, AndroidSuggestEvent}, gfx::Point, mesh::Color, prop::{PropertyAtomicGuard, PropertyColor, PropertyFloat32, PropertyStr}, text2::{TextContext, TEXT_CTX}, - AndroidSuggestEvent, }; use std::{ cmp::{max, min}, diff --git a/bin/app/src/ui/edit/mod.rs b/bin/app/src/ui/edit/mod.rs index cb59769e0..f3ce2c14e 100644 --- a/bin/app/src/ui/edit/mod.rs +++ b/bin/app/src/ui/edit/mod.rs @@ -37,7 +37,7 @@ use std::{ use tracing::instrument; #[cfg(target_os = "android")] -use crate::AndroidSuggestEvent; +use crate::android::AndroidSuggestEvent; use crate::{ gfx::{gfxtag, DrawCall, DrawInstruction, DrawMesh, Point, Rectangle, RenderApi, Vertex}, mesh::MeshBuilder, From c84f3fcb0952376fc839864461945285a11be249 Mon Sep 17 00:00:00 2001 From: jkds Date: Thu, 1 Jan 2026 12:18:23 +0100 Subject: [PATCH 02/12] app: remove old text input stuff and replace with new game text input --- bin/app/src/android/mod.rs | 279 --------------------------- bin/app/src/android/textinput/mod.rs | 6 + bin/app/src/text2/editor/android.rs | 171 +++++----------- bin/app/src/ui/edit/mod.rs | 49 ++--- 4 files changed, 71 insertions(+), 434 deletions(-) diff --git a/bin/app/src/android/mod.rs b/bin/app/src/android/mod.rs index 77998b621..12a389a9f 100644 --- a/bin/app/src/android/mod.rs +++ b/bin/app/src/android/mod.rs @@ -20,17 +20,6 @@ use miniquad::native::android::{self, ndk_sys, ndk_utils}; use parking_lot::Mutex as SyncMutex; use std::{collections::HashMap, path::PathBuf, sync::LazyLock}; -// TODO: Remove this enum after migration to GameTextInput is complete -#[derive(Debug)] -pub enum AndroidSuggestEvent { - Init, - CreateInputConnect, - Compose { text: String, cursor_pos: i32, is_commit: bool }, - ComposeRegion { start: usize, end: usize }, - FinishCompose, - DeleteSurroundingText { left: usize, right: usize }, -} - pub mod insets; pub mod textinput; pub mod vid; @@ -75,274 +64,6 @@ macro_rules! call_mainactivity_bool_method { }}; } -struct GlobalData { - senders: HashMap>, - next_id: usize, -} - -fn send(id: usize, ev: AndroidSuggestEvent) { - let globals = &GLOBALS.lock(); - let Some(sender) = globals.senders.get(&id) else { - warn!(target: "android", "Unknown composer_id={id} discard ev: {ev:?}"); - return - }; - let _ = sender.try_send(ev); -} - -unsafe impl Send for GlobalData {} -unsafe impl Sync for GlobalData {} - -static GLOBALS: LazyLock> = - LazyLock::new(|| SyncMutex::new(GlobalData { senders: HashMap::new(), next_id: 0 })); - -#[no_mangle] -pub unsafe extern "C" fn Java_darkfi_darkfi_1app_MainActivity_onInitEdit( - _env: *mut ndk_sys::JNIEnv, - _: ndk_sys::jobject, - id: ndk_sys::jint, -) { - assert!(id >= 0); - let id = id as usize; - send(id, AndroidSuggestEvent::Init); -} - -#[no_mangle] -pub unsafe extern "C" fn Java_autosuggest_InvisibleInputView_onCreateInputConnect( - _env: *mut ndk_sys::JNIEnv, - _: ndk_sys::jobject, - id: ndk_sys::jint, -) { - assert!(id >= 0); - let id = id as usize; - send(id, AndroidSuggestEvent::CreateInputConnect); -} - -#[no_mangle] -pub unsafe extern "C" fn Java_autosuggest_CustomInputConnection_onCompose( - env: *mut ndk_sys::JNIEnv, - _: ndk_sys::jobject, - id: ndk_sys::jint, - text: ndk_sys::jobject, - cursor_pos: ndk_sys::jint, - is_commit: ndk_sys::jboolean, -) { - assert!(id >= 0); - let id = id as usize; - let text = ndk_utils::get_utf_str!(env, text); - send( - id, - AndroidSuggestEvent::Compose { - text: text.to_string(), - cursor_pos, - is_commit: is_commit == 1, - }, - ); -} -#[no_mangle] -pub unsafe extern "C" fn Java_autosuggest_CustomInputConnection_onSetComposeRegion( - _env: *mut ndk_sys::JNIEnv, - _: ndk_sys::jobject, - id: ndk_sys::jint, - start: ndk_sys::jint, - end: ndk_sys::jint, -) { - assert!(id >= 0); - let id = id as usize; - send(id, AndroidSuggestEvent::ComposeRegion { start: start as usize, end: end as usize }); -} -#[no_mangle] -pub unsafe extern "C" fn Java_autosuggest_CustomInputConnection_onFinishCompose( - _env: *mut ndk_sys::JNIEnv, - _: ndk_sys::jobject, - id: ndk_sys::jint, -) { - assert!(id >= 0); - let id = id as usize; - send(id, AndroidSuggestEvent::FinishCompose); -} -#[no_mangle] -pub unsafe extern "C" fn Java_autosuggest_CustomInputConnection_onDeleteSurroundingText( - _env: *mut ndk_sys::JNIEnv, - _: ndk_sys::jobject, - id: ndk_sys::jint, - left: ndk_sys::jint, - right: ndk_sys::jint, -) { - assert!(id >= 0); - let id = id as usize; - send( - id, - AndroidSuggestEvent::DeleteSurroundingText { left: left as usize, right: right as usize }, - ); -} - -pub fn create_composer(sender: async_channel::Sender) -> usize { - let composer_id = { - let mut globals = GLOBALS.lock(); - let id = globals.next_id; - globals.next_id += 1; - globals.senders.insert(id, sender); - id - }; - unsafe { - let env = android::attach_jni_env(); - ndk_utils::call_void_method!(env, android::ACTIVITY, "createComposer", "(I)V", composer_id); - } - composer_id -} - -pub fn focus(id: usize) -> Option<()> { - let is_success = unsafe { - let env = android::attach_jni_env(); - - ndk_utils::call_bool_method!(env, android::ACTIVITY, "focus", "(I)Z", id as i32) - }; - if is_success == 0u8 { - None - } else { - Some(()) - } -} -pub fn unfocus(id: usize) -> Option<()> { - let is_success = unsafe { - let env = android::attach_jni_env(); - - ndk_utils::call_bool_method!(env, android::ACTIVITY, "unfocus", "(I)Z", id as i32) - }; - if is_success == 0u8 { - None - } else { - Some(()) - } -} - -pub fn set_text(id: usize, text: &str) -> Option<()> { - let ctext = std::ffi::CString::new(text).unwrap(); - let is_success = unsafe { - let env = android::attach_jni_env(); - - let new_string_utf = (**env).NewStringUTF.unwrap(); - let jtext = new_string_utf(env, ctext.as_ptr()); - let delete_local_ref = (**env).DeleteLocalRef.unwrap(); - - let res = ndk_utils::call_bool_method!( - env, - android::ACTIVITY, - "setText", - "(ILjava/lang/String;)Z", - id as i32, - jtext - ); - delete_local_ref(env, jtext); - res - }; - if is_success == 0u8 { - None - } else { - Some(()) - } -} - -pub fn set_selection(id: usize, select_start: usize, select_end: usize) -> Option<()> { - //trace!(target: "android", "set_selection({id}, {select_start}, {select_end})"); - let is_success = unsafe { - let env = android::attach_jni_env(); - ndk_utils::call_bool_method!( - env, - android::ACTIVITY, - "setSelection", - "(III)Z", - id as i32, - select_start as i32, - select_end as i32 - ) - }; - if is_success == 0u8 { - None - } else { - Some(()) - } -} - -pub fn commit_text(id: usize, text: &str) -> Option<()> { - let ctext = std::ffi::CString::new(text).unwrap(); - let is_success = unsafe { - let env = android::attach_jni_env(); - - let new_string_utf = (**env).NewStringUTF.unwrap(); - let delete_local_ref = (**env).DeleteLocalRef.unwrap(); - - let jtext = new_string_utf(env, ctext.as_ptr()); - - let res = ndk_utils::call_bool_method!( - env, - android::ACTIVITY, - "commitText", - "(ILjava/lang/String;)Z", - id as i32, - jtext - ); - delete_local_ref(env, jtext); - res - }; - if is_success == 0u8 { - None - } else { - Some(()) - } -} - -pub struct Editable { - pub buffer: String, - pub select_start: usize, - pub select_end: usize, - pub compose_start: Option, - pub compose_end: Option, -} - -pub fn get_editable(id: usize) -> Option { - //trace!(target: "android", "get_editable({id})"); - unsafe { - let env = android::attach_jni_env(); - let input_view = ndk_utils::call_object_method!( - env, - android::ACTIVITY, - "getInputView", - "(I)Lautosuggest/InvisibleInputView;", - id as i32 - ); - if input_view.is_null() { - return None - } - - let buffer = - ndk_utils::call_object_method!(env, input_view, "rawText", "()Ljava/lang/String;"); - assert!(!buffer.is_null()); - let buffer = ndk_utils::get_utf_str!(env, buffer).to_string(); - - let select_start = ndk_utils::call_int_method!(env, input_view, "getSelectionStart", "()I"); - - let select_end = ndk_utils::call_int_method!(env, input_view, "getSelectionEnd", "()I"); - - let compose_start = ndk_utils::call_int_method!(env, input_view, "getComposeStart", "()I"); - - let compose_end = ndk_utils::call_int_method!(env, input_view, "getComposeEnd", "()I"); - - assert!(select_start >= 0); - assert!(select_end >= 0); - assert!(compose_start >= 0 || compose_start == compose_end); - assert!(compose_start <= compose_end); - - Some(Editable { - buffer, - select_start: select_start as usize, - select_end: select_end as usize, - compose_start: if compose_start < 0 { None } else { Some(compose_start as usize) }, - compose_end: if compose_end < 0 { None } else { Some(compose_end as usize) }, - }) - } -} - pub fn get_appdata_path() -> PathBuf { call_mainactivity_str_method!("getAppDataPath").into() } diff --git a/bin/app/src/android/textinput/mod.rs b/bin/app/src/android/textinput/mod.rs index 734d8b447..ec82fe0c3 100644 --- a/bin/app/src/android/textinput/mod.rs +++ b/bin/app/src/android/textinput/mod.rs @@ -66,6 +66,12 @@ pub struct AndroidTextInput { state: *mut GameTextInput, } +// 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) -> Self { let id = { diff --git a/bin/app/src/text2/editor/android.rs b/bin/app/src/text2/editor/android.rs index a86efee71..41308e125 100644 --- a/bin/app/src/text2/editor/android.rs +++ b/bin/app/src/text2/editor/android.rs @@ -17,40 +17,22 @@ */ use crate::{ - android::{self, AndroidSuggestEvent}, + android::{ + self, + textinput::{AndroidTextInput, AndroidTextInputState}, + }, gfx::Point, mesh::Color, prop::{PropertyAtomicGuard, PropertyColor, PropertyFloat32, PropertyStr}, text2::{TextContext, TEXT_CTX}, }; -use std::{ - cmp::{max, min}, - sync::atomic::{AtomicBool, Ordering}, -}; +use std::cmp::{max, min}; macro_rules! t { ($($arg:tt)*) => { trace!(target: "text::editor::android", $($arg)*); } } -// You must be careful working with string indexes in Java. They are UTF16 string indexs, not UTF8 -fn char16_to_byte_index(s: &str, char_idx: usize) -> Option { - let utf16_data: Vec<_> = s.encode_utf16().take(char_idx).collect(); - let prestr = String::from_utf16(&utf16_data).ok()?; - Some(prestr.len()) -} -fn byte_to_char16_index(s: &str, byte_idx: usize) -> Option { - if byte_idx > s.len() || !s.is_char_boundary(byte_idx) { - return None - } - Some(s[..byte_idx].encode_utf16().count()) -} - pub struct Editor { - pub composer_id: usize, - pub recvr: async_channel::Receiver, - is_init: bool, - is_setup: bool, - /// We cannot receive focus until `AndroidSuggestEvent::Init` has finished. - /// We use this flag to delay calling `android::focus()` until the init has completed. - is_focus_req: AtomicBool, + input: AndroidTextInput, + pub recvr: async_channel::Receiver, layout: parley::Layout, width: Option, @@ -71,15 +53,9 @@ impl Editor { lineheight: PropertyFloat32, ) -> Self { let (sender, recvr) = async_channel::unbounded(); - let composer_id = android::create_composer(sender); - t!("Created composer [{composer_id}]"); - Self { - composer_id, + input: AndroidTextInput::new(sender), recvr, - is_init: false, - is_setup: false, - is_focus_req: AtomicBool::new(false), layout: Default::default(), width: None, @@ -92,42 +68,10 @@ impl Editor { } } - /// Called on `AndroidSuggestEvent::Init` after the View has been added to the main hierarchy - /// and is ready to receive commands such as focus. - pub fn init(&mut self) { - self.is_init = true; - - // Perform any focus requests. - let is_focus_req = self.is_focus_req.swap(false, Ordering::SeqCst); - if is_focus_req { - android::focus(self.composer_id).unwrap(); - } - - //android::focus(self.composer_id).unwrap(); - //let atxt = "A berry is small juicy 😊 pulpy and edible."; - //let atxt = "A berry is a small, pulpy, and often edible fruit. Typically, berries are juicy, rounded, brightly colored, sweet, sour or tart, and do not have a stone or pit, although many pips or seeds may be present. Common examples of berries in the culinary sense are strawberries, raspberries, blueberries, blackberries, white currants, blackcurrants, and redcurrants. In Britain, soft fruit is a horticultural term for such fruits. The common usage of the term berry is different from the scientific or botanical definition of a berry, which refers to a fruit produced from the ovary of a single flower where the outer layer of the ovary wall develops into an edible fleshy portion (pericarp). The botanical definition includes many fruits that are not commonly known or referred to as berries, such as grapes, tomatoes, cucumbers, eggplants, bananas, and chili peppers."; - //let atxt = "small berry terry"; - //android::set_text(self.composer_id, atxt); - //self.set_selection(2, 7); - // Call this after: - //self.on_buffer_changed(&mut PropertyAtomicGuard::none()).await; - } - /// Called on `AndroidSuggestEvent::CreateInputConnect`, which only happens after the View - /// is focused for the first time. - pub fn setup(&mut self) { - assert!(self.is_init); - self.is_setup = true; - - assert!(self.composer_id != usize::MAX); - t!("Initialized composer [{}]", self.composer_id); - } - pub async fn on_text_prop_changed(&mut self) { - // Get modified text property - let txt = self.text.get(); - // Update Android text buffer - android::set_text(self.composer_id, &txt); - assert_eq!(android::get_editable(self.composer_id).unwrap().buffer, txt); + // Update GameTextInput state + let state = AndroidTextInputState { text: self.text.get(), select: (0, 0), compose: None }; + self.input.set_state(&state); // Refresh our layout self.refresh().await; } @@ -136,21 +80,15 @@ impl Editor { self.refresh().await; // Update the text attribute - let edit = android::get_editable(self.composer_id).unwrap(); - self.text.set(atom, &edit.buffer); + let state = self.input.get_state(); + self.text.set(atom, &state.text); } - /// Can only be called after AndroidSuggestEvent::Init. pub fn focus(&self) { - // We're not yet ready to receive focus - if !self.is_init { - self.is_focus_req.store(true, Ordering::SeqCst); - return - } - android::focus(self.composer_id).unwrap(); + self.input.show_ime(); } pub fn unfocus(&self) { - android::unfocus(self.composer_id).unwrap(); + self.input.hide_ime(); } pub async fn refresh(&mut self) { @@ -159,20 +97,16 @@ impl Editor { let window_scale = self.window_scale.get(); let lineheight = self.lineheight.get(); - let edit = android::get_editable(self.composer_id).unwrap(); + let state = self.input.get_state(); let mut underlines = vec![]; - if let Some(compose_start) = edit.compose_start { - let compose_end = edit.compose_end.unwrap(); - - let compose_start = char16_to_byte_index(&edit.buffer, compose_start).unwrap(); - let compose_end = char16_to_byte_index(&edit.buffer, compose_end).unwrap(); + if let Some((compose_start, compose_end)) = state.compose { underlines.push(compose_start..compose_end); } let mut txt_ctx = TEXT_CTX.get().await; self.layout = txt_ctx.make_layout( - &edit.buffer, + &state.text, text_color, font_size, lineheight, @@ -188,12 +122,14 @@ impl Editor { pub fn move_to_pos(&self, pos: Point) { let cursor = parley::Cursor::from_point(&self.layout, pos.x, pos.y); - - let edit = android::get_editable(self.composer_id).unwrap(); let cursor_idx = cursor.index(); - let pos = byte_to_char16_index(&edit.buffer, cursor_idx).unwrap(); - t!(" {cursor_idx} => {pos}"); - android::set_selection(self.composer_id, pos, pos); + 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); } pub async fn select_word_at_point(&mut self, pos: Point) { @@ -205,29 +141,31 @@ impl Editor { pub fn get_cursor_pos(&self) -> Point { let lineheight = self.lineheight.get(); - let edit = android::get_editable(self.composer_id).unwrap(); + let state = self.input.get_state(); - let cursor_byte_idx = char16_to_byte_index(&edit.buffer, edit.select_start).unwrap(); + let cursor_idx = state.select.0; - let cursor = if cursor_byte_idx >= edit.buffer.len() { + let cursor = if cursor_idx >= state.text.len() { parley::Cursor::from_byte_index( &self.layout, - edit.buffer.len(), + state.text.len(), parley::Affinity::Upstream, ) } else { - parley::Cursor::from_byte_index( - &self.layout, - cursor_byte_idx, - parley::Affinity::Downstream, - ) + parley::Cursor::from_byte_index(&self.layout, cursor_idx, parley::Affinity::Downstream) }; let cursor_rect = cursor.geometry(&self.layout, lineheight); Point::new(cursor_rect.x0 as f32, cursor_rect.y0 as f32) } pub async fn insert(&mut self, txt: &str, atom: &mut PropertyAtomicGuard) { - android::commit_text(self.composer_id, txt); + // TODO: need to verify this is correct + // Insert text by updating the state + let mut current_state = self.input.get_state(); + 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(¤t_state); self.on_buffer_changed(atom).await; } @@ -249,26 +187,21 @@ impl Editor { } pub fn selected_text(&self) -> Option { - let edit = android::get_editable(self.composer_id).unwrap(); - if edit.select_start == edit.select_end { + let state = self.input.get_state(); + if state.select.0 == state.select.1 { return None } - let anchor = char16_to_byte_index(&edit.buffer, edit.select_start).unwrap(); - let index = char16_to_byte_index(&edit.buffer, edit.select_end).unwrap(); - let (start, end) = (min(anchor, index), max(anchor, index)); - Some(edit.buffer[start..end].to_string()) + let (start, end) = + (min(state.select.0, state.select.1), max(state.select.0, state.select.1)); + Some(state.text[start..end].to_string()) } pub fn selection(&self, side: isize) -> parley::Selection { assert!(side.abs() == 1); - let edit = android::get_editable(self.composer_id).unwrap(); - - let select_start = char16_to_byte_index(&edit.buffer, edit.select_start).unwrap(); - let select_end = char16_to_byte_index(&edit.buffer, edit.select_end).unwrap(); - //t!("selection() -> ({select_start}, {select_end})"); + let state = self.input.get_state(); let (anchor, focus) = match side { - -1 => (select_end, select_start), - 1 => (select_start, select_end), + -1 => (state.select.1, state.select.0), + 1 => (state.select.0, state.select.1), _ => panic!(), }; @@ -280,16 +213,16 @@ impl Editor { parley::Selection::new(anchor, focus) } pub async fn set_selection(&mut self, select_start: usize, select_end: usize) { - //t!("set_selection({select_start}, {select_end})"); - let edit = android::get_editable(self.composer_id).unwrap(); - let select_start = byte_to_char16_index(&edit.buffer, select_start).unwrap(); - let select_end = byte_to_char16_index(&edit.buffer, select_end).unwrap(); - android::set_selection(self.composer_id, select_start, select_end); + let state = AndroidTextInputState { + text: self.text.get(), + select: (select_start, select_end), + compose: None, + }; + self.input.set_state(&state); } #[allow(dead_code)] pub fn buffer(&self) -> String { - let edit = android::get_editable(self.composer_id).unwrap(); - edit.buffer + self.input.get_state().text } } diff --git a/bin/app/src/ui/edit/mod.rs b/bin/app/src/ui/edit/mod.rs index f3ce2c14e..abd8bc165 100644 --- a/bin/app/src/ui/edit/mod.rs +++ b/bin/app/src/ui/edit/mod.rs @@ -37,7 +37,7 @@ use std::{ use tracing::instrument; #[cfg(target_os = "android")] -use crate::android::AndroidSuggestEvent; +use crate::android::textinput::AndroidTextInputState; use crate::{ gfx::{gfxtag, DrawCall, DrawInstruction, DrawMesh, Point, Rectangle, RenderApi, Vertex}, mesh::MeshBuilder, @@ -1296,49 +1296,26 @@ impl BaseEdit { } #[cfg(target_os = "android")] - async fn handle_android_event(&self, ev: AndroidSuggestEvent) { + async fn handle_android_event(&self, state: AndroidTextInputState) { if !self.is_active.get() { return } - t!("handle_android_event({ev:?})"); + t!("handle_android_event({state:?})"); let atom = &mut self.render_api.make_guard(gfxtag!("BaseEdit::handle_android_event")); - match ev { - AndroidSuggestEvent::Init => { - let mut editor = self.lock_editor().await; - editor.init(); - // For debugging select, enable these and set a selection in the editor. - //self.is_phone_select.store(true, Ordering::Relaxed); - //self.hide_cursor.store(true, Ordering::Relaxed); - // Debug code if we set text in editor.init() - //editor.on_buffer_changed(&mut PropertyAtomicGuard::none()).await; - return - } - AndroidSuggestEvent::CreateInputConnect => { - let mut editor = self.lock_editor().await; - editor.setup(); - } - // Destructive text edits - AndroidSuggestEvent::ComposeRegion { .. } | - AndroidSuggestEvent::Compose { .. } | - AndroidSuggestEvent::DeleteSurroundingText { .. } => { - // Any editing will collapse selections - self.finish_select(atom); - - 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; - } - AndroidSuggestEvent::FinishCompose => { - let mut editor = self.lock_editor().await; - editor.on_buffer_changed(atom).await; - } + // Text changed - finish any active selection + if state.text != self.text.get() { + self.finish_select(atom); } + 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; + // Only redraw once we have the parent_rect // Can happen when we receive an Android event before the canvas is ready if self.parent_rect.lock().is_some() { From 1ee2fe04530a81a271069b88d4198ffbddeca112 Mon Sep 17 00:00:00 2001 From: jkds Date: Thu, 1 Jan 2026 12:32:14 +0100 Subject: [PATCH 03/12] app: remove old text input java code --- bin/app/java/MainActivity.java | 154 ------- .../autosuggest/CustomInputConnection.java | 426 ------------------ .../java/autosuggest/InvisibleInputView.java | 179 -------- bin/app/quad.toml | 3 - 4 files changed, 762 deletions(-) delete mode 100644 bin/app/java/autosuggest/CustomInputConnection.java delete mode 100644 bin/app/java/autosuggest/InvisibleInputView.java diff --git a/bin/app/java/MainActivity.java b/bin/app/java/MainActivity.java index 2f48fa11e..d2f9c7f7b 100644 --- a/bin/app/java/MainActivity.java +++ b/bin/app/java/MainActivity.java @@ -1,19 +1,9 @@ //% IMPORTS import android.view.ViewGroup; -import android.text.Editable; -import android.text.Spannable; -import android.text.SpanWatcher; -import android.text.Spanned; -import android.text.TextWatcher; -import android.widget.EditText; -import android.widget.TextView; -import android.view.inputmethod.BaseInputConnection; import android.view.WindowInsets.Type; import java.util.HashMap; -import autosuggest.InvisibleInputView; -import autosuggest.CustomInputConnection; import videodecode.VideoDecoder; //% END @@ -51,147 +41,6 @@ if (true) //% MAIN_ACTIVITY_BODY -private ViewGroup rootView; - -private HashMap editors; - -native static void onInitEdit(int id); - -public void createComposer(final int id) { - Log.d("darkfi", "createComposer() -> " + id); - - final InvisibleInputView iv = new InvisibleInputView(this, id); - editors.put(id, iv); - - runOnUiThread(new Runnable() { - @Override - public void run() { - rootView.addView(iv); - iv.clearFocus(); - onInitEdit(id); - } - }); -} - -private InputMethodManager getIMM() { - return (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE); -} -public boolean focus(final int id) { - final InvisibleInputView iv = editors.get(id); - if (iv == null) { - return false; - } - - runOnUiThread(new Runnable() { - @Override - public void run() { - boolean isFocused = iv.requestFocus(); - // Just Android things ;) - if (!isFocused) { - Log.w("darkfi", "error requesting focus for id=" + id + ": " + iv); - } - - getIMM().showSoftInput(iv, InputMethodManager.SHOW_IMPLICIT); - } - }); - - return true; -} -public boolean unfocus(final int id) { - final InvisibleInputView iv = editors.get(id); - if (iv == null) { - return false; - } - - runOnUiThread(new Runnable() { - @Override - public void run() { - iv.clearFocus(); - getIMM().hideSoftInputFromWindow(iv.getWindowToken(), 0); - } - }); - - return true; -} - -/* -public CustomInputConnection getInputConnect(int id) { - InvisibleInputView iv = editors.get(id); - if (iv == null) { - return null; - } - return iv.inputConnection; -} -*/ -public InvisibleInputView getInputView(int id) { - return editors.get(id); -} - -public boolean setText(int id, String txt) { - //Log.d("darkfi", "setText(" + id + ", " + txt + ")"); - InvisibleInputView iv = editors.get(id); - if (iv == null) { - return false; - } - - // If inputConnection is not yet ready, then setup the editable directly. - if (iv.inputConnection == null) { - iv.setEditableText(txt); - return true; - } - - // Maybe do this on the UI thread? - iv.inputConnection.setEditableText(txt, txt.length(), txt.length(), 0, 0); - return true; -} -public boolean setSelection(int id, int start, int end) { - InvisibleInputView iv = editors.get(id); - if (iv == null) { - return false; - } - - // If inputConnection is not yet ready, then setup the sel directly. - if (iv.inputConnection == null) { - iv.setSelection(start, end); - return true; - } - - iv.inputConnection.beginBatchEdit(); - // Not sure if this is needed - //if (start != end) - // iv.inputConnection.finishComposingText(); - iv.inputConnection.setSelection(start, end); - iv.inputConnection.endBatchEdit(); - - return true; -} -public boolean commitText(int id, String txt) { - //Log.d("darkfi", "setText(" + id + ", " + txt + ")"); - InvisibleInputView iv = editors.get(id); - if (iv == null) { - return false; - } - - if (iv.inputConnection == null) { - return false; - } - - iv.inputConnection.beginBatchEdit(); - iv.inputConnection.finishComposingText(); - iv.inputConnection.commitText(txt, 1); - iv.inputConnection.endBatchEdit(); - return true; -} - -/* -// Editable string with the spans displayed inline -public String getDebugEditableStr() { - String edit = view.inputConnection.debugEditableStr(); - Log.d("darkfi", "getDebugEditableStr() -> " + edit); - return edit; -} -*/ - public String getAppDataPath() { return getApplicationContext().getDataDir().getAbsolutePath(); } @@ -232,9 +81,6 @@ public VideoDecoder createVideoDecoder() { //% MAIN_ACTIVITY_ON_CREATE -rootView = layout; -editors = new HashMap<>(); - view.setFocusable(false); view.setFocusableInTouchMode(false); view.clearFocus(); diff --git a/bin/app/java/autosuggest/CustomInputConnection.java b/bin/app/java/autosuggest/CustomInputConnection.java deleted file mode 100644 index 0cd284d02..000000000 --- a/bin/app/java/autosuggest/CustomInputConnection.java +++ /dev/null @@ -1,426 +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 . - */ - -package autosuggest; - -import android.content.Context; -import android.text.Editable; -import android.text.Selection; -import android.util.Log; -import android.view.KeyEvent; -import android.view.inputmethod.BaseInputConnection; -import android.view.inputmethod.EditorInfo; -import android.view.View; -import android.view.inputmethod.ExtractedText; -import android.view.inputmethod.ExtractedTextRequest; -import android.view.inputmethod.SurroundingText; -import android.view.inputmethod.InputMethodManager; - -public class CustomInputConnection extends BaseInputConnection { - private static final boolean DEBUG = false; - private int id = -1; - - private View mInternalView; - private Editable mEditable; - private boolean mSingleLine; - private int numBatchEdits; - private boolean shouldUpdateImeSelection; - - native static void onCompose(int id, String text, int newCursorPos, boolean isCommit); - native static void onSetComposeRegion(int id, int start, int end); - native static void onFinishCompose(int id); - native static void onDeleteSurroundingText(int id, int left, int right); - - public CustomInputConnection(int id, Editable editable, View view) { - super(view, true); - log("CustomInputConnection()"); - this.id = id; - mEditable = editable; - mInternalView = view; - mSingleLine = false; - } - - private void log(String fstr, Object... args) { - if (!DEBUG) return; - String text = "(" + id + "): " + String.format(fstr, args); - Log.d("darkfi", text); - } - - /** - * Updates the AdapterInputConnection's internal representation of the text - * being edited and its selection and composition properties. The resulting - * Editable is accessible through the getEditable() method. - * If the text has not changed, this also calls updateSelection on the InputMethodManager. - * @param text The String contents of the field being edited - * @param selectionStart The character offset of the selection start, or the caret - * position if there is no selection - * @param selectionEnd The character offset of the selection end, or the caret - * position if there is no selection - * @param compositionStart The character offset of the composition start, or -1 - * if there is no composition - * @param compositionEnd The character offset of the composition end, or -1 - * if there is no selection - */ - public void setEditableText(String text, int selectionStart, int selectionEnd, - int compositionStart, int compositionEnd) { - log("setEditableText(%s, %d, %d, %d, %d)", text, - selectionStart, selectionEnd, - compositionStart, compositionEnd); - - int prevSelectionStart = Selection.getSelectionStart(mEditable); - int prevSelectionEnd = Selection.getSelectionEnd(mEditable); - int prevEditableLength = mEditable.length(); - int prevCompositionStart = getComposingSpanStart(mEditable); - int prevCompositionEnd = getComposingSpanEnd(mEditable); - String prevText = mEditable.toString(); - - selectionStart = Math.min(selectionStart, text.length()); - selectionEnd = Math.min(selectionEnd, text.length()); - compositionStart = Math.min(compositionStart, text.length()); - compositionEnd = Math.min(compositionEnd, text.length()); - - boolean textUnchanged = prevText.equals(text); - - if (textUnchanged - && prevSelectionStart == selectionStart && prevSelectionEnd == selectionEnd - && prevCompositionStart == compositionStart - && prevCompositionEnd == compositionEnd) { - // Nothing has changed; don't need to do anything - return; - } - - // When a programmatic change has been made to the editable field, both the start - // and end positions for the composition will equal zero. In this case we cancel the - // active composition in the editor as this no longer is relevant. - if (textUnchanged && compositionStart == 0 && compositionEnd == 0) { - cancelComposition(); - } - - if (!textUnchanged) { - log("replace mEditable with: %s", text); - mEditable.replace(0, mEditable.length(), text); - } - Selection.setSelection(mEditable, selectionStart, selectionEnd); - super.setComposingRegion(compositionStart, compositionEnd); - - //log("textUnchanged=%s prevText=%s", textUnchanged, prevText); - //if (textUnchanged || prevText.equals("")) { - // log("setEditableText updating selection"); - // updateSelection should be called when a manual selection change occurs. - // Should not be called if text is being entered else issues can occur - // e.g. backspace to undo autocorrection will not work with the default OSK. - getInputMethodManager().updateSelection(mInternalView, - selectionStart, selectionEnd, compositionStart, compositionEnd); - //} - } - - @Override - public Editable getEditable() { - log("getEditable() -> %s", editableToXml(mEditable)); - return mEditable; - } - - @Override - public boolean setComposingText(CharSequence text, int newCursorPosition) { - log("setComposingText(%s, %d)", text, newCursorPosition); - super.setComposingText(text, newCursorPosition); - shouldUpdateImeSelection = true; - onCompose(id, text.toString(), newCursorPosition, false); - return true; - } - - @Override - public boolean commitText(CharSequence text, int newCursorPosition) { - log("commitText(%s, %d)", text, newCursorPosition); - super.commitText(text, newCursorPosition); - shouldUpdateImeSelection = true; - onCompose(id, text.toString(), newCursorPosition, text.length() > 0); - return true; - } - - @Override - public boolean performEditorAction(int actionCode) { - log("performEditorAction(%d)", actionCode); - switch (actionCode) { - case EditorInfo.IME_ACTION_NEXT: - cancelComposition(); - // Send TAB key event - long timeStampMs = System.currentTimeMillis(); - //mImeAdapter.sendSyntheticKeyEvent( - // sEventTypeRawKeyDown, timeStampMs, KeyEvent.KEYCODE_TAB, 0); - return true; - case EditorInfo.IME_ACTION_GO: - case EditorInfo.IME_ACTION_SEARCH: - //mImeAdapter.dismissInput(true); - break; - } - - return super.performEditorAction(actionCode); - } - - @Override - public boolean performContextMenuAction(int id) { - log("performContextMenuAction(%d)", id); - /* - switch (id) { - case android.R.id.selectAll: - return mImeAdapter.selectAll(); - case android.R.id.cut: - return mImeAdapter.cut(); - case android.R.id.copy: - return mImeAdapter.copy(); - case android.R.id.paste: - return mImeAdapter.paste(); - default: - return false; - } - */ - return false; - } - - @Override - public CharSequence getTextAfterCursor(int length, int flags) { - log("getTextAfterCursor(%d, %d)", length, flags); - return super.getTextAfterCursor(length, flags); - } - @Override - public CharSequence getTextBeforeCursor(int length, int flags) { - log("getTextBeforeCursor(%d, %d)", length, flags); - return super.getTextBeforeCursor(length, flags); - } - @Override - public SurroundingText getSurroundingText(int beforeLength, int afterLength, int flags) { - log("getSurroundingText(%d, %d, %d)", beforeLength, afterLength, flags); - return super.getSurroundingText(beforeLength, afterLength, flags); - } - @Override - public CharSequence getSelectedText(int flags) { - log("getSelectedText(%d)", flags); - return super.getSelectedText(flags); - } - - @Override - public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) { - log("getExtractedText(...)"); - ExtractedText et = new ExtractedText(); - et.text = mEditable.toString(); - et.partialEndOffset = mEditable.length(); - et.selectionStart = Selection.getSelectionStart(mEditable); - et.selectionEnd = Selection.getSelectionEnd(mEditable); - et.flags = mSingleLine ? ExtractedText.FLAG_SINGLE_LINE : 0; - return et; - } - - @Override - public boolean deleteSurroundingText(int leftLength, int rightLength) { - log("deleteSurroundingText(%d, %d)", leftLength, rightLength); - if (!super.deleteSurroundingText(leftLength, rightLength)) { - return false; - } - shouldUpdateImeSelection = true; - //return mImeAdapter.deleteSurroundingText(leftLength, rightLength); - onDeleteSurroundingText(id, leftLength, rightLength); - return true; - } - - @Override - public boolean sendKeyEvent(KeyEvent event) { - int action = event.getAction(); - int keycode = event.getKeyCode(); - log("sendKeyEvent() [action=%d, keycode=%d]", action, keycode); - - //mImeAdapter.mSelectionHandleController.hideAndDisallowAutomaticShowing(); - //mImeAdapter.mInsertionHandleController.hideAndDisallowAutomaticShowing(); - - // If this is a key-up, and backspace/del or if the key has a character representation, - // need to update the underlying Editable (i.e. the local representation of the text - // being edited). - if (event.getAction() == KeyEvent.ACTION_UP) { - if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) { - super.deleteSurroundingText(1, 0); - } else if (event.getKeyCode() == KeyEvent.KEYCODE_FORWARD_DEL) { - super.deleteSurroundingText(0, 1); - } else { - int unicodeChar = event.getUnicodeChar(); - if (unicodeChar != 0) { - Editable editable = getEditable(); - int selectionStart = Selection.getSelectionStart(editable); - int selectionEnd = Selection.getSelectionEnd(editable); - if (selectionStart > selectionEnd) { - int temp = selectionStart; - selectionStart = selectionEnd; - selectionEnd = temp; - } - - String inputChar = Character.toString((char)unicodeChar); - editable.replace(selectionStart, selectionEnd, inputChar); - onCompose(id, inputChar, selectionStart, true); - } - } - } - shouldUpdateImeSelection = true; - return super.sendKeyEvent(event); - } - - @Override - public boolean finishComposingText() { - if (getComposingSpanStart(mEditable) == getComposingSpanEnd(mEditable)) { - log("finishComposingText() [DISABLED]"); - return true; - } - log("finishComposingText()"); - super.finishComposingText(); - onFinishCompose(id); - return true; - } - - @Override - public boolean setSelection(int start, int end) { - log("setSelection(%d, %d)", start, end); - if (start < 0 || end < 0) return true; - super.setSelection(start, end); - shouldUpdateImeSelection = true; - //return mImeAdapter.setEditableSelectionOffsets(start, end); - return true; - } - - /** - * Informs the InputMethodManager and InputMethodSession (i.e. the IME) that there - * is no longer a current composition. Note this differs from finishComposingText, which - * is called by the IME when it wants to end a composition. - */ - void cancelComposition() { - log("cancelComposition()"); - getInputMethodManager().restartInput(mInternalView); - } - - @Override - public boolean setComposingRegion(int start, int end) { - log("setComposingRegion(%d, %d)", start, end); - int a = Math.min(start, end); - int b = Math.max(start, end); - super.setComposingRegion(a, b); - onSetComposeRegion(id, a, b); - return true; - } - - boolean isActive() { - return getInputMethodManager().isActive(); - } - - private InputMethodManager getInputMethodManager() { - InputMethodManager imm = (InputMethodManager)mInternalView.getContext() - .getSystemService(Context.INPUT_METHOD_SERVICE); - if (imm == null) { - Log.e("darkfi", "[IC]: InputMethodManager is NULL!"); - } - return imm; - } - - private void updateImeSelection() { - log("updateImeSelection()"); - getInputMethodManager().updateSelection( - mInternalView, - Selection.getSelectionStart(mEditable), - Selection.getSelectionEnd(mEditable), - getComposingSpanStart(mEditable), - getComposingSpanEnd(mEditable) - ); - log("updateImeSelection() DONE"); - } - - @Override - public boolean beginBatchEdit() { - log("beginBatchEdit"); - ++numBatchEdits; - return false; - } - - @Override - public boolean endBatchEdit() { - log("endBatchEdit"); - if (--numBatchEdits == 0 && shouldUpdateImeSelection) { - updateImeSelection(); - shouldUpdateImeSelection = false; - } - log("endBatchEdit DONE"); - return false; - } - - public static String editableToXml(Editable editable) { - StringBuilder xmlBuilder = new StringBuilder(); - int length = editable.length(); - - Object[] spans = editable.getSpans(0, editable.length(), Object.class); - - for (int i = 0; i < length; i++) { - // Find spans starting at this position - for (Object span : spans) { - if (editable.getSpanStart(span) == i) { - xmlBuilder - .append("<") - .append(span.getClass().getSimpleName()) - .append(">"); - } - } - - // Append the character - char c = editable.charAt(i); - xmlBuilder.append(c); - - if (Character.isHighSurrogate(c)) { - if (i + 1 < editable.length() && Character.isLowSurrogate(editable.charAt(i + 1))) { - i += 1; - xmlBuilder.append(editable.charAt(i)); - } - } - - // Find spans ending at this position - for (Object span : spans) { - if (editable.getSpanEnd(span) == i) { - xmlBuilder - .append(""); - } - } - } - - // Find spans starting at this position - for (Object span : spans) { - if (editable.getSpanStart(span) == length) { - xmlBuilder - .append("<") - .append(span.getClass().getSimpleName()) - .append(">"); - } - } - // Find spans ending at this position - for (Object span : spans) { - if (editable.getSpanEnd(span) == length) { - xmlBuilder - .append(""); - } - } - - return xmlBuilder.toString(); - } -} - diff --git a/bin/app/java/autosuggest/InvisibleInputView.java b/bin/app/java/autosuggest/InvisibleInputView.java deleted file mode 100644 index 39e805ec7..000000000 --- a/bin/app/java/autosuggest/InvisibleInputView.java +++ /dev/null @@ -1,179 +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 . - */ - -package autosuggest; - -import android.content.Context; -import android.graphics.Rect; -import android.text.Editable; -import android.text.Selection; -import android.util.Log; -import android.view.KeyEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.inputmethod.BaseInputConnection; -import android.view.inputmethod.EditorInfo; -import android.view.inputmethod.InputConnection; - -import autosuggest.CustomInputConnection; - -public class InvisibleInputView extends View { - public CustomInputConnection inputConnection; - public int id = -1; - public Editable editable; - - native static void onCreateInputConnect(int id); - - public InvisibleInputView(Context ctx, int id) { - super(ctx); - setFocusable(true); - setFocusableInTouchMode(true); - //setVisibility(INVISIBLE); - setVisibility(VISIBLE); - //setAlpha(0f); - setLayoutParams(new ViewGroup.LayoutParams(400, 200)); - this.id = id; - editable = Editable.Factory.getInstance().newEditable(""); - Selection.setSelection(editable, 0); - } - - // Maybe move CustomInputConnection.setEditableText() to here? - // For now this is called when the InputConnection is not yet available. - public void setEditableText(String text) { - editable.replace(0, editable.length(), text); - Selection.setSelection(editable, text.length(), text.length()); - } - // Same as above - public void setSelection(int start, int end) { - Selection.setSelection(editable, start, end); - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - Log.d("darkfi", "InvisibleInputView " + id + " attached to window"); - } - @Override - public boolean onCheckIsTextEditor() { - Log.d("darkfi", "onCheckIsTextEditor"); - return true; - } - - @Override - public InputConnection onCreateInputConnection(EditorInfo outAttrs) { - Log.d("darkfi", "Create InputConnection for view=" + this.toString()); - // Losing focus requires the inputConnection to be destroyed - //if (inputConnection != null) { - // Log.d("darkfi", " -> return existing InputConnection"); - // return inputConnection; - //} - - outAttrs.inputType = EditorInfo.TYPE_CLASS_TEXT - | EditorInfo.TYPE_TEXT_FLAG_AUTO_CORRECT; - //| EditorInfo.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT; - outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN - //| EditorInfo.IME_ACTION_NONE; - | EditorInfo.IME_ACTION_GO; - outAttrs.initialSelStart = getSelectionStart(); - outAttrs.initialSelEnd = getSelectionEnd(); - //if (outAttrs.initialSelStart != 0) { - // Log.d("darkfi", " select: [" + outAttrs.initialSelStart + ", " + - // outAttrs.initialSelEnd + "]"); - //} - - inputConnection = new CustomInputConnection(id, editable, this); - onCreateInputConnect(id); - return inputConnection; - } - - @Override - protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) { - super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); - Log.d("darkfi", "onFocusChanged: " + gainFocus); - } - - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - Log.d("darkfi", "onKeyDown(" + keyCode + ", " + event + ")"); - // Copied from CustomInputConnection - // Seems only the down event is sent. - int selectionStart = Selection.getSelectionStart(editable); - if (event.getAction() == KeyEvent.ACTION_DOWN) { - if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) { - if (selectionStart > 0) { - editable.delete(selectionStart - 1, selectionStart); - CustomInputConnection.onDeleteSurroundingText(id, 1, 0); - } - } else if (event.getKeyCode() == KeyEvent.KEYCODE_FORWARD_DEL) { - if (selectionStart < editable.length()) { - editable.delete(selectionStart, selectionStart + 1); - CustomInputConnection.onDeleteSurroundingText(id, 0, 1); - } - } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_LEFT) { - if (selectionStart > 0) { - Selection.setSelection(editable, selectionStart - 1); - CustomInputConnection.onSetComposeRegion( - id, selectionStart - 1, selectionStart); - } - } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT) { - if (selectionStart < editable.length()) { - Selection.setSelection(editable, selectionStart + 1); - CustomInputConnection.onSetComposeRegion( - id, selectionStart + 1, selectionStart + 2); - } - } /* else { - int unicodeChar = event.getUnicodeChar(); - if (unicodeChar != 0) { - int selectionEnd = Selection.getSelectionEnd(editable); - if (selectionStart > selectionEnd) { - int temp = selectionStart; - selectionStart = selectionEnd; - selectionEnd = temp; - } - - String inputChar = Character.toString((char)unicodeChar); - Log.d("darkfi", "-> " + inputChar + " [" + selectionStart + ", " + selectionEnd + "]"); - editable.replace(selectionStart, selectionEnd, inputChar); - CustomInputConnection.onCompose( - id, inputChar, selectionStart, true); - } - } */ - } - return super.onKeyDown(keyCode, event); - } - - public String debugEditableStr() { - return CustomInputConnection.editableToXml(editable); - } - public String rawText() { - return editable.toString(); - } - public int getSelectionStart() { - return Selection.getSelectionStart(editable); - } - public int getSelectionEnd() { - return Selection.getSelectionEnd(editable); - } - public int getComposeStart() { - return BaseInputConnection.getComposingSpanStart(editable); - } - public int getComposeEnd() { - return BaseInputConnection.getComposingSpanEnd(editable); - } -} - diff --git a/bin/app/quad.toml b/bin/app/quad.toml index 12662dc34..b93e6d9a7 100644 --- a/bin/app/quad.toml +++ b/bin/app/quad.toml @@ -1,8 +1,5 @@ main_activity_inject = "java/MainActivity.java" java_files = [ - "java/autosuggest/CustomInputConnection.java", - "java/autosuggest/InvisibleInputView.java", - #"java/autosuggest/InvisibleInputManager.java", "java/ForegroundService.java", "java/videodecode/VideoDecoder.java" ] From 64e38580b2c7dc0152f8308e8e9bf7ae920b74dc Mon Sep 17 00:00:00 2001 From: jkds Date: Fri, 2 Jan 2026 05:15:49 +0100 Subject: [PATCH 04/12] app: copy GameTextInput java code into app --- bin/app/build.rs | 17 - bin/app/java/MainActivity.java | 96 ++- bin/app/java/textinput/GameTextInput.java | 52 ++ bin/app/java/textinput/InputConnection.java | 711 ++++++++++++++++++++ bin/app/java/textinput/Listener.java | 55 ++ bin/app/java/textinput/Pair.java | 25 + bin/app/java/textinput/Settings.java | 29 + bin/app/java/textinput/State.java | 34 + bin/app/quad.toml | 14 +- bin/app/src/android/textinput/ffi.rs | 111 --- bin/app/src/android/textinput/jni.rs | 52 ++ bin/app/src/android/textinput/mod.rs | 187 ++--- bin/app/src/android/textinput/state.rs | 327 +++++++++ bin/app/src/app/schema/mod.rs | 20 +- bin/app/src/text2/editor/android.rs | 49 +- bin/app/src/ui/edit/mod.rs | 4 +- 16 files changed, 1490 insertions(+), 293 deletions(-) create mode 100644 bin/app/java/textinput/GameTextInput.java create mode 100644 bin/app/java/textinput/InputConnection.java create mode 100644 bin/app/java/textinput/Listener.java create mode 100644 bin/app/java/textinput/Pair.java create mode 100644 bin/app/java/textinput/Settings.java create mode 100644 bin/app/java/textinput/State.java delete mode 100644 bin/app/src/android/textinput/ffi.rs create mode 100644 bin/app/src/android/textinput/jni.rs create mode 100644 bin/app/src/android/textinput/state.rs diff --git a/bin/app/build.rs b/bin/app/build.rs index b0148a896..a399394f6 100644 --- a/bin/app/build.rs +++ b/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"); } } diff --git a/bin/app/java/MainActivity.java b/bin/app/java/MainActivity.java index d2f9c7f7b..31abbe92b 100644 --- a/bin/app/java/MainActivity.java +++ b/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 diff --git a/bin/app/java/textinput/GameTextInput.java b/bin/app/java/textinput/GameTextInput.java new file mode 100644 index 000000000..a9be73ad9 --- /dev/null +++ b/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() {} +} diff --git a/bin/app/java/textinput/InputConnection.java b/bin/app/java/textinput/InputConnection.java new file mode 100644 index 000000000..92ec7f854 --- /dev/null +++ b/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; + } + } +} diff --git a/bin/app/java/textinput/Listener.java b/bin/app/java/textinput/Listener.java new file mode 100644 index 000000000..f1018380c --- /dev/null +++ b/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); +} diff --git a/bin/app/java/textinput/Pair.java b/bin/app/java/textinput/Pair.java new file mode 100644 index 000000000..bdbd01b07 --- /dev/null +++ b/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; + } +} diff --git a/bin/app/java/textinput/Settings.java b/bin/app/java/textinput/Settings.java new file mode 100644 index 000000000..13ad97e29 --- /dev/null +++ b/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; + } +} diff --git a/bin/app/java/textinput/State.java b/bin/app/java/textinput/State.java new file mode 100644 index 000000000..70a22ceea --- /dev/null +++ b/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; +} diff --git a/bin/app/quad.toml b/bin/app/quad.toml index b93e6d9a7..5c11f427f 100644 --- a/bin/app/quad.toml +++ b/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" ] diff --git a/bin/app/src/android/textinput/ffi.rs b/bin/app/src/android/textinput/ffi.rs deleted file mode 100644 index 7c769d8e2..000000000 --- a/bin/app/src/android/textinput/ffi.rs +++ /dev/null @@ -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 . - */ - -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, - 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); -} diff --git a/bin/app/src/android/textinput/jni.rs b/bin/app/src/android/textinput/jni.rs new file mode 100644 index 000000000..ce91e112c --- /dev/null +++ b/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); + } +} diff --git a/bin/app/src/android/textinput/mod.rs b/bin/app/src/android/textinput/mod.rs index ec82fe0c3..a26147828 100644 --- a/bin/app/src/android/textinput/mod.rs +++ b/bin/app/src/android/textinput/mod.rs @@ -16,20 +16,36 @@ * along with this program. If not, see . */ -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, -}; +use async_channel::Sender as AsyncSender; +use miniquad::native::android::attach_jni_env; +use parking_lot::RwLock; +use std::sync::LazyLock; -mod ffi; -use ffi::{ - GameTextInput, GameTextInputSpan, GameTextInputState, GameTextInput_destroy, - GameTextInput_getState, GameTextInput_hideIme, GameTextInput_init, - GameTextInput_setEventCallback, GameTextInput_setState, GameTextInput_showIme, -}; +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>> = + 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>, -} - -static GLOBALS: LazyLock> = - 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::(), 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, + 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) -> 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::(), 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; + } + 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 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 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 hide_ime(&self) { - unsafe { - GameTextInput_hideIme(self.state, 0); - } - } - - 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, >_state); - } - } - - 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(); - } - - unsafe { - GameTextInput_getState( - self.state, - callback, - &mut state as *mut AndroidTextInputState as *mut c_void, - ); - } - - 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 } } diff --git a/bin/app/src/android/textinput/state.rs b/bin/app/src/android/textinput/state.rs new file mode 100644 index 000000000..a67a5322f --- /dev/null +++ b/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 . + */ + +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, + input_connection: Option, + 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> +} + +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"\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 {} diff --git a/bin/app/src/app/schema/mod.rs b/bin/app/src/app/schema/mod.rs index 8bd265689..c73333f47 100644 --- a/bin/app/src/app/schema/mod.rs +++ b/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(); } diff --git a/bin/app/src/text2/editor/android.rs b/bin/app/src/text2/editor/android.rs index 41308e125..445e22f3b 100644 --- a/bin/app/src/text2/editor/android.rs +++ b/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(¤t_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() } } diff --git a/bin/app/src/ui/edit/mod.rs b/bin/app/src/ui/edit/mod.rs index abd8bc165..1483c1f9d 100644 --- a/bin/app/src/ui/edit/mod.rs +++ b/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 } From 6835e16ff2d2192292a1694f76f43b391f85dc72 Mon Sep 17 00:00:00 2001 From: jkds Date: Fri, 2 Jan 2026 10:00:55 +0100 Subject: [PATCH 05/12] app: do a big cleanup of java GameTextInput code and make everything have a sane API. --- bin/app/Cargo.lock | 2 +- bin/app/Cargo.toml | 3 +- bin/app/Makefile | 21 +- bin/app/java/MainActivity.java | 145 +++++----- bin/app/java/textinput/GameTextInput.java | 9 + bin/app/java/textinput/InputConnection.java | 2 + bin/app/java/textinput/Pair.java | 25 -- bin/app/pkg/android/dl-textinput-lib.sh | 41 --- bin/app/quad.toml | 3 +- bin/app/src/android/mod.rs | 1 + .../textinput/{state.rs => gametextinput.rs} | 263 ++++++++++-------- bin/app/src/android/textinput/jni.rs | 47 ++-- bin/app/src/android/textinput/mod.rs | 102 +++---- bin/app/src/android/util.rs | 36 +++ bin/app/src/ui/edit/mod.rs | 2 +- 15 files changed, 338 insertions(+), 364 deletions(-) delete mode 100644 bin/app/java/textinput/Pair.java delete mode 100755 bin/app/pkg/android/dl-textinput-lib.sh rename bin/app/src/android/textinput/{state.rs => gametextinput.rs} (51%) create mode 100644 bin/app/src/android/util.rs 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; From bcb10050df9075f7ff0a888ab1b02bfea50e7c5e Mon Sep 17 00:00:00 2001 From: jkds Date: Fri, 2 Jan 2026 10:38:29 +0100 Subject: [PATCH 06/12] app: fix race condition in handling android IME events in BaseEdit --- .../src/android/textinput/gametextinput.rs | 2 + bin/app/src/android/textinput/mod.rs | 4 - bin/app/src/text2/editor/android.rs | 75 +++++++++---------- bin/app/src/ui/edit/mod.rs | 7 +- 4 files changed, 41 insertions(+), 47 deletions(-) diff --git a/bin/app/src/android/textinput/gametextinput.rs b/bin/app/src/android/textinput/gametextinput.rs index 5ec5f1865..0fc4cb050 100644 --- a/bin/app/src/android/textinput/gametextinput.rs +++ b/bin/app/src/android/textinput/gametextinput.rs @@ -25,6 +25,7 @@ use std::{ use super::{AndroidTextInputState, SharedStatePtr}; +macro_rules! t { ($($arg:tt)*) => { trace!(target: "android::textinput::gametextinput", $($arg)*); } } macro_rules! w { ($($arg:tt)*) => { warn!(target: "android::textinput::gametextinput", $($arg)*); } } pub const SPAN_UNDEFINED: i32 = -1; @@ -213,6 +214,7 @@ impl GameTextInput { pub fn process_event(&self, event_state: ndk_sys::jobject) { let state = self.state_from_java(event_state); + t!("IME event: {state:?}"); let Some(shared) = &*self.state.lock() else { w!("process_event() - no shared state set"); diff --git a/bin/app/src/android/textinput/mod.rs b/bin/app/src/android/textinput/mod.rs index c47988a36..72e60b328 100644 --- a/bin/app/src/android/textinput/mod.rs +++ b/bin/app/src/android/textinput/mod.rs @@ -85,8 +85,4 @@ impl AndroidTextInput { gti.push_update(&state); } } - - pub fn get_state(&self) -> AndroidTextInputState { - self.state.lock().state.clone() - } } diff --git a/bin/app/src/text2/editor/android.rs b/bin/app/src/text2/editor/android.rs index 445e22f3b..1961292ea 100644 --- a/bin/app/src/text2/editor/android.rs +++ b/bin/app/src/text2/editor/android.rs @@ -32,6 +32,7 @@ macro_rules! t { ($($arg:tt)*) => { trace!(target: "text::editor::android", $($a pub struct Editor { input: AndroidTextInput, + pub state: AndroidTextInputState, pub recvr: async_channel::Receiver, layout: parley::Layout, @@ -54,9 +55,9 @@ impl Editor { ) -> Self { let (sender, recvr) = async_channel::unbounded(); let input = AndroidTextInput::new(sender); - Self { input, + state: Default::default(), recvr, layout: Default::default(), @@ -72,11 +73,10 @@ impl Editor { pub async fn on_text_prop_changed(&mut self) { // Update GameTextInput 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); + self.state.text = self.text.get(); + self.state.select = (0, 0); + self.state.compose = None; + self.input.set_state(self.state.clone()); // Refresh our layout self.refresh().await; } @@ -85,8 +85,7 @@ impl Editor { self.refresh().await; // Update the text attribute - let state = self.input.get_state(); - self.text.set(atom, &state.text); + self.text.set(atom, &self.state.text); } pub fn focus(&mut self) { @@ -102,16 +101,14 @@ impl Editor { let window_scale = self.window_scale.get(); let lineheight = self.lineheight.get(); - let state = self.input.get_state(); - let mut underlines = vec![]; - if let Some((compose_start, compose_end)) = state.compose { + if let Some((compose_start, compose_end)) = self.state.compose { underlines.push(compose_start..compose_end); } let mut txt_ctx = TEXT_CTX.get().await; self.layout = txt_ctx.make_layout( - &state.text, + &self.state.text, text_color, font_size, lineheight, @@ -129,11 +126,10 @@ impl Editor { let cursor = parley::Cursor::from_point(&self.layout, pos.x, pos.y); let cursor_idx = cursor.index(); t!(" move_to_pos: {cursor_idx}"); - 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); + self.state.text = self.text.get(); + self.state.select = (cursor_idx, cursor_idx); + self.state.compose = None; + self.input.set_state(self.state.clone()); } pub async fn select_word_at_point(&mut self, pos: Point) { @@ -145,14 +141,12 @@ impl Editor { pub fn get_cursor_pos(&self) -> Point { let lineheight = self.lineheight.get(); - let state = self.input.get_state(); + let cursor_idx = self.state.select.0; - let cursor_idx = state.select.0; - - let cursor = if cursor_idx >= state.text.len() { + let cursor = if cursor_idx >= self.state.text.len() { parley::Cursor::from_byte_index( &self.layout, - state.text.len(), + self.state.text.len(), parley::Affinity::Upstream, ) } else { @@ -165,11 +159,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().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.state.text.push_str(txt); + let cursor_idx = self.state.text.len(); + self.state.select = (cursor_idx, cursor_idx); + self.state.compose = None; + self.input.set_state(self.state.clone()); self.on_buffer_changed(atom).await; } @@ -191,21 +185,21 @@ impl Editor { } pub fn selected_text(&self) -> Option { - let state = self.input.get_state(); - if state.select.0 == state.select.1 { + let (start, end) = (self.state.select.0, self.state.select.1); + if start == end { return None } - let (start, end) = - (min(state.select.0, state.select.1), max(state.select.0, state.select.1)); - Some(state.text[start..end].to_string()) + let (start, end) = (min(start, end), max(start, end)); + Some(self.state.text[start..end].to_string()) } pub fn selection(&self, side: isize) -> parley::Selection { assert!(side.abs() == 1); - let state = self.input.get_state(); + t!("selection({side}) [state={:?}]", self.state); + let (start, end) = (self.state.select.0, self.state.select.1); let (anchor, focus) = match side { - -1 => (state.select.1, state.select.0), - 1 => (state.select.0, state.select.1), + -1 => (end, start), + 1 => (start, end), _ => panic!(), }; @@ -217,15 +211,14 @@ impl Editor { parley::Selection::new(anchor, focus) } pub async fn set_selection(&mut self, select_start: usize, select_end: usize) { - 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); + self.state.text = self.text.get(); + self.state.select = (select_start, select_end); + self.state.compose = None; + self.input.set_state(self.state.clone()); } #[allow(dead_code)] pub fn buffer(&self) -> String { - self.input.get_state().text.clone() + self.state.text.clone() } } diff --git a/bin/app/src/ui/edit/mod.rs b/bin/app/src/ui/edit/mod.rs index 662b86f2d..5d7a7832a 100644 --- a/bin/app/src/ui/edit/mod.rs +++ b/bin/app/src/ui/edit/mod.rs @@ -1300,16 +1300,19 @@ impl BaseEdit { if !self.is_active.get() { return } - t!("handle_android_event({state:?})"); + t!("handle_android_event({state:?})"); let atom = &mut self.render_api.make_guard(gfxtag!("BaseEdit::handle_android_event")); // Text changed - finish any active selection - if state.text != self.text.get() { + if state.text != self.text.get() || state.select.0 == state.select.1 { + // Safe to call before we update the editor. + // I just wanna avoid cloning state since we move it into editor. self.finish_select(atom); } let mut editor = self.lock_editor().await; + editor.state = state; editor.on_buffer_changed(atom).await; drop(editor); From 02530cc0f41f5d07a4e04d1284a838c4858c9cba Mon Sep 17 00:00:00 2001 From: jkds Date: Fri, 2 Jan 2026 12:33:24 +0100 Subject: [PATCH 07/12] app: make editor support multiline --- bin/app/java/MainActivity.java | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/app/java/MainActivity.java b/bin/app/java/MainActivity.java index b57c20644..b115773f5 100644 --- a/bin/app/java/MainActivity.java +++ b/bin/app/java/MainActivity.java @@ -22,6 +22,7 @@ MainActivity main = (MainActivity)getContext(); if (main.inpcon == null) { EditorInfo editorInfo = new EditorInfo(); editorInfo.inputType = InputType.TYPE_CLASS_TEXT | + InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_AUTO_CORRECT; editorInfo.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN; From b182fa7fa939aa3533211ebf2176508f344b6f4b Mon Sep 17 00:00:00 2001 From: jkds Date: Sat, 3 Jan 2026 02:04:52 +0100 Subject: [PATCH 08/12] app: improve android editor perf and eliminate a deadlock in android text input --- .../src/android/textinput/gametextinput.rs | 26 ++++++++-------- bin/app/src/android/textinput/mod.rs | 4 ++- bin/app/src/text2/editor/android.rs | 11 +++++-- bin/app/src/ui/edit/mod.rs | 30 ++++++++++++------- 4 files changed, 42 insertions(+), 29 deletions(-) diff --git a/bin/app/src/android/textinput/gametextinput.rs b/bin/app/src/android/textinput/gametextinput.rs index 0fc4cb050..e63e27301 100644 --- a/bin/app/src/android/textinput/gametextinput.rs +++ b/bin/app/src/android/textinput/gametextinput.rs @@ -182,21 +182,19 @@ impl GameTextInput { 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!( - env, - input_connection, - "setState", - "(Ltextinput/State;)V", - jstate - ); + unsafe { + let env = android::attach_jni_env(); + let jstate = self.state_to_java(state); + call_void_method!( + env, + input_connection, + "setState", + "(Ltextinput/State;)V", + jstate + ); - let delete_local_ref = (**env).DeleteLocalRef.unwrap(); - delete_local_ref(env, jstate); - } + let delete_local_ref = (**env).DeleteLocalRef.unwrap(); + delete_local_ref(env, jstate); } } diff --git a/bin/app/src/android/textinput/mod.rs b/bin/app/src/android/textinput/mod.rs index 72e60b328..b41c19c4f 100644 --- a/bin/app/src/android/textinput/mod.rs +++ b/bin/app/src/android/textinput/mod.rs @@ -78,9 +78,11 @@ impl AndroidTextInput { // Always update our own state. let mut ours = self.state.lock(); ours.state = state.clone(); + let is_active = ours.is_active; + drop(ours); // Only update java state when this input is active - if ours.is_active { + if is_active { let gti = GAME_TEXT_INPUT.get().unwrap(); gti.push_update(&state); } diff --git a/bin/app/src/text2/editor/android.rs b/bin/app/src/text2/editor/android.rs index 1961292ea..6a4fc7321 100644 --- a/bin/app/src/text2/editor/android.rs +++ b/bin/app/src/text2/editor/android.rs @@ -81,9 +81,14 @@ impl Editor { self.refresh().await; } pub async fn on_buffer_changed(&mut self, atom: &mut PropertyAtomicGuard) { - // Refresh the layout using the Android buffer - self.refresh().await; + // Only refresh layout if text content actually changed + // Avoid triggering expensive recomputes of layout and property tree. + let old_text = self.text.get(); + if old_text == self.state.text { + return + } + self.refresh().await; // Update the text attribute self.text.set(atom, &self.state.text); } @@ -126,7 +131,7 @@ impl Editor { let cursor = parley::Cursor::from_point(&self.layout, pos.x, pos.y); let cursor_idx = cursor.index(); t!(" move_to_pos: {cursor_idx}"); - self.state.text = self.text.get(); + assert!(cursor_idx <= self.state.text.len()); self.state.select = (cursor_idx, cursor_idx); self.state.compose = None; self.input.set_state(self.state.clone()); diff --git a/bin/app/src/ui/edit/mod.rs b/bin/app/src/ui/edit/mod.rs index 5d7a7832a..a8bded8cb 100644 --- a/bin/app/src/ui/edit/mod.rs +++ b/bin/app/src/ui/edit/mod.rs @@ -1304,14 +1304,11 @@ impl BaseEdit { t!("handle_android_event({state:?})"); let atom = &mut self.render_api.make_guard(gfxtag!("BaseEdit::handle_android_event")); - // Text changed - finish any active selection - if state.text != self.text.get() || state.select.0 == state.select.1 { - // Safe to call before we update the editor. - // I just wanna avoid cloning state since we move it into editor. - self.finish_select(atom); - } - let mut editor = self.lock_editor().await; + // Diff old and new state so we know what changed + let is_text_changed = editor.state.text != state.text; + let is_select_changed = editor.state.select != state.select; + let is_compose_changed = editor.state.compose != state.compose; editor.state = state; editor.on_buffer_changed(atom).await; drop(editor); @@ -1319,10 +1316,21 @@ impl BaseEdit { self.eval_rect().await; self.behave.apply_cursor_scroll().await; - // Only redraw once we have the parent_rect - // Can happen when we receive an Android event before the canvas is ready - if self.parent_rect.lock().is_some() { - self.redraw(atom).await; + // Text changed - finish any active selection + if is_text_changed || is_compose_changed { + self.pause_blinking(); + //assert!(state.text != self.text.get()); + self.finish_select(atom); + + // Only redraw once we have the parent_rect + // Can happen when we receive an Android event before the canvas is ready + if self.parent_rect.lock().is_some() { + self.redraw(atom).await; + } + } else if is_select_changed { + // Redrawing the entire text just for select changes is expensive + self.redraw_cursor(atom.batch_id).await; + self.redraw_select(atom.batch_id).await; } } } From 737ba095f9289fadda11f04ec02cea8e6e7c8b2e Mon Sep 17 00:00:00 2001 From: jkds Date: Sat, 3 Jan 2026 02:40:47 +0100 Subject: [PATCH 09/12] app: android event handler improve editor logic --- bin/app/src/ui/edit/mod.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bin/app/src/ui/edit/mod.rs b/bin/app/src/ui/edit/mod.rs index a8bded8cb..9f4f17f06 100644 --- a/bin/app/src/ui/edit/mod.rs +++ b/bin/app/src/ui/edit/mod.rs @@ -1313,20 +1313,28 @@ impl BaseEdit { editor.on_buffer_changed(atom).await; drop(editor); + // Nothing changed. Just return. + if !is_text_changed && !is_select_changed && !is_compose_changed { + t!("Skipping update since nothing changed"); + return + } + self.eval_rect().await; self.behave.apply_cursor_scroll().await; + //t!("is_text_changed={is_text_changed}, is_select_changed={is_select_changed}, is_compose_changed={is_compose_changed}"); + // Only redraw once we have the parent_rect + // Can happen when we receive an Android event before the canvas is ready + if self.parent_rect.lock().is_none() { + return + } + // Text changed - finish any active selection if is_text_changed || is_compose_changed { self.pause_blinking(); //assert!(state.text != self.text.get()); self.finish_select(atom); - - // Only redraw once we have the parent_rect - // Can happen when we receive an Android event before the canvas is ready - if self.parent_rect.lock().is_some() { - self.redraw(atom).await; - } + self.redraw(atom).await; } else if is_select_changed { // Redrawing the entire text just for select changes is expensive self.redraw_cursor(atom.batch_id).await; From 7d589fd2ac38ea35fe2d5c2316688ee6b5d86e87 Mon Sep 17 00:00:00 2001 From: jkds Date: Sat, 3 Jan 2026 06:36:44 +0100 Subject: [PATCH 10/12] app/edit: improve perf of editing selection. only recalc rect when editing text itself (not select changes) --- .../src/android/textinput/gametextinput.rs | 8 +----- bin/app/src/android/util.rs | 2 ++ bin/app/src/text2/editor/android.rs | 2 +- bin/app/src/ui/chatview/mod.rs | 2 +- bin/app/src/ui/edit/mod.rs | 27 +++++++++++-------- bin/app/src/ui/mod.rs | 2 ++ 6 files changed, 23 insertions(+), 20 deletions(-) diff --git a/bin/app/src/android/textinput/gametextinput.rs b/bin/app/src/android/textinput/gametextinput.rs index e63e27301..e3294c8f2 100644 --- a/bin/app/src/android/textinput/gametextinput.rs +++ b/bin/app/src/android/textinput/gametextinput.rs @@ -185,13 +185,7 @@ impl GameTextInput { unsafe { let env = android::attach_jni_env(); let jstate = self.state_to_java(state); - call_void_method!( - env, - input_connection, - "setState", - "(Ltextinput/State;)V", - jstate - ); + call_void_method!(env, input_connection, "setState", "(Ltextinput/State;)V", jstate); let delete_local_ref = (**env).DeleteLocalRef.unwrap(); delete_local_ref(env, jstate); diff --git a/bin/app/src/android/util.rs b/bin/app/src/android/util.rs index b77cc482d..6738fc3c0 100644 --- a/bin/app/src/android/util.rs +++ b/bin/app/src/android/util.rs @@ -32,5 +32,7 @@ unsafe fn check_except(env: *mut ndk_sys::JNIEnv, context: &str) { let exception_clear = (**env).ExceptionClear.unwrap(); exception_clear(env); + + panic!("Java exception detected in {context}"); } } diff --git a/bin/app/src/text2/editor/android.rs b/bin/app/src/text2/editor/android.rs index 6a4fc7321..33f7d99ff 100644 --- a/bin/app/src/text2/editor/android.rs +++ b/bin/app/src/text2/editor/android.rs @@ -199,7 +199,7 @@ impl Editor { } pub fn selection(&self, side: isize) -> parley::Selection { assert!(side.abs() == 1); - t!("selection({side}) [state={:?}]", self.state); + //t!("selection({side}) [state={:?}]", self.state); let (start, end) = (self.state.select.0, self.state.select.1); let (anchor, focus) = match side { diff --git a/bin/app/src/ui/chatview/mod.rs b/bin/app/src/ui/chatview/mod.rs index bdcc99cf5..d2e3f3a86 100644 --- a/bin/app/src/ui/chatview/mod.rs +++ b/bin/app/src/ui/chatview/mod.rs @@ -1003,7 +1003,7 @@ impl UIObject for ChatView { } let rect = self.rect.get(); - t!("handle_touch({phase:?}, {id},{id}, {touch_pos:?})"); + //t!("handle_touch({phase:?}, {id},{id}, {touch_pos:?})"); let atom = &mut self.render_api.make_guard(gfxtag!("ChatView::handle_touch")); let touch_y = touch_pos.y; diff --git a/bin/app/src/ui/edit/mod.rs b/bin/app/src/ui/edit/mod.rs index 9f4f17f06..1411bb3f5 100644 --- a/bin/app/src/ui/edit/mod.rs +++ b/bin/app/src/ui/edit/mod.rs @@ -857,7 +857,7 @@ impl BaseEdit { } true } - async fn handle_touch_end(&self, atom: &mut PropertyAtomicGuard, mut touch_pos: Point) -> bool { + async fn handle_touch_end(&self, mut touch_pos: Point) -> bool { //t!("handle_touch_end({touch_pos:?})"); self.abs_to_local(&mut touch_pos); @@ -865,6 +865,7 @@ impl BaseEdit { match state { TouchStateAction::Inactive => return false, TouchStateAction::Started { pos: _, instant: _ } | TouchStateAction::SetCursorPos => { + let atom = &mut self.render_api.make_guard(gfxtag!("BaseEdit::handle_touch_end")); self.touch_set_cursor_pos(atom, touch_pos).await; self.redraw(atom).await; } @@ -980,7 +981,7 @@ impl BaseEdit { editor.selected_text() }; - //d!("Select {seltext:?} from {clip_mouse_pos:?} (unclipped: {mouse_pos:?}) to ({sel_start}, {sel_end})"); + //d!("Select {seltext:?} from {clip_mouse_pos:?} (unclipped: {mouse_pos:?})"); // Android editor impl detail: selection disappears when anchor == index // But we disallow this so it should never happen. Just making a note of it here. @@ -1047,6 +1048,7 @@ impl BaseEdit { } async fn redraw_select(&self, batch_id: BatchGuardId) { + //t!("redraw_select"); let sel_instrs = self.regen_select_mesh().await; let phone_sel_instrs = self.regen_phone_select_handle_mesh().await; let draw_calls = vec![ @@ -1315,13 +1317,10 @@ impl BaseEdit { // Nothing changed. Just return. if !is_text_changed && !is_select_changed && !is_compose_changed { - t!("Skipping update since nothing changed"); + //t!("Skipping update since nothing changed"); return } - self.eval_rect().await; - self.behave.apply_cursor_scroll().await; - //t!("is_text_changed={is_text_changed}, is_select_changed={is_select_changed}, is_compose_changed={is_compose_changed}"); // Only redraw once we have the parent_rect // Can happen when we receive an Android event before the canvas is ready @@ -1331,6 +1330,9 @@ impl BaseEdit { // Text changed - finish any active selection if is_text_changed || is_compose_changed { + self.eval_rect().await; + self.behave.apply_cursor_scroll().await; + self.pause_blinking(); //assert!(state.text != self.text.get()); self.finish_select(atom); @@ -1338,6 +1340,7 @@ impl BaseEdit { } else if is_select_changed { // Redrawing the entire text just for select changes is expensive self.redraw_cursor(atom.batch_id).await; + //t!("handle_android_event calling redraw_select"); self.redraw_select(atom.batch_id).await; } } @@ -1360,13 +1363,17 @@ impl UIObject for BaseEdit { fn init(&self) { let mut guard = self.editor.lock_blocking(); assert!(guard.is_none()); - *guard = Some(Editor::new( + let mut editor = Editor::new( self.text.clone(), self.font_size.clone(), self.text_color.clone(), self.window_scale.clone(), self.lineheight.clone(), - )); + ); + let atom = &mut PropertyAtomicGuard::none(); + self.text.set(atom, "the quick brown fox jumped over the"); + smol::block_on(editor.on_text_prop_changed()); + *guard = Some(editor); } async fn start(self: Arc, ex: ExecutorPtr) { @@ -1758,12 +1765,10 @@ impl UIObject for BaseEdit { return false } - let atom = &mut self.render_api.make_guard(gfxtag!("BaseEdit::handle_touch")); - match phase { TouchPhase::Started => self.handle_touch_start(touch_pos).await, TouchPhase::Moved => self.handle_touch_move(touch_pos).await, - TouchPhase::Ended => self.handle_touch_end(atom, touch_pos).await, + TouchPhase::Ended => self.handle_touch_end(touch_pos).await, TouchPhase::Cancelled => false, } } diff --git a/bin/app/src/ui/mod.rs b/bin/app/src/ui/mod.rs index d352bfef6..ffd233640 100644 --- a/bin/app/src/ui/mod.rs +++ b/bin/app/src/ui/mod.rs @@ -65,8 +65,10 @@ macro_rules! t { ($($arg:tt)*) => { trace!(target: "scene::on_modify", $($arg)*) pub trait UIObject: Sync { fn priority(&self) -> u32; + /// Called after schema and scenegraph is init but before miniquad starts. fn init(&self) {} + /// Done after miniquad has started and the first window draw has been done. async fn start(self: Arc, _ex: ExecutorPtr) {} /// Clear all buffers and caches From d131828285a332e01de336b2327daad4da7421dc Mon Sep 17 00:00:00 2001 From: jkds Date: Sat, 3 Jan 2026 11:22:03 +0100 Subject: [PATCH 11/12] app: reduce JNI overhead when adjusting a selection by creating a special setSelection() fn so we dont have to pass the entire state over the bridge. --- bin/app/java/textinput/InputConnection.java | 73 ++++++++++--------- .../src/android/textinput/gametextinput.rs | 20 ++++- bin/app/src/android/textinput/mod.rs | 18 +++++ bin/app/src/app/schema/chat.rs | 14 +++- bin/app/src/app/schema/mod.rs | 20 +++-- bin/app/src/text2/editor/android.rs | 9 ++- bin/app/src/ui/edit/mod.rs | 9 ++- 7 files changed, 110 insertions(+), 53 deletions(-) diff --git a/bin/app/java/textinput/InputConnection.java b/bin/app/java/textinput/InputConnection.java index 5f411d870..5b4d4929c 100644 --- a/bin/app/java/textinput/InputConnection.java +++ b/bin/app/java/textinput/InputConnection.java @@ -45,7 +45,6 @@ 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; private final View targetView; private final Settings settings; @@ -53,6 +52,10 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi private Listener listener; private boolean mSoftKeyboardActive; + private void log(String text) { + //Log.d("darkfi", text); + } + /* * 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 @@ -100,7 +103,7 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi */ public InputConnection(Context ctx, View targetView, Settings settings) { super(targetView, settings.mEditorInfo.inputType != 0); - Log.d(TAG, "InputConnection created"); + log("InputConnection created"); this.targetView = targetView; this.settings = settings; @@ -143,7 +146,7 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi * 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); + log("setSoftKeyboardActive, active: " + active); this.mSoftKeyboardActive = active; if (active) { @@ -171,7 +174,7 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi * @param editorInfo The EditorInfo to use */ public final void setEditorInfo(EditorInfo editorInfo) { - Log.d(TAG, "setEditorInfo"); + log("setEditorInfo"); settings.mEditorInfo = editorInfo; // Depending on the multiline state, we might need a different set of filters. @@ -195,7 +198,7 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi public final void setState(State state) { if (state == null) return; - Log.d(TAG, + log( "setState: '" + state.text + "', selection=(" + state.selectionStart + "," + state.selectionEnd + "), composing region=(" + state.composingRegionStart + "," + state.composingRegionEnd + ")"); @@ -232,7 +235,7 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi // From View.OnKeyListener @Override public boolean onKey(View view, int i, KeyEvent keyEvent) { - Log.d(TAG, "onKey: " + keyEvent); + log("onKey: " + keyEvent); if (!getSoftKeyboardActive()) { return false; } @@ -251,22 +254,22 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi // From BaseInputConnection @Override public Editable getEditable() { - Log.d(TAG, "getEditable"); + log("getEditable"); return mEditable; } // From BaseInputConnection @Override public boolean setSelection(int start, int end) { - Log.d(TAG, "setSelection: " + start + ":" + end); + log("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)); + log( + "setComposingText='" + text + "' newCursorPosition=" + newCursorPosition); if (text == null) { return false; } @@ -275,40 +278,40 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi @Override public boolean setComposingRegion(int start, int end) { - Log.d(TAG, "setComposingRegion: " + start + ":" + end); + log("setComposingRegion: " + start + ":" + end); return super.setComposingRegion(start, end); } // From BaseInputConnection @Override public boolean finishComposingText() { - Log.d(TAG, "finishComposingText"); + log("finishComposingText"); return super.finishComposingText(); } @Override public boolean endBatchEdit() { - Log.d(TAG, "endBatchEdit"); + log("endBatchEdit"); stateUpdated(); return super.endBatchEdit(); } @Override public boolean commitCompletion(CompletionInfo text) { - Log.d(TAG, "commitCompletion"); + log("commitCompletion"); return super.commitCompletion(text); } @Override public boolean commitCorrection(CorrectionInfo text) { - Log.d(TAG, "commitCompletion"); + log("commitCompletion"); return super.commitCorrection(text); } // From BaseInputConnection @Override public boolean commitText(CharSequence text, int newCursorPosition) { - Log.d(TAG, + log( (new StringBuilder()) .append("commitText: ") .append(text) @@ -321,21 +324,21 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi // From BaseInputConnection @Override public boolean deleteSurroundingText(int beforeLength, int afterLength) { - Log.d(TAG, "deleteSurroundingText: " + beforeLength + ":" + afterLength); + log("deleteSurroundingText: " + beforeLength + ":" + afterLength); return super.deleteSurroundingText(beforeLength, afterLength); } // From BaseInputConnection @Override public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) { - Log.d(TAG, "deleteSurroundingTextInCodePoints: " + beforeLength + ":" + afterLength); + log("deleteSurroundingTextInCodePoints: " + beforeLength + ":" + afterLength); return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength); } // From BaseInputConnection @Override public boolean sendKeyEvent(KeyEvent event) { - Log.d(TAG, "sendKeyEvent: " + event); + log("sendKeyEvent: " + event); return super.sendKeyEvent(event); } @@ -346,16 +349,16 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi if (result == null) { result = ""; } - Log.d(TAG, "getSelectedText: " + flags + ", result: " + result); + log("getSelectedText: " + flags + ", result: " + result); return result; } // From BaseInputConnection @Override public CharSequence getTextAfterCursor(int length, int flags) { - Log.d(TAG, "getTextAfterCursor: " + length + ":" + flags); + log("getTextAfterCursor: " + length + ":" + flags); if (length < 0) { - Log.i(TAG, "getTextAfterCursor: returning null to due to an invalid length=" + length); + Log.i("darkfi", "getTextAfterCursor: returning null to due to an invalid length=" + length); return null; } return super.getTextAfterCursor(length, flags); @@ -364,9 +367,9 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi // From BaseInputConnection @Override public CharSequence getTextBeforeCursor(int length, int flags) { - Log.d(TAG, "getTextBeforeCursor: " + length + ", flags=" + flags); + log("getTextBeforeCursor: " + length + ", flags=" + flags); if (length < 0) { - Log.i(TAG, "getTextBeforeCursor: returning null to due to an invalid length=" + length); + Log.i("darkfi", "getTextBeforeCursor: returning null to due to an invalid length=" + length); return null; } return super.getTextBeforeCursor(length, flags); @@ -375,39 +378,39 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi // From BaseInputConnection @Override public boolean requestCursorUpdates(int cursorUpdateMode) { - Log.d(TAG, "Request cursor updates: " + cursorUpdateMode); + log("Request cursor updates: " + cursorUpdateMode); return super.requestCursorUpdates(cursorUpdateMode); } // From BaseInputConnection @Override public void closeConnection() { - Log.d(TAG, "closeConnection"); + log("closeConnection"); super.closeConnection(); } @Override public boolean setImeConsumesInput(boolean imeConsumesInput) { - Log.d(TAG, "setImeConsumesInput: " + imeConsumesInput); + log("setImeConsumesInput: " + imeConsumesInput); return super.setImeConsumesInput(imeConsumesInput); } @Override public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) { - Log.d(TAG, "getExtractedText"); + log("getExtractedText"); return super.getExtractedText(request, flags); } @Override public boolean performPrivateCommand(String action, Bundle data) { - Log.d(TAG, "performPrivateCommand"); + log("performPrivateCommand"); return super.performPrivateCommand(action, data); } private void immUpdateSelection() { Pair selection = this.getSelection(); Pair cr = this.getComposingRegion(); - Log.d(TAG, + log( "immUpdateSelection: " + selection.first + "," + selection.second + ". " + cr.first + "," + cr.second); settings.mEditorInfo.initialSelStart = selection.first; @@ -428,8 +431,8 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi return false; } int keyCode = event.getKeyCode(); - Log.d( - TAG, String.format("processKeyEvent(key=%d) text=%s", keyCode, this.mEditable.toString())); + log( + "processKeyEvent(key=" + keyCode + ") text=" + 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) @@ -503,7 +506,7 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi } if (selection.first != selection.second) { - Log.d(TAG, String.format("processKeyEvent: deleting selection")); + log("processKeyEvent: deleting selection"); this.mEditable.delete(selection.first, selection.second); } @@ -525,7 +528,7 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi 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())); + log("processKeyEvent: exit, text=" + this.mEditable.toString()); return true; } @@ -594,7 +597,7 @@ public class InputConnection extends BaseInputConnection implements View.OnKeyLi */ @Override public boolean performEditorAction(int action) { - Log.d(TAG, "performEditorAction, action=" + action); + log("performEditorAction, action=" + action); if (action == IME_ACTION_UNSPECIFIED) { // Super emulates Enter key press/release return super.performEditorAction(action); diff --git a/bin/app/src/android/textinput/gametextinput.rs b/bin/app/src/android/textinput/gametextinput.rs index e3294c8f2..18481e918 100644 --- a/bin/app/src/android/textinput/gametextinput.rs +++ b/bin/app/src/android/textinput/gametextinput.rs @@ -173,8 +173,11 @@ impl GameTextInput { let mut new_focus = state.lock(); // Mark new state as active new_focus.is_active = true; + let new_state = new_focus.state.clone(); + drop(new_focus); + // Push changes to the Java side - self.push_update(&new_focus.state); + self.push_update(&new_state); } pub fn push_update(&self, state: &AndroidTextInputState) { @@ -192,6 +195,21 @@ impl GameTextInput { } } + pub fn set_select(&self, start: i32, end: i32) -> Result<(), ()> { + let Some(input_connection) = *self.input_connection.read() else { + w!("push_update() - no input_connection set"); + return Err(()) + }; + let is_success = unsafe { + let env = android::attach_jni_env(); + call_bool_method!(env, input_connection, "setSelection", "(II)Z", start, end) + }; + if is_success == 0u8 { + return Err(()) + } + Ok(()) + } + pub fn set_input_connection(&self, input_connection: ndk_sys::jobject) { unsafe { let env = android::attach_jni_env(); diff --git a/bin/app/src/android/textinput/mod.rs b/bin/app/src/android/textinput/mod.rs index b41c19c4f..bacd76b31 100644 --- a/bin/app/src/android/textinput/mod.rs +++ b/bin/app/src/android/textinput/mod.rs @@ -87,4 +87,22 @@ impl AndroidTextInput { gti.push_update(&state); } } + + pub fn set_select(&self, select_start: usize, select_end: usize) { + //t!("set_select({select_start}, {select_end})"); + // Always update our own state. + let mut ours = self.state.lock(); + let state = &mut ours.state; + assert!(select_start <= state.text.len()); + assert!(select_end <= state.text.len()); + state.select = (select_start, select_end); + let is_active = ours.is_active; + drop(ours); + + // Only update java state when this input is active + if is_active { + let gti = GAME_TEXT_INPUT.get().unwrap(); + gti.set_select(select_start as i32, select_end as i32).unwrap(); + } + } } diff --git a/bin/app/src/app/schema/chat.rs b/bin/app/src/app/schema/chat.rs index 17d82f0cf..f88e25234 100644 --- a/bin/app/src/app/schema/chat.rs +++ b/bin/app/src/app/schema/chat.rs @@ -1559,13 +1559,19 @@ pub async fn make( let atom = &mut render_api.make_guard(gfxtag!("edit select task")); if editz_select_text.is_null(0).unwrap() { info!(target: "app::chat", "selection changed: null"); - actions_is_visible.set(atom, false); - pasta_is_visible2.set(atom, false); + // Avoid triggering unecessary redraws + if actions_is_visible.get() { + actions_is_visible.set(atom, false); + pasta_is_visible2.set(atom, false); + } } else { let select_text = editz_select_text.get_str(0).unwrap(); info!(target: "app::chat", "selection changed: {select_text}"); - actions_is_visible.set(atom, true); - pasta_is_visible2.set(atom, false); + // Avoid triggering unecessary redraws + if !actions_is_visible.get() { + actions_is_visible.set(atom, true); + pasta_is_visible2.set(atom, false); + } } } }); diff --git a/bin/app/src/app/schema/mod.rs b/bin/app/src/app/schema/mod.rs index c73333f47..8bd265689 100644 --- a/bin/app/src/app/schema/mod.rs +++ b/bin/app/src/app/schema/mod.rs @@ -529,15 +529,23 @@ 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(), "dev", &db, i18n_fish, emoji_meshes.clone(), is_first_time) + for channel in CHANNELS { + chat::make( + app, + content.clone(), + channel, + &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(); } diff --git a/bin/app/src/text2/editor/android.rs b/bin/app/src/text2/editor/android.rs index 33f7d99ff..c0905565d 100644 --- a/bin/app/src/text2/editor/android.rs +++ b/bin/app/src/text2/editor/android.rs @@ -132,9 +132,10 @@ impl Editor { let cursor_idx = cursor.index(); t!(" move_to_pos: {cursor_idx}"); assert!(cursor_idx <= self.state.text.len()); + assert_eq!(self.state.text, self.text.get()); self.state.select = (cursor_idx, cursor_idx); self.state.compose = None; - self.input.set_state(self.state.clone()); + self.input.set_select(cursor_idx, cursor_idx); } pub async fn select_word_at_point(&mut self, pos: Point) { @@ -216,10 +217,12 @@ impl Editor { parley::Selection::new(anchor, focus) } pub async fn set_selection(&mut self, select_start: usize, select_end: usize) { - self.state.text = self.text.get(); + assert!(select_start <= self.state.text.len()); + assert!(select_end <= self.state.text.len()); + assert_eq!(self.state.text, self.text.get()); self.state.select = (select_start, select_end); self.state.compose = None; - self.input.set_state(self.state.clone()); + self.input.set_select(select_start, select_end); } #[allow(dead_code)] diff --git a/bin/app/src/ui/edit/mod.rs b/bin/app/src/ui/edit/mod.rs index 1411bb3f5..7e1947127 100644 --- a/bin/app/src/ui/edit/mod.rs +++ b/bin/app/src/ui/edit/mod.rs @@ -1363,16 +1363,17 @@ impl UIObject for BaseEdit { fn init(&self) { let mut guard = self.editor.lock_blocking(); assert!(guard.is_none()); - let mut editor = Editor::new( + let editor = Editor::new( self.text.clone(), self.font_size.clone(), self.text_color.clone(), self.window_scale.clone(), self.lineheight.clone(), ); - let atom = &mut PropertyAtomicGuard::none(); - self.text.set(atom, "the quick brown fox jumped over the"); - smol::block_on(editor.on_text_prop_changed()); + // For Android you can do this: + //let atom = &mut PropertyAtomicGuard::none(); + //self.text.set(atom, "the quick brown fox jumped over the"); + //smol::block_on(editor.on_text_prop_changed()); *guard = Some(editor); } From af086abc6113852113098b66711e63d52772efb4 Mon Sep 17 00:00:00 2001 From: jkds Date: Sun, 4 Jan 2026 05:11:38 +0100 Subject: [PATCH 12/12] app/edit: fix bug where android sends us spurious compose changes. Just drop them. --- bin/app/src/ui/edit/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/app/src/ui/edit/mod.rs b/bin/app/src/ui/edit/mod.rs index 7e1947127..2dbe0dfa4 100644 --- a/bin/app/src/ui/edit/mod.rs +++ b/bin/app/src/ui/edit/mod.rs @@ -1328,8 +1328,11 @@ impl BaseEdit { return } + // Not sure what to do if only compose changes lol + // For now just ignore it. + // Text changed - finish any active selection - if is_text_changed || is_compose_changed { + if is_text_changed { self.eval_rect().await; self.behave.apply_cursor_scroll().await;