feat(android): Java activity for keyboard text and the system file dialog (Issue #42)
InkActivity extends NativeActivity: - An invisible view with its own input connection receives the soft keyboard's text (commitText, deleteSurroundingText), so characters without a key event (umlauts by long press, ...) arrive as well. The field is a visible-password field, so keyboards commit each character instead of composing words. - Import and "insert image" open the system file dialog (ACTION_OPEN_DOCUMENT); the chosen file is copied to the cache folder and handed to Rust by path. src/android_bridge.rs calls the activity through JNI and receives its native calls; package-android.sh compiles the class with javac and d8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a8aef30db7
commit
0be321837c
10 files changed
+472
-20
No files matched your search
+1
-1
@@ -156,7 +156,7 @@ Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets --
|
||||
|
||||
Andere Systeme:
|
||||
- **Windows** wird unter Linux übersetzt: `scripts/package-windows.sh` (braucht `gcc-mingw-w64-x86-64`); die Tests laufen mit `wine` als Runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
|
||||
- **Android** (experimentell, Issue #42): `scripts/package-android.sh` baut `target/ink-notes_<version>_arm64.apk` ohne Gradle. Nötig sind das Android-SDK (build-tools, Plattform 34; `ANDROID_HOME`, Standard `~/android-sdk`) und das NDK; der Signierschlüssel kommt aus `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`, sonst wird der Debug-Schlüssel verwendet. Installieren mit `adb install -r target/ink-notes_<version>_arm64.apk`, Log-Ausgaben mit `adb logcat -s ink-notes`. Der Stift wird von Fingern unterschieden (Stifttasten, Radierer-Ende, Handballen-Erkennung), Textfelder öffnen die Bildschirmtastatur (Zeichen, die die Tastatur als Text überträgt, z. B. per langem Druck gewählte Umlaute, kommen noch nicht an), und Exporte landen in `Download/ink-notes` (ab Android 10). Dateien importieren geht noch nicht.
|
||||
- **Android** (experimentell, Issue #42): `scripts/package-android.sh` baut `target/ink-notes_<version>_arm64.apk` ohne Gradle. Nötig sind das Android-SDK (build-tools, Plattform 34; `ANDROID_HOME`, Standard `~/android-sdk`) und das NDK; der Signierschlüssel kommt aus `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`, sonst wird der Debug-Schlüssel verwendet. Installieren mit `adb install -r target/ink-notes_<version>_arm64.apk`, Log-Ausgaben mit `adb logcat -s ink-notes`. Der Stift wird von Fingern unterschieden (Stifttasten, Radierer-Ende, Handballen-Erkennung), Textfelder öffnen die Bildschirmtastatur, Import und „Bild einfügen“ nutzen den System-Dateidialog, und Exporte landen in `Download/ink-notes` (ab Android 10). Tastaturtext und Dateidialog kommen von der kleinen Java-Activity `packaging/android/java/…/InkActivity.java` (zum Bauen ist ein JDK nötig).
|
||||
|
||||
### Projektstruktur
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ CI additionally checks `cargo fmt --check` and `cargo clippy --all-targets -- -D
|
||||
|
||||
Other systems:
|
||||
- **Windows** is cross-compiled on Linux: `scripts/package-windows.sh` (needs `gcc-mingw-w64-x86-64`); the tests run with `wine` as runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
|
||||
- **Android** (experimental, Issue #42): `scripts/package-android.sh` builds `target/ink-notes_<version>_arm64.apk` without Gradle. It needs the Android SDK (build-tools, platform 34; `ANDROID_HOME`, default `~/android-sdk`) and the NDK; the signing key comes from `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`, otherwise the debug key is used. Install with `adb install -r target/ink-notes_<version>_arm64.apk`, log output with `adb logcat -s ink-notes`. The pen is told apart from fingers (pen buttons, eraser end, palm rejection), text fields open the soft keyboard (characters the keyboard commits as text, e.g. umlauts chosen by long press, don't arrive yet), and exports are saved to `Download/ink-notes` (Android 10 and newer). Importing files is still missing.
|
||||
- **Android** (experimental, Issue #42): `scripts/package-android.sh` builds `target/ink-notes_<version>_arm64.apk` without Gradle. It needs the Android SDK (build-tools, platform 34; `ANDROID_HOME`, default `~/android-sdk`) and the NDK; the signing key comes from `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`, otherwise the debug key is used. Install with `adb install -r target/ink-notes_<version>_arm64.apk`, log output with `adb logcat -s ink-notes`. The pen is told apart from fingers (pen buttons, eraser end, palm rejection), text fields open the soft keyboard, import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). The small Java activity `packaging/android/java/…/InkActivity.java` (needs a JDK to build) provides keyboard text and the file dialog.
|
||||
|
||||
New code comments and commit messages are written in English; older code and history still contain German.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Manifest of the Android app (Issue #42). scripts/package-android.sh fills in the version.
|
||||
NativeActivity loads libink_notes.so, whose android_main runs the app. -->
|
||||
InkActivity (packaging/android/java) extends NativeActivity, which loads libink_notes.so;
|
||||
its android_main runs the app. -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="eu.pansi.inknotes"
|
||||
android:versionCode="@VERSION_CODE@"
|
||||
@@ -11,13 +12,13 @@
|
||||
<application
|
||||
android:label="ink-notes"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:hasCode="false"
|
||||
android:hasCode="true"
|
||||
android:extractNativeLibs="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<!-- configChanges: rotating the tablet or attaching a keyboard must not restart the
|
||||
activity; the app adapts to the new window size itself -->
|
||||
<activity
|
||||
android:name="android.app.NativeActivity"
|
||||
android:name="eu.pansi.inknotes.InkActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|density|uiMode|fontScale|locale|layoutDirection">
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package eu.pansi.inknotes;
|
||||
|
||||
import android.app.NativeActivity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.text.InputType;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.BaseInputConnection;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* The app's activity (Issue #42): NativeActivity runs the Rust code (android_main in
|
||||
* libink_notes.so); this class adds what NativeActivity lacks.
|
||||
*
|
||||
* - Text input: NativeActivity's view offers keyboards no input connection, so they can only
|
||||
* send key events, and characters without a key (umlauts chosen by long press, ...) got lost.
|
||||
* An invisible view with its own input connection passes committed text to Rust.
|
||||
* - Picking files: the system file dialog (ACTION_OPEN_DOCUMENT); the chosen file is copied to
|
||||
* the cache folder, as Rust can only open files by path.
|
||||
*
|
||||
* Rust calls showKeyboard and pickFile through JNI and receives the native* calls
|
||||
* (src/android_bridge.rs).
|
||||
*/
|
||||
public class InkActivity extends NativeActivity {
|
||||
static {
|
||||
// NativeActivity loads the library too, but without System.loadLibrary the native
|
||||
// methods below would not be found
|
||||
System.loadLibrary("ink_notes");
|
||||
}
|
||||
|
||||
private static final int PICK_FILE = 1;
|
||||
|
||||
static native void nativeCommitText(String text);
|
||||
|
||||
static native void nativeDeleteSurroundingText(int before, int after);
|
||||
|
||||
/** Path of the copied file, or null if nothing was chosen or copying failed. */
|
||||
static native void nativeFilePicked(String path);
|
||||
|
||||
private TextSink textSink;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
textSink = new TextSink(this);
|
||||
addContentView(textSink, new ViewGroup.LayoutParams(1, 1));
|
||||
}
|
||||
|
||||
/** Shows or hides the soft keyboard; called from Rust on any thread. */
|
||||
public void showKeyboard(final boolean show) {
|
||||
runOnUiThread(() -> {
|
||||
InputMethodManager imm =
|
||||
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
if (show) {
|
||||
textSink.requestFocus();
|
||||
imm.restartInput(textSink);
|
||||
imm.showSoftInput(textSink, 0);
|
||||
} else {
|
||||
imm.hideSoftInputFromWindow(textSink.getWindowToken(), 0);
|
||||
textSink.clearFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens the system file dialog for files of this MIME type ("image/*", "*" + "/*"). */
|
||||
public void pickFile(final String mimeType) {
|
||||
runOnUiThread(() -> {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType(mimeType);
|
||||
try {
|
||||
startActivityForResult(intent, PICK_FILE);
|
||||
} catch (Exception e) {
|
||||
nativeFilePicked(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode != PICK_FILE) {
|
||||
return;
|
||||
}
|
||||
final Uri uri = (resultCode == RESULT_OK && data != null) ? data.getData() : null;
|
||||
if (uri == null) {
|
||||
nativeFilePicked(null);
|
||||
return;
|
||||
}
|
||||
// Copying a large file must not block the UI thread
|
||||
new Thread(() -> {
|
||||
String path = null;
|
||||
try {
|
||||
path = copyToCache(uri);
|
||||
} catch (Exception e) {
|
||||
android.util.Log.w("ink-notes", "copying the picked file failed", e);
|
||||
}
|
||||
nativeFilePicked(path);
|
||||
}).start();
|
||||
}
|
||||
|
||||
private String copyToCache(Uri uri) throws Exception {
|
||||
String name = "import";
|
||||
try (Cursor cursor = getContentResolver().query(
|
||||
uri, new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst() && !cursor.isNull(0)) {
|
||||
name = cursor.getString(0).replace('/', '_');
|
||||
}
|
||||
}
|
||||
File dir = new File(getCacheDir(), "picked");
|
||||
File[] old = dir.listFiles();
|
||||
if (old != null) {
|
||||
for (File f : old) {
|
||||
f.delete();
|
||||
}
|
||||
}
|
||||
dir.mkdirs();
|
||||
File target = new File(dir, name);
|
||||
try (InputStream in = getContentResolver().openInputStream(uri);
|
||||
OutputStream out = new FileOutputStream(target)) {
|
||||
byte[] buffer = new byte[64 * 1024];
|
||||
int n;
|
||||
while ((n = in.read(buffer)) > 0) {
|
||||
out.write(buffer, 0, n);
|
||||
}
|
||||
}
|
||||
return target.getAbsolutePath();
|
||||
}
|
||||
|
||||
/** Invisible view that receives the keyboard's text. */
|
||||
private static class TextSink extends View {
|
||||
TextSink(Context context) {
|
||||
super(context);
|
||||
setFocusable(true);
|
||||
setFocusableInTouchMode(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCheckIsTextEditor() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
|
||||
// Visible password: keyboards commit every character right away instead of
|
||||
// composing words with suggestions, which the app could not show
|
||||
outAttrs.inputType = InputType.TYPE_CLASS_TEXT
|
||||
| InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
||||
| InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||
| InputType.TYPE_TEXT_FLAG_MULTI_LINE;
|
||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI
|
||||
| EditorInfo.IME_FLAG_NO_FULLSCREEN
|
||||
| EditorInfo.IME_ACTION_NONE;
|
||||
return new TextConnection(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** Passes committed text and deletions to Rust; key events go the usual way. */
|
||||
private static class TextConnection extends BaseInputConnection {
|
||||
private String composing = "";
|
||||
|
||||
TextConnection(View view) {
|
||||
super(view, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean commitText(CharSequence text, int newCursorPosition) {
|
||||
composing = "";
|
||||
if (text.length() > 0) {
|
||||
nativeCommitText(text.toString());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean setComposingText(CharSequence text, int newCursorPosition) {
|
||||
// Not shown while composing; taken over once finished
|
||||
composing = text.toString();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean finishComposingText() {
|
||||
if (!composing.isEmpty()) {
|
||||
nativeCommitText(composing);
|
||||
composing = "";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
|
||||
nativeDeleteSurroundingText(beforeLength, afterLength);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Builds the Android app target/ink-notes_<version>_arm64.apk (Issues #42, #45)
|
||||
# without Gradle: the Rust library is built as cdylib with the NDK, aapt2 links the
|
||||
# manifest and the icon, zipalign and apksigner finish the APK.
|
||||
# without Gradle: the Rust library is built as cdylib with the NDK, javac and d8 compile the
|
||||
# activity (packaging/android/java), aapt2 links the manifest and the icon, zipalign and
|
||||
# apksigner finish the APK. Needs a JDK (javac, keytool).
|
||||
#
|
||||
# Needs the Android SDK (build-tools, platform android-34) and the NDK:
|
||||
# ANDROID_HOME SDK folder (default: ~/android-sdk)
|
||||
@@ -35,12 +36,16 @@ BUILD_TOOLS=$(ls -d "${ANDROID_HOME}"/build-tools/*/ 2>/dev/null | sort -V | tai
|
||||
ANDROID_JAR="${ANDROID_HOME}/platforms/android-${TARGET_SDK}/android.jar"
|
||||
NDK_BIN="${ANDROID_NDK_HOME%/}/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
|
||||
for needed in "${NDK_BIN}/${TARGET}${MIN_SDK}-clang" "${BUILD_TOOLS}aapt2" "${ANDROID_JAR}"; do
|
||||
for needed in "${NDK_BIN}/${TARGET}${MIN_SDK}-clang" "${BUILD_TOOLS}aapt2" "${BUILD_TOOLS}d8" "${ANDROID_JAR}"; do
|
||||
if [ ! -e "${needed}" ]; then
|
||||
echo "FEHLER: ${needed} nicht gefunden (Android-SDK/NDK, siehe Kopf des Skripts)."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! command -v javac >/dev/null 2>&1; then
|
||||
echo "FEHLER: javac nicht gefunden (JDK 17 oder neuer)."
|
||||
exit 1
|
||||
fi
|
||||
if command -v rustup >/dev/null 2>&1; then
|
||||
rustup target add "${TARGET}"
|
||||
fi
|
||||
@@ -55,7 +60,7 @@ LIB="${CARGO_TARGET_DIR:-target}/${TARGET}/release/libink_notes.so"
|
||||
echo "=== [2/4] Ressourcen und Manifest ==="
|
||||
WORK="target/android-apk"
|
||||
rm -rf "${WORK}"
|
||||
mkdir -p "${WORK}/res/mipmap-xxxhdpi" "${WORK}/compiled" "${WORK}/apk/lib/${ABI}"
|
||||
mkdir -p "${WORK}/res/mipmap-xxxhdpi" "${WORK}/compiled" "${WORK}/apk/lib/${ABI}" "${WORK}/classes"
|
||||
cp assets/ink-notes.png "${WORK}/res/mipmap-xxxhdpi/ic_launcher.png"
|
||||
sed -e "s/@VERSION_CODE@/${VERSION_CODE}/" -e "s/@VERSION@/${VERSION}/" \
|
||||
packaging/android/AndroidManifest.xml >"${WORK}/AndroidManifest.xml"
|
||||
@@ -65,9 +70,13 @@ sed -e "s/@VERSION_CODE@/${VERSION_CODE}/" -e "s/@VERSION@/${VERSION}/" \
|
||||
--min-sdk-version "${MIN_SDK}" --target-sdk-version "${TARGET_SDK}" \
|
||||
"${WORK}/compiled/res.zip"
|
||||
|
||||
echo "=== [3/4] Native Bibliothek hinzufügen ==="
|
||||
echo "=== [3/4] Activity und native Bibliothek hinzufügen ==="
|
||||
javac -source 8 -target 8 -nowarn -Xlint:-options -classpath "${ANDROID_JAR}" \
|
||||
-d "${WORK}/classes" $(find packaging/android/java -name '*.java')
|
||||
"${BUILD_TOOLS}d8" --min-api "${MIN_SDK}" --lib "${ANDROID_JAR}" --output "${WORK}/apk" \
|
||||
$(find "${WORK}/classes" -name '*.class')
|
||||
cp "${LIB}" "${WORK}/apk/lib/${ABI}/"
|
||||
(cd "${WORK}/apk" && zip -q -r ../unaligned.apk lib)
|
||||
(cd "${WORK}/apk" && zip -q -r ../unaligned.apk classes.dex lib)
|
||||
"${BUILD_TOOLS}zipalign" -f -p 4 "${WORK}/unaligned.apk" "${WORK}/aligned.apk"
|
||||
|
||||
echo "=== [4/4] Signieren ==="
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Android: calls into the app's Java activity and what it reports back (Issue #42).
|
||||
//!
|
||||
//! `packaging/android/java/eu/pansi/inknotes/InkActivity.java` extends NativeActivity with
|
||||
//! text input through an input connection (so that keyboards can send any character) and the
|
||||
//! system file dialog. Rust calls its methods through JNI; it calls the `native*` functions
|
||||
//! below, possibly on another thread, so they only queue messages and wake the event loop.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
use jni::sys::jint;
|
||||
use jni::JNIEnv;
|
||||
|
||||
use crate::file_picker::PickerKind;
|
||||
use crate::ui::UiAction;
|
||||
|
||||
enum Message {
|
||||
Text(String),
|
||||
Delete { before: u32, after: u32 },
|
||||
Picked(Option<PathBuf>),
|
||||
}
|
||||
|
||||
static MESSAGES: Mutex<Vec<Message>> = Mutex::new(Vec::new());
|
||||
/// What the open file dialog is for
|
||||
static PICKING: Mutex<Option<PickerKind>> = Mutex::new(None);
|
||||
|
||||
fn push(message: Message) {
|
||||
MESSAGES
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(message);
|
||||
if let Some(app) = crate::platform::android_app() {
|
||||
app.create_waker().wake();
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeCommitText(
|
||||
mut env: JNIEnv,
|
||||
_class: JObject,
|
||||
text: JString,
|
||||
) {
|
||||
if let Ok(text) = env.get_string(&text) {
|
||||
push(Message::Text(text.into()));
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeDeleteSurroundingText(
|
||||
_env: JNIEnv,
|
||||
_class: JObject,
|
||||
before: jint,
|
||||
after: jint,
|
||||
) {
|
||||
push(Message::Delete {
|
||||
before: before.max(0) as u32,
|
||||
after: after.max(0) as u32,
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeFilePicked(
|
||||
mut env: JNIEnv,
|
||||
_class: JObject,
|
||||
path: JString,
|
||||
) {
|
||||
let path = if path.is_null() {
|
||||
None
|
||||
} else {
|
||||
env.get_string(&path)
|
||||
.ok()
|
||||
.map(|p| PathBuf::from(String::from(p)))
|
||||
};
|
||||
push(Message::Picked(path));
|
||||
}
|
||||
|
||||
/// Is a message waiting (then the app draws a new frame to take it)?
|
||||
pub fn has_messages() -> bool {
|
||||
!MESSAGES
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
/// Typed text and deletions as egui input, and the action for a picked file.
|
||||
pub fn take_messages() -> (Vec<egui::Event>, Vec<UiAction>) {
|
||||
let messages = std::mem::take(&mut *MESSAGES.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
let mut events = Vec::new();
|
||||
let mut actions = Vec::new();
|
||||
for message in messages {
|
||||
match message {
|
||||
Message::Text(text) => events.extend(crate::osk::committed_text_events(&text)),
|
||||
Message::Delete { before, after } => {
|
||||
events.extend(crate::osk::deletion_key_events(before, after))
|
||||
}
|
||||
Message::Picked(path) => {
|
||||
let kind = PICKING.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||
match (kind, path) {
|
||||
(Some(PickerKind::Image), Some(path)) => {
|
||||
actions.push(UiAction::InsertImageFile(path))
|
||||
}
|
||||
(Some(PickerKind::Import), Some(path)) => {
|
||||
actions.push(UiAction::ImportFile(path))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(events, actions)
|
||||
}
|
||||
|
||||
/// Runs `f` with the JNI environment and the activity; local references it creates are
|
||||
/// freed afterwards.
|
||||
fn with_activity(
|
||||
f: impl FnOnce(&mut JNIEnv, &JObject) -> jni::errors::Result<()>,
|
||||
) -> Result<(), String> {
|
||||
let app = crate::platform::android_app().ok_or("no activity")?;
|
||||
// Safety: pointers to the VM and the activity (a global reference) that live as long as
|
||||
// the app
|
||||
let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) }.map_err(|e| e.to_string())?;
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
|
||||
let result = env.with_local_frame(16, |env| f(env, &activity));
|
||||
if env.exception_check().unwrap_or(false) {
|
||||
let _ = env.exception_describe();
|
||||
let _ = env.exception_clear();
|
||||
}
|
||||
result.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn show_keyboard(show: bool) {
|
||||
let result = with_activity(|env, activity| {
|
||||
env.call_method(
|
||||
activity,
|
||||
"showKeyboard",
|
||||
"(Z)V",
|
||||
&[JValue::Bool(show.into())],
|
||||
)
|
||||
.map(|_| ())
|
||||
});
|
||||
if let Err(err) = result {
|
||||
log::warn!("showKeyboard: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the system file dialog instead of the app's own; false if that is not possible
|
||||
/// (e.g. for choosing a folder).
|
||||
pub fn pick_file(kind: PickerKind) -> bool {
|
||||
let mime_type = match kind {
|
||||
PickerKind::Image => "image/*",
|
||||
// Xournal++, Rnote and Saber files have no MIME type of their own
|
||||
PickerKind::Import => "*/*",
|
||||
PickerKind::Folder => return false,
|
||||
};
|
||||
*PICKING.lock().unwrap_or_else(|e| e.into_inner()) = Some(kind);
|
||||
let result = with_activity(|env, activity| {
|
||||
let mime = env.new_string(mime_type)?;
|
||||
env.call_method(
|
||||
activity,
|
||||
"pickFile",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::Object(&mime)],
|
||||
)
|
||||
.map(|_| ())
|
||||
});
|
||||
match result {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
log::warn!("pickFile: {err}");
|
||||
*PICKING.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -1664,6 +1664,10 @@ pub fn run_with(
|
||||
window.request_redraw();
|
||||
}
|
||||
Event::AboutToWait => {
|
||||
#[cfg(target_os = "android")]
|
||||
if crate::android_bridge::has_messages() {
|
||||
window.request_redraw();
|
||||
}
|
||||
let prev_status = autosave.status();
|
||||
let new_status = autosave.update();
|
||||
if new_status != prev_status {
|
||||
@@ -4037,6 +4041,13 @@ pub fn run_with(
|
||||
if let Some(requester) = keyboard_requester.as_mut() {
|
||||
raw_input.events.extend(requester.take_deletions());
|
||||
}
|
||||
// Android: text from the keyboard and files from the system dialog
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let (events, actions) = crate::android_bridge::take_messages();
|
||||
raw_input.events.extend(events);
|
||||
ui_state.queued_actions.extend(actions);
|
||||
}
|
||||
egui_ctx.begin_frame(raw_input);
|
||||
|
||||
// 2. Auswahl-Overlay rendern (Bounding Box, Griffe, Lasso)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! ink-notes: Latenzoptimierte Inking-App für Linux.
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android_bridge;
|
||||
pub mod app;
|
||||
pub mod assets;
|
||||
pub mod autosave;
|
||||
|
||||
+46
-9
@@ -17,30 +17,29 @@ mod wayland;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use wayland::KeyboardRequester;
|
||||
|
||||
/// Android: NativeActivity shows the soft keyboard only when asked to (Issue #42). The typed
|
||||
/// characters arrive as key events. Characters the keyboard can't send as key events (e.g.
|
||||
/// umlauts chosen by long press, which it commits as text) don't arrive.
|
||||
/// Android: the soft keyboard is shown only when asked to (Issue #42). Its text comes through
|
||||
/// the activity's input connection (`android_bridge`); keys like Enter or Backspace may also
|
||||
/// arrive as key events through winit.
|
||||
#[cfg(target_os = "android")]
|
||||
pub struct KeyboardRequester {
|
||||
app: winit::platform::android::activity::AndroidApp,
|
||||
}
|
||||
pub struct KeyboardRequester;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl KeyboardRequester {
|
||||
/// # Safety
|
||||
/// Nothing to uphold; `unsafe` like the Wayland version.
|
||||
pub unsafe fn for_window(_window: &winit::window::Window) -> Option<Self> {
|
||||
crate::platform::android_app().map(|app| Self { app })
|
||||
Some(Self)
|
||||
}
|
||||
|
||||
pub fn request(&mut self) {
|
||||
self.app.show_soft_input(false);
|
||||
crate::android_bridge::show_keyboard(true);
|
||||
}
|
||||
|
||||
pub fn release(&mut self) {
|
||||
self.app.hide_soft_input(false);
|
||||
crate::android_bridge::show_keyboard(false);
|
||||
}
|
||||
|
||||
/// Typed text and deletions; taking them is left to the app's frame (see `app.rs`)
|
||||
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -106,6 +105,30 @@ pub fn deletion_key_events(back: u32, forward: u32) -> Vec<egui::Event> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// egui input for committed text: line breaks become the Enter key, as egui's text fields
|
||||
/// take them only that way.
|
||||
pub fn committed_text_events(text: &str) -> Vec<egui::Event> {
|
||||
let mut events = Vec::new();
|
||||
for (i, line) in text.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
for pressed in [true, false] {
|
||||
events.push(egui::Event::Key {
|
||||
key: egui::Key::Enter,
|
||||
physical_key: None,
|
||||
pressed,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
}
|
||||
}
|
||||
let line = line.trim_end_matches('\r');
|
||||
if !line.is_empty() {
|
||||
events.push(egui::Event::Text(line.to_string()));
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// Entscheidet, ob die Bildschirmtastatur angefordert wird: sobald ein Textfeld den Fokus
|
||||
/// bekommt und erneut bei jedem Antippen des fokussierten Felds (falls die Tastatur inzwischen
|
||||
/// zugeklappt wurde).
|
||||
@@ -196,6 +219,20 @@ mod tests {
|
||||
assert!(deletion_key_events(0, 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_line_breaks_become_enter() {
|
||||
let events = committed_text_events("ab\nc\n");
|
||||
let kinds: Vec<String> = events
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
egui::Event::Text(t) => t.clone(),
|
||||
egui::Event::Key { pressed: true, .. } => "⏎".into(),
|
||||
_ => "-".into(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(kinds, ["ab", "⏎", "-", "c", "⏎", "-"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_request_without_focused_text_field() {
|
||||
assert!(!should_request(false, false, true));
|
||||
|
||||
@@ -690,6 +690,11 @@ impl UiState {
|
||||
fn open_file_picker(&mut self, kind: PickerKind) {
|
||||
let dir = self.last_picker_dir.clone();
|
||||
self.close_overlays();
|
||||
// Android: the app can't look into other apps' folders; the system dialog can
|
||||
#[cfg(target_os = "android")]
|
||||
if crate::android_bridge::pick_file(kind) {
|
||||
return;
|
||||
}
|
||||
self.file_picker = Some(FilePicker::open(kind, dir.as_deref()));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user