@@ -5,9 +5,13 @@ 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 java.io.FileOutputStream
11
15
import java.io.IOException
12
16
import java.io.OutputStream
13
17
import java.util.UUID
@@ -29,100 +33,149 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
29
33
// A simple data class that holds a SAF OutputStream along with its URI.
30
34
data class SafStream (val uri : String , val stream : OutputStream )
31
35
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)
74
70
}
75
71
76
- // This method returns a SafStream containing the SAF URI and its corresponding OutputStream.
72
+ private val currentUri = ConcurrentHashMap <String , String >()
73
+
77
74
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())
80
84
}
81
85
82
86
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
85
99
}
86
100
87
101
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
+
92
120
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 " )
106
134
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 " )
123
174
}
124
175
}
125
176
177
+ override fun treeURI (): String = savedUri ? : throw IllegalStateException (" not initialized" )
178
+
126
179
fun generateNewFilename (filename : String ): String {
127
180
val dotIndex = filename.lastIndexOf(' .' )
128
181
val baseName = if (dotIndex != - 1 ) filename.substring(0 , dotIndex) else filename
@@ -131,4 +184,83 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
131
184
val uuid = UUID .randomUUID()
132
185
return " $baseName -$uuid$extension "
133
186
}
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()
134
266
}
0 commit comments