Skip to content

Commit 8370ee8

Browse files
committed
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]>
1 parent e5a704f commit 8370ee8

File tree

5 files changed

+153
-32
lines changed

5 files changed

+153
-32
lines changed

android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package com.tailscale.ipn.util
66
import android.content.Context
77
import android.net.Uri
88
import androidx.documentfile.provider.DocumentFile
9+
import com.tailscale.ipn.ui.util.InputStreamAdapter
910
import com.tailscale.ipn.ui.util.OutputStreamAdapter
1011
import libtailscale.Libtailscale
1112
import java.io.IOException
@@ -123,6 +124,22 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
123124
}
124125
}
125126

127+
@Throws(IOException::class)
128+
override fun deleteFile(uriString: String) {
129+
val ctx = appContext ?: throw IOException("DeleteFile: not initialized")
130+
131+
val uri = Uri.parse(uriString)
132+
val doc =
133+
DocumentFile.fromSingleUri(ctx, uri)
134+
?: throw IOException("DeleteFile: cannot resolve URI $uriString")
135+
136+
if (!doc.delete()) {
137+
throw IOException("DeleteFile: delete() returned false for $uriString")
138+
}
139+
}
140+
141+
override fun treeURI(): String = savedUri ?: throw IllegalStateException("not initialized")
142+
126143
fun generateNewFilename(filename: String): String {
127144
val dotIndex = filename.lastIndexOf('.')
128145
val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename
@@ -131,4 +148,51 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
131148
val uuid = UUID.randomUUID()
132149
return "$baseName-$uuid$extension"
133150
}
151+
152+
fun listPartialFiles(suffix: String): Array<String> {
153+
val context = appContext ?: return emptyArray()
154+
val rootUri = savedUri ?: return emptyArray()
155+
val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray()
156+
157+
return dir.listFiles()
158+
.filter { it.name?.endsWith(suffix) == true }
159+
.mapNotNull { it.name }
160+
.toTypedArray()
161+
}
162+
163+
override fun listPartialFilesJSON(suffix: String): String {
164+
return listPartialFiles(suffix)
165+
.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]")
166+
}
167+
168+
override fun openPartialFileReader(name: String): libtailscale.InputStream? {
169+
val context = appContext ?: return null
170+
val rootUri = savedUri ?: return null
171+
val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return null
172+
173+
// We know `name` includes the suffix (e.g. ".<id>.partial"), but the actual
174+
// file in SAF might include extra bits, so let's just match by that suffix.
175+
// You could also match exactly `endsWith(name)` if the filenames line up
176+
val suffix = name.substringAfterLast('.', ".$name") // or hard-code ".partial"
177+
178+
val file =
179+
dir.listFiles().firstOrNull {
180+
val fname = it.name ?: return@firstOrNull false
181+
// call the String overload explicitly:
182+
fname.endsWith(suffix, /*ignoreCase=*/ false)
183+
}
184+
?: run {
185+
TSLog.d("ShareFileHelper", "no file ending with $suffix in SAF directory")
186+
return null
187+
}
188+
189+
TSLog.d("ShareFileHelper", "found SAF file ${file.name}, opening")
190+
val inStream =
191+
context.contentResolver.openInputStream(file.uri)
192+
?: run {
193+
TSLog.d("ShareFileHelper", "openInputStream returned null for ${file.uri}")
194+
return null
195+
}
196+
return InputStreamAdapter(inStream)
197+
}
134198
}

libtailscale/fileops.go

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,72 @@ package libtailscale
55
import (
66
"fmt"
77
"io"
8+
"net/url"
9+
"os"
10+
"path"
11+
12+
"tailscale.com/feature/taildrop"
813
)
914

1015
// AndroidFileOps implements the ShareFileHelper interface using the Android helper.
1116
type AndroidFileOps struct {
1217
helper ShareFileHelper
1318
}
1419

20+
// compile-time assertion
21+
var _ taildrop.FileOps = (*AndroidFileOps)(nil)
22+
1523
func NewAndroidFileOps(helper ShareFileHelper) *AndroidFileOps {
1624
return &AndroidFileOps{helper: helper}
1725
}
1826

19-
func (ops *AndroidFileOps) OpenFileURI(filename string) string {
20-
return ops.helper.OpenFileURI(filename)
27+
func (ops *AndroidFileOps) OpenWriter(partialStr, dest string, offset int64, perm os.FileMode) (io.WriteCloser, string, error) {
28+
if offset != 0 {
29+
return nil, "", fmt.Errorf("resume unsupported in SAF mode")
30+
}
31+
partial := dest + partialStr
32+
wc := ops.helper.OpenFileWriter(partial)
33+
if wc == nil {
34+
return nil, "", fmt.Errorf("OpenFileWriter returned nil for %q", dest)
35+
}
36+
uri := ops.helper.OpenFileURI(partial)
37+
return wc, uri, nil
2138
}
2239

23-
func (ops *AndroidFileOps) OpenFileWriter(filename string) (io.WriteCloser, string, error) {
24-
uri := ops.helper.OpenFileURI(filename)
25-
outputStream := ops.helper.OpenFileWriter(filename)
26-
if outputStream == nil {
27-
return nil, uri, fmt.Errorf("failed to open SAF output stream for %s", filename)
40+
func (ops *AndroidFileOps) Base(pathOrURI string) string {
41+
if u, err := url.Parse(pathOrURI); err == nil && u.Scheme != "" {
42+
return path.Base(u.Path)
2843
}
29-
return outputStream, uri, nil
44+
return path.Base(pathOrURI)
3045
}
3146

32-
func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error) {
33-
newURI := ops.helper.RenamePartialFile(partialUri, targetDirUri, targetName)
47+
func (ops *AndroidFileOps) Join(dir, name string) string {
48+
return ops.helper.OpenFileURI(name)
49+
}
50+
51+
func (ops *AndroidFileOps) Remove(baseName string) error {
52+
uri := ops.helper.OpenFileURI(baseName)
53+
return ops.helper.DeleteFile(uri)
54+
}
55+
56+
func (ops *AndroidFileOps) Rename(partialURI, finalName string) (string, error) {
57+
tree := ops.helper.TreeURI()
58+
newURI := ops.helper.RenamePartialFile(partialURI, tree, finalName)
3459
if newURI == "" {
35-
return "", fmt.Errorf("failed to rename partial file via SAF")
60+
return "", fmt.Errorf("SAF rename failed")
3661
}
3762
return newURI, nil
3863
}
64+
65+
func (ops *AndroidFileOps) OpenReader(name string) (io.ReadCloser, error) {
66+
in := ops.helper.OpenPartialFileReader(name)
67+
if in == nil {
68+
return nil, fmt.Errorf("OpenPartialFileReader returned nil for %q", name)
69+
}
70+
// adapt the gobind InputStream to an io.ReadCloser
71+
return adaptInputStream(in), nil
72+
}
73+
74+
func (ops *AndroidFileOps) IsDirect() bool {
75+
return false
76+
}

libtailscale/interfaces.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,14 @@ type ShareFileHelper interface {
183183
// RenamePartialFile takes SAF URIs and a target file name,
184184
// and returns the new SAF URI and an error.
185185
RenamePartialFile(partialUri string, targetDirUri string, targetName string) string
186+
187+
ListPartialFilesJSON(suffix string) string
188+
189+
OpenPartialFileReader(name string) InputStream
190+
191+
DeleteFile(uriString string) error
192+
193+
TreeURI() string
186194
}
187195

188196
// The below are global callbacks that allow the Java application to notify Go

libtailscale/localapi.go

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -230,27 +230,6 @@ func (r *Response) Flush() {
230230
})
231231
}
232232

233-
func adaptInputStream(in InputStream) io.ReadCloser {
234-
if in == nil {
235-
return nil
236-
}
237-
r, w := io.Pipe()
238-
go func() {
239-
defer w.Close()
240-
for {
241-
b, err := in.Read()
242-
if err != nil {
243-
log.Printf("error reading from inputstream: %s", err)
244-
}
245-
if b == nil {
246-
return
247-
}
248-
w.Write(b)
249-
}
250-
}()
251-
return r
252-
}
253-
254233
// Below taken from Go stdlib
255234
var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
256235

libtailscale/streamutil.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package libtailscale
5+
6+
import (
7+
"io"
8+
"log"
9+
)
10+
11+
// adaptInputStream wraps a libtailscale.InputStream into an io.ReadCloser.
12+
// It launches a goroutine to stream reads into a pipe.
13+
func adaptInputStream(in InputStream) io.ReadCloser {
14+
if in == nil {
15+
return nil
16+
}
17+
r, w := io.Pipe()
18+
go func() {
19+
defer w.Close()
20+
for {
21+
b, err := in.Read()
22+
if err != nil {
23+
log.Printf("error reading from inputstream: %s", err)
24+
}
25+
if b == nil {
26+
return
27+
}
28+
w.Write(b)
29+
}
30+
}()
31+
return r
32+
}

0 commit comments

Comments
 (0)