Skip to content

Commit d33b745

Browse files
kari-tsbarnstar
authored andcommitted
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]> (cherry picked from commit e71641a) Signed-off-by: kari-ts <[email protected]> Signed-off-by: Jonathan Nobels <[email protected]>
1 parent 529f5d4 commit d33b745

File tree

9 files changed

+390
-129
lines changed

9 files changed

+390
-129
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
@@ -51,7 +51,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi
5151
val currentUser by viewModel.loggedInUser.collectAsState()
5252
val showHeaderMenu by viewModel.showHeaderMenu.collectAsState()
5353

54-
Scaffold(
54+
Scaffold(
5555
topBar = {
5656
Header(
5757
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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,19 @@
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+
<string name="delete_tailnet">Delete tailnet</string>
140+
<string name="contact_support">Contact support</string>
141+
<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>
142+
<string name="request_deletion_owner_part1">
143+
As the owner of this tailnet, to remove yourself from the tailnet you can either reassign ownership and contact our Support team, or delete the whole tailnet through the admin console. To do the latter, go to
144+
</string>
145+
<string name="request_deletion_owner_part2a">
146+
and look for “Delete tailnet”.
147+
</string>
148+
149+
<string name="request_deletion_owner_part2b">
150+
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.
151+
</string>
139152

140153
<!-- Strings for ExitNode picker -->
141154
<string name="choose_exit_node">Choose exit node</string>

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)