Flush the session snapshot on activity onStop (Android)

The OS provides no user-space hook for a process kill, but the
activity onStop fires on every 'going away' transition the framework
still controls: entering recents (the swipe-wipe path), app switch,
and home. Recents-wipe then kills the process right after onStop
returns, so that moment is the last reliable flush.

- Logic.FlushSession (any goroutine, buffered, non-blocking) ->
  flushSessionSave on the owner: persist the snapshot now, bypassing
  the rate limit (still honoring the restore-pending suppression).
  saveSessionIfChanged's persist tail is deduplicated into writeSession.
- JNI: GioActivity.onStop (patched into the smali by the build
  scripts, in sync) now calls the static native padFlushSession, which
  maps to the pad_flush_session cgo export.

Verified on emulator: the flush fires on home/app-switch and when the
app is backgrounded into recents before a kill; a state change made
inside the 250ms rate window and then backed out of the app persists
the latest position. A hard kill while foregrounded (force-stop,
memory pressure) still has no hook — the immediate edit saves plus the
250ms rate window bound that loss.
This commit is contained in:
Greg Pomerantz 2026-08-20 18:24:43 -04:00
parent 2eb18b5c70
commit 2bdff5c8e8
8 changed files with 164 additions and 34 deletions

View File

@ -1,4 +1,5 @@
//+build !darwin !linux
//go:build !darwin || !linux
// +build !darwin !linux
package main
@ -17,18 +18,25 @@ import (
"unsafe"
"gioui.org/app"
"gioui.org/io/event"
_ "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
startpath = "/storage/emulated/0/Notes"
jvm uintptr
theJVM *C.JavaVM
)
func impl_start() { }
// 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) {
@ -41,26 +49,26 @@ func handleEvent(e event.Event) {
}
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
}
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)
if detach {
defer func() {
C.DetachCurrentThread(theJVM)
}()
}
f(env)
}
// SetGestureExclusions forwards the selection-handle grab boxes (view-local
@ -68,6 +76,19 @@ func RunInJVM(f func(env *C.JNIEnv)) {
// 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
@ -97,14 +118,14 @@ func sessionFilePath() string {
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.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
detach = true
}
if detach {
@ -116,4 +137,3 @@ func OpenFile(path string) {
C.open_file_in_termux(env, cpath)
C.free(unsafe.Pointer(cpath))
}

View File

@ -1,16 +1,18 @@
//+build !android
//go:build !android
// +build !android
package main
import (
"os"
"pad/internal/editor"
"path/filepath"
"gioui.org/io/event"
)
var (
startpath="."
startpath = "."
)
// sessionFilePath is where the relaunch session file (spec §7) lives, off
@ -23,10 +25,15 @@ func sessionFilePath() string {
return filepath.Join(os.TempDir(), "pad", "session.json")
}
func handleEvent(e event.Event) { }
func handleEvent(e event.Event) {}
func OpenFile(path string) { }
func OpenFile(path string) {}
// SetGestureExclusions is a no-op off Android (system gesture exclusion
// rects are an Android API 29+ feature).
func SetGestureExclusions(rects [][4]int) {}
// activeLogic exists on the Android build (the onStop JNI hook, see
// impl_android.go); on other platforms it is unused but main assigns it.
var activeLogic *editor.Logic
var _ = activeLogic // keep staticcheck quiet on non-Android builds

View File

@ -191,3 +191,14 @@ void SetGestureExclusions(JNIEnv *env, jint nrects, const jint *xyxy) {
(*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();
}

View File

@ -60,6 +60,7 @@ func run(w *app.Window) error {
log.Printf("using filesystem at / (startup directory: %s)", startAbs)
logic := editor.NewLogic(fs, startAbs, OpenFile)
activeLogic = logic
renderer := ui.New(ui.Theme{FontSize: 14}, shaper)
// In-app frame profiler (default off). Enabled by the presence of a marker

View File

@ -73,6 +73,10 @@ type Logic struct {
openFileChan chan string
retryChan chan string // auto-save retries
autosaveChan chan struct{} // auto-save debounce ticks (timer -> owner)
// flushSession: one-shot request to persist the session snapshot now
// (the OS activity onStop hook, see FlushSession). Buffered 1 so the
// requester never blocks, even if an earlier flush is still queued.
flushSession chan struct{}
inspectChan chan *inspectReq
// Per-file write protocol (see requestSave). Workers are a shared pool and
@ -156,6 +160,7 @@ func NewLogic(mfs pool.FileSystem, path string, openfunc func(string)) *Logic {
openFileChan: make(chan string),
retryChan: make(chan string, 1), // Buffered channel
autosaveChan: make(chan struct{}),
flushSession: make(chan struct{}, 1),
inspectChan: make(chan *inspectReq),
writeInFlight: make(map[string]int),
savePending: make(map[string]bool),
@ -252,6 +257,11 @@ func (l *Logic) Run() {
// the final flush and promote a stale snapshot.
l.drainWrites()
return
case <-l.flushSession:
// Persist now, bypassing the rate limit: the activity is going
// away (recents-wipe or app switch) and the process may die
// shortly after this returns.
l.flushSessionSave()
case update := <-l.configChan:
update.apply(l.state)
if _, ok := update.(ScaleEvent); ok {

View File

@ -120,11 +120,46 @@ func (l *Logic) saveSessionIfChanged() {
if !urgent && now.Sub(l.lastSessionSave) < sessionSaveInterval {
return
}
l.writeSession(s, now)
}
// flushSessionSave persists the current snapshot now, bypassing the rate
// limit (still honoring the restore-pending suppression: a pre-land
// snapshot would clobber the positions being restored). Must be called on
// the logic goroutine.
func (l *Logic) flushSessionSave() {
if l.sessionSaver == nil || l.session.File != "" {
return
}
s := l.SnapshotSession()
if s == l.lastSession {
return
}
l.writeSession(s, time.Now())
}
// writeSession is the shared persist tail: hand the snapshot to the cmd
// layer's saver and record it as the new baseline. Must be called on the
// logic goroutine.
func (l *Logic) writeSession(s SessionState, now time.Time) {
l.sessionSaver(s)
l.lastSession = s
l.lastSessionSave = now
}
// FlushSession requests an immediate session persist from any goroutine.
// The Android activity's onStop (recents-wipe, app switch) calls it via
// JNI: the OS gives no user-space hook for the subsequent process kill,
// so onStop is the last reliable moment to flush. Buffered delivery means
// the caller never blocks; if an earlier flush is still queued, one
// snapshot is coalesced into it (the state is the same frame's anyway).
func (l *Logic) FlushSession() {
select {
case l.flushSession <- struct{}{}:
default:
}
}
// BeginRestore prepares the state for relaunch restoration: the last file
// is re-opened and the editor page shown immediately. The cursor,
// selection, find state and scroll land as the file's stat/content arrive

View File

@ -363,8 +363,31 @@ new2 = ".method public onCreate(Landroid/os/Bundle;)V\n .locals 3"
if old2 not in t:
sys.exit("ERROR: GioActivity onCreate .locals anchor not found")
t = t.replace(old2, new2)
# 3. onStop hook: persist the session snapshot the moment the activity goes
# away (recents-wipe, app switch). The OS provides no user-space hook for
# the process kill that follows, so this is the last reliable flush. Go
# side: pad_flush_session (impl_android.go) -> Logic.FlushSession.
old3 = """.method public onStop()V
.locals 1
.line 47
iget-object v0, p0, Lorg/gioui/GioActivity;->view:Lorg/gioui/GioView;"""
new3 = """.method public static native padFlushSession()V
.end method
.method public onStop()V
.locals 1
# Pad: persist the session snapshot before the activity goes away.
invoke-static {}, Lorg/gioui/GioActivity;->padFlushSession()V
.line 47
iget-object v0, p0, Lorg/gioui/GioActivity;->view:Lorg/gioui/GioView;"""
if old3 not in t:
sys.exit("ERROR: GioActivity onStop anchor not found")
t = t.replace(old3, new3)
p.write_text(t)
print("patched: GioActivity IME insets wiring")
print("patched: GioActivity IME insets wiring + onStop session flush")
PYEOF
echo "=== apktool rebuild ==="

View File

@ -362,8 +362,31 @@ new2 = ".method public onCreate(Landroid/os/Bundle;)V\n .locals 3"
if old2 not in t:
sys.exit("ERROR: GioActivity onCreate .locals anchor not found")
t = t.replace(old2, new2)
# 3. onStop hook: persist the session snapshot the moment the activity goes
# away (recents-wipe, app switch). The OS provides no user-space hook for
# the process kill that follows, so this is the last reliable flush. Go
# side: pad_flush_session (impl_android.go) -> Logic.FlushSession.
old3 = """.method public onStop()V
.locals 1
.line 47
iget-object v0, p0, Lorg/gioui/GioActivity;->view:Lorg/gioui/GioView;"""
new3 = """.method public static native padFlushSession()V
.end method
.method public onStop()V
.locals 1
# Pad: persist the session snapshot before the activity goes away.
invoke-static {}, Lorg/gioui/GioActivity;->padFlushSession()V
.line 47
iget-object v0, p0, Lorg/gioui/GioActivity;->view:Lorg/gioui/GioView;"""
if old3 not in t:
sys.exit("ERROR: GioActivity onStop anchor not found")
t = t.replace(old3, new3)
p.write_text(t)
print("patched: GioActivity IME insets wiring")
print("patched: GioActivity IME insets wiring + onStop session flush")
PYEOF
echo "=== apktool rebuild ==="