Skip to content

android: use SAF for storing Taildropped files #632

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 28 additions & 36 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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 <shared>/Downloads and fallback to
// an app local directory "Taildrop" if we cannot create that. This mode does not support
// user notifications for incoming files.
val directFileDir = this.prepareDownloadsFolder()
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
// Check if a directory URI has already been stored.
val storedUri = getStoredDirectoryUri()
if (storedUri != null && storedUri.toString().startsWith("content://")) {
startLibtailscale(storedUri.toString())
} else {
startLibtailscale(this.getFilesDir().absolutePath)
}
healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
92 changes: 73 additions & 19 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Intent>
private val viewModel: MainViewModel by lazy {
Expand Down Expand Up @@ -150,6 +158,41 @@ class MainActivity : ComponentActivity() {
}
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)

val directoryPickerLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are incoming Taildrop files relevant on Android TV? They are not on AppleTV. If the user can actually pick a directory and use the files - all good - otherwise we should skip the dialog.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good call. gating it with an AndroidTV check

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.
}
}
Comment on lines +161 to +192

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the MainActivity.kt directoryPickerLauncher, there's a missing call to app.getStoredDirectoryUri() when the user selects a valid URI. This might cause inconsistent behavior where the URI is stored but not properly retrieved on app restart. Consider updating App.getStoredDirectoryUri() to handle the saved URI correctly.


viewModel.setDirectoryPickerLauncher(directoryPickerLauncher)

setContent {
navController = rememberNavController()

Expand Down Expand Up @@ -198,7 +241,7 @@ class MainActivity : ComponentActivity() {
onNavigateToSearch = {
viewModel.enableSearchAutoFocus()
navController.navigate("search")
})
})

val settingsNav =
SettingsNav(
Expand Down Expand Up @@ -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")) }
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
Comment on lines +12 to +17

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the OutputStreamAdapter.write method, the return value is always data.size.toLong() regardless of how many bytes were actually written. Java's OutputStream.write might write fewer bytes than requested. Consider capturing the actual bytes written or ensuring the entire buffer is written (possibly with multiple calls).

TSLog.d("OutputStreamAdapter", "write exception: $e")
-1L
}
}

override fun close() {
outputStream.close()
}
}
14 changes: 11 additions & 3 deletions android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -209,6 +210,9 @@ fun MainView(
PromptPermissionsIfNecessary()

viewModel.showVPNPermissionLauncherIfUnauthorized()
if (AndroidTVUtil.isAndroidTV()){
viewModel.showDirectoryPickerLauncher()
}

if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
Expand Down Expand Up @@ -239,7 +243,11 @@ fun MainView(
{ viewModel.login() },
loginAtUrl,
netmap?.SelfNode,
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
{ viewModel.showVPNPermissionLauncherIfUnauthorized()
if (!AndroidTVUtil.isAndroidTV()){
viewModel.showDirectoryPickerLauncher()
}
} )
}
}
}
Expand Down Expand Up @@ -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()) {
Expand Down
Loading
Loading