Skip to content

Commit e71641a

Browse files
authored
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 <[email protected]> * android: bump OSS OSS and Version updated to 1.87.25-t0f15e4419-gde3b6dbfd Signed-off-by: kari-ts <[email protected]> --------- Signed-off-by: kari-ts <[email protected]> Signed-off-by: kari-ts <[email protected]>
1 parent 7aab785 commit e71641a

File tree

11 files changed

+380
-133
lines changed

11 files changed

+380
-133
lines changed

android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale
2424
outputStream.close()
2525
}
2626
}
27+

android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi
6565
val capabilityIsOwner = "https://tailscale.com/cap/is-owner"
6666
val isOwner = netmapState?.hasCap(capabilityIsOwner) == true
6767

68-
Scaffold(
68+
Scaffold(
6969
topBar = {
7070
Header(
7171
R.string.accounts,

android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt

Lines changed: 228 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@ package com.tailscale.ipn.util
55

66
import android.content.Context
77
import android.net.Uri
8+
import android.os.ParcelFileDescriptor
9+
import android.provider.DocumentsContract
810
import androidx.documentfile.provider.DocumentFile
11+
import com.tailscale.ipn.ui.util.InputStreamAdapter
912
import com.tailscale.ipn.ui.util.OutputStreamAdapter
1013
import libtailscale.Libtailscale
14+
import org.json.JSONObject
15+
import java.io.FileOutputStream
1116
import java.io.IOException
1217
import java.io.OutputStream
1318
import java.util.UUID
@@ -29,100 +34,169 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
2934
// A simple data class that holds a SAF OutputStream along with its URI.
3035
data class SafStream(val uri: String, val stream: OutputStream)
3136

32-
// Cache for streams; keyed by file name and savedUri.
33-
private val streamCache = ConcurrentHashMap<String, SafStream>()
34-
35-
// A helper function that creates (or reuses) a SafStream for a given file.
36-
private fun createStreamCached(fileName: String): SafStream {
37-
val key = "$fileName|$savedUri"
38-
return streamCache.getOrPut(key) {
39-
val context: Context =
40-
appContext
41-
?: run {
42-
TSLog.e("ShareFileHelper", "appContext is null, cannot create file: $fileName")
43-
return SafStream("", OutputStream.nullOutputStream())
44-
}
45-
val directoryUriString =
46-
savedUri
47-
?: run {
48-
TSLog.e("ShareFileHelper", "savedUri is null, cannot create file: $fileName")
49-
return SafStream("", OutputStream.nullOutputStream())
50-
}
51-
val dirUri = Uri.parse(directoryUriString)
52-
val pickedDir: DocumentFile =
53-
DocumentFile.fromTreeUri(context, dirUri)
54-
?: run {
55-
TSLog.e("ShareFileHelper", "Could not access directory for URI: $dirUri")
56-
return SafStream("", OutputStream.nullOutputStream())
57-
}
58-
val newFile: DocumentFile =
59-
pickedDir.createFile("application/octet-stream", fileName)
60-
?: run {
61-
TSLog.e("ShareFileHelper", "Failed to create file: $fileName in directory: $dirUri")
62-
return SafStream("", OutputStream.nullOutputStream())
63-
}
64-
// Attempt to open an OutputStream for writing.
65-
val os: OutputStream? = context.contentResolver.openOutputStream(newFile.uri)
66-
if (os == null) {
67-
TSLog.e("ShareFileHelper", "openOutputStream returned null for URI: ${newFile.uri}")
68-
SafStream(newFile.uri.toString(), OutputStream.nullOutputStream())
69-
} else {
70-
TSLog.d("ShareFileHelper", "Opened OutputStream for file: $fileName")
71-
SafStream(newFile.uri.toString(), os)
72-
}
73-
}
37+
// A helper function that opens or creates a SafStream for a given file.
38+
private fun openSafFileOutputStream(fileName: String): Pair<String, OutputStream?> {
39+
val context = appContext ?: return "" to null
40+
val dirUri = savedUri ?: return "" to null
41+
val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null
42+
43+
val file =
44+
dir.findFile(fileName)
45+
?: dir.createFile("application/octet-stream", fileName)
46+
?: return "" to null
47+
48+
val os = context.contentResolver.openOutputStream(file.uri, "rw")
49+
return file.uri.toString() to os
7450
}
7551

76-
// This method returns a SafStream containing the SAF URI and its corresponding OutputStream.
77-
override fun openFileWriter(fileName: String): libtailscale.OutputStream {
78-
val stream = createStreamCached(fileName)
79-
return OutputStreamAdapter(stream.stream)
52+
@Throws(IOException::class)
53+
private fun openWriterFD(fileName: String, offset: Long): Pair<String, SeekableOutputStream> {
54+
val ctx = appContext ?: throw IOException("App context not initialized")
55+
val dirUri = savedUri ?: throw IOException("No directory URI")
56+
val dir =
57+
DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri))
58+
?: throw IOException("Invalid tree URI: $dirUri")
59+
val file =
60+
dir.findFile(fileName)
61+
?: dir.createFile("application/octet-stream", fileName)
62+
?: throw IOException("Failed to create file: $fileName")
63+
64+
val pfd =
65+
ctx.contentResolver.openFileDescriptor(file.uri, "rw")
66+
?: throw IOException("Failed to open file descriptor for ${file.uri}")
67+
val fos = FileOutputStream(pfd.fileDescriptor)
68+
69+
if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0)
70+
return file.uri.toString() to SeekableOutputStream(fos, pfd)
8071
}
8172

82-
override fun openFileURI(fileName: String): String {
83-
val safFile = createStreamCached(fileName)
84-
return safFile.uri
73+
private val currentUri = ConcurrentHashMap<String, String>()
74+
75+
@Throws(IOException::class)
76+
override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream {
77+
val (uri, stream) = openWriterFD(fileName, offset)
78+
if (stream == null) {
79+
throw IOException("Failed to open file writer for $fileName")
80+
}
81+
currentUri[fileName] = uri
82+
return OutputStreamAdapter(stream)
8583
}
8684

87-
override fun renamePartialFile(
88-
partialUri: String,
89-
targetDirUri: String,
90-
targetName: String
91-
): String {
85+
@Throws(IOException::class)
86+
override fun getFileURI(fileName: String): String {
87+
currentUri[fileName]?.let {
88+
return it
89+
}
90+
91+
val ctx = appContext ?: throw IOException("App context not initialized")
92+
val dirStr = savedUri ?: throw IOException("No saved directory URI")
93+
val dir =
94+
DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr))
95+
?: throw IOException("Invalid tree URI: $dirStr")
96+
97+
val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName")
98+
val uri = file.uri.toString()
99+
currentUri[fileName] = uri
100+
return uri
101+
}
102+
103+
@Throws(IOException::class)
104+
override fun renameFile(oldPath: String, targetName: String): String {
105+
val ctx = appContext ?: throw IOException("not initialized")
106+
val dirUri = savedUri ?: throw IOException("directory not set")
107+
val srcUri = Uri.parse(oldPath)
108+
val dir =
109+
DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri))
110+
?: throw IOException("cannot open dir $dirUri")
111+
112+
var finalName = targetName
113+
dir.findFile(finalName)?.let { existing ->
114+
if (lengthOfUri(ctx, existing.uri) == 0L) {
115+
existing.delete()
116+
} else {
117+
finalName = generateNewFilename(finalName)
118+
}
119+
}
120+
92121
try {
93-
val context = appContext ?: throw IllegalStateException("appContext is null")
94-
val partialUriObj = Uri.parse(partialUri)
95-
val targetDirUriObj = Uri.parse(targetDirUri)
96-
val targetDir =
97-
DocumentFile.fromTreeUri(context, targetDirUriObj)
98-
?: throw IllegalStateException(
99-
"Unable to get target directory from URI: $targetDirUri")
100-
var finalTargetName = targetName
101-
102-
var destFile = targetDir.findFile(finalTargetName)
103-
if (destFile != null) {
104-
finalTargetName = generateNewFilename(finalTargetName)
122+
DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri ->
123+
runCatching { ctx.contentResolver.delete(srcUri, null, null) }
124+
cleanupPartials(dir, targetName)
125+
return newUri.toString()
105126
}
127+
} catch (e: Exception) {
128+
TSLog.w("renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}")
106129

107-
destFile =
108-
targetDir.createFile("application/octet-stream", finalTargetName)
109-
?: throw IOException("Failed to create new file with name: $finalTargetName")
130+
}
131+
132+
val dest =
133+
dir.createFile("application/octet-stream", finalName)
134+
?: throw IOException("createFile failed for $finalName")
135+
136+
ctx.contentResolver.openInputStream(srcUri).use { inp ->
137+
ctx.contentResolver.openOutputStream(dest.uri, "w").use { out ->
138+
if (inp == null || out == null) {
139+
dest.delete()
140+
throw IOException("Unable to open output stream for URI: ${dest.uri}")
141+
}
142+
inp.copyTo(out)
143+
}
144+
}
145+
146+
ctx.contentResolver.delete(srcUri, null, null)
147+
cleanupPartials(dir, targetName)
148+
return dest.uri.toString()
149+
}
110150

111-
context.contentResolver.openInputStream(partialUriObj)?.use { input ->
112-
context.contentResolver.openOutputStream(destFile.uri)?.use { output ->
113-
input.copyTo(output)
114-
} ?: throw IOException("Unable to open output stream for URI: ${destFile.uri}")
115-
} ?: throw IOException("Unable to open input stream for URI: $partialUri")
151+
private fun lengthOfUri(ctx: Context, uri: Uri): Long =
152+
ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 }
116153

117-
DocumentFile.fromSingleUri(context, partialUriObj)?.delete()
118-
return destFile.uri.toString()
119-
} catch (e: Exception) {
120-
throw IOException(
121-
"Failed to rename partial file from URI $partialUri to final file in $targetDirUri with name $targetName: ${e.message}",
122-
e)
154+
// delete any stray “.partial” files for this base name
155+
private fun cleanupPartials(dir: DocumentFile, base: String) {
156+
for (child in dir.listFiles()) {
157+
val n = child.name ?: continue
158+
if (n.endsWith(".partial") && n.contains(base, ignoreCase = false)) {
159+
child.delete()
160+
}
123161
}
124162
}
125163

164+
@Throws(IOException::class)
165+
override fun deleteFile(uri: String) {
166+
val ctx = appContext ?: throw IOException("DeleteFile: not initialized")
167+
168+
val uri = Uri.parse(uri)
169+
val doc =
170+
DocumentFile.fromSingleUri(ctx, uri)
171+
?: throw IOException("DeleteFile: cannot resolve URI $uri")
172+
173+
if (!doc.delete()) {
174+
throw IOException("DeleteFile: delete() returned false for $uri")
175+
}
176+
}
177+
178+
@Throws(IOException::class)
179+
override fun getFileInfo(fileName: String): String {
180+
val context = appContext ?: throw IOException("app context not initialized")
181+
val dirUri = savedUri ?: throw IOException("SAF URI not initialized")
182+
val dir =
183+
DocumentFile.fromTreeUri(context, Uri.parse(dirUri))
184+
?: throw IOException("could not resolve SAF root")
185+
186+
val file =
187+
dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory")
188+
189+
val name = file.name ?: throw IOException("file name missing for $fileName")
190+
val size = file.length()
191+
val modTime = file.lastModified()
192+
193+
return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}"""
194+
}
195+
196+
private fun jsonEscape(s: String): String {
197+
return JSONObject.quote(s)
198+
}
199+
126200
fun generateNewFilename(filename: String): String {
127201
val dotIndex = filename.lastIndexOf('.')
128202
val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename
@@ -131,4 +205,78 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
131205
val uuid = UUID.randomUUID()
132206
return "$baseName-$uuid$extension"
133207
}
208+
209+
fun listPartialFiles(suffix: String): Array<String> {
210+
val context = appContext ?: return emptyArray()
211+
val rootUri = savedUri ?: return emptyArray()
212+
val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray()
213+
214+
return dir.listFiles()
215+
.filter { it.name?.endsWith(suffix) == true }
216+
.mapNotNull { it.name }
217+
.toTypedArray()
218+
}
219+
220+
@Throws(IOException::class)
221+
override fun listFilesJSON(suffix: String): String {
222+
val list = listPartialFiles(suffix)
223+
if (list.isEmpty()) {
224+
throw IOException("no files found matching suffix \"$suffix\"")
225+
}
226+
return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]")
227+
}
228+
229+
@Throws(IOException::class)
230+
override fun openFileReader(name: String): libtailscale.InputStream {
231+
val context = appContext ?: throw IOException("app context not initialized")
232+
val rootUri = savedUri ?: throw IOException("SAF URI not initialized")
233+
val dir =
234+
DocumentFile.fromTreeUri(context, Uri.parse(rootUri))
235+
?: throw IOException("could not open SAF root")
236+
237+
val suffix = name.substringAfterLast('.', ".$name")
238+
239+
val file =
240+
dir.listFiles().firstOrNull {
241+
val fname = it.name ?: return@firstOrNull false
242+
fname.endsWith(suffix, ignoreCase = false)
243+
} ?: throw IOException("no file ending with \"$suffix\" in SAF directory")
244+
245+
val inStream =
246+
context.contentResolver.openInputStream(file.uri)
247+
?: throw IOException("openInputStream returned null for ${file.uri}")
248+
249+
return InputStreamAdapter(inStream)
250+
}
251+
252+
private class SeekableOutputStream(
253+
private val fos: FileOutputStream,
254+
private val pfd: ParcelFileDescriptor
255+
) : OutputStream() {
256+
257+
private var closed = false
258+
259+
override fun write(b: Int) = fos.write(b)
260+
261+
override fun write(b: ByteArray) = fos.write(b)
262+
263+
override fun write(b: ByteArray, off: Int, len: Int) {
264+
fos.write(b, off, len)
265+
}
266+
267+
override fun close() {
268+
if (!closed) {
269+
closed = true
270+
try {
271+
fos.flush()
272+
fos.fd.sync() // blocks until data + metadata are durable
273+
} finally {
274+
fos.close()
275+
pfd.close()
276+
}
277+
}
278+
}
279+
280+
override fun flush() = fos.flush()
281+
}
134282
}

android/src/main/res/values/strings.xml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,6 @@
136136
<string name="invalidAuthKeyTitle">Invalid key</string>
137137
<string name="custom_control_url_title">Custom control server URL</string>
138138
<string name="auth_key_input_title">Auth key</string>
139-
140139
<string name="delete_tailnet">Delete tailnet</string>
141140
<string name="contact_support">Contact support</string>
142141
<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>

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ go 1.24.4
55
require (
66
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da
77
golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab
8-
tailscale.com v1.85.0-pre.0.20250722205428-729d6532ff35
8+
tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683
99
)
1010

1111
require (

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
235235
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
236236
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
237237
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
238-
tailscale.com v1.85.0-pre.0.20250722205428-729d6532ff35 h1:RaZ9EcaONTkfAerz5hbjpFbtok9uqB46I34Q9T7VGQg=
239-
tailscale.com v1.85.0-pre.0.20250722205428-729d6532ff35/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M=
238+
tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683 h1:meEUX1Nsr5SaXiaeivOGG4c7gsQm/P3Jr3dzbtE0j6k=
239+
tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M=

libtailscale/backend.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore,
337337
}
338338
lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0)
339339
if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok {
340-
ext.SetFileOps(NewAndroidFileOps(a.shareFileHelper))
340+
ext.SetFileOps(newAndroidFileOps(a.shareFileHelper))
341341
ext.SetDirectFileRoot(a.directFileRoot)
342342
}
343343

0 commit comments

Comments
 (0)