Pad/cmd/pad/impl_android.go
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

149 lines
3.7 KiB
Go

//go:build !darwin || !linux
// +build !darwin !linux
package main
/*
#cgo LDFLAGS: -landroid -llog
#include <stdlib.h>
#include "jni_android.h"
*/
import "C"
import (
"errors"
"fmt"
"runtime"
"unsafe"
"gioui.org/app"
_ "gioui.org/app/permission/storage"
"gioui.org/io/event"
"pad/internal/editor"
)
type JNIEnv = C.JNIEnv
var (
startpath = "/storage/emulated/0/Notes"
jvm uintptr
theJVM *C.JavaVM
)
// activeLogic is set in main before the window loop: the onStop JNI hook
// (pad_flush_session, below) needs a handle to the logic it serves.
var activeLogic *editor.Logic
func impl_start() {}
func handleEvent(e event.Event) {
switch e := e.(type) {
case app.AndroidViewEvent:
// View == 0 is Gio's DETACH signal (window.detach sends an empty
// AndroidViewEvent when the GioView is destroyed — e.g. the activity
// going away on a recents-wipe). There is nothing to register for a
// detached view; passing the null ref on would abort in JNI
// (GetObjectClass on null). A re-attach arrives as a fresh event
// with a live view.
if e.View == 0 {
return
}
theJVM = (*C.JavaVM)(unsafe.Pointer(app.JavaVM()))
RunInJVM(func(env *JNIEnv) {
C.registerFragment(env, (C.jobject)(unsafe.Pointer(e.View)))
})
}
}
func RunInJVM(f func(env *C.JNIEnv)) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
var env *C.JNIEnv
var detach bool
if res := C.GetEnv(theJVM, &env, C.JNI_VERSION_1_6); res != C.JNI_OK {
if res != C.JNI_EDETACHED {
panic(fmt.Errorf("JNI GetEnv failed with error %d", res))
}
if C.AttachCurrentThread(theJVM, &env, nil) != C.JNI_OK {
panic(errors.New("runInJVM: AttachCurrentThread failed"))
}
detach = true
}
if detach {
defer func() {
C.DetachCurrentThread(theJVM)
}()
}
f(env)
}
// SetGestureExclusions forwards the selection-handle grab boxes (view-local
// px) to View.setSystemGestureExclusionRects on Android so drags starting on
// an edge handle are not stolen by the system back gesture (see
// jni_android.c). It is called from the frame loop, which runs on the Android
// UI thread. No-op (C-side) before API 29.
// pad_flush_session is the cgo export behind GioActivity.onStop (the
// build script patches onStop into the smali and declares the matching
// static native method). The OS gives no user-space hook for the process
// kill that follows a recents-wipe, so onStop is the last reliable moment
// to persist the session snapshot.
//
//export pad_flush_session
func pad_flush_session() {
if activeLogic != nil {
activeLogic.FlushSession()
}
}
func SetGestureExclusions(rects [][4]int) {
if theJVM == nil {
return
}
RunInJVM(func(env *C.JNIEnv) {
if len(rects) == 0 {
C.SetGestureExclusions(env, 0, nil)
return
}
buf := make([]C.jint, len(rects)*4)
for i, rc := range rects {
for j, v := range rc {
buf[i*4+j] = C.jint(v)
}
}
C.SetGestureExclusions(env, C.jint(len(rects)), &buf[0])
})
}
// sessionFilePath is where the relaunch session file (spec §7) lives. The
// primary storage dir already carries the app's caches (.pad browser index
// dirs, PadPerf), and the app holds the storage permission.
func sessionFilePath() string {
return "/storage/emulated/0/Pad/session.json"
}
func OpenFile(path string) {
var env *C.JNIEnv
var detach bool
if res := C.GetEnv(theJVM, &env, C.JNI_VERSION_1_6); res != C.JNI_OK {
if res != C.JNI_EDETACHED {
panic(fmt.Errorf("JNI GetEnv failed with error %d", res))
}
if C.AttachCurrentThread(theJVM, &env, nil) != C.JNI_OK {
panic(errors.New("OpenFile: AttachCurrentThread failed"))
}
detach = true
}
if detach {
defer func() {
C.DetachCurrentThread(theJVM)
}()
}
cpath := C.CString(path)
C.open_file_in_termux(env, cpath)
C.free(unsafe.Pointer(cpath))
}