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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user