diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index a91325ea38..7dbf9734bc 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 @@ -36,12 +36,8 @@ 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 java.io.File -import java.io.IOException -import java.net.NetworkInterface -import java.security.GeneralSecurityException -import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -53,12 +49,18 @@ import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import libtailscale.Libtailscale +import java.io.IOException +import java.net.NetworkInterface +import java.security.GeneralSecurityException +import java.util.Locale class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) 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 @@ -150,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) @@ -205,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) } @@ -247,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 @@ -310,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 0b3b7621a4..99065a8eaf 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,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() @@ -366,19 +410,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 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 fdb16bbd10..5817b91acd 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 @@ -98,6 +98,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 @@ -212,6 +213,9 @@ fun MainView( PromptPermissionsIfNecessary() viewModel.maybeRequestVpnPermission() LaunchVpnPermissionIfNeeded(viewModel) + if (AndroidTVUtil.isAndroidTV()){ + viewModel.showDirectoryPickerLauncher() + } if (showKeyExpiry) { ExpiryNotification(netmap = netmap, action = { viewModel.login() }) @@ -242,7 +246,11 @@ fun MainView( { viewModel.login() }, loginAtUrl, netmap?.SelfNode, - { viewModel.showVPNPermissionLauncherIfUnauthorized() }) + { viewModel.showVPNPermissionLauncherIfUnauthorized() + if (!AndroidTVUtil.isAndroidTV()){ + viewModel.showDirectoryPickerLauncher() + } + } ) } } } @@ -433,11 +441,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 c0e205ac81..7190764759 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 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 b95d343189..949b629248 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -314,15 +314,28 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor w.Start() } lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0) - if err != nil { - engine.Close() - return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err) - } + shareFileHelper := <-onShareFileHelper + fileOps := NewAndroidFileOps(shareFileHelper) if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok { + ext.SetFileOps(fileOps) ext.SetDirectFileRoot(directFileRoot) + + // directFileRoot may be reset at some time after the backend is created. + go func() { + for { + select { + case filepath := <-onFilePath: + ext.SetDirectFileRoot(filepath) + } + } + }() } + if err != nil { + engine.Close() + return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err) + } 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 +}