The renderer kept a mirror of the pushed IME snippet (the 'IME model') to translate commit positions, but it transiently desynced from the buffer on fling/tap sequences (observed as a few-byte mapping drift on both the x86_64 emulator and the ARM phone), corrupting text. The model string also sat on the main goroutine next to the JNI render path, where the app observed states that were impossible for Go memory (string contents changing between reads microseconds apart), pointing at corruption in the native bridge layer. Restructure along the lines of the Android InputConnection contract and Gio's own reference editor (widget/editor.go): - Commits carry absolute file runes (the pushed snippet's coordinate space) straight to the logic goroutine, which maps them to bytes against the WHOLE buffer (runeToByteWhole, an 8 KiB-step scan). Scrolling moves the window, not the buffer, so the mapping is exact mid-fling by construction — no mirror to desync. - Drift guard in HandleIMECommit: a small commit (range <= 2 runes) is always anchored at the caret the IME was last told about; if the IME reports it ending elsewhere, its snippet text is stale (a dropped restartInput, as Gboard does during flings) and its position is in the stale text's coordinates — snap the commit to the cursor, the only position it cannot drift from. - FlushIME simplifies to: push the snippet when the frame's (context+window) text differs from the last push (gioui dedupes against its own cache), force the selection re-push in the same frame. After a commit the frame text equals what the IME already holds locally, so the restart is naturally suppressed; a fling re-anchors the IME once per text change. - Remove the renderer model (adoptFrame/ModelTranslate/ ApplyIMEEdit/ApplyIMEKey/IMECaret), the IME freeze/settle machinery (IMEFrozen, markIMEScrollActive, imeSettleChan), and the window-relative imeRuneToByte. Also fixed along the way (both found while chasing the corruption): - real.ReadFileAt: loop over short reads. A single ReadAt on Android FUSE can return a short read, silently truncating a chunk and shifting every byte offset after it. - logic: a late lazy-chunk result no longer clobbers a buffer that SetContent has already fully loaded. - e2e: large-file IME test (1.6 MB file, fling + commit). - app icon (scripts/make_icon.py + cmd/pad/appicon.png) so gogio builds the mipmap/adaptive icon set. Verified: go vet + staticcheck, go test -race (all packages), and the emulator scenario loop (open moby excerpt, fling to mid-file, tap, type 'a', byte-compare the saved file) 75/75 clean.
187 lines
6.0 KiB
Go
187 lines
6.0 KiB
Go
package real
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"pad/internal/io/pool/types"
|
|
)
|
|
|
|
// RealFileSystem implements the pool.FileSystem interface using the real OS filesystem.
|
|
type RealFileSystem struct {
|
|
WorkingDir string
|
|
}
|
|
|
|
// NewRealFileSystem creates a RealFileSystem rooted at the given working directory.
|
|
// If workingDir is empty, it defaults to "/".
|
|
func NewRealFileSystem(workingDir string) *RealFileSystem {
|
|
if workingDir == "" {
|
|
workingDir = "/"
|
|
}
|
|
abs, err := filepath.Abs(workingDir)
|
|
if err != nil {
|
|
abs = workingDir
|
|
}
|
|
return &RealFileSystem{WorkingDir: abs}
|
|
}
|
|
|
|
func (fs *RealFileSystem) ReadDir(path string) ([]types.DirEntry, error) {
|
|
entries, err := os.ReadDir(filepath.Join(fs.WorkingDir, path))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var res []types.DirEntry
|
|
for _, e := range entries {
|
|
res = append(res, &realDirEntry{e})
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (fs *RealFileSystem) DirExists(path string) bool {
|
|
info, err := os.Stat(filepath.Join(fs.WorkingDir, path))
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
func (fs *RealFileSystem) FileExists(path string) bool {
|
|
info, err := os.Stat(filepath.Join(fs.WorkingDir, path))
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
func (fs *RealFileSystem) ReadFile(path string) ([]byte, error) {
|
|
return os.ReadFile(filepath.Join(fs.WorkingDir, path))
|
|
}
|
|
|
|
func (fs *RealFileSystem) ReadFileAt(path string, offset, size int) ([]byte, error) {
|
|
fullPath := filepath.Join(fs.WorkingDir, path)
|
|
f, err := os.Open(fullPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
buf := make([]byte, size)
|
|
// ReadAt may return a SHORT read with a nil or io.EOF error (POSIX
|
|
// allows it; the Android FUSE layer does it), so loop until the whole
|
|
// range is read or the file truly ends. A silently short result would
|
|
// truncate a chunk and shift every byte offset after it — taps and IME
|
|
// commits would then land at the wrong buffer position (text corruption).
|
|
total := 0
|
|
for total < size {
|
|
n, err := f.ReadAt(buf[total:], int64(offset)+int64(total))
|
|
total += n
|
|
if err != nil {
|
|
if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) {
|
|
break
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf[:total], nil
|
|
}
|
|
|
|
func (fs *RealFileSystem) WriteFile(path string, content []byte) error {
|
|
// Simple write for backward compatibility if needed,
|
|
// but defer to Atomic implementation.
|
|
return fs.WriteFileAtomic(path, content)
|
|
}
|
|
|
|
// tmpSeq numbers the per-call temp files so two writes never share a staging
|
|
// path. Combined with the owner-side per-file write serialization this makes
|
|
// same-file write interleaving structurally impossible.
|
|
var tmpSeq atomic.Uint64
|
|
|
|
func (fs *RealFileSystem) WriteFileAtomic(path string, content []byte) error {
|
|
// Atomic write implementation
|
|
fullPath := filepath.Join(fs.WorkingDir, path)
|
|
// Temp file in the same directory as the target to ensure same filesystem
|
|
// rename. Unique per call (".<name>.tmp.<pid>.<seq>"): even a concurrent
|
|
// write or a crashed process's leftover cannot interleave with this one
|
|
// on the staging file, and rename promotes only this call's complete
|
|
// content.
|
|
base := filepath.Base(fullPath)
|
|
tmpPath := filepath.Join(filepath.Dir(fullPath),
|
|
"."+base+".tmp."+strconv.Itoa(os.Getpid())+"."+strconv.FormatUint(tmpSeq.Add(1), 10))
|
|
|
|
if err := os.MkdirAll(filepath.Dir(tmpPath), 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.WriteFile(tmpPath, content, 0644); err != nil {
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
|
|
fs.removeStaleTemps(filepath.Dir(fullPath), base, tmpPath)
|
|
|
|
if err := os.Rename(tmpPath, fullPath); err != nil {
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
|
|
// The Android shared-storage layer (FUSE backed by MediaProvider
|
|
// metadata) re-serves the PREVIOUS file's mtime after a rename: its
|
|
// media-scan DB row for the path is not updated when the staging file
|
|
// is renamed into place (logcat: "Database update failed while
|
|
// renaming"). Change detectors that combine inotify events with a
|
|
// stat compare (syncthing) then treat the new content as unmodified
|
|
// and skip it until the next full rescan — up to an hour of "edited
|
|
// but not synced" on a phone. Setting the timestamps explicitly after
|
|
// the rename publishes the current mtime through the FUSE layer, and
|
|
// the timestamp update is the event the watcher acts on immediately
|
|
// (verified on device: a plain rename's mtime is reverted to the old
|
|
// file's, while utimensat sticks and triggers the sync at once).
|
|
if err := os.Chtimes(fullPath, time.Now(), time.Now()); err != nil {
|
|
// The content is already safely in place; the timestamps are a
|
|
// best-effort hint for external change detectors, not data.
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// removeStaleTemps best-effort removes leftover staging files for the same
|
|
// file: any ".<base>.tmp.<pid>.<seq>" that is not the caller's own temp, plus
|
|
// the legacy deterministic name ".<base>.tmp" from older app versions. A
|
|
// live write never owns either: same-file writes are serialized by the logic
|
|
// owner, and a live temp always carries the caller's own pid/seq (excluded
|
|
// via keepTmp). Errors are ignored.
|
|
func (fs *RealFileSystem) removeStaleTemps(dir, base, keepTmp string) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
keep := filepath.Base(keepTmp)
|
|
prefix := "." + base + ".tmp."
|
|
legacy := "." + base + ".tmp"
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if name == keep {
|
|
continue // this call's own live temp file
|
|
}
|
|
if name == legacy || strings.HasPrefix(name, prefix) {
|
|
os.Remove(filepath.Join(dir, name))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (fs *RealFileSystem) DeleteFile(path string) error {
|
|
return os.Remove(filepath.Join(fs.WorkingDir, path))
|
|
}
|
|
|
|
func (fs *RealFileSystem) CreateDir(path string) error {
|
|
return os.MkdirAll(filepath.Join(fs.WorkingDir, path), 0755)
|
|
}
|
|
|
|
// realDirEntry wraps os.DirEntry
|
|
type realDirEntry struct {
|
|
entry os.DirEntry
|
|
}
|
|
|
|
func (e *realDirEntry) Name() string { return e.entry.Name() }
|
|
func (e *realDirEntry) IsDir() bool { return e.entry.IsDir() }
|
|
func (e *realDirEntry) Info() (os.FileInfo, error) { return e.entry.Info() }
|