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 1/2] 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 9e73a42837..2a9c2b2098 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 6fb76a6d81..ea0ae56488 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 fed568d095..ae4f3519f9 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 ed1fa16f9c..d524e951d5 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 241097c6b4..8daf9ff140 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 44b9616d88..111bb62e1b 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 678d44c33b..d25312bfc8 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 0000000000..13c3685915 --- /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 +} From 3022490db60c902297a0b64430ef66d91ea81c4f Mon Sep 17 00:00:00 2001 From: kari-ts Date: Fri, 1 Aug 2025 16:43:22 -0700 Subject: [PATCH 2/2] android: bump OSS OSS and Version updated to 1.87.25-t0f15e4419-gde3b6dbfd Signed-off-by: kari-ts --- .../com/tailscale/ipn/util/ShareFileHelper.kt | 198 +++++++++--------- go.mod | 4 +- go.sum | 8 +- libtailscale/backend.go | 2 +- libtailscale/fileops.go | 102 +++++---- libtailscale/interfaces.go | 59 +++--- libtailscale/streamutil.go | 10 +- 7 files changed, 186 insertions(+), 197 deletions(-) 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 ae4f3519f9..a15636bf22 100644 --- a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -49,91 +49,90 @@ object ShareFileHelper : libtailscale.ShareFileHelper { return file.uri.toString() to os } - 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 + @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) - ?: return "" to null + ?: throw IOException("Failed to create file: $fileName") - // Always get a ParcelFileDescriptor so we can sync - val pfd = ctx.contentResolver.openFileDescriptor(file.uri, "rw") ?: return "" to null + 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) } private val currentUri = ConcurrentHashMap() - override fun openFileWriter(fileName: String): libtailscale.OutputStream { - 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 { + @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 ?: OutputStream.nullOutputStream()) + return OutputStreamAdapter(stream) } - override fun openFileURI(fileName: String): String { + @Throws(IOException::class) + override fun getFileURI(fileName: String): String { 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() + 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 } - override fun renamePartialFile( - partialUri: String, - targetDirUri: String, - targetName: String - ): String { + @Throws(IOException::class) + override fun renameFile(oldPath: String, targetName: String): String { val ctx = appContext ?: throw IOException("not initialized") - val srcUri = Uri.parse(partialUri) + val dirUri = savedUri ?: throw IOException("directory not set") + val srcUri = Uri.parse(oldPath) val dir = - DocumentFile.fromTreeUri(ctx, Uri.parse(targetDirUri)) - ?: throw IOException("cannot open dir $targetDirUri") - + 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() // remove stale 0‑byte file + existing.delete() } else { finalName = generateNewFilename(finalName) } } - + try { 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 - } + } catch (e: Exception) { + TSLog.w("renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}") - // 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) { @@ -143,7 +142,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { inp.copyTo(out) } } - // delete the original .partial + ctx.contentResolver.delete(srcUri, null, null) cleanupPartials(dir, targetName) return dest.uri.toString() @@ -163,33 +162,35 @@ object ShareFileHelper : libtailscale.ShareFileHelper { } @Throws(IOException::class) - override fun deleteFile(uriString: String) { + override fun deleteFile(uri: String) { val ctx = appContext ?: throw IOException("DeleteFile: not initialized") - val uri = Uri.parse(uriString) + val uri = Uri.parse(uri) val doc = DocumentFile.fromSingleUri(ctx, uri) - ?: throw IOException("DeleteFile: cannot resolve URI $uriString") + ?: throw IOException("DeleteFile: cannot resolve URI $uri") if (!doc.delete()) { - throw IOException("DeleteFile: delete() returned false for $uriString") + throw IOException("DeleteFile: delete() returned false for $uri") } } - override fun treeURI(): String = savedUri ?: throw IllegalStateException("not initialized") - + @Throws(IOException::class) override fun getFileInfo(fileName: String): String { - val context = appContext ?: return "" - val dirUri = savedUri ?: return "" - val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" + 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) ?: return "" + val file = + dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory") - val name = file.name ?: return "" + val name = file.name ?: throw IOException("file name missing for $fileName") val size = file.length() - val modTime = file.lastModified() // milliseconds since epoch + val modTime = file.lastModified() - return """{"name":${jsonEscape(name)},"size":$size,"modTime":$modTime}""" + return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}""" } private fun jsonEscape(s: String): String { @@ -216,71 +217,66 @@ object ShareFileHelper : libtailscale.ShareFileHelper { .toTypedArray() } - override fun listPartialFilesJSON(suffix: String): String { - return listPartialFiles(suffix) - .joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]") + @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 = "\"]") } - 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 + @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") - // 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 suffix = name.substringAfterLast('.', ".$name") 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 - } + fname.endsWith(suffix, ignoreCase = false) + } ?: throw IOException("no file ending with \"$suffix\" in SAF directory") - 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 - } + ?: throw IOException("openInputStream returned null for ${file.uri}") + return InputStreamAdapter(inStream) } -} -private class SeekableOutputStream( - private val fos: FileOutputStream, - private val pfd: ParcelFileDescriptor -) : OutputStream() { + private class SeekableOutputStream( + private val fos: FileOutputStream, + private val pfd: ParcelFileDescriptor + ) : OutputStream() { - private var closed = false + private var closed = false - override fun write(b: Int) = fos.write(b) + override fun write(b: Int) = fos.write(b) - override fun write(b: ByteArray) = 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 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 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() + override fun flush() = fos.flush() + } } diff --git a/go.mod b/go.mod index 73ff6e617e..05da9e2521 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module github.com/tailscale/tailscale-android go 1.24.4 require ( - github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f + github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8 + tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683 ) require ( diff --git a/go.sum b/go.sum index 868652f201..3dc2046628 100644 --- a/go.sum +++ b/go.sum @@ -163,8 +163,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= -github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f h1:vg3PmQdq1BbB2V81iC1VBICQtfwbVGZ/4A/p7QKXTK0= -github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw= +github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= @@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8 h1:gR3XF35IWpV4WhON27gR2vd8ypXbnjnrj5WreLWFxWk= -tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8/go.mod h1:zrtwlwmFfEWbUz77UN58gaLADx4rXSecFhGO+XW0JbU= +tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683 h1:meEUX1Nsr5SaXiaeivOGG4c7gsQm/P3Jr3dzbtE0j6k= +tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M= diff --git a/libtailscale/backend.go b/libtailscale/backend.go index c8d5f4ad79..bb1704ddc2 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 8daf9ff140..769cd91a4a 100644 --- a/libtailscale/fileops.go +++ b/libtailscale/fileops.go @@ -4,7 +4,6 @@ package libtailscale import ( "encoding/json" - "fmt" "io" "os" "time" @@ -12,47 +11,47 @@ import ( "tailscale.com/feature/taildrop" ) -// AndroidFileOps implements the FileOps interface using the Android ShareFileHelper. -type AndroidFileOps struct { +// androidFileOps implements [taildrop.FileOps] using the Android ShareFileHelper. +type androidFileOps struct { helper ShareFileHelper } -var _ taildrop.FileOps = (*AndroidFileOps)(nil) +var _ taildrop.FileOps = (*androidFileOps)(nil) -func NewAndroidFileOps(helper ShareFileHelper) *AndroidFileOps { - return &AndroidFileOps{helper: helper} +func newAndroidFileOps(helper ShareFileHelper) *androidFileOps { + return &androidFileOps{helper: helper} } -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) +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 } - if wc == nil { - return nil, "", fmt.Errorf("OpenFileWriter returned nil for %q", name) + uri, err := ops.helper.GetFileURI(name) + if err != nil { + wc.Close() + return nil, "", err } - uri := ops.helper.OpenFileURI(name) return wc, uri, nil } -func (ops *AndroidFileOps) Remove(baseName string) error { - uri := ops.helper.OpenFileURI(baseName) +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) { - 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) Rename(oldPath, newName string) (string, error) { + return ops.helper.RenameFile(oldPath, newName) } -func (ops *AndroidFileOps) ListFiles() ([]string, error) { - namesJSON := ops.helper.ListPartialFilesJSON("") +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 @@ -60,43 +59,42 @@ func (ops *AndroidFileOps) ListFiles() ([]string, error) { 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) +func (ops *androidFileOps) OpenReader(name string) (io.ReadCloser, error) { + in, err := ops.helper.OpenFileReader(name) + if err != nil { + return nil, err } 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 +func (ops *androidFileOps) Stat(name string) (os.FileInfo, error) { + infoJSON, err := ops.helper.GetFileInfo(name) + if err != nil { + return nil, err } - if err := json.Unmarshal([]byte(infoJSON), &info); err != nil { + var fi androidFileInfo + if err := json.Unmarshal([]byte(infoJSON), &fi); err != nil { return nil, err } - return &androidFileInfo{ - name: info.Name, - size: info.Size, - modTime: time.UnixMilli(info.ModTime), - }, nil + return &fi, nil +} + +type androidFileInfoJSON struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModTime int64 `json:"modTime"` } type androidFileInfo struct { - name string - size int64 - modTime time.Time + data androidFileInfoJSON } -func (fi *androidFileInfo) Name() string { return fi.name } -func (fi *androidFileInfo) Size() int64 { return fi.size } +// 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 fi.modTime } +func (fi *androidFileInfo) ModTime() time.Time { return time.UnixMilli(fi.data.ModTime) } func (fi *androidFileInfo) IsDir() bool { return false } -func (fi *androidFileInfo) Sys() interface{} { return nil } +func (fi *androidFileInfo) Sys() any { return nil } diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index 111bb62e1b..67a108cfeb 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -175,45 +175,36 @@ 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 - - // OpenFileWriterAt opens fileName for writing at a given offset. - // Returns nil if the file cannot be opened. - OpenFileWriterAt(fileName string, offset int64) OutputStream - - // 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) + // 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 nil if the file cannot be opened. - OpenPartialFileReader(name string) InputStream + // 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(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 + DeleteFile(uri string) error - // 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 + // 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/streamutil.go b/libtailscale/streamutil.go index 13c3685915..a656923277 100644 --- a/libtailscale/streamutil.go +++ b/libtailscale/streamutil.go @@ -8,7 +8,7 @@ import ( "log" ) -// adaptInputStream wraps a libtailscale.InputStream into an io.ReadCloser. +// 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 { @@ -20,12 +20,16 @@ func adaptInputStream(in InputStream) io.ReadCloser { for { b, err := in.Read() if err != nil { - log.Printf("error reading from inputstream: %s", err) + log.Printf("error reading from inputstream: %v", err) + return } if b == nil { return } - w.Write(b) + if _, err := w.Write(b); err != nil { + log.Printf("error writing to pipe: %v", err) + return + } } }() return r