Skip to content

Commit fcd9bb0

Browse files
committed
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]>
1 parent e5a704f commit fcd9bb0

File tree

8 files changed

+328
-130
lines changed

8 files changed

+328
-130
lines changed

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

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,16 @@ import java.io.OutputStream
88

99
// This class adapts a Java OutputStream to the libtailscale.OutputStream interface.
1010
class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale.OutputStream {
11-
// writes data to the outputStream in its entirety. Returns -1 on error.
12-
override fun write(data: ByteArray): Long {
13-
return try {
14-
outputStream.write(data)
15-
outputStream.flush()
16-
data.size.toLong()
17-
} catch (e: Exception) {
18-
TSLog.d("OutputStreamAdapter", "write exception: $e")
19-
-1L
11+
// Write the entire buffer. If the underlying stream throws,
12+
// gomobile will convert the IOException into a Go error and
13+
// io.Copy will stop immediately.
14+
override fun write(data: ByteArray): Long {
15+
outputStream.write(data) // may throw IOException=
16+
return data.size.toLong() // reached only on success
17+
}
18+
override fun close() {
19+
try { outputStream.flush() } catch (_: Exception) { /* ignore */ }
20+
outputStream.close()
2021
}
21-
}
22-
23-
override fun close() {
24-
outputStream.close()
25-
}
2622
}
23+

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

Lines changed: 212 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ 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 java.io.FileOutputStream
1115
import java.io.IOException
1216
import java.io.OutputStream
1317
import java.util.UUID
@@ -29,100 +33,149 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
2933
// A simple data class that holds a SAF OutputStream along with its URI.
3034
data class SafStream(val uri: String, val stream: OutputStream)
3135

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-
}
36+
// A helper function that opens or creates a SafStream for a given file.
37+
private fun openSafFileOutputStream(fileName: String): Pair<String, OutputStream?> {
38+
val context = appContext ?: return "" to null
39+
val dirUri = savedUri ?: return "" to null
40+
val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null
41+
42+
val file =
43+
dir.findFile(fileName)
44+
?: dir.createFile("application/octet-stream", fileName)
45+
?: return "" to null
46+
47+
val os = context.contentResolver.openOutputStream(file.uri, "rw")
48+
return file.uri.toString() to os
49+
}
50+
51+
private fun openWriterFD(fileName: String, offset: Long): Pair<String, SeekableOutputStream?> {
52+
53+
val ctx = appContext ?: return "" to null
54+
val dirUri = savedUri ?: return "" to null
55+
val dir = DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) ?: return "" to null
56+
57+
// Reuse existing doc if it exists
58+
val file =
59+
dir.findFile(fileName)
60+
?: dir.createFile("application/octet-stream", fileName)
61+
?: return "" to null
62+
63+
// Always get a ParcelFileDescriptor so we can sync
64+
val pfd = ctx.contentResolver.openFileDescriptor(file.uri, "rw") ?: return "" to null
65+
val fos = FileOutputStream(pfd.fileDescriptor)
66+
67+
if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0)
68+
69+
return file.uri.toString() to SeekableOutputStream(fos, pfd)
7470
}
7571

76-
// This method returns a SafStream containing the SAF URI and its corresponding OutputStream.
72+
private val currentUri = ConcurrentHashMap<String, String>()
73+
7774
override fun openFileWriter(fileName: String): libtailscale.OutputStream {
78-
val stream = createStreamCached(fileName)
79-
return OutputStreamAdapter(stream.stream)
75+
val (uri, stream) = openWriterFD(fileName, 0)
76+
currentUri[fileName] = uri // 🠚 cache the exact doc we opened
77+
return OutputStreamAdapter(stream ?: OutputStream.nullOutputStream())
78+
}
79+
80+
override fun openFileWriterAt(fileName: String, offset: Long): libtailscale.OutputStream {
81+
val (uri, stream) = openWriterFD(fileName, offset)
82+
currentUri[fileName] = uri
83+
return OutputStreamAdapter(stream ?: OutputStream.nullOutputStream())
8084
}
8185

8286
override fun openFileURI(fileName: String): String {
83-
val safFile = createStreamCached(fileName)
84-
return safFile.uri
87+
currentUri[fileName]?.let {
88+
return it
89+
}
90+
val ctx = appContext ?: return ""
91+
val dirStr = savedUri ?: return ""
92+
val dir = DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr)) ?: return ""
93+
94+
val file = dir.findFile(fileName) ?: return ""
95+
val uri = file.uri.toString()
96+
97+
currentUri[fileName] = uri
98+
return uri
8599
}
86100

87101
override fun renamePartialFile(
88-
partialUri: String,
89-
targetDirUri: String,
90-
targetName: String
91-
): String {
102+
partialUri: String,
103+
targetDirUri: String,
104+
targetName: String
105+
): String {
106+
val ctx = appContext ?: throw IOException("not initialized")
107+
val srcUri = Uri.parse(partialUri)
108+
val dir = DocumentFile.fromTreeUri(ctx, Uri.parse(targetDirUri))
109+
?: throw IOException("cannot open dir $targetDirUri")
110+
111+
var finalName = targetName
112+
dir.findFile(finalName)?.let { existing ->
113+
if (lengthOfUri(ctx, existing.uri) == 0L) {
114+
existing.delete() // remove stale 0‑byte file
115+
} else {
116+
finalName = generateNewFilename(finalName)
117+
}
118+
}
119+
92120
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)
105-
}
121+
DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)
122+
?.also { newUri ->
123+
runCatching { ctx.contentResolver.delete(srcUri, null, null) }
124+
cleanupPartials(dir, targetName)
125+
return newUri.toString()
126+
}
127+
} catch (_: Exception) {
128+
// rename not supported; fall through to copy‑delete
129+
}
130+
131+
// fallback - copy contents then delete source
132+
val dest = dir.createFile("application/octet-stream", finalName)
133+
?: throw IOException("createFile failed for $finalName")
106134

107-
destFile =
108-
targetDir.createFile("application/octet-stream", finalTargetName)
109-
?: throw IOException("Failed to create new file with name: $finalTargetName")
110-
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")
116-
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)
135+
ctx.contentResolver.openInputStream(srcUri).use { inp ->
136+
ctx.contentResolver.openOutputStream(dest.uri, "w").use { out ->
137+
if (inp == null || out == null) {
138+
dest.delete()
139+
throw IOException("Unable to open output stream for URI: ${dest.uri}")
140+
}
141+
inp.copyTo(out)
142+
}
143+
}
144+
// delete the original .partial
145+
ctx.contentResolver.delete(srcUri, null, null)
146+
cleanupPartials(dir, targetName)
147+
return dest.uri.toString()
148+
}
149+
150+
private fun lengthOfUri(ctx: Context, uri: Uri): Long =
151+
ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 }
152+
153+
// delete any stray “.partial” files for this base name
154+
private fun cleanupPartials(dir: DocumentFile, base: String) {
155+
for (child in dir.listFiles()) {
156+
val n = child.name ?: continue
157+
if (n.endsWith(".partial") && n.contains(base, ignoreCase = false)) {
158+
child.delete()
159+
}
160+
}
161+
}
162+
163+
@Throws(IOException::class)
164+
override fun deleteFile(uriString: String) {
165+
val ctx = appContext ?: throw IOException("DeleteFile: not initialized")
166+
167+
val uri = Uri.parse(uriString)
168+
val doc =
169+
DocumentFile.fromSingleUri(ctx, uri)
170+
?: throw IOException("DeleteFile: cannot resolve URI $uriString")
171+
172+
if (!doc.delete()) {
173+
throw IOException("DeleteFile: delete() returned false for $uriString")
123174
}
124175
}
125176

177+
override fun treeURI(): String = savedUri ?: throw IllegalStateException("not initialized")
178+
126179
fun generateNewFilename(filename: String): String {
127180
val dotIndex = filename.lastIndexOf('.')
128181
val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename
@@ -131,4 +184,83 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
131184
val uuid = UUID.randomUUID()
132185
return "$baseName-$uuid$extension"
133186
}
187+
188+
fun listPartialFiles(suffix: String): Array<String> {
189+
val context = appContext ?: return emptyArray()
190+
val rootUri = savedUri ?: return emptyArray()
191+
val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray()
192+
193+
return dir.listFiles()
194+
.filter { it.name?.endsWith(suffix) == true }
195+
.mapNotNull { it.name }
196+
.toTypedArray()
197+
}
198+
199+
override fun listPartialFilesJSON(suffix: String): String {
200+
return listPartialFiles(suffix)
201+
.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]")
202+
}
203+
204+
override fun openPartialFileReader(name: String): libtailscale.InputStream? {
205+
val context = appContext ?: return null
206+
val rootUri = savedUri ?: return null
207+
val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return null
208+
209+
// We know `name` includes the suffix (e.g. ".<id>.partial"), but the actual
210+
// file in SAF might include extra bits, so let's just match by that suffix.
211+
// You could also match exactly `endsWith(name)` if the filenames line up
212+
val suffix = name.substringAfterLast('.', ".$name") // or hard-code ".partial"
213+
214+
val file =
215+
dir.listFiles().firstOrNull {
216+
val fname = it.name ?: return@firstOrNull false
217+
// call the String overload explicitly:
218+
fname.endsWith(suffix, /*ignoreCase=*/ false)
219+
}
220+
?: run {
221+
TSLog.d("ShareFileHelper", "no file ending with $suffix in SAF directory")
222+
return null
223+
}
224+
225+
TSLog.d("ShareFileHelper", "found SAF file ${file.name}, opening")
226+
val inStream =
227+
context.contentResolver.openInputStream(file.uri)
228+
?: run {
229+
TSLog.d("ShareFileHelper", "openInputStream returned null for ${file.uri}")
230+
return null
231+
}
232+
return InputStreamAdapter(inStream)
233+
}
234+
}
235+
236+
private class SeekableOutputStream(
237+
private val fos: FileOutputStream,
238+
private val pfd: ParcelFileDescriptor
239+
) : OutputStream() {
240+
241+
private var closed = false
242+
243+
override fun write(b: Int) = fos.write(b)
244+
245+
override fun write(b: ByteArray) = fos.write(b)
246+
247+
override fun write(b: ByteArray, off: Int, len: Int) {
248+
fos.write(b, off, len)
249+
}
250+
251+
override fun close() {
252+
if (!closed) {
253+
closed = true
254+
try {
255+
fos.flush()
256+
fos.fd.sync() // blocks until data + metadata are durable
257+
val size = fos.channel.size()
258+
} finally {
259+
fos.close()
260+
pfd.close()
261+
}
262+
}
263+
}
264+
265+
override fun flush() = fos.flush()
134266
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/tailscale/tailscale-android
33
go 1.24.4
44

55
require (
6-
github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f
6+
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da
77
golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab
88
tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8
99
)

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U
163163
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
164164
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
165165
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
166-
github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f h1:vg3PmQdq1BbB2V81iC1VBICQtfwbVGZ/4A/p7QKXTK0=
167-
github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
166+
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
167+
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
168168
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
169169
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
170170
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=

0 commit comments

Comments
 (0)