android: use SAF for storing Taildropped files
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 <kari@tailscale.com>pull/632/head
parent
eb0a124ba6
commit
0b959d43dd
@ -0,0 +1,26 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import java.io.OutputStream
|
||||||
|
|
||||||
|
// This class adapts a Java OutputStream to the libtailscale.OutputStream interface.
|
||||||
|
class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale.OutputStream {
|
||||||
|
// writes data to the outputStream in its entirety. Returns -1 on error.
|
||||||
|
override fun write(data: ByteArray): Long {
|
||||||
|
return try {
|
||||||
|
outputStream.write(data)
|
||||||
|
outputStream.flush()
|
||||||
|
data.size.toLong()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TSLog.d("OutputStreamAdapter", "write exception: $e")
|
||||||
|
-1L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
outputStream.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,134 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import com.tailscale.ipn.ui.util.OutputStreamAdapter
|
||||||
|
import libtailscale.Libtailscale
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
data class SafFile(val fd: Int, val uri: String)
|
||||||
|
|
||||||
|
object ShareFileHelper : libtailscale.ShareFileHelper {
|
||||||
|
private var appContext: Context? = null
|
||||||
|
private var savedUri: String? = null
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun init(context: Context, uri: String) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
savedUri = uri
|
||||||
|
Libtailscale.setShareFileHelper(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A simple data class that holds a SAF OutputStream along with its URI.
|
||||||
|
data class SafStream(val uri: String, val stream: OutputStream)
|
||||||
|
|
||||||
|
// Cache for streams; keyed by file name and savedUri.
|
||||||
|
private val streamCache = ConcurrentHashMap<String, SafStream>()
|
||||||
|
|
||||||
|
// A helper function that creates (or reuses) a SafStream for a given file.
|
||||||
|
private fun createStreamCached(fileName: String): SafStream {
|
||||||
|
val key = "$fileName|$savedUri"
|
||||||
|
return streamCache.getOrPut(key) {
|
||||||
|
val context: Context =
|
||||||
|
appContext
|
||||||
|
?: run {
|
||||||
|
TSLog.e("ShareFileHelper", "appContext is null, cannot create file: $fileName")
|
||||||
|
return SafStream("", OutputStream.nullOutputStream())
|
||||||
|
}
|
||||||
|
val directoryUriString =
|
||||||
|
savedUri
|
||||||
|
?: run {
|
||||||
|
TSLog.e("ShareFileHelper", "savedUri is null, cannot create file: $fileName")
|
||||||
|
return SafStream("", OutputStream.nullOutputStream())
|
||||||
|
}
|
||||||
|
val dirUri = Uri.parse(directoryUriString)
|
||||||
|
val pickedDir: DocumentFile =
|
||||||
|
DocumentFile.fromTreeUri(context, dirUri)
|
||||||
|
?: run {
|
||||||
|
TSLog.e("ShareFileHelper", "Could not access directory for URI: $dirUri")
|
||||||
|
return SafStream("", OutputStream.nullOutputStream())
|
||||||
|
}
|
||||||
|
val newFile: DocumentFile =
|
||||||
|
pickedDir.createFile("application/octet-stream", fileName)
|
||||||
|
?: run {
|
||||||
|
TSLog.e("ShareFileHelper", "Failed to create file: $fileName in directory: $dirUri")
|
||||||
|
return SafStream("", OutputStream.nullOutputStream())
|
||||||
|
}
|
||||||
|
// Attempt to open an OutputStream for writing.
|
||||||
|
val os: OutputStream? = context.contentResolver.openOutputStream(newFile.uri)
|
||||||
|
if (os == null) {
|
||||||
|
TSLog.e("ShareFileHelper", "openOutputStream returned null for URI: ${newFile.uri}")
|
||||||
|
SafStream(newFile.uri.toString(), OutputStream.nullOutputStream())
|
||||||
|
} else {
|
||||||
|
TSLog.d("ShareFileHelper", "Opened OutputStream for file: $fileName")
|
||||||
|
SafStream(newFile.uri.toString(), os)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openFileURI(fileName: String): String {
|
||||||
|
val safFile = createStreamCached(fileName)
|
||||||
|
return safFile.uri
|
||||||
|
}
|
||||||
|
|
||||||
|
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}",
|
||||||
|
e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
package libtailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AndroidFileOps implements the ShareFileHelper interface using the Android helper.
|
||||||
|
type AndroidFileOps struct {
|
||||||
|
helper ShareFileHelper
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAndroidFileOps(helper ShareFileHelper) *AndroidFileOps {
|
||||||
|
return &AndroidFileOps{helper: helper}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ops *AndroidFileOps) OpenFileURI(filename string) string {
|
||||||
|
return ops.helper.OpenFileURI(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ops *AndroidFileOps) OpenFileWriter(filename string) (io.WriteCloser, string, error) {
|
||||||
|
uri := ops.helper.OpenFileURI(filename)
|
||||||
|
outputStream := ops.helper.OpenFileWriter(filename)
|
||||||
|
if outputStream == nil {
|
||||||
|
return nil, uri, fmt.Errorf("failed to open SAF output stream for %s", filename)
|
||||||
|
}
|
||||||
|
return outputStream, uri, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue