android: bump OSS

OSS and Version updated to 1.87.25-t0f15e4419-gde3b6dbfd

Signed-off-by: kari-ts <kari@tailscale.com>
pull/675/head
kari-ts 4 months ago
parent de3b6dbfd6
commit 3022490db6

@ -49,91 +49,90 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
return file.uri.toString() to os
}
private fun openWriterFD(fileName: String, offset: Long): Pair<String, SeekableOutputStream?> {
val ctx = appContext ?: return "" to null
val dirUri = savedUri ?: return "" to null
val dir = DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) ?: return "" to null
// Reuse existing doc if it exists
@Throws(IOException::class)
private fun openWriterFD(fileName: String, offset: Long): Pair<String, SeekableOutputStream> {
val ctx = appContext ?: throw IOException("App context not initialized")
val dirUri = savedUri ?: throw IOException("No directory URI")
val dir =
DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri))
?: throw IOException("Invalid tree URI: $dirUri")
val file =
dir.findFile(fileName)
?: dir.createFile("application/octet-stream", fileName)
?: return "" to null
?: throw IOException("Failed to create file: $fileName")
// Always get a ParcelFileDescriptor so we can sync
val pfd = ctx.contentResolver.openFileDescriptor(file.uri, "rw") ?: return "" to null
val pfd =
ctx.contentResolver.openFileDescriptor(file.uri, "rw")
?: throw IOException("Failed to open file descriptor for ${file.uri}")
val fos = FileOutputStream(pfd.fileDescriptor)
if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0)
return file.uri.toString() to SeekableOutputStream(fos, pfd)
}
private val currentUri = ConcurrentHashMap<String, String>()
override fun openFileWriter(fileName: String): libtailscale.OutputStream {
val (uri, stream) = openWriterFD(fileName, 0)
currentUri[fileName] = uri // 🠚 cache the exact doc we opened
return OutputStreamAdapter(stream ?: OutputStream.nullOutputStream())
}
override fun openFileWriterAt(fileName: String, offset: Long): libtailscale.OutputStream {
@Throws(IOException::class)
override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream {
val (uri, stream) = openWriterFD(fileName, offset)
if (stream == null) {
throw IOException("Failed to open file writer for $fileName")
}
currentUri[fileName] = uri
return OutputStreamAdapter(stream ?: OutputStream.nullOutputStream())
return OutputStreamAdapter(stream)
}
override fun openFileURI(fileName: String): String {
@Throws(IOException::class)
override fun getFileURI(fileName: String): String {
currentUri[fileName]?.let {
return it
}
val ctx = appContext ?: return ""
val dirStr = savedUri ?: return ""
val dir = DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr)) ?: return ""
val file = dir.findFile(fileName) ?: return ""
val uri = file.uri.toString()
val ctx = appContext ?: throw IOException("App context not initialized")
val dirStr = savedUri ?: throw IOException("No saved directory URI")
val dir =
DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr))
?: throw IOException("Invalid tree URI: $dirStr")
val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName")
val uri = file.uri.toString()
currentUri[fileName] = uri
return uri
}
override fun renamePartialFile(
partialUri: String,
targetDirUri: String,
targetName: String
): String {
@Throws(IOException::class)
override fun renameFile(oldPath: String, targetName: String): String {
val ctx = appContext ?: throw IOException("not initialized")
val srcUri = Uri.parse(partialUri)
val dirUri = savedUri ?: throw IOException("directory not set")
val srcUri = Uri.parse(oldPath)
val dir =
DocumentFile.fromTreeUri(ctx, Uri.parse(targetDirUri))
?: throw IOException("cannot open dir $targetDirUri")
DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri))
?: throw IOException("cannot open dir $dirUri")
var finalName = targetName
dir.findFile(finalName)?.let { existing ->
if (lengthOfUri(ctx, existing.uri) == 0L) {
existing.delete() // remove stale 0byte file
existing.delete()
} else {
finalName = generateNewFilename(finalName)
}
}
try {
DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri ->
runCatching { ctx.contentResolver.delete(srcUri, null, null) }
cleanupPartials(dir, targetName)
return newUri.toString()
}
} catch (_: Exception) {
// rename not supported; fall through to copydelete
}
} catch (e: Exception) {
TSLog.w("renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}")
// fallback - copy contents then delete source
}
val dest =
dir.createFile("application/octet-stream", finalName)
?: throw IOException("createFile failed for $finalName")
ctx.contentResolver.openInputStream(srcUri).use { inp ->
ctx.contentResolver.openOutputStream(dest.uri, "w").use { out ->
if (inp == null || out == null) {
@ -143,7 +142,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
inp.copyTo(out)
}
}
// delete the original .partial
ctx.contentResolver.delete(srcUri, null, null)
cleanupPartials(dir, targetName)
return dest.uri.toString()
@ -163,33 +162,35 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
}
@Throws(IOException::class)
override fun deleteFile(uriString: String) {
override fun deleteFile(uri: String) {
val ctx = appContext ?: throw IOException("DeleteFile: not initialized")
val uri = Uri.parse(uriString)
val uri = Uri.parse(uri)
val doc =
DocumentFile.fromSingleUri(ctx, uri)
?: throw IOException("DeleteFile: cannot resolve URI $uriString")
?: throw IOException("DeleteFile: cannot resolve URI $uri")
if (!doc.delete()) {
throw IOException("DeleteFile: delete() returned false for $uriString")
throw IOException("DeleteFile: delete() returned false for $uri")
}
}
override fun treeURI(): String = savedUri ?: throw IllegalStateException("not initialized")
@Throws(IOException::class)
override fun getFileInfo(fileName: String): String {
val context = appContext ?: return ""
val dirUri = savedUri ?: return ""
val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return ""
val context = appContext ?: throw IOException("app context not initialized")
val dirUri = savedUri ?: throw IOException("SAF URI not initialized")
val dir =
DocumentFile.fromTreeUri(context, Uri.parse(dirUri))
?: throw IOException("could not resolve SAF root")
val file = dir.findFile(fileName) ?: return ""
val file =
dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory")
val name = file.name ?: return ""
val name = file.name ?: throw IOException("file name missing for $fileName")
val size = file.length()
val modTime = file.lastModified() // milliseconds since epoch
val modTime = file.lastModified()
return """{"name":${jsonEscape(name)},"size":$size,"modTime":$modTime}"""
return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}"""
}
private fun jsonEscape(s: String): String {
@ -216,71 +217,66 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
.toTypedArray()
}
override fun listPartialFilesJSON(suffix: String): String {
return listPartialFiles(suffix)
.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]")
@Throws(IOException::class)
override fun listFilesJSON(suffix: String): String {
val list = listPartialFiles(suffix)
if (list.isEmpty()) {
throw IOException("no files found matching suffix \"$suffix\"")
}
return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]")
}
override fun openPartialFileReader(name: String): libtailscale.InputStream? {
val context = appContext ?: return null
val rootUri = savedUri ?: return null
val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return null
@Throws(IOException::class)
override fun openFileReader(name: String): libtailscale.InputStream {
val context = appContext ?: throw IOException("app context not initialized")
val rootUri = savedUri ?: throw IOException("SAF URI not initialized")
val dir =
DocumentFile.fromTreeUri(context, Uri.parse(rootUri))
?: throw IOException("could not open SAF root")
// We know `name` includes the suffix (e.g. ".<id>.partial"), but the actual
// file in SAF might include extra bits, so let's just match by that suffix.
// You could also match exactly `endsWith(name)` if the filenames line up
val suffix = name.substringAfterLast('.', ".$name") // or hard-code ".partial"
val suffix = name.substringAfterLast('.', ".$name")
val file =
dir.listFiles().firstOrNull {
val fname = it.name ?: return@firstOrNull false
// call the String overload explicitly:
fname.endsWith(suffix, /*ignoreCase=*/ false)
}
?: run {
TSLog.d("ShareFileHelper", "no file ending with $suffix in SAF directory")
return null
}
fname.endsWith(suffix, ignoreCase = false)
} ?: throw IOException("no file ending with \"$suffix\" in SAF directory")
TSLog.d("ShareFileHelper", "found SAF file ${file.name}, opening")
val inStream =
context.contentResolver.openInputStream(file.uri)
?: run {
TSLog.d("ShareFileHelper", "openInputStream returned null for ${file.uri}")
return null
}
?: throw IOException("openInputStream returned null for ${file.uri}")
return InputStreamAdapter(inStream)
}
}
private class SeekableOutputStream(
private val fos: FileOutputStream,
private val pfd: ParcelFileDescriptor
) : OutputStream() {
private class SeekableOutputStream(
private val fos: FileOutputStream,
private val pfd: ParcelFileDescriptor
) : OutputStream() {
private var closed = false
private var closed = false
override fun write(b: Int) = fos.write(b)
override fun write(b: Int) = fos.write(b)
override fun write(b: ByteArray) = fos.write(b)
override fun write(b: ByteArray) = fos.write(b)
override fun write(b: ByteArray, off: Int, len: Int) {
fos.write(b, off, len)
}
override fun write(b: ByteArray, off: Int, len: Int) {
fos.write(b, off, len)
}
override fun close() {
if (!closed) {
closed = true
try {
fos.flush()
fos.fd.sync() // blocks until data + metadata are durable
val size = fos.channel.size()
} finally {
fos.close()
pfd.close()
override fun close() {
if (!closed) {
closed = true
try {
fos.flush()
fos.fd.sync() // blocks until data + metadata are durable
} finally {
fos.close()
pfd.close()
}
}
}
}
override fun flush() = fos.flush()
override fun flush() = fos.flush()
}
}

@ -3,9 +3,9 @@ module github.com/tailscale/tailscale-android
go 1.24.4
require (
github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da
golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab
tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8
tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683
)
require (

@ -163,8 +163,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f h1:vg3PmQdq1BbB2V81iC1VBICQtfwbVGZ/4A/p7QKXTK0=
github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8 h1:gR3XF35IWpV4WhON27gR2vd8ypXbnjnrj5WreLWFxWk=
tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8/go.mod h1:zrtwlwmFfEWbUz77UN58gaLADx4rXSecFhGO+XW0JbU=
tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683 h1:meEUX1Nsr5SaXiaeivOGG4c7gsQm/P3Jr3dzbtE0j6k=
tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M=

@ -337,7 +337,7 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore,
}
lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0)
if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok {
ext.SetFileOps(NewAndroidFileOps(a.shareFileHelper))
ext.SetFileOps(newAndroidFileOps(a.shareFileHelper))
ext.SetDirectFileRoot(a.directFileRoot)
}

@ -4,7 +4,6 @@ package libtailscale
import (
"encoding/json"
"fmt"
"io"
"os"
"time"
@ -12,47 +11,47 @@ import (
"tailscale.com/feature/taildrop"
)
// AndroidFileOps implements the FileOps interface using the Android ShareFileHelper.
type AndroidFileOps struct {
// androidFileOps implements [taildrop.FileOps] using the Android ShareFileHelper.
type androidFileOps struct {
helper ShareFileHelper
}
var _ taildrop.FileOps = (*AndroidFileOps)(nil)
var _ taildrop.FileOps = (*androidFileOps)(nil)
func NewAndroidFileOps(helper ShareFileHelper) *AndroidFileOps {
return &AndroidFileOps{helper: helper}
func newAndroidFileOps(helper ShareFileHelper) *androidFileOps {
return &androidFileOps{helper: helper}
}
func (ops *AndroidFileOps) OpenWriter(name string, offset int64, perm os.FileMode) (io.WriteCloser, string, error) {
var wc OutputStream
if offset == 0 {
wc = ops.helper.OpenFileWriter(name)
} else {
wc = ops.helper.OpenFileWriterAt(name, offset)
func (ops *androidFileOps) OpenWriter(name string, offset int64, _ os.FileMode) (io.WriteCloser, string, error) {
wc, err := ops.helper.OpenFileWriter(name, offset)
if err != nil {
return nil, "", err
}
if wc == nil {
return nil, "", fmt.Errorf("OpenFileWriter returned nil for %q", name)
uri, err := ops.helper.GetFileURI(name)
if err != nil {
wc.Close()
return nil, "", err
}
uri := ops.helper.OpenFileURI(name)
return wc, uri, nil
}
func (ops *AndroidFileOps) Remove(baseName string) error {
uri := ops.helper.OpenFileURI(baseName)
func (ops *androidFileOps) Remove(baseName string) error {
uri, err := ops.helper.GetFileURI(baseName)
if err != nil {
return err
}
return ops.helper.DeleteFile(uri)
}
func (ops *AndroidFileOps) Rename(oldPath, newName string) (string, error) {
tree := ops.helper.TreeURI()
newURI := ops.helper.RenamePartialFile(oldPath, tree, newName)
if newURI == "" {
return "", fmt.Errorf("failed to rename partial file via SAF")
}
return newURI, nil
func (ops *androidFileOps) Rename(oldPath, newName string) (string, error) {
return ops.helper.RenameFile(oldPath, newName)
}
func (ops *AndroidFileOps) ListFiles() ([]string, error) {
namesJSON := ops.helper.ListPartialFilesJSON("")
func (ops *androidFileOps) ListFiles() ([]string, error) {
namesJSON, err := ops.helper.ListFilesJSON("")
if err != nil {
return nil, err
}
var names []string
if err := json.Unmarshal([]byte(namesJSON), &names); err != nil {
return nil, err
@ -60,43 +59,42 @@ func (ops *AndroidFileOps) ListFiles() ([]string, error) {
return names, nil
}
func (ops *AndroidFileOps) OpenReader(name string) (io.ReadCloser, error) {
in := ops.helper.OpenPartialFileReader(name)
if in == nil {
return nil, fmt.Errorf("OpenPartialFileReader returned nil for %q", name)
func (ops *androidFileOps) OpenReader(name string) (io.ReadCloser, error) {
in, err := ops.helper.OpenFileReader(name)
if err != nil {
return nil, err
}
return adaptInputStream(in), nil
}
func (ops *AndroidFileOps) Stat(name string) (os.FileInfo, error) {
infoJSON := ops.helper.GetFileInfo(name)
if infoJSON == "" {
return nil, os.ErrNotExist
}
var info struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModTime int64 `json:"modTime"` // Unix millis
func (ops *androidFileOps) Stat(name string) (os.FileInfo, error) {
infoJSON, err := ops.helper.GetFileInfo(name)
if err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(infoJSON), &info); err != nil {
var fi androidFileInfo
if err := json.Unmarshal([]byte(infoJSON), &fi); err != nil {
return nil, err
}
return &androidFileInfo{
name: info.Name,
size: info.Size,
modTime: time.UnixMilli(info.ModTime),
}, nil
return &fi, nil
}
type androidFileInfoJSON struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModTime int64 `json:"modTime"`
}
type androidFileInfo struct {
name string
size int64
modTime time.Time
data androidFileInfoJSON
}
func (fi *androidFileInfo) Name() string { return fi.name }
func (fi *androidFileInfo) Size() int64 { return fi.size }
// compile-time check
var _ os.FileInfo = (*androidFileInfo)(nil)
func (fi *androidFileInfo) Name() string { return fi.data.Name }
func (fi *androidFileInfo) Size() int64 { return fi.data.Size }
func (fi *androidFileInfo) Mode() os.FileMode { return 0o600 }
func (fi *androidFileInfo) ModTime() time.Time { return fi.modTime }
func (fi *androidFileInfo) ModTime() time.Time { return time.UnixMilli(fi.data.ModTime) }
func (fi *androidFileInfo) IsDir() bool { return false }
func (fi *androidFileInfo) Sys() interface{} { return nil }
func (fi *androidFileInfo) Sys() any { return nil }

@ -175,45 +175,36 @@ type OutputStream interface {
// ShareFileHelper corresponds to the Kotlin ShareFileHelper class
type ShareFileHelper interface {
// OpenFileWriter creates or truncates a file named fileName
// and returns an OutputStream for writing to it from the beginning.
// Returns nil if the file cannot be opened.
OpenFileWriter(fileName string) OutputStream
// OpenFileWriterAt opens fileName for writing at a given offset.
// Returns nil if the file cannot be opened.
OpenFileWriterAt(fileName string, offset int64) OutputStream
// OpenFileURI returns the SAF URI string for the file named fileName,
// or an empty string if the file cannot be resolved.
OpenFileURI(fileName string) string
// RenamePartialFile renames the file at oldPath (a SAF URI)
// into the directory identified by newPath (a tree URI),
// giving it the new targetName. Returns the SAF URI of the renamed file,
// or an empty string if the operation failed.
RenamePartialFile(oldPath string, newPath string, targetName string) string
// ListPartialFilesJSON returns a JSON-encoded list of partial filenames
// (e.g., ["foo.partial", "bar.partial"]) that match the given suffix.
ListPartialFilesJSON(suffix string) string
// OpenPartialFileReader opens the file with the given name (typically a .partial file)
// OpenFileWriter creates or truncates a file named fileName at a given offset,
// returning an OutputStream for writing. Returns an error if the file cannot be opened.
OpenFileWriter(fileName string, offset int64) (stream OutputStream, err error)
// GetFileURI returns the SAF URI string for the file named fileName,
// or an error if the file cannot be resolved.
GetFileURI(fileName string) (uri string, err error)
// RenameFile renames the file at oldPath (a SAF URI) into the Taildrop directory,
// giving it the new targetName. Returns the SAF URI of the renamed file, or an error.
RenameFile(oldPath string, targetName string) (newURI string, err error)
// ListFilesJSON returns a JSON-encoded list of filenames in the Taildrop directory
// that end with the specified suffix. If the suffix is empty, it returns all files.
// Returns an error if no matching files are found or the directory cannot be accessed.
ListFilesJSON(suffix string) (json string, err error)
// OpenFileReader opens the file with the given name (typically a .partial file)
// and returns an InputStream for reading its contents.
// Returns nil if the file cannot be opened.
OpenPartialFileReader(name string) InputStream
// Returns an error if the file cannot be opened.
OpenFileReader(name string) (stream InputStream, err error)
// DeleteFile deletes the file identified by the given SAF URI string.
// Returns an error if the file could not be deleted.
DeleteFile(uriString string) error
// TreeURI returns the SAF tree URI representing the root directory for Taildrop files.
// This is typically the URI granted by the user via the Android directory picker.
TreeURI() string
DeleteFile(uri string) error
// GetFileInfo returns a JSON-encoded string with file metadata for fileName.
// Returns an empty string if the file does not exist or cannot be accessed.
GetFileInfo(fileName string) string
// GetFileInfo returns a JSON-encoded string containing metadata for fileName,
// matching the fields of androidFileInfo (name, size, modTime).
// Returns an error if the file does not exist or cannot be accessed.
GetFileInfo(fileName string) (json string, err error)
}
// The below are global callbacks that allow the Java application to notify Go

@ -8,7 +8,7 @@ import (
"log"
)
// adaptInputStream wraps a libtailscale.InputStream into an io.ReadCloser.
// adaptInputStream wraps an [InputStream] into an [io.ReadCloser].
// It launches a goroutine to stream reads into a pipe.
func adaptInputStream(in InputStream) io.ReadCloser {
if in == nil {
@ -20,12 +20,16 @@ func adaptInputStream(in InputStream) io.ReadCloser {
for {
b, err := in.Read()
if err != nil {
log.Printf("error reading from inputstream: %s", err)
log.Printf("error reading from inputstream: %v", err)
return
}
if b == nil {
return
}
w.Write(b)
if _, err := w.Write(b); err != nil {
log.Printf("error writing to pipe: %v", err)
return
}
}
}()
return r

Loading…
Cancel
Save