Skip to content

Conversation

kari-ts
Copy link
Collaborator

@kari-ts kari-ts commented Apr 3, 2025

Use Android Storage Access Framework for receiving Taildropped files.

-Add a picker to allow users to select where Taildropped files go
-If no directory is selected, internal app storage is used
-Provide SAF API for Go to use when writing and renaming files
-Provide Android FileOps implementation

Updates tailscale/tailscale#15263

@ghost
Copy link

ghost commented Apr 3, 2025

Pull Request Revisions

RevisionDescription
r20
Added Storage Access Framework file handlingImplemented advanced file handling for Taildrop using Storage Access Framework, including directory selection, persistent URI storage, and file stream management across multiple Android components.
r19
Removed unused variable in backendDeleted an unused variable from the GetExt method before the conditional check in the backend initialization
r18
Added SAF directory selection for TaildropEnhanced Android app to support Storage Access Framework (SAF) directory selection for Taildrop file sharing, with persistent URI storage and backend restart mechanisms
r17
Updated Tailscale dependency versionAdded a new Tailscale module version 1.83.0-pre.0 to the project's go.sum file
r16
Implemented Storage Access Framework supportAdded Storage Access Framework support for Taildrop file sharing, enabling user-selected download directories and persistent file access permissions
15 more revisions
r15
Removed Creachadair taskgroup dependencyDeleted github.com/creachadair/taskgroup package from go.sum, removing this specific dependency from the project
r14
Removed eventbus from netmon initializationSimplified netmon.New() call by removing eventbus parameter and associated bus.Close() deferred call
r13
Removed several dependency modulesDeleted Prometheus, goautoneg, and protobuf dependencies from go.mod and go.sum files
r12
Added Storage Access Framework file handlingImplemented comprehensive Storage Access Framework (SAF) support for file operations in Android, including directory selection, file writing, and renaming, with modifications to App, MainActivity, and backend systems to enable flexible Taildrop file handling
r11
Updated Go toolchain revision hashReplaced Go toolchain revision hash with a new commit identifier
r10
Module dependencies updated and upgradedUpdated Go module dependencies, including Prometheus client, crypto, and system libraries to newer versions
r9No changes since last revision
r8
Added Storage Access Framework file supportImplemented Storage Access Framework (SAF) file handling for Android, enabling flexible file storage selection and management for Taildrop file transfers with support for dynamic directory selection and URI persistence.
r7
Implemented Storage Access Framework supportAdded Storage Access Framework (SAF) support for Taildrop file sharing, enabling users to choose custom download directories and improving file management across Android versions
r6
Removed duplicate Tailscale start methodRemoved redundant startLibtailscale call and removed local Tailscale module replacement in go.mod
r5
Updated Android file sharing implementationRefactored ShareFileHelper and file operations with improved error handling, stream management, and added OutputStreamAdapter for Java-Go interoperability
r4
Code formatting and import reorderingPerformed consistent code formatting in Kotlin files, including import reorganization and whitespace alignment
r3
Improved file handling and error managementEnhanced ShareFileHelper and MainViewModel with robust file URI validation, better error handling, and more comprehensive file operations in Android implementation
r2
Refactored ShareFileHelper and file handlingUpdated ShareFileHelper with new methods, improved file descriptor handling, added caching for file creation, and modified Android file operations interface
r1
Added Storage Access Framework supportImplemented SAF file sharing mechanism for Taildrop, with file operations, directory selection, and persistent URI storage

☑️ AI review skipped after 5 revisions, comment with `/review` to review again
Help React with emojis to give feedback on AI-generated reviews:
  • 👍 means the feedback was helpful and actionable
  • 👎 means the feedback was incorrect or unhelpful
💬 Replying to feedback with a comment helps us improve the system. Your input also contributes to shaping future interactions with the AI reviewer.

We'd love to hear from you—reach out anytime at [email protected].

@kari-ts kari-ts force-pushed the kari/taildropsaf branch from 0a9a3d8 to 3d78bbd Compare April 4, 2025 23:10
@kari-ts kari-ts force-pushed the kari/taildropsaf branch from 3d78bbd to 892300f Compare April 4, 2025 23:28
@kari-ts kari-ts force-pushed the kari/taildropsaf branch from 892300f to 92047bf Compare April 4, 2025 23:33
}

viewModelScope.launch {
showDirectoryPickerLauncher()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The showDirectoryPickerLauncher() call is made inside a viewModelScope.launch block during toggleVpn. Was this intentional? It could cause the directory picker to appear unexpectedly during VPN toggling, which might confuse users.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of agree with Skynet here. An actionable error message might be a less disruptive. Perhaps modelled after health warnings. Along with knowing where Taildrop files go (or don't go) and being able to change that in Settings after the fact.

...but this will do for now.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100%

Comment on lines +209 to +219
if (storedUri == null) {
// No stored URI, so launch the directory picker.
directoryPickerLauncher?.launch(null)
return
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After launching the directory picker, the function returns immediately. However, there's no indication that the function will be called again after the picker completes. Consider adding a callback mechanism to ensure the flow continues appropriately after directory selection.

Comment on lines +205 to +218
func SetShareFileHelper(fileHelper ShareFileHelper) {
// Drain the channel if there's an old value.
select {
case <-onShareFileHelper:
default:
// Channel was already empty.
}
select {
case onShareFileHelper <- fileHelper:
default:
// In the unlikely case the channel is still full, drain it and try again.
<-onShareFileHelper
onShareFileHelper <- fileHelper
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In SetShareFileHelper in interfaces.go, the current implementation handles a full channel by draining and adding the new value, but it could potentially lose an unprocessed helper. Is a buffered channel size of 1 sufficient, or should it be increased to handle more concurrent operations?

// Successfully sent log
default:
// Channel is full, log not sent
log.Printf("Log %v not sent", logstr) // missing argument in original code
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a missing format argument in SendLog function in interfaces.go. The log.Printf call uses '%v' but doesn't include the logstr variable in the argument list, which would cause a runtime error.

@kari-ts kari-ts requested a review from barnstar April 15, 2025 21:44
Copy link
Member

@barnstar barnstar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of nits, but looks good.

Small concern about the annoyance of popping up a forced picker, but allow users to know that the Taildrop dest is set/unset in Settings and set/change it and perhaps pop up a heath-esque warning if it's unset or unwritable but I understand that adds a bunch of complexity. Also unsure about AndroidTV and whether any of this is relevant.

}

viewModelScope.launch {
showDirectoryPickerLauncher()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of agree with Skynet here. An actionable error message might be a less disruptive. Perhaps modelled after health warnings. Along with knowing where Taildrop files go (or don't go) and being able to change that in Settings after the fact.

...but this will do for now.


context.contentResolver.openInputStream(partialUriObj)?.use { input ->
context.contentResolver.openOutputStream(destFile.uri)?.use { output ->
input.copyTo(output)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a lot of work to move a file but I image there's no alternative?

viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)

val directoryPickerLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are incoming Taildrop files relevant on Android TV? They are not on AppleTV. If the user can actually pick a directory and use the files - all good - otherwise we should skip the dialog.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good call. gating it with an AndroidTV check

data class SafStream(val uri: String, val stream: OutputStream)

// Cache for streams; keyed by file name and savedUri.
private val streamCache = ConcurrentHashMap<String, SafStream>()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The streamCache in ShareFileHelper could grow indefinitely since entries are never removed. This might lead to memory leaks if many files are created. Consider adding a cleanup mechanism that removes entries after they're no longer needed or implements an LRU cache with size limits.

Comment on lines +22 to +27
fun init(context: Context, uri: String) {
appContext = context.applicationContext
savedUri = uri
Libtailscale.setShareFileHelper(this)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The init function in ShareFileHelper doesn't handle URI validation. If an invalid URI is passed, it will be stored and used, potentially causing errors later. Consider adding validation to prevent storing invalid URIs.

Comment on lines +81 to +85
override fun openFileURI(fileName: String): String {
val safFile = createStreamCached(fileName)
return safFile.uri
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createStreamCached method always creates a file even when merely getting the URI in openFileURI. This could lead to empty files being created unnecessarily. Consider separating the file creation from URI retrieval or implementing lazy file creation.

Comment on lines +86 to +91
override fun renamePartialFile(
partialUri: String,
targetDirUri: String,
targetName: String
): String {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the renamePartialFile method, there's no validation if the target directory is writable before attempting operations. This could lead to failures during file operations. Consider checking write permissions to the target directory before proceeding.

Comment on lines +29 to +31
data class SafStream(val uri: String, val stream: OutputStream)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SafStream class doesn't implement proper resource management. If a file is cached but not used for a while, the underlying stream may be closed by the OS or could hold system resources unnecessarily. Consider adding a mechanism to close idle streams after a timeout period.

} ?: throw IOException("Unable to open output stream for URI: ${destFile.uri}")
} ?: throw IOException("Unable to open input stream for URI: $partialUri")

DocumentFile.fromSingleUri(context, partialUriObj)?.delete()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The renamePartialFile method could fail silently if DocumentFile.fromSingleUri(context, partialUriObj)?.delete() returns false or null. This might leave orphaned temporary files. Consider checking the return value and logging a warning.

@kari-ts kari-ts force-pushed the kari/taildropsaf branch 2 times, most recently from 8e19414 to aca0993 Compare April 24, 2025 21:54
Comment on lines +126 to +133
fun generateNewFilename(filename: String): String {
val dotIndex = filename.lastIndexOf('.')
val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename
val extension = if (dotIndex != -1) filename.substring(dotIndex) else ""

val uuid = UUID.randomUUID()
return "$baseName-$uuid$extension"
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generateNewFilename method uses UUID which is better than a timestamp, but it adds the UUID after the base filename. Consider using a more human-readable format where the original filename is still easily recognizable. For example, appending a shorter unique identifier with a timestamp like $baseName-$timestamp-$shortId$extension would make sorting and identification easier.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In libtailscale/backend.go, the directFileRoot value is set during initialization and there's a setDirectFileRoot method, but there's no synchronization mechanism when changing this value. If setDirectFileRoot is called while file operations are in progress, it could lead to race conditions or inconsistent behavior. Consider adding proper synchronization or ensuring the value can only be changed when no file operations are active.

Comment on lines +76 to +80
// This method returns a SafStream containing the SAF URI and its corresponding OutputStream.
override fun openFileWriter(fileName: String): libtailscale.OutputStream {
val stream = createStreamCached(fileName)
return OutputStreamAdapter(stream.stream)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's potential for resource leakage in the streamCache. The OutputStreamAdapter returned from openFileWriter has no mechanism to remove entries from the cache when the stream is closed. This can lead to accumulating stale streams and growing memory usage. Consider implementing a reference counting or purging mechanism for closed streams.

Comment on lines 161 to 200
val directoryPickerLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
if (uri != null) {
try {
// Try to take persistable permissions for both read and write.
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
} catch (e: SecurityException) {
TSLog.e("MainActivity", "Failed to persist permissions: $e")
}

// Check if write permission is actually granted.
val writePermission =
this.checkUriPermission(
uri, Process.myPid(), Process.myUid(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
if (writePermission == PackageManager.PERMISSION_GRANTED) {
TSLog.d("MainActivity", "Write permission granted for $uri")
Libtailscale.setDirectFileRoot(uri.toString())
saveFileDirectory(uri)
} else {
TSLog.d(
"MainActivity",
"Write access not granted for $uri. Falling back to internal storage.")
// Don't save directory URI and fall back to internal storage.
}
} else {
TSLog.d(
"MainActivity", "Taildrop directory not saved. Will fall back to internal storage.")
// Fall back to internal storage.
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the MainActivity.kt directoryPickerLauncher, there's a missing call to app.getStoredDirectoryUri() when the user selects a valid URI. This might cause inconsistent behavior where the URI is stored but not properly retrieved on app restart. Consider updating App.getStoredDirectoryUri() to handle the saved URI correctly.

Comment on lines +221 to +222
func SetDirectFileRoot(filePath string) {
onFilePath <- filePath
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SetDirectFileRoot function sends to onFilePath channel without checking if there are receivers. Consider adding a select statement with a default case to prevent blocking if no goroutine is receiving from this channel. This could block the UI thread if the Go side isn't ready to receive.

Comment on lines +165 to +167
// OutputStream provides an adapter between Java's OutputStream and Go's
// io.WriteCloser.
type OutputStream interface {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a documentation issue in interfaces.go where the comment for OutputStream likely has incorrect information. It appears to be copied from InputStream without proper modification.

Comment on lines 322 to 329
go func() {
for {
select {
case filepath := <-onFilePath:
lb.SetDirectFileRoot(filepath)
}
}
}()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The infinite goroutine in backend.go that listens to onFilePath channel doesn't have any cancellation mechanism. This could lead to a goroutine leak if the backend is recreated multiple times during app lifecycle. Consider using a context or a done channel for proper cleanup.

Comment on lines +221 to +223
func SetDirectFileRoot(filePath string) {
onFilePath <- filePath
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SetDirectFileRoot function in interfaces.go sends to onFilePath channel without a non-blocking fallback. If the receiving goroutine isn't running yet, this will block the UI thread. Consider using a buffered channel or adding a select with a default case.

Comment on lines +33 to +34
private val streamCache = ConcurrentHashMap<String, SafStream>()

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The streamCache in ShareFileHelper doesn't have any mechanism to remove entries when the stream is closed. This can lead to memory leaks as stale entries accumulate over time. Consider implementing a cleanup mechanism in the close() method of OutputStreamAdapter to remove its entry from the cache.

Comment on lines +12 to +17
override fun write(data: ByteArray): Long {
return try {
outputStream.write(data)
outputStream.flush()
data.size.toLong()
} catch (e: Exception) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the OutputStreamAdapter.write method, the return value is always data.size.toLong() regardless of how many bytes were actually written. Java's OutputStream.write might write fewer bytes than requested. Consider capturing the actual bytes written or ensuring the entire buffer is written (possibly with multiple calls).

Comment on lines +32 to +37
func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error) {
newURI := ops.helper.RenamePartialFile(partialUri, targetDirUri, targetName)
if newURI == "" {
return "", fmt.Errorf("failed to rename partial file via SAF")
}
return newURI, nil
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In RenamePartialFile in fileops.go, the empty string check doesn't distinguish between the file not being found and other errors. Consider returning a more specific error message to help with debugging.

Comment on lines +30 to +31
// onFilePath receives the SAF path used for Taildrop
onFilePath = make(chan string)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In libtailscale/callbacks.go, the onFilePath channel is unbuffered. Since SetDirectFileRoot doesn't have a non-blocking fallback, it could deadlock if called before the receiving goroutine is started. Consider using a buffered channel similar to onShareFileHelper.

@kari-ts kari-ts force-pushed the kari/taildropsaf branch from fae21a0 to 64c378d Compare May 14, 2025 22:51
Comment on lines 324 to 332
// directFileRoot may be reset at some time after the backend is created.
go func() {
for {
select {
case filepath := <-onFilePath:
ext.SetDirectFileRoot(filepath)
}
}
}()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The infinite goroutine in backend.go that listens for filepath changes has no cancellation mechanism. If the App is created multiple times during the application lifecycle, this will leak goroutines. Consider adding a context with cancellation and a done channel to properly terminate this goroutine when it's no longer needed.

Comment on lines +32 to +37
func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error) {
newURI := ops.helper.RenamePartialFile(partialUri, targetDirUri, targetName)
if newURI == "" {
return "", fmt.Errorf("failed to rename partial file via SAF")
}
return newURI, nil
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in RenamePartialFile method is opaque. The empty string check doesn't distinguish between different error conditions that might occur on the Android side. Consider having the Android helper return error details along with the URI to enable more specific error messages and troubleshooting.

Comment on lines +221 to +223
func SetDirectFileRoot(filePath string) {
onFilePath <- filePath
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SetDirectFileRoot function sends to the onFilePath channel without a non-blocking pattern. If no goroutine is currently receiving from this channel, calls to this function will block indefinitely. Consider using a select with a default case similar to what's used in the SetShareFileHelper function.

@kari-ts kari-ts force-pushed the kari/taildropsaf branch from 64c378d to 199f027 Compare May 14, 2025 23:01
Comment on lines +86 to +121

override fun renamePartialFile(
partialUri: String,
targetDirUri: String,
targetName: String
): String {
try {
val context = appContext ?: throw IllegalStateException("appContext is null")
val partialUriObj = Uri.parse(partialUri)
val targetDirUriObj = Uri.parse(targetDirUri)
val targetDir =
DocumentFile.fromTreeUri(context, targetDirUriObj)
?: throw IllegalStateException(
"Unable to get target directory from URI: $targetDirUri")
var finalTargetName = targetName

var destFile = targetDir.findFile(finalTargetName)
if (destFile != null) {
finalTargetName = generateNewFilename(finalTargetName)
}

destFile =
targetDir.createFile("application/octet-stream", finalTargetName)
?: throw IOException("Failed to create new file with name: $finalTargetName")

context.contentResolver.openInputStream(partialUriObj)?.use { input ->
context.contentResolver.openOutputStream(destFile.uri)?.use { output ->
input.copyTo(output)
} ?: throw IOException("Unable to open output stream for URI: ${destFile.uri}")
} ?: throw IOException("Unable to open input stream for URI: $partialUri")

DocumentFile.fromSingleUri(context, partialUriObj)?.delete()
return destFile.uri.toString()
} catch (e: Exception) {
throw IOException(
"Failed to rename partial file from URI $partialUri to final file in $targetDirUri with name $targetName: ${e.message}",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In RenamePartialFile method of ShareFileHelper.kt, try and catch blocks are being used, but some exceptions are being silently ignored with just an empty string return. This can make debugging difficult. Consider logging exceptions at minimum or propagating a more descriptive error.

Comment on lines +205 to +223
func SetShareFileHelper(fileHelper ShareFileHelper) {
// Drain the channel if there's an old value.
select {
case <-onShareFileHelper:
default:
// Channel was already empty.
}
select {
case onShareFileHelper <- fileHelper:
default:
// In the unlikely case the channel is still full, drain it and try again.
<-onShareFileHelper
onShareFileHelper <- fileHelper
}
}

func SetDirectFileRoot(filePath string) {
onFilePath <- filePath
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation of SetShareFileHelper and SetDirectFileRoot have different channel handling approaches. SetShareFileHelper has non-blocking fallbacks with proper draining, while SetDirectFileRoot sends directly to the channel which could block. Consider standardizing the pattern across both functions to prevent potential deadlocks.

Comment on lines +19 to +21
private var appContext: Context? = null
private var savedUri: String? = null

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ShareFileHelper.kt class doesn't handle concurrent access to its caches. If multiple threads access streamCache or fileCache simultaneously, there could be race conditions. Consider using thread-safe collections like ConcurrentHashMap instead of regular HashMap for these shared resources.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Taildrop directory URI persistence lacks verification on app startup. If the URI is no longer valid (e.g., directory deleted or permissions revoked), the app will silently fail when trying to access it. Consider adding validation when loading the URI from preferences and providing a fallback mechanism.

@kari-ts kari-ts force-pushed the kari/taildropsaf branch 2 times, most recently from d797faf to b5b31c3 Compare May 20, 2025 22:34
Use Android Storage Access Framework for receiving Taildropped files.

-Add a picker to allow users to select where Taildropped files go
-If no directory is selected, internal app storage is used
-Provide SAF API for Go to use when writing and renaming files
-Provide Android FileOps implementation

Updates tailscale/tailscale#15263

Signed-off-by: kari-ts <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants