diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index a449bf0adb..68079da9f7 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -14,8 +14,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 @@ -46,7 +47,6 @@ import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import libtailscale.Libtailscale -import java.io.File import java.io.IOException import java.net.NetworkInterface import java.security.GeneralSecurityException @@ -57,6 +57,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 @@ -148,17 +150,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) @@ -195,6 +193,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) } @@ -237,6 +247,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 @@ -300,29 +315,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 7a68160dab..de4abc6019 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -10,18 +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 android.util.Log 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 @@ -89,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 { @@ -150,6 +158,41 @@ 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") + Libtailscale.setDirectFileRoot(uri.toString()) + saveFileDirectory(uri) + } else { + TSLog.d( + "MainActivity", + "Write access not granted for $uri. Falling back to internal storage.") + // Don't save directory URI and fall back to internal storage. + } + } else { + TSLog.d( + "MainActivity", "Taildrop directory not saved. Will fall back to internal storage.") + // Fall back to internal storage. + } + } + + viewModel.setDirectoryPickerLauncher(directoryPickerLauncher) + setContent { navController = rememberNavController() @@ -198,7 +241,7 @@ class MainActivity : ComponentActivity() { onNavigateToSearch = { viewModel.enableSearchAutoFocus() navController.navigate("search") - }) + }) val settingsNav = SettingsNav( @@ -245,9 +288,8 @@ class MainActivity : ComponentActivity() { viewModel = viewModel, navController = navController, onNavigateBack = { navController.popBackStack() }, - autoFocus = autoFocus - ) - } + autoFocus = autoFocus) + } composable("settings") { SettingsView(settingsNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("health") { HealthView(backTo("main")) } @@ -365,23 +407,35 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) if (intent.getBooleanExtra(START_AT_ROOT, false)) { - 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 (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 } } } + } } -} - + } + @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 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 0000000000..9e73a42837 --- /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 90e99e6c8e..03db6f5af2 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 @@ -95,6 +95,7 @@ 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.isAndroidTV +import com.tailscale.ipn.ui.util.AndroidTVUtil import com.tailscale.ipn.ui.util.AutoResizingText import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.LoadingIndicator @@ -209,6 +210,9 @@ fun MainView( PromptPermissionsIfNecessary() viewModel.showVPNPermissionLauncherIfUnauthorized() + if (AndroidTVUtil.isAndroidTV()){ + viewModel.showDirectoryPickerLauncher() + } if (showKeyExpiry) { ExpiryNotification(netmap = netmap, action = { viewModel.login() }) @@ -239,7 +243,11 @@ fun MainView( { viewModel.login() }, loginAtUrl, netmap?.SelfNode, - { viewModel.showVPNPermissionLauncherIfUnauthorized() }) + { viewModel.showVPNPermissionLauncherIfUnauthorized() + if (!AndroidTVUtil.isAndroidTV()){ + viewModel.showDirectoryPickerLauncher() + } + } ) } } } @@ -415,11 +423,11 @@ fun ConnectView( loginAction: () -> Unit, loginAtUrlAction: (String) -> Unit, selfNode: Tailcfg.Node?, - showVPNPermissionLauncherIfUnauthorized: () -> Unit + showVPNPermissionAndDirectoryPickerLaunchers: () -> Unit ) { LaunchedEffect(isPrepared) { if (!isPrepared && shouldStartAutomatically) { - showVPNPermissionLauncherIfUnauthorized() + showVPNPermissionAndDirectoryPickerLaunchers() } } 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 8ff53531a9..db344b3172 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 @@ -61,6 +64,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { // Permission to prepare VPN private var vpnPermissionLauncher: ActivityResultLauncher? = null + // Select Taildrop directory + private var directoryPickerLauncher: ActivityResultLauncher? = null + // The list of peers private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers @@ -197,6 +203,26 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } + 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 @@ -204,6 +230,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } viewModelScope.launch { + showDirectoryPickerLauncher() isToggleInProgress.value = true try { val currentState = Notifier.state.value @@ -243,6 +270,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 0000000000..fed568d095 --- /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 21eea06a4e..9dfa7fe66e 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -267,7 +267,6 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor } else { logID.UnmarshalText([]byte(storedLogID)) } - netMon, err := netmon.New(logf) if err != nil { log.Printf("netmon.New: %w", err) @@ -308,12 +307,27 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor w.Start() } lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0) + + shareFileHelper := <-onShareFileHelper + fileOps := NewAndroidFileOps(shareFileHelper) + lb.SetFileOps(fileOps) + if err != nil { engine.Close() return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err) } lb.SetDirectFileRoot(directFileRoot) + // directFileRoot may be reset at some time after the backend is created. + go func() { + for { + select { + case filepath := <-onFilePath: + lb.SetDirectFileRoot(filepath) + } + } + }() + if err := ns.Start(lb); err != nil { return nil, fmt.Errorf("startNetstack: %w", err) } diff --git a/libtailscale/callbacks.go b/libtailscale/callbacks.go index 2ee022a060..3e1a88fcc1 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 0000000000..241097c6b4 --- /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 6460c9f3c2..56636987fe 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 +}