@@ -5,9 +5,14 @@ package com.tailscale.ipn.util
5
5
6
6
import android.content.Context
7
7
import android.net.Uri
8
+ import android.os.ParcelFileDescriptor
9
+ import android.provider.DocumentsContract
8
10
import androidx.documentfile.provider.DocumentFile
11
+ import com.tailscale.ipn.ui.util.InputStreamAdapter
9
12
import com.tailscale.ipn.ui.util.OutputStreamAdapter
10
13
import libtailscale.Libtailscale
14
+ import org.json.JSONObject
15
+ import java.io.FileOutputStream
11
16
import java.io.IOException
12
17
import java.io.OutputStream
13
18
import java.util.UUID
@@ -29,100 +34,169 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
29
34
// A simple data class that holds a SAF OutputStream along with its URI.
30
35
data class SafStream (val uri : String , val stream : OutputStream )
31
36
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
74
50
}
75
51
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)
80
71
}
81
72
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)
85
83
}
86
84
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
+
92
121
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()
105
126
}
127
+ } catch (e: Exception ) {
128
+ TSLog .w(" renameFile" , " renameDocument fallback triggered for $srcUri -> $finalName : ${e.message} " )
106
129
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
+ }
110
150
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 }
116
153
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
+ }
123
161
}
124
162
}
125
163
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
+
126
200
fun generateNewFilename (filename : String ): String {
127
201
val dotIndex = filename.lastIndexOf(' .' )
128
202
val baseName = if (dotIndex != - 1 ) filename.substring(0 , dotIndex) else filename
@@ -131,4 +205,78 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
131
205
val uuid = UUID .randomUUID()
132
206
return " $baseName -$uuid$extension "
133
207
}
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
+ }
134
282
}
0 commit comments