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 <kari@tailscale.com>

* android: bump OSS

OSS and Version updated to 1.87.25-t0f15e4419-gde3b6dbfd

Signed-off-by: kari-ts <kari@tailscale.com>

---------

Signed-off-by: kari-ts <kari@tailscale.com>
Signed-off-by: kari-ts <135075563+kari-ts@users.noreply.github.com>
kari/dirselmove
kari-ts 4 months ago committed by GitHub
parent 7aab785be0
commit e71641a422
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -24,3 +24,4 @@ class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale
outputStream.close()
}
}

@ -65,7 +65,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi
val capabilityIsOwner = "https://tailscale.com/cap/is-owner"
val isOwner = netmapState?.hasCap(capabilityIsOwner) == true
Scaffold(
Scaffold(
topBar = {
Header(
R.string.accounts,

@ -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<String, SafStream>()
// 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<String, OutputStream?> {
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<String, SeekableOutputStream> {
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<String, String>()
@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<String> {
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()
}
}

@ -136,7 +136,6 @@
<string name="invalidAuthKeyTitle">Invalid key</string>
<string name="custom_control_url_title">Custom control server URL</string>
<string name="auth_key_input_title">Auth key</string>
<string name="delete_tailnet">Delete tailnet</string>
<string name="contact_support">Contact support</string>
<string name="request_deletion_nonowner">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.</string>

@ -5,7 +5,7 @@ go 1.24.4
require (
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.20250722205428-729d6532ff35
tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683
)
require (

@ -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.20250722205428-729d6532ff35 h1:RaZ9EcaONTkfAerz5hbjpFbtok9uqB46I34Q9T7VGQg=
tailscale.com v1.85.0-pre.0.20250722205428-729d6532ff35/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M=
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=

@ -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)
}

@ -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 }

@ -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

@ -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("\\", "\\\\", `"`, "\\\"")

@ -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
}
Loading…
Cancel
Save