Files
ink-notes/packaging/android/java/eu/pansi/inknotes/InkActivity.java
T
haraldandClaude Opus 5.5 0be321837c 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>
2026-10-27 21:23:52 +01:00

210 lines
7.3 KiB
Java

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;
}
}
}