From de3b6dbfd634bed5c8b0d197014929ff72697640 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Mon, 16 Jun 2025 11:39:32 -0700 Subject: [PATCH] android: expand SAF FileOps implementation This expands the SAF FileOps to implement the refactored FileOps Updates tailscale/corp#29211 Signed-off-by: kari-ts --- .../ipn/ui/util/OutputStreamAdapter.kt | 1 + .../tailscale/ipn/ui/view/UserSwitcherView.kt | 9 +- .../com/tailscale/ipn/util/ShareFileHelper.kt | 302 +++++++++++++----- android/src/main/res/values/strings.xml | 2 +- libtailscale/fileops.go | 88 ++++- libtailscale/interfaces.go | 41 ++- libtailscale/localapi.go | 21 -- libtailscale/streamutil.go | 32 ++ 8 files changed, 381 insertions(+), 115 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..ea0ae56 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 @@ -3,6 +3,8 @@ package com.tailscale.ipn.ui.view +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -20,12 +23,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -51,7 +57,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, @@ -138,6 +144,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi } }) } + } } } 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..ae4f351 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,59 +34,69 @@ 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. + private fun openWriterFD(fileName: String, offset: Long): Pair { + + val ctx = appContext ?: return "" to null + val dirUri = savedUri ?: return "" to null + val dir = DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) ?: return "" to null + + // Reuse existing doc if it exists + val file = + dir.findFile(fileName) + ?: dir.createFile("application/octet-stream", fileName) + ?: return "" to null + + // Always get a ParcelFileDescriptor so we can sync + val pfd = ctx.contentResolver.openFileDescriptor(file.uri, "rw") ?: return "" to null + 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) + } + + private val currentUri = ConcurrentHashMap() + override fun openFileWriter(fileName: String): libtailscale.OutputStream { - val stream = createStreamCached(fileName) - return OutputStreamAdapter(stream.stream) + val (uri, stream) = openWriterFD(fileName, 0) + currentUri[fileName] = uri // 🠚 cache the exact doc we opened + return OutputStreamAdapter(stream ?: OutputStream.nullOutputStream()) + } + + override fun openFileWriterAt(fileName: String, offset: Long): libtailscale.OutputStream { + val (uri, stream) = openWriterFD(fileName, offset) + currentUri[fileName] = uri + return OutputStreamAdapter(stream ?: OutputStream.nullOutputStream()) } override fun openFileURI(fileName: String): String { - val safFile = createStreamCached(fileName) - return safFile.uri + currentUri[fileName]?.let { + return it + } + val ctx = appContext ?: return "" + val dirStr = savedUri ?: return "" + val dir = DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr)) ?: return "" + + val file = dir.findFile(fileName) ?: return "" + val uri = file.uri.toString() + + currentUri[fileName] = uri + return uri } override fun renamePartialFile( @@ -89,40 +104,98 @@ object ShareFileHelper : libtailscale.ShareFileHelper { targetDirUri: String, targetName: String ): String { + val ctx = appContext ?: throw IOException("not initialized") + val srcUri = Uri.parse(partialUri) + val dir = + DocumentFile.fromTreeUri(ctx, Uri.parse(targetDirUri)) + ?: throw IOException("cannot open dir $targetDirUri") + + var finalName = targetName + dir.findFile(finalName)?.let { existing -> + if (lengthOfUri(ctx, existing.uri) == 0L) { + existing.delete() // remove stale 0‑byte file + } 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 (_: Exception) { + // rename not supported; fall through to copy‑delete + } + + // fallback - copy contents then delete source + 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) + } + } + // delete the original .partial + ctx.contentResolver.delete(srcUri, null, null) + cleanupPartials(dir, targetName) + return dest.uri.toString() + } + + private fun lengthOfUri(ctx: Context, uri: Uri): Long = + ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 } + + // 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(uriString: String) { + val ctx = appContext ?: throw IOException("DeleteFile: not initialized") + + val uri = Uri.parse(uriString) + val doc = + DocumentFile.fromSingleUri(ctx, uri) + ?: throw IOException("DeleteFile: cannot resolve URI $uriString") - destFile = - targetDir.createFile("application/octet-stream", finalTargetName) - ?: throw IOException("Failed to create new file with name: $finalTargetName") - - 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") - - 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) + if (!doc.delete()) { + throw IOException("DeleteFile: delete() returned false for $uriString") } } + override fun treeURI(): String = savedUri ?: throw IllegalStateException("not initialized") + + override fun getFileInfo(fileName: String): String { + val context = appContext ?: return "" + val dirUri = savedUri ?: return "" + val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" + + val file = dir.findFile(fileName) ?: return "" + + val name = file.name ?: return "" + val size = file.length() + val modTime = file.lastModified() // milliseconds since epoch + + return """{"name":${jsonEscape(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 +204,83 @@ 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() + } + + override fun listPartialFilesJSON(suffix: String): String { + return listPartialFiles(suffix) + .joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]") + } + + override fun openPartialFileReader(name: String): libtailscale.InputStream? { + val context = appContext ?: return null + val rootUri = savedUri ?: return null + val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return null + + // We know `name` includes the suffix (e.g. "..partial"), but the actual + // file in SAF might include extra bits, so let's just match by that suffix. + // You could also match exactly `endsWith(name)` if the filenames line up + val suffix = name.substringAfterLast('.', ".$name") // or hard-code ".partial" + + val file = + dir.listFiles().firstOrNull { + val fname = it.name ?: return@firstOrNull false + // call the String overload explicitly: + fname.endsWith(suffix, /*ignoreCase=*/ false) + } + ?: run { + TSLog.d("ShareFileHelper", "no file ending with $suffix in SAF directory") + return null + } + + TSLog.d("ShareFileHelper", "found SAF file ${file.name}, opening") + val inStream = + context.contentResolver.openInputStream(file.uri) + ?: run { + TSLog.d("ShareFileHelper", "openInputStream returned null for ${file.uri}") + return null + } + 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 + val size = fos.channel.size() + } 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..d524e95 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -136,7 +136,7 @@ Invalid key Custom control server URL Auth key - + Choose exit node Mullvad exit nodes diff --git a/libtailscale/fileops.go b/libtailscale/fileops.go index 241097c..8daf9ff 100644 --- a/libtailscale/fileops.go +++ b/libtailscale/fileops.go @@ -3,36 +3,100 @@ package libtailscale import ( + "encoding/json" "fmt" "io" + "os" + "time" + + "tailscale.com/feature/taildrop" ) -// AndroidFileOps implements the ShareFileHelper interface using the Android helper. +// AndroidFileOps implements the FileOps interface using the Android ShareFileHelper. type AndroidFileOps struct { helper ShareFileHelper } +var _ taildrop.FileOps = (*AndroidFileOps)(nil) + func NewAndroidFileOps(helper ShareFileHelper) *AndroidFileOps { return &AndroidFileOps{helper: helper} } -func (ops *AndroidFileOps) OpenFileURI(filename string) string { - return ops.helper.OpenFileURI(filename) +func (ops *AndroidFileOps) OpenWriter(name string, offset int64, perm os.FileMode) (io.WriteCloser, string, error) { + var wc OutputStream + if offset == 0 { + wc = ops.helper.OpenFileWriter(name) + } else { + wc = ops.helper.OpenFileWriterAt(name, offset) + } + if wc == nil { + return nil, "", fmt.Errorf("OpenFileWriter returned nil for %q", name) + } + uri := ops.helper.OpenFileURI(name) + return wc, uri, 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) - } - return outputStream, uri, nil +func (ops *AndroidFileOps) Remove(baseName string) error { + uri := ops.helper.OpenFileURI(baseName) + return ops.helper.DeleteFile(uri) } -func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error) { - newURI := ops.helper.RenamePartialFile(partialUri, targetDirUri, targetName) +func (ops *AndroidFileOps) Rename(oldPath, newName string) (string, error) { + tree := ops.helper.TreeURI() + newURI := ops.helper.RenamePartialFile(oldPath, tree, newName) if newURI == "" { return "", fmt.Errorf("failed to rename partial file via SAF") } return newURI, nil } + +func (ops *AndroidFileOps) ListFiles() ([]string, error) { + namesJSON := ops.helper.ListPartialFilesJSON("") + var names []string + if err := json.Unmarshal([]byte(namesJSON), &names); err != nil { + return nil, err + } + return names, nil +} + +func (ops *AndroidFileOps) OpenReader(name string) (io.ReadCloser, error) { + in := ops.helper.OpenPartialFileReader(name) + if in == nil { + return nil, fmt.Errorf("OpenPartialFileReader returned nil for %q", name) + } + return adaptInputStream(in), nil +} + +func (ops *AndroidFileOps) Stat(name string) (os.FileInfo, error) { + infoJSON := ops.helper.GetFileInfo(name) + if infoJSON == "" { + return nil, os.ErrNotExist + } + var info struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModTime int64 `json:"modTime"` // Unix millis + } + if err := json.Unmarshal([]byte(infoJSON), &info); err != nil { + return nil, err + } + return &androidFileInfo{ + name: info.Name, + size: info.Size, + modTime: time.UnixMilli(info.ModTime), + }, nil +} + +type androidFileInfo struct { + name string + size int64 + modTime time.Time +} + +func (fi *androidFileInfo) Name() string { return fi.name } +func (fi *androidFileInfo) Size() int64 { return fi.size } +func (fi *androidFileInfo) Mode() os.FileMode { return 0o600 } +func (fi *androidFileInfo) ModTime() time.Time { return fi.modTime } +func (fi *androidFileInfo) IsDir() bool { return false } +func (fi *androidFileInfo) Sys() interface{} { return nil } diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index 44b9616..111bb62 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -175,14 +175,45 @@ type OutputStream interface { // ShareFileHelper corresponds to the Kotlin ShareFileHelper class type ShareFileHelper interface { + // OpenFileWriter creates or truncates a file named fileName + // and returns an OutputStream for writing to it from the beginning. + // Returns nil if the file cannot be opened. OpenFileWriter(fileName string) OutputStream - // OpenFileURI opens the file and returns its SAF URI. - OpenFileURI(filename string) string + // OpenFileWriterAt opens fileName for writing at a given offset. + // Returns nil if the file cannot be opened. + OpenFileWriterAt(fileName string, offset int64) OutputStream - // 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 + // OpenFileURI returns the SAF URI string for the file named fileName, + // or an empty string if the file cannot be resolved. + OpenFileURI(fileName string) string + + // RenamePartialFile renames the file at oldPath (a SAF URI) + // into the directory identified by newPath (a tree URI), + // giving it the new targetName. Returns the SAF URI of the renamed file, + // or an empty string if the operation failed. + RenamePartialFile(oldPath string, newPath string, targetName string) string + + // ListPartialFilesJSON returns a JSON-encoded list of partial filenames + // (e.g., ["foo.partial", "bar.partial"]) that match the given suffix. + ListPartialFilesJSON(suffix string) string + + // OpenPartialFileReader opens the file with the given name (typically a .partial file) + // and returns an InputStream for reading its contents. + // Returns nil if the file cannot be opened. + OpenPartialFileReader(name string) InputStream + + // DeleteFile deletes the file identified by the given SAF URI string. + // Returns an error if the file could not be deleted. + DeleteFile(uriString string) error + + // TreeURI returns the SAF tree URI representing the root directory for Taildrop files. + // This is typically the URI granted by the user via the Android directory picker. + TreeURI() string + + // GetFileInfo returns a JSON-encoded string with file metadata for fileName. + // Returns an empty string if the file does not exist or cannot be accessed. + GetFileInfo(fileName string) string } // 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..13c3685 --- /dev/null +++ b/libtailscale/streamutil.go @@ -0,0 +1,32 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package libtailscale + +import ( + "io" + "log" +) + +// adaptInputStream wraps a libtailscale.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: %s", err) + } + if b == nil { + return + } + w.Write(b) + } + }() + return r +}