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.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.util.Log import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat 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.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -58,6 +59,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
companion object { companion object {
private const val FILE_CHANNEL_ID = "tailscale-files" 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 const val TAG = "App"
private lateinit var appInstance: App private lateinit var appInstance: App
@ -149,17 +152,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
} }
private fun initializeApp() { private fun initializeApp() {
val dataDir = this.filesDir.absolutePath // Check if a directory URI has already been stored.
val storedUri = getStoredDirectoryUri()
// Set this to enable direct mode for taildrop whereby downloads will be saved directly if (storedUri != null && storedUri.toString().startsWith("content://")) {
// to the given folder. We will preferentially use <shared>/Downloads and fallback to startLibtailscale(storedUri.toString())
// an app local directory "Taildrop" if we cannot create that. This mode does not support } else {
// user notifications for incoming files. startLibtailscale(this.getFilesDir().absolutePath)
val directFileDir = this.prepareDownloadsFolder() }
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope) healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns) NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
@ -204,6 +203,18 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
FeatureFlags.initialize(mapOf("enable_new_search" to true)) 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() { private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java) vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
} }
@ -246,6 +257,11 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) 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 * 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 * 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() 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( @Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean { override fun getSyspolicyBooleanValue(key: String): Boolean {

@ -10,17 +10,21 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.RestrictionsManager import android.content.RestrictionsManager
import android.content.pm.ActivityInfo 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_LARGE
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Process
import android.provider.Settings import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing
@ -88,8 +92,13 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import libtailscale.Libtailscale
import java.io.IOException
import java.security.GeneralSecurityException
class MainActivity : ComponentActivity() { 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 navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent> private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by lazy { private val viewModel: MainViewModel by lazy {
@ -149,6 +158,49 @@ class MainActivity : ComponentActivity() {
} }
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher) 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 { setContent {
navController = rememberNavController() navController = rememberNavController()
@ -366,19 +418,37 @@ class MainActivity : ComponentActivity() {
if (this::navController.isInitialized) { if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
if (previousEntry != null) { if (previousEntry != null) {
navController.popBackStack(route = "main", inclusive = false) navController.popBackStack(route = "main", inclusive = false)
} else { } else {
TSLog.e( TSLog.e(
"MainActivity", "MainActivity",
"onNewIntent: No previous back stack entry, navigating directly to 'main'") "onNewIntent: No previous back stack entry, navigating directly to 'main'")
navController.navigate("main") { popUpTo("main") { inclusive = true } } 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) { private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch // Launch coroutine to listen for state changes. When the user completes login, relaunch
// MainActivity to bring the app back to focus. // 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.surfaceContainerListItem
import com.tailscale.ipn.ui.theme.warningButton import com.tailscale.ipn.ui.theme.warningButton
import com.tailscale.ipn.ui.theme.warningListItem 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.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AutoResizingText import com.tailscale.ipn.ui.util.AutoResizingText
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
@ -212,6 +213,11 @@ fun MainView(
PromptPermissionsIfNecessary() PromptPermissionsIfNecessary()
viewModel.maybeRequestVpnPermission() viewModel.maybeRequestVpnPermission()
LaunchVpnPermissionIfNeeded(viewModel) LaunchVpnPermissionIfNeeded(viewModel)
LaunchedEffect(state) {
if (state == Ipn.State.Running && !AndroidTVUtil.isAndroidTV()) {
viewModel.showDirectoryPickerLauncher()
}
}
if (showKeyExpiry) { if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() }) ExpiryNotification(netmap = netmap, action = { viewModel.login() })
@ -242,7 +248,9 @@ fun MainView(
{ viewModel.login() }, { viewModel.login() },
loginAtUrl, loginAtUrl,
netmap?.SelfNode, netmap?.SelfNode,
{ viewModel.showVPNPermissionLauncherIfUnauthorized() }) {
viewModel.showVPNPermissionLauncherIfUnauthorized()
})
} }
} }
} }
@ -433,11 +441,11 @@ fun ConnectView(
loginAction: () -> Unit, loginAction: () -> Unit,
loginAtUrlAction: (String) -> Unit, loginAtUrlAction: (String) -> Unit,
selfNode: Tailcfg.Node?, selfNode: Tailcfg.Node?,
showVPNPermissionLauncherIfUnauthorized: () -> Unit showVPNPermissionLauncher: () -> Unit
) { ) {
LaunchedEffect(isPrepared) { LaunchedEffect(isPrepared) {
if (!isPrepared && shouldStartAutomatically) { if (!isPrepared && shouldStartAutomatically) {
showVPNPermissionLauncherIfUnauthorized() showVPNPermissionLauncher()
} }
} }
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {

@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.net.VpnService import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -11,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope 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.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -63,6 +66,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
private val _requestVpnPermission = MutableStateFlow(false) private val _requestVpnPermission = MutableStateFlow(false)
val requestVpnPermission: StateFlow<Boolean> = _requestVpnPermission val requestVpnPermission: StateFlow<Boolean> = _requestVpnPermission
// Select Taildrop directory
private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
// The list of peers // The list of peers
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList()) private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
val peers: StateFlow<List<PeerSet>> = _peers val peers: StateFlow<List<PeerSet>> = _peers
@ -204,6 +210,26 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
_requestVpnPermission.value = false // reset _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) { fun toggleVpn(desiredState: Boolean) {
if (isToggleInProgress.value) { if (isToggleInProgress.value) {
// Prevent toggling while a previous toggle is in progress // Prevent toggling while a previous toggle is in progress
@ -211,6 +237,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
showDirectoryPickerLauncher()
isToggleInProgress.value = true isToggleInProgress.value = true
try { try {
val currentState = Notifier.state.value val currentState = Notifier.state.value
@ -250,6 +277,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// No intent means we're already authorized // No intent means we're already authorized
vpnPermissionLauncher = launcher vpnPermissionLauncher = launcher
} }
fun setDirectoryPickerLauncher(launcher: ActivityResultLauncher<Uri?>) {
directoryPickerLauncher = launcher
}
} }
private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int { 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 { type App struct {
dataDir string dataDir string
// enables direct file mode for the taildrop manager // passes along SAF file information for the taildrop manager
directFileRoot string directFileRoot string
shareFileHelper ShareFileHelper
// appCtx is a global reference to the com.tailscale.ipn.App instance. // appCtx is a global reference to the com.tailscale.ipn.App instance.
appCtx AppContext appCtx AppContext
@ -56,6 +57,9 @@ type App struct {
localAPIHandler http.Handler localAPIHandler http.Handler
backend *ipnlocal.LocalBackend backend *ipnlocal.LocalBackend
ready sync.WaitGroup ready sync.WaitGroup
backendMu sync.Mutex
backendRestartCh chan struct{}
} }
func start(dataDir, directFileRoot string, appCtx AppContext) Application { func start(dataDir, directFileRoot string, appCtx AppContext) Application {
@ -110,6 +114,23 @@ type backend struct {
type settingsFunc func(*router.Config, *dns.OSConfig) error type settingsFunc func(*router.Config, *dns.OSConfig) error
func (a *App) runBackend(ctx context.Context) 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) paths.AppSharedDir.Store(a.dataDir)
hostinfo.SetOSVersion(a.osVersion()) hostinfo.SetOSVersion(a.osVersion())
hostinfo.SetPackage(a.appCtx.GetInstallSource()) hostinfo.SetPackage(a.appCtx.GetInstallSource())
@ -125,7 +146,7 @@ func (a *App) runBackend(ctx context.Context) error {
} }
configs := make(chan configPair) configs := make(chan configPair)
configErrs := make(chan error) 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 { if rcfg == nil {
return 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) { settings settingsFunc) (*backend, error) {
sys := new(tsd.System) sys := new(tsd.System)
@ -314,15 +335,15 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor
w.Start() w.Start()
} }
lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0) 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 { if err != nil {
engine.Close() engine.Close()
return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err) 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 { if err := ns.Start(lb); err != nil {
return nil, fmt.Errorf("startNetstack: %w", err) return nil, fmt.Errorf("startNetstack: %w", err)
} }
@ -343,6 +364,21 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor
return b, nil 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 { func (b *backend) isConfigNonNilAndDifferent(rcfg *router.Config, dcfg *dns.OSConfig) bool {
if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) { if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) {
b.logger.Logf("isConfigNonNilAndDifferent: no change to Routes or DNS, ignore") 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 receives Android logs to be sent to the logger
onLog = make(chan string, 10) 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. // 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 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 // The below are global callbacks that allow the Java application to notify Go
// of various state changes. // of various state changes.
@ -182,3 +201,23 @@ func SendLog(logstr []byte) {
log.Printf("Log %v not sent", logstr) // missing argument in original code 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 { func newApp(dataDir, directFileRoot string, appCtx AppContext) Application {
a := &App{ a := &App{
directFileRoot: directFileRoot, directFileRoot: directFileRoot,
dataDir: dataDir, dataDir: dataDir,
appCtx: appCtx, appCtx: appCtx,
backendRestartCh: make(chan struct{}, 1),
} }
a.ready.Add(2) a.ready.Add(2)
@ -42,6 +43,8 @@ func newApp(dataDir, directFileRoot string, appCtx AppContext) Application {
a.policyStore = &syspolicyHandler{a: a} a.policyStore = &syspolicyHandler{a: a}
netmon.RegisterInterfaceGetter(a.getInterfaces) netmon.RegisterInterfaceGetter(a.getInterfaces)
syspolicy.RegisterHandler(a.policyStore) syspolicy.RegisterHandler(a.policyStore)
go a.watchFileOpsChanges()
go func() { go func() {
defer func() { defer func() {
if p := recover(); p != nil { if p := recover(); p != nil {

Loading…
Cancel
Save