From bd5191363c2167116cc749460fb3a48adbdcf88f Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Tue, 20 May 2025 16:05:13 -0700 Subject: [PATCH] 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 --- .../src/main/java/com/tailscale/ipn/App.kt | 63 ++++---- .../java/com/tailscale/ipn/MainActivity.kt | 84 ++++++++++- .../ipn/ui/util/OutputStreamAdapter.kt | 26 ++++ .../com/tailscale/ipn/ui/view/MainView.kt | 14 +- .../ipn/ui/viewModel/MainViewModel.kt | 31 ++++ .../com/tailscale/ipn/util/ShareFileHelper.kt | 134 ++++++++++++++++++ libtailscale/backend.go | 54 +++++-- libtailscale/callbacks.go | 6 + libtailscale/fileops.go | 38 +++++ libtailscale/interfaces.go | 39 +++++ libtailscale/tailscale.go | 9 +- 11 files changed, 441 insertions(+), 57 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt create mode 100644 android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt create mode 100644 libtailscale/fileops.go diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 3e834cb..249a42d 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -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 /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 { diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 0b3b762..98f591e 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -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 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. diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt new file mode 100644 index 0000000..9e73a42 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt @@ -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() + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index fdb16bb..96b0491 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -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()) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index c0e205a..7190764 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -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 = _requestVpnPermission + // Select Taildrop directory + private var directoryPickerLauncher: ActivityResultLauncher? = null + // The list of peers private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _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) { + directoryPickerLauncher = launcher + } } private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int { diff --git a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt new file mode 100644 index 0000000..fed568d --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -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() + + // 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" + } +} diff --git a/libtailscale/backend.go b/libtailscale/backend.go index b95d343..e8dbc4c 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -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") diff --git a/libtailscale/callbacks.go b/libtailscale/callbacks.go index 2ee022a..3e1a88f 100644 --- a/libtailscale/callbacks.go +++ b/libtailscale/callbacks.go @@ -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. diff --git a/libtailscale/fileops.go b/libtailscale/fileops.go new file mode 100644 index 0000000..241097c --- /dev/null +++ b/libtailscale/fileops.go @@ -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 +} diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index 6460c9f..5663698 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -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 +} diff --git a/libtailscale/tailscale.go b/libtailscale/tailscale.go index 6ae9131..3a785fa 100644 --- a/libtailscale/tailscale.go +++ b/libtailscale/tailscale.go @@ -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 {