Pad/cmd/pad/jni_android.c
Greg Pomerantz 275a78efaa Fix selection-handle drags, menu anchoring, and left-edge back-gesture theft
Three user-reported selection bugs, one root cause each:

1. Start handle ungrabbable at line start. Two interacting causes:
   a) The 48dp grab box straddles two visual lines; a finger in the
      lower half mapped (by y-to-line) to the neighbouring line, whose
      byte past the other handle clamped to a zero-length selection ->
      cleared on the first drag event. The cleared selection
      un-registered the drag op, so the router silently stopped
      delivering drag events (the observed 'stream cutoff'). Fix:
      handle drags now project the finger's x onto the anchor's own
      visual line (visualLineOfByte + textPosOnLineAtX); the anchor
      never crosses lines during a handle drag.
   b) A horizontal flick from the line-start handle (screen x~26px)
      started the system back gesture, which cancelled the touch
      stream. Fix: report the handle grab rects as system gesture
      exclusion rects (setSystemGestureExclusionRects, API 29+),
      marshalled to the UI thread via a PadExcl smali Runnable
      (generated identically by build_emu.sh/build_phone.sh).

2. End-handle drag downward made the menu chase the finger and cover
   the selection. Fix: the menu anchors to the STABLE end of the
   selection (the end not being dragged), so it stays parked by the
   selection start, clear of the finger and the highlighted text.

3. Menu above the selection vanished permanently when the selection
   was extended onto the top line. Fix: off-window anchors no longer
   hide the menu while any part of the selection is visible (keep-last
   rect, clamped); hiding happens only for fully off-window selections.

Also: registerDrag simplified (single shared drag path, body before
handles in z-order), debug logging removed, regression tests
(mutation-verified) for line projection and menu anchoring, docs
section 17. Verified on device: start-handle drag shrinks the word
without clearing or triggering back navigation; end-handle vertical
drag leaves menu/highlight/handles undisturbed; menu stays visible
with the selection at the top line.
2026-08-18 13:09:29 -04:00

194 lines
8.0 KiB
C

#include <stdlib.h>
#include <stdio.h>
#include <jni.h>
#include <string.h>
#include "jni_android.h"
#include "_cgo_export.h"
// Global context
static jobject ctx = NULL;
// g_view: global ref to the GioView, set in registerFragment. Used by
// SetGestureExclusions to reach View.setSystemGestureExclusionRects.
static jobject g_view = NULL;
void
registerFragment(JNIEnv *env, jobject view) {
if (view != NULL) {
if (g_view == NULL) {
g_view = (*env)->NewGlobalRef(env, view);
} else if (g_view != view) {
(*env)->DeleteGlobalRef(env, g_view);
g_view = (*env)->NewGlobalRef(env, view);
}
}
jclass cls = (*env)->GetObjectClass(env, view);
jmethodID mid = (*env)->GetMethodID(env, cls, "getContext", "()Landroid/content/Context;");
jobject l_ctx = (*env)->CallObjectMethod(env, view, mid);
ctx = (*env)->NewGlobalRef(env, l_ctx);
cls = (*env)->GetObjectClass(env, ctx);
mid = (*env)->GetMethodID(env, cls, "getClassLoader", "()Ljava/lang/ClassLoader;");
jobject loader = (*env)->CallObjectMethod(env, ctx, mid);
cls = (*env)->GetObjectClass(env, loader);
mid = (*env)->GetMethodID(env, cls, "findClass", "(Ljava/lang/String;)Ljava/lang/Class;");
jstring str = (*env)->NewStringUTF(env, "st/wow/git/logbook/Permissions");
cls = (*env)->CallObjectMethod(env, loader, mid, str);
mid = (*env)->GetMethodID(env, cls, "<init>", "(Landroid/view/View;)V");
jobject inst = (*env)->NewObject(env, cls, mid, view);
}
void CallVoidMethod(JNIEnv *env, jobject obj, jmethodID methodID) {
(*env)->CallVoidMethod(env, obj, methodID);
}
jint GetEnv(JavaVM *vm, JNIEnv **env, jint version) {
return (*vm)->GetEnv(vm, (void **)env, version);
}
jint AttachCurrentThread(JavaVM *vm, JNIEnv **p_env, void *thr_args) {
return (*vm)->AttachCurrentThread(vm, p_env, thr_args);
}
jint DetachCurrentThread(JavaVM *vm) {
return (*vm)->DetachCurrentThread(vm);
}
jobject
NewGlobalRef(JNIEnv *env, jobject o) {
return (*env)->NewGlobalRef(env, o);
}
// Open file in Termux's file editor via ACTION_SEND share intent.
// env: JNIEnv* from your JNI callback
// path: absolute filesystem path to the file (e.g. "/storage/emulated/0/foo.txt")
void open_file_in_termux(JNIEnv *env, const char *path) {
if (!ctx) return;
jclass intentClass = (*env)->FindClass(env, "android/content/Intent");
if (!intentClass) return;
// new Intent("android.intent.action.SEND")
jmethodID intentInit = (*env)->GetMethodID(env, intentClass, "<init>", "(Ljava/lang/String;)V");
if (!intentInit) return;
jstring actionSend = (*env)->NewStringUTF(env, "android.intent.action.SEND");
jobject intent = (*env)->NewObject(env, intentClass, intentInit, actionSend);
if (!intent) return;
// intent.setType("text/plain");
jmethodID setType = (*env)->GetMethodID(env, intentClass,
"setType", "(Ljava/lang/String;)Landroid/content/Intent;");
if (!setType) return;
jstring type = (*env)->NewStringUTF(env, "text/plain");
(*env)->CallObjectMethod(env, intent, setType, type);
// Uri uri = Uri.parse("file://" + path);
jclass uriClass = (*env)->FindClass(env, "android/net/Uri");
if (!uriClass) return;
jmethodID uriParse = (*env)->GetStaticMethodID(env, uriClass,
"parse", "(Ljava/lang/String;)Landroid/net/Uri;");
if (!uriParse) return;
// Build "file://" + path
size_t uriLen = strlen("file://") + strlen(path) + 1;
char *uriStr = (char *)malloc(uriLen);
if (!uriStr) return;
strcpy(uriStr, "file://");
strcat(uriStr, path);
jstring uriJstr = (*env)->NewStringUTF(env, uriStr);
jobject uri = (*env)->CallStaticObjectMethod(env, uriClass, uriParse, uriJstr);
free(uriStr);
if (!uri) return;
// intent.setData(uri);
jmethodID setData = (*env)->GetMethodID(env, intentClass,
"setData", "(Landroid/net/Uri;)Landroid/content/Intent;");
if (!setData) return;
(*env)->CallObjectMethod(env, intent, setData, uri);
// intent.putExtra("android.intent.extra.STREAM", uri.toString());
// Use putExtra(String, String)
jmethodID putExtra = (*env)->GetMethodID(env, intentClass,
"putExtra", "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;");
if (!putExtra) return;
jstring extraKey = (*env)->NewStringUTF(env, "android.intent.extra.STREAM");
(*env)->CallObjectMethod(env, intent, putExtra, extraKey, uriJstr);
// ctx.startActivity(intent);
jclass contextClass = (*env)->GetObjectClass(env, ctx);
jmethodID startActivity = (*env)->GetMethodID(env, contextClass,
"startActivity", "(Landroid/content/Intent;)V");
if (!startActivity) return;
(*env)->CallVoidMethod(env, ctx, startActivity, intent);
}
// Set the exclusion rects ON THE UI THREAD. This function is called from the
// Go frame loop, which runs on its own OS thread (NOT the Android UI thread
// — the JNI event pump merely enqueues events that the app goroutine drains).
// setSystemGestureExclusionRects stores the rects on the view and triggers a
// relayout; it is the RELAYOUT that reports them to the window manager, which
// owns the edge back-gesture zone. A direct cross-thread call leaves the
// window manager unaware, so the rects are applied through
// view.post(PadExcl) — a Runnable (org.gioui.PadExcl, added by the build
// scripts' smali patch) that runs on the UI thread.
void SetGestureExclusions(JNIEnv *env, jint nrects, const jint *xyxy) {
if (g_view == NULL) return;
jclass verCls = (*env)->FindClass(env, "android/os/Build$VERSION");
if (verCls == NULL) return;
jfieldID sdkF = (*env)->GetStaticFieldID(env, verCls, "SDK_INT", "I");
if (sdkF == NULL) return;
if ((*env)->GetStaticIntField(env, verCls, sdkF) < 29) return;
jclass rectCls = (*env)->FindClass(env, "android/graphics/Rect");
jmethodID rectInit = (*env)->GetMethodID(env, rectCls, "<init>", "(IIII)V");
jclass listCls = (*env)->FindClass(env, "java/util/ArrayList");
jmethodID listInit = (*env)->GetMethodID(env, listCls, "<init>", "()V");
jobject list = (*env)->NewObject(env, listCls, listInit);
if (list == NULL) return;
jmethodID addM = (*env)->GetMethodID(env, listCls, "add", "(Ljava/lang/Object;)Z");
for (int i = 0; i < nrects; i++) {
jobject rect = (*env)->NewObject(env, rectCls, rectInit,
xyxy[i * 4], xyxy[i * 4 + 1], xyxy[i * 4 + 2], xyxy[i * 4 + 3]);
if (rect == NULL) continue;
(*env)->CallBooleanMethod(env, list, addM, rect);
(*env)->DeleteLocalRef(env, rect);
}
// Find the app class through the context's class loader. A bare
// FindClass from this (non-Java) thread resolves against the BOOT
// class loader only and would never find org.gioui.PadExcl.
jclass ctxCls = (*env)->GetObjectClass(env, ctx);
jmethodID gclM = (*env)->GetMethodID(env, ctxCls, "getClassLoader", "()Ljava/lang/ClassLoader;");
jobject loader = (*env)->CallObjectMethod(env, ctx, gclM);
jclass loaderCls = (*env)->GetObjectClass(env, loader);
jmethodID fcM = (*env)->GetMethodID(env, loaderCls, "findClass", "(Ljava/lang/String;)Ljava/lang/Class;");
jstring clsName = (*env)->NewStringUTF(env, "org.gioui.PadExcl");
jclass padExclCls = (jclass)(*env)->CallObjectMethod(env, loader, fcM, clsName);
if (padExclCls == NULL) {
(*env)->ExceptionClear(env);
(*env)->DeleteLocalRef(env, list);
return;
}
jmethodID padExclInit = (*env)->GetMethodID(env, padExclCls, "<init>", "(Landroid/view/View;Ljava/util/List;)V");
jobject runnable = (*env)->NewObject(env, padExclCls, padExclInit, g_view, list);
if (runnable == NULL) {
(*env)->ExceptionClear(env);
(*env)->DeleteLocalRef(env, list);
return;
}
jmethodID postM = (*env)->GetMethodID(env, (*env)->GetObjectClass(env, g_view),
"post", "(Ljava/lang/Runnable;)Z");
(void)(*env)->CallBooleanMethod(env, g_view, postM, runnable);
(*env)->ExceptionClear(env);
(*env)->DeleteLocalRef(env, runnable);
(*env)->DeleteLocalRef(env, list);
}