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
|
package main
|
||||||
|
|
||||||
|
|
@ -17,18 +18,25 @@ import (
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"gioui.org/app"
|
"gioui.org/app"
|
||||||
"gioui.org/io/event"
|
|
||||||
_ "gioui.org/app/permission/storage"
|
_ "gioui.org/app/permission/storage"
|
||||||
|
"gioui.org/io/event"
|
||||||
|
|
||||||
|
"pad/internal/editor"
|
||||||
)
|
)
|
||||||
|
|
||||||
type JNIEnv = C.JNIEnv
|
type JNIEnv = C.JNIEnv
|
||||||
|
|
||||||
var (
|
var (
|
||||||
startpath="/storage/emulated/0/Notes"
|
startpath = "/storage/emulated/0/Notes"
|
||||||
jvm uintptr
|
jvm uintptr
|
||||||
theJVM *C.JavaVM
|
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) {
|
func handleEvent(e event.Event) {
|
||||||
switch e := e.(type) {
|
switch e := e.(type) {
|
||||||
|
|
@ -68,6 +76,19 @@ func RunInJVM(f func(env *C.JNIEnv)) {
|
||||||
// an edge handle are not stolen by the system back gesture (see
|
// 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
|
// jni_android.c). It is called from the frame loop, which runs on the Android
|
||||||
// UI thread. No-op (C-side) before API 29.
|
// 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) {
|
func SetGestureExclusions(rects [][4]int) {
|
||||||
if theJVM == nil {
|
if theJVM == nil {
|
||||||
return
|
return
|
||||||
|
|
@ -116,4 +137,3 @@ func OpenFile(path string) {
|
||||||
C.open_file_in_termux(env, cpath)
|
C.open_file_in_termux(env, cpath)
|
||||||
C.free(unsafe.Pointer(cpath))
|
C.free(unsafe.Pointer(cpath))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
//+build !android
|
//go:build !android
|
||||||
|
// +build !android
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"pad/internal/editor"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gioui.org/io/event"
|
"gioui.org/io/event"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
startpath="."
|
startpath = "."
|
||||||
)
|
)
|
||||||
|
|
||||||
// sessionFilePath is where the relaunch session file (spec §7) lives, off
|
// 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")
|
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
|
// SetGestureExclusions is a no-op off Android (system gesture exclusion
|
||||||
// rects are an Android API 29+ feature).
|
// rects are an Android API 29+ feature).
|
||||||
func SetGestureExclusions(rects [][4]int) {}
|
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, runnable);
|
||||||
(*env)->DeleteLocalRef(env, list);
|
(*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)
|
log.Printf("using filesystem at / (startup directory: %s)", startAbs)
|
||||||
|
|
||||||
logic := editor.NewLogic(fs, startAbs, OpenFile)
|
logic := editor.NewLogic(fs, startAbs, OpenFile)
|
||||||
|
activeLogic = logic
|
||||||
renderer := ui.New(ui.Theme{FontSize: 14}, shaper)
|
renderer := ui.New(ui.Theme{FontSize: 14}, shaper)
|
||||||
|
|
||||||
// In-app frame profiler (default off). Enabled by the presence of a marker
|
// In-app frame profiler (default off). Enabled by the presence of a marker
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,10 @@ type Logic struct {
|
||||||
openFileChan chan string
|
openFileChan chan string
|
||||||
retryChan chan string // auto-save retries
|
retryChan chan string // auto-save retries
|
||||||
autosaveChan chan struct{} // auto-save debounce ticks (timer -> owner)
|
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
|
inspectChan chan *inspectReq
|
||||||
|
|
||||||
// Per-file write protocol (see requestSave). Workers are a shared pool and
|
// 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),
|
openFileChan: make(chan string),
|
||||||
retryChan: make(chan string, 1), // Buffered channel
|
retryChan: make(chan string, 1), // Buffered channel
|
||||||
autosaveChan: make(chan struct{}),
|
autosaveChan: make(chan struct{}),
|
||||||
|
flushSession: make(chan struct{}, 1),
|
||||||
inspectChan: make(chan *inspectReq),
|
inspectChan: make(chan *inspectReq),
|
||||||
writeInFlight: make(map[string]int),
|
writeInFlight: make(map[string]int),
|
||||||
savePending: make(map[string]bool),
|
savePending: make(map[string]bool),
|
||||||
|
|
@ -252,6 +257,11 @@ func (l *Logic) Run() {
|
||||||
// the final flush and promote a stale snapshot.
|
// the final flush and promote a stale snapshot.
|
||||||
l.drainWrites()
|
l.drainWrites()
|
||||||
return
|
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:
|
case update := <-l.configChan:
|
||||||
update.apply(l.state)
|
update.apply(l.state)
|
||||||
if _, ok := update.(ScaleEvent); ok {
|
if _, ok := update.(ScaleEvent); ok {
|
||||||
|
|
|
||||||
|
|
@ -120,11 +120,46 @@ func (l *Logic) saveSessionIfChanged() {
|
||||||
if !urgent && now.Sub(l.lastSessionSave) < sessionSaveInterval {
|
if !urgent && now.Sub(l.lastSessionSave) < sessionSaveInterval {
|
||||||
return
|
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.sessionSaver(s)
|
||||||
l.lastSession = s
|
l.lastSession = s
|
||||||
l.lastSessionSave = now
|
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
|
// BeginRestore prepares the state for relaunch restoration: the last file
|
||||||
// is re-opened and the editor page shown immediately. The cursor,
|
// is re-opened and the editor page shown immediately. The cursor,
|
||||||
// selection, find state and scroll land as the file's stat/content arrive
|
// 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:
|
if old2 not in t:
|
||||||
sys.exit("ERROR: GioActivity onCreate .locals anchor not found")
|
sys.exit("ERROR: GioActivity onCreate .locals anchor not found")
|
||||||
t = t.replace(old2, new2)
|
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)
|
p.write_text(t)
|
||||||
print("patched: GioActivity IME insets wiring")
|
print("patched: GioActivity IME insets wiring + onStop session flush")
|
||||||
PYEOF
|
PYEOF
|
||||||
|
|
||||||
echo "=== apktool rebuild ==="
|
echo "=== apktool rebuild ==="
|
||||||
|
|
|
||||||
|
|
@ -362,8 +362,31 @@ new2 = ".method public onCreate(Landroid/os/Bundle;)V\n .locals 3"
|
||||||
if old2 not in t:
|
if old2 not in t:
|
||||||
sys.exit("ERROR: GioActivity onCreate .locals anchor not found")
|
sys.exit("ERROR: GioActivity onCreate .locals anchor not found")
|
||||||
t = t.replace(old2, new2)
|
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)
|
p.write_text(t)
|
||||||
print("patched: GioActivity IME insets wiring")
|
print("patched: GioActivity IME insets wiring + onStop session flush")
|
||||||
PYEOF
|
PYEOF
|
||||||
|
|
||||||
echo "=== apktool rebuild ==="
|
echo "=== apktool rebuild ==="
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user