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:
parent
2eb18b5c70
commit
2bdff5c8e8
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ==="
|
||||
|
|
|
|||
|
|
@ -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 ==="
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user