From 2bdff5c8e83d930bfa0a3a01917f0f0abc826927 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Thu, 20 Aug 2026 18:24:43 -0400 Subject: [PATCH] Flush the session snapshot on activity onStop (Android) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/pad/impl_android.go | 76 ++++++++++++++++++++++++-------------- cmd/pad/impl_other.go | 15 ++++++-- cmd/pad/jni_android.c | 11 ++++++ cmd/pad/main.go | 1 + internal/editor/logic.go | 10 +++++ internal/editor/session.go | 35 ++++++++++++++++++ scripts/build_emu.sh | 25 ++++++++++++- scripts/build_phone.sh | 25 ++++++++++++- 8 files changed, 164 insertions(+), 34 deletions(-) diff --git a/cmd/pad/impl_android.go b/cmd/pad/impl_android.go index abe11bb..442cb5a 100644 --- a/cmd/pad/impl_android.go +++ b/cmd/pad/impl_android.go @@ -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)) } - diff --git a/cmd/pad/impl_other.go b/cmd/pad/impl_other.go index 9964d16..c6333cd 100644 --- a/cmd/pad/impl_other.go +++ b/cmd/pad/impl_other.go @@ -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 diff --git a/cmd/pad/jni_android.c b/cmd/pad/jni_android.c index ca270c3..3ca7b9f 100644 --- a/cmd/pad/jni_android.c +++ b/cmd/pad/jni_android.c @@ -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(); +} diff --git a/cmd/pad/main.go b/cmd/pad/main.go index a26c305..fced39d 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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 diff --git a/internal/editor/logic.go b/internal/editor/logic.go index 38a64da..88656fd 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -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 { diff --git a/internal/editor/session.go b/internal/editor/session.go index dd22344..41ce1eb 100644 --- a/internal/editor/session.go +++ b/internal/editor/session.go @@ -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 diff --git a/scripts/build_emu.sh b/scripts/build_emu.sh index 308f529..0fb2a26 100755 --- a/scripts/build_emu.sh +++ b/scripts/build_emu.sh @@ -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 ===" diff --git a/scripts/build_phone.sh b/scripts/build_phone.sh index 73e7a35..79fb282 100755 --- a/scripts/build_phone.sh +++ b/scripts/build_phone.sh @@ -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 ==="