Pad/cmd/pad/jni_android.c
Greg Pomerantz fdbffc9f5c Fix SIGABRT on activity destroy: null view in registerFragment
Crash on device (Pixel 9 Pro, Android 17) when swiping the app away
from recents:

  JNI DETECTED ERROR: java_object == null in call to GetObjectClass
  #06 libgio.so (registerFragment+104)

Root cause: Gio's window.detach sends an EMPTY AndroidViewEvent
(View == 0) as its detach signal (os_android.go: window.detach ->
processEvent(AndroidViewEvent{})), which fires when the GioView is
destroyed — i.e. the activity going away on a recents-wipe. Our
handleEvent passed that null ref straight into registerFragment,
whose GetObjectClass(null) aborts. Pre-existing latent bug; the
emulator never delivered a detach event in testing (home keeps the
view attached, force-stop kills before the event dispatches).

Guard on both sides: handleEvent ignores the View == 0 detach
signal (a re-attach arrives as a fresh event with a live view), and
registerFragment returns early on a null view as defense in depth.

Verified on the crashing device: recents swipe now closes the app
cleanly, crash buffer empty.
2026-08-20 18:49:00 -04:00

209 lines
8.6 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) {
// Detach signal (Gio sends an empty AndroidViewEvent when the
// view is destroyed). The Go side already filters these; this
// guard keeps the JNI calls safe if one ever slips through.
return;
}
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);
}
// Java_org_gioui_GioActivity_padFlushSession
//
// Called from GioActivity.onStop (injected by the build script): persist
// the session snapshot before the process is likely killed. The Go side
// (pad_flush_session) only enqueues a request on the logic goroutine's
// channel, so this returns immediately on the UI thread.
void
Java_org_gioui_GioActivity_padFlushSession(JNIEnv *env, jclass cls) {
pad_flush_session();
}