@@ -5,9 +5,14 @@ package com.tailscale.ipn.util
55
66import android.content.Context
77import android.net.Uri
8+ import android.os.ParcelFileDescriptor
9+ import android.provider.DocumentsContract
810import androidx.documentfile.provider.DocumentFile
11+ import com.tailscale.ipn.ui.util.InputStreamAdapter
912import com.tailscale.ipn.ui.util.OutputStreamAdapter
1013import libtailscale.Libtailscale
14+ import org.json.JSONObject
15+ import java.io.FileOutputStream
1116import java.io.IOException
1217import java.io.OutputStream
1318import 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}
0 commit comments