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>
pull/675/head
kari-ts 6 months ago committed by kari-ts
parent e5a704f785
commit de3b6dbfd6

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

@ -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
@ -138,6 +144,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi
}
})
}
}
}
}

@ -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<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.
private fun openWriterFD(fileName: String, offset: Long): Pair<String, SeekableOutputStream?> {
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<String, String>()
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 0byte 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 copydelete
}
// 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<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()
}
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. ".<id>.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()
}

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

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

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