Files
ink-notes/packaging/android/java/eu/pansi/inknotes/InkActivity.java
T
haraldandClaude Opus 5.5 3d6dbf09da fix(android): keep the toolbar out of the status and navigation bars (Issue #50)
NativeActivity draws the whole window, also below the system bars, so the
top toolbar button lay under the status bar and taps there went to
Android (no other notebook could be opened). InkActivity now reports the
system bar and cutout insets; the app shrinks egui's screen rect to the
free area and adds the side insets to the page centering. The page itself
still scrolls below the bars.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:32:43 +01:00

238 lines
8.6 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.Build;
import android.os.Bundle;
import android.provider.OpenableColumns;
import android.text.InputType;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
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.
* - System bars: NativeActivity draws the whole window, also below the status and navigation
* bars and display cutouts. Their sizes go to Rust, which keeps its buttons out of them
* (Issue #50).
*
* 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);
/** Window edges covered by system bars or cutouts, in pixels. */
static native void nativeSetInsets(int left, int top, int right, int bottom);
private TextSink textSink;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
textSink = new TextSink(this);
addContentView(textSink, new ViewGroup.LayoutParams(1, 1));
View decor = getWindow().getDecorView();
decor.setOnApplyWindowInsetsListener((view, insets) -> {
reportInsets(insets);
return view.onApplyWindowInsets(insets);
});
decor.requestApplyInsets();
}
@SuppressWarnings("deprecation")
private static void reportInsets(WindowInsets insets) {
if (Build.VERSION.SDK_INT >= 30) {
android.graphics.Insets bars = insets.getInsets(
WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout());
nativeSetInsets(bars.left, bars.top, bars.right, bars.bottom);
} else {
// Before Android 11 the system window insets include the bars (and since
// Android 9 the cutout)
nativeSetInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(),
insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom());
}
}
/** 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;
}
}
}