android: use SAF for storing Taildropped files (#632)

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/655/head
kari-ts 7 months ago committed by GitHub
parent eb0a124ba6
commit bd5191363c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -13,8 +13,8 @@ import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
@ -35,6 +35,7 @@ import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -58,6 +59,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
companion object {
private const val FILE_CHANNEL_ID = "tailscale-files"
// Key to store the SAF URI in EncryptedSharedPreferences.
private val PREF_KEY_SAF_URI = "saf_directory_uri"
private const val TAG = "App"
private lateinit var appInstance: App
@ -149,17 +152,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
}
private fun initializeApp() {
val dataDir = this.filesDir.absolutePath
// Set this to enable direct mode for taildrop whereby downloads will be saved directly
// to the given folder. We will preferentially use <shared>/Downloads and fallback to
// an app local directory "Taildrop" if we cannot create that. This mode does not support
// user notifications for incoming files.
val directFileDir = this.prepareDownloadsFolder()
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
// Check if a directory URI has already been stored.
val storedUri = getStoredDirectoryUri()
if (storedUri != null && storedUri.toString().startsWith("content://")) {
startLibtailscale(storedUri.toString())
} else {
startLibtailscale(this.getFilesDir().absolutePath)
}
healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
@ -204,6 +203,18 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
FeatureFlags.initialize(mapOf("enable_new_search" to true))
}
/**
* Called when a SAF directory URI is available (either already stored or chosen). We must restart
* Tailscale because directFileRoot must be set before LocalBackend starts being used.
*/
fun startLibtailscale(directFileRoot: String) {
ShareFileHelper.init(this, directFileRoot)
app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this)
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
}
private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
}
@ -246,6 +257,11 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
}
fun getStoredDirectoryUri(): Uri? {
val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null)
return uriString?.let { Uri.parse(it) }
}
/*
* setAbleToStartVPN remembers whether or not we're able to start the VPN
* by storing this in a shared preference. This allows us to check this
@ -309,29 +325,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
return sb.toString()
}
private fun prepareDownloadsFolder(): File {
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
try {
if (!downloads.exists()) {
downloads.mkdirs()
}
} catch (e: Exception) {
TSLog.e(TAG, "Failed to create downloads folder: $e")
downloads = File(this.filesDir, "Taildrop")
try {
if (!downloads.exists()) {
downloads.mkdirs()
}
} catch (e: Exception) {
TSLog.e(TAG, "Failed to create Taildrop folder: $e")
downloads = File("")
}
}
return downloads
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean {

@ -10,17 +10,21 @@ import android.content.Context
import android.content.Intent
import android.content.RestrictionsManager
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Process
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.LinearOutSlowInEasing
@ -88,8 +92,13 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import libtailscale.Libtailscale
import java.io.IOException
import java.security.GeneralSecurityException
class MainActivity : ComponentActivity() {
// Key to store the SAF URI in EncryptedSharedPreferences.
val PREF_KEY_SAF_URI = "saf_directory_uri"
private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by lazy {
@ -149,6 +158,49 @@ class MainActivity : ComponentActivity() {
}
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
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")
lifecycleScope.launch(Dispatchers.IO) {
try {
Libtailscale.setDirectFileRoot(uri.toString())
saveFileDirectory(uri)
} catch (e: Exception) {
TSLog.e("MainActivity", "Failed to set Taildrop root: $e")
}
}
} 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.
}
}
viewModel.setDirectoryPickerLauncher(directoryPickerLauncher)
setContent {
navController = rememberNavController()
@ -366,19 +418,37 @@ class MainActivity : ComponentActivity() {
if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
if (previousEntry != null) {
navController.popBackStack(route = "main", inclusive = false)
} else {
TSLog.e(
"MainActivity",
"onNewIntent: No previous back stack entry, navigating directly to 'main'")
navController.navigate("main") { popUpTo("main") { inclusive = true } }
if (previousEntry != null) {
navController.popBackStack(route = "main", inclusive = false)
} else {
TSLog.e(
"MainActivity",
"onNewIntent: No previous back stack entry, navigating directly to 'main'")
navController.navigate("main") { popUpTo("main") { inclusive = true } }
}
}
}
}
}
@Throws(IOException::class, GeneralSecurityException::class)
fun saveFileDirectory(directoryUri: Uri) {
val prefs = App.get().getEncryptedPrefs()
prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).apply()
try {
// Must restart Tailscale because a new LocalBackend with the new directory must be created.
App.get().startLibtailscale(directoryUri.toString())
} catch (e: Exception) {
TSLog.d(
"MainActivity",
"saveFileDirectory: Failed to restart Libtailscale with the new directory: $e")
}
}
private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch
// MainActivity to bring the app back to focus.

@ -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()
}
}

@ -97,6 +97,7 @@ import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
import com.tailscale.ipn.ui.theme.warningButton
import com.tailscale.ipn.ui.theme.warningListItem
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AutoResizingText
import com.tailscale.ipn.ui.util.Lists
@ -212,6 +213,11 @@ fun MainView(
PromptPermissionsIfNecessary()
viewModel.maybeRequestVpnPermission()
LaunchVpnPermissionIfNeeded(viewModel)
LaunchedEffect(state) {
if (state == Ipn.State.Running && !AndroidTVUtil.isAndroidTV()) {
viewModel.showDirectoryPickerLauncher()
}
}
if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
@ -242,7 +248,9 @@ fun MainView(
{ viewModel.login() },
loginAtUrl,
netmap?.SelfNode,
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
{
viewModel.showVPNPermissionLauncherIfUnauthorized()
})
}
}
}
@ -433,11 +441,11 @@ fun ConnectView(
loginAction: () -> Unit,
loginAtUrlAction: (String) -> Unit,
selfNode: Tailcfg.Node?,
showVPNPermissionLauncherIfUnauthorized: () -> Unit
showVPNPermissionLauncher: () -> Unit
) {
LaunchedEffect(isPrepared) {
if (!isPrepared && shouldStartAutomatically) {
showVPNPermissionLauncherIfUnauthorized()
showVPNPermissionLauncher()
}
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {

@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.net.Uri
import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.runtime.getValue
@ -11,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@ -25,6 +27,7 @@ import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
@ -63,6 +66,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
private val _requestVpnPermission = MutableStateFlow(false)
val requestVpnPermission: StateFlow<Boolean> = _requestVpnPermission
// Select Taildrop directory
private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
// The list of peers
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
val peers: StateFlow<List<PeerSet>> = _peers
@ -204,6 +210,26 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
_requestVpnPermission.value = false // reset
}
fun showDirectoryPickerLauncher() {
val app = App.get()
val storedUri = app.getStoredDirectoryUri()
if (storedUri == null) {
// No stored URI, so launch the directory picker.
directoryPickerLauncher?.launch(null)
return
}
val documentFile = DocumentFile.fromTreeUri(app, storedUri)
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
TSLog.d(
"MainViewModel",
"Stored directory URI is invalid or inaccessible; launching directory picker.")
directoryPickerLauncher?.launch(null)
} else {
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
}
}
fun toggleVpn(desiredState: Boolean) {
if (isToggleInProgress.value) {
// Prevent toggling while a previous toggle is in progress
@ -211,6 +237,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
}
viewModelScope.launch {
showDirectoryPickerLauncher()
isToggleInProgress.value = true
try {
val currentState = Notifier.state.value
@ -250,6 +277,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// No intent means we're already authorized
vpnPermissionLauncher = launcher
}
fun setDirectoryPickerLauncher(launcher: ActivityResultLauncher<Uri?>) {
directoryPickerLauncher = launcher
}
}
private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int {

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

@ -43,8 +43,9 @@ import (
type App struct {
dataDir string
// enables direct file mode for the taildrop manager
directFileRoot string
// passes along SAF file information for the taildrop manager
directFileRoot string
shareFileHelper ShareFileHelper
// appCtx is a global reference to the com.tailscale.ipn.App instance.
appCtx AppContext
@ -56,6 +57,9 @@ type App struct {
localAPIHandler http.Handler
backend *ipnlocal.LocalBackend
ready sync.WaitGroup
backendMu sync.Mutex
backendRestartCh chan struct{}
}
func start(dataDir, directFileRoot string, appCtx AppContext) Application {
@ -110,6 +114,23 @@ type backend struct {
type settingsFunc func(*router.Config, *dns.OSConfig) error
func (a *App) runBackend(ctx context.Context) error {
for {
err := a.runBackendOnce(ctx)
if err != nil {
log.Printf("runBackendOnce error: %v", err)
}
// Wait for a restart trigger
<-a.backendRestartCh
}
}
func (a *App) runBackendOnce(ctx context.Context) error {
select {
case <-a.backendRestartCh:
default:
}
paths.AppSharedDir.Store(a.dataDir)
hostinfo.SetOSVersion(a.osVersion())
hostinfo.SetPackage(a.appCtx.GetInstallSource())
@ -125,7 +146,7 @@ func (a *App) runBackend(ctx context.Context) error {
}
configs := make(chan configPair)
configErrs := make(chan error)
b, err := a.newBackend(a.dataDir, a.directFileRoot, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error {
b, err := a.newBackend(a.dataDir, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error {
if rcfg == nil {
return nil
}
@ -242,7 +263,7 @@ func (a *App) runBackend(ctx context.Context) error {
}
}
func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, store *stateStore,
func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore,
settings settingsFunc) (*backend, error) {
sys := new(tsd.System)
@ -314,15 +335,15 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor
w.Start()
}
lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0)
if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok {
ext.SetFileOps(NewAndroidFileOps(a.shareFileHelper))
ext.SetDirectFileRoot(a.directFileRoot)
}
if err != nil {
engine.Close()
return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err)
}
if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok {
ext.SetDirectFileRoot(directFileRoot)
}
if err := ns.Start(lb); err != nil {
return nil, fmt.Errorf("startNetstack: %w", err)
}
@ -343,6 +364,21 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor
return b, nil
}
func (a *App) watchFileOpsChanges() {
for {
select {
case newPath := <-onFilePath:
log.Printf("Got new directFileRoot")
a.directFileRoot = newPath
a.backendRestartCh <- struct{}{}
case helper := <-onShareFileHelper:
log.Printf("Got shareFIleHelper")
a.shareFileHelper = helper
a.backendRestartCh <- struct{}{}
}
}
}
func (b *backend) isConfigNonNilAndDifferent(rcfg *router.Config, dcfg *dns.OSConfig) bool {
if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) {
b.logger.Logf("isConfigNonNilAndDifferent: no change to Routes or DNS, ignore")

@ -23,6 +23,12 @@ var (
// onLog receives Android logs to be sent to the logger
onLog = make(chan string, 10)
// onShareFileHelper receives ShareFileHelper references when the app is initialized so that files can be received via Storage Access Framework
onShareFileHelper = make(chan ShareFileHelper, 1)
// onFilePath receives the SAF path used for Taildrop
onFilePath = make(chan string)
)
// ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available.

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

@ -162,6 +162,25 @@ type InputStream interface {
Close() error
}
// OutputStream provides an adapter between Java's OutputStream and Go's
// io.WriteCloser.
type OutputStream interface {
Write([]byte) (int, error)
Close() error
}
// ShareFileHelper corresponds to the Kotlin ShareFileHelper class
type ShareFileHelper interface {
OpenFileWriter(fileName string) OutputStream
// OpenFileURI opens the file and returns its SAF URI.
OpenFileURI(filename string) string
// RenamePartialFile takes SAF URIs and a target file name,
// and returns the new SAF URI and an error.
RenamePartialFile(partialUri string, targetDirUri string, targetName string) string
}
// The below are global callbacks that allow the Java application to notify Go
// of various state changes.
@ -182,3 +201,23 @@ func SendLog(logstr []byte) {
log.Printf("Log %v not sent", logstr) // missing argument in original code
}
}
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
}

@ -32,9 +32,10 @@ const (
func newApp(dataDir, directFileRoot string, appCtx AppContext) Application {
a := &App{
directFileRoot: directFileRoot,
dataDir: dataDir,
appCtx: appCtx,
directFileRoot: directFileRoot,
dataDir: dataDir,
appCtx: appCtx,
backendRestartCh: make(chan struct{}, 1),
}
a.ready.Add(2)
@ -42,6 +43,8 @@ func newApp(dataDir, directFileRoot string, appCtx AppContext) Application {
a.policyStore = &syspolicyHandler{a: a}
netmon.RegisterInterfaceGetter(a.getInterfaces)
syspolicy.RegisterHandler(a.policyStore)
go a.watchFileOpsChanges()
go func() {
defer func() {
if p := recover(); p != nil {

Loading…
Cancel
Save