From d33b7451c64bb7b121c4075bd171d47c4bac5802 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Tue, 5 Aug 2025 10:39:46 -0700 Subject: [PATCH] android: expand SAF FileOps implementation (#675) * android: expand SAF FileOps implementation This expands the SAF FileOps to implement the refactored FileOps Updates tailscale/corp#29211 Signed-off-by: kari-ts (cherry picked from commit e71641a4227e4c457c6b15318d52575640bd7d59) Signed-off-by: kari-ts Signed-off-by: Jonathan Nobels --- .../ipn/ui/util/OutputStreamAdapter.kt | 1 + .../tailscale/ipn/ui/view/UserSwitcherView.kt | 2 +- .../com/tailscale/ipn/util/ShareFileHelper.kt | 308 +++++++++++++----- android/src/main/res/values/strings.xml | 13 + libtailscale/backend.go | 2 +- libtailscale/fileops.go | 98 +++++- libtailscale/interfaces.go | 38 ++- libtailscale/localapi.go | 21 -- libtailscale/streamutil.go | 36 ++ 9 files changed, 390 insertions(+), 129 deletions(-) create mode 100644 libtailscale/streamutil.go diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt index 9e73a42..2a9c2b2 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt @@ -24,3 +24,4 @@ class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale outputStream.close() } } + diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt index 6fb76a6..a672ae4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt @@ -51,7 +51,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi val currentUser by viewModel.loggedInUser.collectAsState() val showHeaderMenu by viewModel.showHeaderMenu.collectAsState() - Scaffold( + Scaffold( topBar = { Header( R.string.accounts, diff --git a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt index fed568d..a15636b 100644 --- a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -5,9 +5,14 @@ package com.tailscale.ipn.util import android.content.Context import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile +import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.OutputStreamAdapter import libtailscale.Libtailscale +import org.json.JSONObject +import java.io.FileOutputStream import java.io.IOException import java.io.OutputStream import java.util.UUID @@ -29,100 +34,169 @@ object ShareFileHelper : libtailscale.ShareFileHelper { // A simple data class that holds a SAF OutputStream along with its URI. data class SafStream(val uri: String, val stream: OutputStream) - // Cache for streams; keyed by file name and savedUri. - private val streamCache = ConcurrentHashMap() - - // A helper function that creates (or reuses) a SafStream for a given file. - private fun createStreamCached(fileName: String): SafStream { - val key = "$fileName|$savedUri" - return streamCache.getOrPut(key) { - val context: Context = - appContext - ?: run { - TSLog.e("ShareFileHelper", "appContext is null, cannot create file: $fileName") - return SafStream("", OutputStream.nullOutputStream()) - } - val directoryUriString = - savedUri - ?: run { - TSLog.e("ShareFileHelper", "savedUri is null, cannot create file: $fileName") - return SafStream("", OutputStream.nullOutputStream()) - } - val dirUri = Uri.parse(directoryUriString) - val pickedDir: DocumentFile = - DocumentFile.fromTreeUri(context, dirUri) - ?: run { - TSLog.e("ShareFileHelper", "Could not access directory for URI: $dirUri") - return SafStream("", OutputStream.nullOutputStream()) - } - val newFile: DocumentFile = - pickedDir.createFile("application/octet-stream", fileName) - ?: run { - TSLog.e("ShareFileHelper", "Failed to create file: $fileName in directory: $dirUri") - return SafStream("", OutputStream.nullOutputStream()) - } - // Attempt to open an OutputStream for writing. - val os: OutputStream? = context.contentResolver.openOutputStream(newFile.uri) - if (os == null) { - TSLog.e("ShareFileHelper", "openOutputStream returned null for URI: ${newFile.uri}") - SafStream(newFile.uri.toString(), OutputStream.nullOutputStream()) - } else { - TSLog.d("ShareFileHelper", "Opened OutputStream for file: $fileName") - SafStream(newFile.uri.toString(), os) - } - } + // A helper function that opens or creates a SafStream for a given file. + private fun openSafFileOutputStream(fileName: String): Pair { + val context = appContext ?: return "" to null + val dirUri = savedUri ?: return "" to null + val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null + + val file = + dir.findFile(fileName) + ?: dir.createFile("application/octet-stream", fileName) + ?: return "" to null + + val os = context.contentResolver.openOutputStream(file.uri, "rw") + return file.uri.toString() to os } - // This method returns a SafStream containing the SAF URI and its corresponding OutputStream. - override fun openFileWriter(fileName: String): libtailscale.OutputStream { - val stream = createStreamCached(fileName) - return OutputStreamAdapter(stream.stream) + @Throws(IOException::class) + private fun openWriterFD(fileName: String, offset: Long): Pair { + val ctx = appContext ?: throw IOException("App context not initialized") + val dirUri = savedUri ?: throw IOException("No directory URI") + val dir = + DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) + ?: throw IOException("Invalid tree URI: $dirUri") + val file = + dir.findFile(fileName) + ?: dir.createFile("application/octet-stream", fileName) + ?: throw IOException("Failed to create file: $fileName") + + val pfd = + ctx.contentResolver.openFileDescriptor(file.uri, "rw") + ?: throw IOException("Failed to open file descriptor for ${file.uri}") + val fos = FileOutputStream(pfd.fileDescriptor) + + if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0) + return file.uri.toString() to SeekableOutputStream(fos, pfd) } - override fun openFileURI(fileName: String): String { - val safFile = createStreamCached(fileName) - return safFile.uri + private val currentUri = ConcurrentHashMap() + + @Throws(IOException::class) + override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream { + val (uri, stream) = openWriterFD(fileName, offset) + if (stream == null) { + throw IOException("Failed to open file writer for $fileName") + } + currentUri[fileName] = uri + return OutputStreamAdapter(stream) } - override fun renamePartialFile( - partialUri: String, - targetDirUri: String, - targetName: String - ): String { + @Throws(IOException::class) + override fun getFileURI(fileName: String): String { + currentUri[fileName]?.let { + return it + } + + val ctx = appContext ?: throw IOException("App context not initialized") + val dirStr = savedUri ?: throw IOException("No saved directory URI") + val dir = + DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr)) + ?: throw IOException("Invalid tree URI: $dirStr") + + val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName") + val uri = file.uri.toString() + currentUri[fileName] = uri + return uri + } + + @Throws(IOException::class) + override fun renameFile(oldPath: String, targetName: String): String { + val ctx = appContext ?: throw IOException("not initialized") + val dirUri = savedUri ?: throw IOException("directory not set") + val srcUri = Uri.parse(oldPath) + val dir = + DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) + ?: throw IOException("cannot open dir $dirUri") + + var finalName = targetName + dir.findFile(finalName)?.let { existing -> + if (lengthOfUri(ctx, existing.uri) == 0L) { + existing.delete() + } else { + finalName = generateNewFilename(finalName) + } + } + try { - val context = appContext ?: throw IllegalStateException("appContext is null") - val partialUriObj = Uri.parse(partialUri) - val targetDirUriObj = Uri.parse(targetDirUri) - val targetDir = - DocumentFile.fromTreeUri(context, targetDirUriObj) - ?: throw IllegalStateException( - "Unable to get target directory from URI: $targetDirUri") - var finalTargetName = targetName - - var destFile = targetDir.findFile(finalTargetName) - if (destFile != null) { - finalTargetName = generateNewFilename(finalTargetName) + DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri -> + runCatching { ctx.contentResolver.delete(srcUri, null, null) } + cleanupPartials(dir, targetName) + return newUri.toString() } + } catch (e: Exception) { + TSLog.w("renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}") - destFile = - targetDir.createFile("application/octet-stream", finalTargetName) - ?: throw IOException("Failed to create new file with name: $finalTargetName") + } + + val dest = + dir.createFile("application/octet-stream", finalName) + ?: throw IOException("createFile failed for $finalName") + + ctx.contentResolver.openInputStream(srcUri).use { inp -> + ctx.contentResolver.openOutputStream(dest.uri, "w").use { out -> + if (inp == null || out == null) { + dest.delete() + throw IOException("Unable to open output stream for URI: ${dest.uri}") + } + inp.copyTo(out) + } + } + + ctx.contentResolver.delete(srcUri, null, null) + cleanupPartials(dir, targetName) + return dest.uri.toString() + } - context.contentResolver.openInputStream(partialUriObj)?.use { input -> - context.contentResolver.openOutputStream(destFile.uri)?.use { output -> - input.copyTo(output) - } ?: throw IOException("Unable to open output stream for URI: ${destFile.uri}") - } ?: throw IOException("Unable to open input stream for URI: $partialUri") + private fun lengthOfUri(ctx: Context, uri: Uri): Long = + ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 } - DocumentFile.fromSingleUri(context, partialUriObj)?.delete() - return destFile.uri.toString() - } catch (e: Exception) { - throw IOException( - "Failed to rename partial file from URI $partialUri to final file in $targetDirUri with name $targetName: ${e.message}", - e) + // delete any stray “.partial” files for this base name + private fun cleanupPartials(dir: DocumentFile, base: String) { + for (child in dir.listFiles()) { + val n = child.name ?: continue + if (n.endsWith(".partial") && n.contains(base, ignoreCase = false)) { + child.delete() + } } } + @Throws(IOException::class) + override fun deleteFile(uri: String) { + val ctx = appContext ?: throw IOException("DeleteFile: not initialized") + + val uri = Uri.parse(uri) + val doc = + DocumentFile.fromSingleUri(ctx, uri) + ?: throw IOException("DeleteFile: cannot resolve URI $uri") + + if (!doc.delete()) { + throw IOException("DeleteFile: delete() returned false for $uri") + } + } + + @Throws(IOException::class) + override fun getFileInfo(fileName: String): String { + val context = appContext ?: throw IOException("app context not initialized") + val dirUri = savedUri ?: throw IOException("SAF URI not initialized") + val dir = + DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) + ?: throw IOException("could not resolve SAF root") + + val file = + dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory") + + val name = file.name ?: throw IOException("file name missing for $fileName") + val size = file.length() + val modTime = file.lastModified() + + return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}""" + } + + private fun jsonEscape(s: String): String { + return JSONObject.quote(s) + } + fun generateNewFilename(filename: String): String { val dotIndex = filename.lastIndexOf('.') val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename @@ -131,4 +205,78 @@ object ShareFileHelper : libtailscale.ShareFileHelper { val uuid = UUID.randomUUID() return "$baseName-$uuid$extension" } + + fun listPartialFiles(suffix: String): Array { + val context = appContext ?: return emptyArray() + val rootUri = savedUri ?: return emptyArray() + val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray() + + return dir.listFiles() + .filter { it.name?.endsWith(suffix) == true } + .mapNotNull { it.name } + .toTypedArray() + } + + @Throws(IOException::class) + override fun listFilesJSON(suffix: String): String { + val list = listPartialFiles(suffix) + if (list.isEmpty()) { + throw IOException("no files found matching suffix \"$suffix\"") + } + return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]") + } + + @Throws(IOException::class) + override fun openFileReader(name: String): libtailscale.InputStream { + val context = appContext ?: throw IOException("app context not initialized") + val rootUri = savedUri ?: throw IOException("SAF URI not initialized") + val dir = + DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) + ?: throw IOException("could not open SAF root") + + val suffix = name.substringAfterLast('.', ".$name") + + val file = + dir.listFiles().firstOrNull { + val fname = it.name ?: return@firstOrNull false + fname.endsWith(suffix, ignoreCase = false) + } ?: throw IOException("no file ending with \"$suffix\" in SAF directory") + + val inStream = + context.contentResolver.openInputStream(file.uri) + ?: throw IOException("openInputStream returned null for ${file.uri}") + + return InputStreamAdapter(inStream) + } + + private class SeekableOutputStream( + private val fos: FileOutputStream, + private val pfd: ParcelFileDescriptor + ) : OutputStream() { + + private var closed = false + + override fun write(b: Int) = fos.write(b) + + override fun write(b: ByteArray) = fos.write(b) + + override fun write(b: ByteArray, off: Int, len: Int) { + fos.write(b, off, len) + } + + override fun close() { + if (!closed) { + closed = true + try { + fos.flush() + fos.fd.sync() // blocks until data + metadata are durable + } finally { + fos.close() + pfd.close() + } + } + } + + override fun flush() = fos.flush() + } } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index ed1fa16..8ae8121 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -136,6 +136,19 @@ Invalid key Custom control server URL Auth key + Delete tailnet + Contact support + All requests related to the removal or deletion of data are handled by our Support team. To open a request, tap the Contact Support button below to be taken to our contact form in the browser. Complete the form, and a Customer Support Engineer will work with you directly to assist. + + As the owner of this tailnet, to remove yourself from the tailnet you can either reassign ownership and contact our Support team, or delete the whole tailnet through the admin console. To do the latter, go to + + + and look for “Delete tailnet”. + + + + All requests related to the removal or deletion of data are handled by our Support team. To open a request, tap the Contact Support button below to be taken to our contact form in the browser. Complete the form, and a Customer Support Engineer will work with you directly to assist. + Choose exit node diff --git a/libtailscale/backend.go b/libtailscale/backend.go index c8d5f4a..bb1704d 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -337,7 +337,7 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore, } lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0) if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok { - ext.SetFileOps(NewAndroidFileOps(a.shareFileHelper)) + ext.SetFileOps(newAndroidFileOps(a.shareFileHelper)) ext.SetDirectFileRoot(a.directFileRoot) } diff --git a/libtailscale/fileops.go b/libtailscale/fileops.go index 241097c..769cd91 100644 --- a/libtailscale/fileops.go +++ b/libtailscale/fileops.go @@ -3,36 +3,98 @@ package libtailscale import ( - "fmt" + "encoding/json" "io" + "os" + "time" + + "tailscale.com/feature/taildrop" ) -// AndroidFileOps implements the ShareFileHelper interface using the Android helper. -type AndroidFileOps struct { +// androidFileOps implements [taildrop.FileOps] using the Android ShareFileHelper. +type androidFileOps struct { helper ShareFileHelper } -func NewAndroidFileOps(helper ShareFileHelper) *AndroidFileOps { - return &AndroidFileOps{helper: helper} +var _ taildrop.FileOps = (*androidFileOps)(nil) + +func newAndroidFileOps(helper ShareFileHelper) *androidFileOps { + return &androidFileOps{helper: helper} +} + +func (ops *androidFileOps) OpenWriter(name string, offset int64, _ os.FileMode) (io.WriteCloser, string, error) { + wc, err := ops.helper.OpenFileWriter(name, offset) + if err != nil { + return nil, "", err + } + uri, err := ops.helper.GetFileURI(name) + if err != nil { + wc.Close() + return nil, "", err + } + return wc, uri, nil +} + +func (ops *androidFileOps) Remove(baseName string) error { + uri, err := ops.helper.GetFileURI(baseName) + if err != nil { + return err + } + return ops.helper.DeleteFile(uri) +} + +func (ops *androidFileOps) Rename(oldPath, newName string) (string, error) { + return ops.helper.RenameFile(oldPath, newName) } -func (ops *AndroidFileOps) OpenFileURI(filename string) string { - return ops.helper.OpenFileURI(filename) +func (ops *androidFileOps) ListFiles() ([]string, error) { + namesJSON, err := ops.helper.ListFilesJSON("") + if err != nil { + return nil, err + } + var names []string + if err := json.Unmarshal([]byte(namesJSON), &names); err != nil { + return nil, err + } + return names, nil } -func (ops *AndroidFileOps) OpenFileWriter(filename string) (io.WriteCloser, string, error) { - uri := ops.helper.OpenFileURI(filename) - outputStream := ops.helper.OpenFileWriter(filename) - if outputStream == nil { - return nil, uri, fmt.Errorf("failed to open SAF output stream for %s", filename) +func (ops *androidFileOps) OpenReader(name string) (io.ReadCloser, error) { + in, err := ops.helper.OpenFileReader(name) + if err != nil { + return nil, err } - return outputStream, uri, nil + return adaptInputStream(in), nil } -func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error) { - newURI := ops.helper.RenamePartialFile(partialUri, targetDirUri, targetName) - if newURI == "" { - return "", fmt.Errorf("failed to rename partial file via SAF") +func (ops *androidFileOps) Stat(name string) (os.FileInfo, error) { + infoJSON, err := ops.helper.GetFileInfo(name) + if err != nil { + return nil, err } - return newURI, nil + var fi androidFileInfo + if err := json.Unmarshal([]byte(infoJSON), &fi); err != nil { + return nil, err + } + return &fi, nil +} + +type androidFileInfoJSON struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModTime int64 `json:"modTime"` +} + +type androidFileInfo struct { + data androidFileInfoJSON } + +// compile-time check +var _ os.FileInfo = (*androidFileInfo)(nil) + +func (fi *androidFileInfo) Name() string { return fi.data.Name } +func (fi *androidFileInfo) Size() int64 { return fi.data.Size } +func (fi *androidFileInfo) Mode() os.FileMode { return 0o600 } +func (fi *androidFileInfo) ModTime() time.Time { return time.UnixMilli(fi.data.ModTime) } +func (fi *androidFileInfo) IsDir() bool { return false } +func (fi *androidFileInfo) Sys() any { return nil } diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index 44b9616..67a108c 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -175,14 +175,36 @@ type OutputStream interface { // ShareFileHelper corresponds to the Kotlin ShareFileHelper class type ShareFileHelper interface { - OpenFileWriter(fileName string) OutputStream - - // OpenFileURI opens the file and returns its SAF URI. - OpenFileURI(filename string) string - - // RenamePartialFile takes SAF URIs and a target file name, - // and returns the new SAF URI and an error. - RenamePartialFile(partialUri string, targetDirUri string, targetName string) string + // OpenFileWriter creates or truncates a file named fileName at a given offset, + // returning an OutputStream for writing. Returns an error if the file cannot be opened. + OpenFileWriter(fileName string, offset int64) (stream OutputStream, err error) + + // GetFileURI returns the SAF URI string for the file named fileName, + // or an error if the file cannot be resolved. + GetFileURI(fileName string) (uri string, err error) + + // RenameFile renames the file at oldPath (a SAF URI) into the Taildrop directory, + // giving it the new targetName. Returns the SAF URI of the renamed file, or an error. + RenameFile(oldPath string, targetName string) (newURI string, err error) + + // ListFilesJSON returns a JSON-encoded list of filenames in the Taildrop directory + // that end with the specified suffix. If the suffix is empty, it returns all files. + // Returns an error if no matching files are found or the directory cannot be accessed. + ListFilesJSON(suffix string) (json string, err error) + + // OpenFileReader opens the file with the given name (typically a .partial file) + // and returns an InputStream for reading its contents. + // Returns an error if the file cannot be opened. + OpenFileReader(name string) (stream InputStream, err error) + + // DeleteFile deletes the file identified by the given SAF URI string. + // Returns an error if the file could not be deleted. + DeleteFile(uri string) error + + // GetFileInfo returns a JSON-encoded string containing metadata for fileName, + // matching the fields of androidFileInfo (name, size, modTime). + // Returns an error if the file does not exist or cannot be accessed. + GetFileInfo(fileName string) (json string, err error) } // The below are global callbacks that allow the Java application to notify Go diff --git a/libtailscale/localapi.go b/libtailscale/localapi.go index 678d44c..d25312b 100644 --- a/libtailscale/localapi.go +++ b/libtailscale/localapi.go @@ -230,27 +230,6 @@ func (r *Response) Flush() { }) } -func adaptInputStream(in InputStream) io.ReadCloser { - if in == nil { - return nil - } - r, w := io.Pipe() - go func() { - defer w.Close() - for { - b, err := in.Read() - if err != nil { - log.Printf("error reading from inputstream: %s", err) - } - if b == nil { - return - } - w.Write(b) - } - }() - return r -} - // Below taken from Go stdlib var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") diff --git a/libtailscale/streamutil.go b/libtailscale/streamutil.go new file mode 100644 index 0000000..a656923 --- /dev/null +++ b/libtailscale/streamutil.go @@ -0,0 +1,36 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package libtailscale + +import ( + "io" + "log" +) + +// adaptInputStream wraps an [InputStream] into an [io.ReadCloser]. +// It launches a goroutine to stream reads into a pipe. +func adaptInputStream(in InputStream) io.ReadCloser { + if in == nil { + return nil + } + r, w := io.Pipe() + go func() { + defer w.Close() + for { + b, err := in.Read() + if err != nil { + log.Printf("error reading from inputstream: %v", err) + return + } + if b == nil { + return + } + if _, err := w.Write(b); err != nil { + log.Printf("error writing to pipe: %v", err) + return + } + } + }() + return r +}