diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index fec5c166f..5c367c876 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -406,6 +406,9 @@ type LocalBackend struct { // outgoingFiles keeps track of Taildrop outgoing files keyed to their OutgoingFile.ID outgoingFiles map[string]*ipn.OutgoingFile + // getSafFd gets the Storage Access Framework file descriptor for writing Taildrop files to + GetSafFd func(filename string) int32 + // lastSuggestedExitNode stores the last suggested exit node suggestion to // avoid unnecessary churn between multiple equally-good options. lastSuggestedExitNode tailcfg.StableNodeID @@ -5303,7 +5306,7 @@ func (b *LocalBackend) initPeerAPIListener() { Dir: fileRoot, DirectFileMode: b.directFileRoot != "", SendFileNotify: b.sendFileNotify, - }.New(), + }.New(b.getSafFd), } if dm, ok := b.sys.DNSManager.GetOK(); ok { ps.resolver = dm.Resolver() diff --git a/taildrop/send.go b/taildrop/send.go index 0dff71b24..27d881c18 100644 --- a/taildrop/send.go +++ b/taildrop/send.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "strings" "sync" "time" @@ -82,9 +83,17 @@ func (m *Manager) PutFile(id ClientID, baseName string, r io.Reader, offset, len case distro.Get() == distro.Unraid && !m.opts.DirectFileMode: return 0, ErrNotAccessible } - dstPath, err := joinDir(m.opts.Dir, baseName) - if err != nil { - return 0, err + var dstPath string + var err error + if !(m.opts.DirectFileMode && strings.HasPrefix(m.opts.Dir, "content://")) { + dstPath, err = joinDir(m.opts.Dir, baseName) + if err != nil { + return 0, err + } + } else { + // For Android which uses SAF mode, we can simply use the baseName as our destination "path" + // (since we won't be using traditional file paths). + dstPath = baseName } redactAndLogError := func(action string, err error) error { @@ -116,9 +125,21 @@ func (m *Manager) PutFile(id ClientID, baseName string, r io.Reader, offset, len m.deleter.Remove(filepath.Base(partialPath)) // avoid deleting the partial file while receiving // Create (if not already) the partial file with read-write permissions. - f, err := os.OpenFile(partialPath, os.O_CREATE|os.O_RDWR, 0666) - if err != nil { - return 0, redactAndLogError("Create", err) + var f *os.File + if m.opts.DirectFileMode && strings.HasPrefix(m.opts.Dir, "content://") { + // SAF mode: open the file using Android's SAF. + fd := m.GetSafFd(m.opts.Dir, baseName) + if err != nil { + return 0, redactAndLogError("Create (SAF)", err) + } + f = os.NewFile(uintptr(fd), baseName) + // In SAF mode, we don't have a traditional filesystem path; partialPath remains only for bookkeeping. + } else { + // Non-SAF (traditional filesystem) mode. + f, err = os.OpenFile(partialPath, os.O_CREATE|os.O_RDWR, 0666) + if err != nil { + return 0, redactAndLogError("Create", err) + } } defer func() { f.Close() // best-effort to cleanup dangling file handles diff --git a/taildrop/taildrop.go b/taildrop/taildrop.go index 4d14787af..58d1ee02e 100644 --- a/taildrop/taildrop.go +++ b/taildrop/taildrop.go @@ -101,6 +101,8 @@ type ManagerOptions struct { type Manager struct { opts ManagerOptions + getSafFd func() int + // incomingFiles is a map of files actively being received. incomingFiles syncs.Map[incomingFileKey, *incomingFile] // deleter managers asynchronous deletion of files. @@ -119,14 +121,14 @@ type Manager struct { // New initializes a new taildrop manager. // It may spawn asynchronous goroutines to delete files, // so the Shutdown method must be called for resource cleanup. -func (opts ManagerOptions) New() *Manager { +func (opts ManagerOptions) New(getSafFd func() int) *Manager { if opts.Logf == nil { opts.Logf = logger.Discard } if opts.SendFileNotify == nil { opts.SendFileNotify = func() {} } - m := &Manager{opts: opts} + m := &Manager{opts: opts, getSafFd: getSafFd} m.deleter.Init(m, func(string) {}) m.emptySince.Store(-1) // invalidate this cache return m