@@ -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