Skip to content

android: expand SAF FileOps implementation #675

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale
outputStream.close()
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
308 changes: 228 additions & 80 deletions android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
}
}
1 change: 0 additions & 1 deletion android/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
2 changes: 1 addition & 1 deletion libtailscale/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading