Skip to content

Commit 92047bf

Browse files
committed
android: use SAF for storing Taildropped files
Use Android Storage Access Framework for receiving Taildropped files. -Add a picker to allow users to select where Taildropped files go -If no directory is selected, internal app storage is used -Provide SAF API for Go to use when writing and renaming files -Provide Android FileOps implementation Updates tailscale/tailscale#15263 Signed-off-by: kari-ts <kari@tailscale.com>
1 parent 9a69bc3 commit 92047bf

File tree

11 files changed

+329
-64
lines changed

11 files changed

+329
-64
lines changed

android/src/main/java/com/tailscale/ipn/App.kt

+28-36
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import android.content.IntentFilter
1414
import android.content.SharedPreferences
1515
import android.content.pm.PackageManager
1616
import android.net.ConnectivityManager
17+
import android.net.Uri
1718
import android.os.Build
18-
import android.os.Environment
1919
import android.util.Log
2020
import androidx.core.app.ActivityCompat
2121
import androidx.core.app.NotificationCompat
@@ -35,6 +35,7 @@ import com.tailscale.ipn.ui.notifier.Notifier
3535
import com.tailscale.ipn.ui.viewModel.VpnViewModel
3636
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
3737
import com.tailscale.ipn.util.FeatureFlags
38+
import com.tailscale.ipn.util.ShareFileHelper
3839
import com.tailscale.ipn.util.TSLog
3940
import kotlinx.coroutines.CoroutineScope
4041
import kotlinx.coroutines.Dispatchers
@@ -46,7 +47,6 @@ import kotlinx.coroutines.launch
4647
import kotlinx.serialization.encodeToString
4748
import kotlinx.serialization.json.Json
4849
import libtailscale.Libtailscale
49-
import java.io.File
5050
import java.io.IOException
5151
import java.net.NetworkInterface
5252
import java.security.GeneralSecurityException
@@ -57,6 +57,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
5757

5858
companion object {
5959
private const val FILE_CHANNEL_ID = "tailscale-files"
60+
// Key to store the SAF URI in EncryptedSharedPreferences.
61+
private val PREF_KEY_SAF_URI = "saf_directory_uri"
6062
private const val TAG = "App"
6163
private lateinit var appInstance: App
6264

@@ -148,17 +150,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
148150
}
149151

150152
private fun initializeApp() {
151-
val dataDir = this.filesDir.absolutePath
152-
153-
// Set this to enable direct mode for taildrop whereby downloads will be saved directly
154-
// to the given folder. We will preferentially use <shared>/Downloads and fallback to
155-
// an app local directory "Taildrop" if we cannot create that. This mode does not support
156-
// user notifications for incoming files.
157-
val directFileDir = this.prepareDownloadsFolder()
158-
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
159-
Request.setApp(app)
160-
Notifier.setApp(app)
161-
Notifier.start(applicationScope)
153+
// Check if a directory URI has already been stored.
154+
val storedUri = getStoredDirectoryUri()
155+
if (storedUri != null && storedUri.toString().startsWith("content://")) {
156+
startLibtailscale(storedUri.toString())
157+
} else {
158+
startLibtailscale(this.getFilesDir().absolutePath)
159+
}
162160
healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope)
163161
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
164162
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
@@ -195,6 +193,18 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
195193
FeatureFlags.initialize(mapOf("enable_new_search" to true))
196194
}
197195

196+
/**
197+
* Called when a SAF directory URI is available (either already stored or chosen). We must restart
198+
* Tailscale because directFileRoot must be set before LocalBackend starts being used.
199+
*/
200+
fun startLibtailscale(directFileRoot: String) {
201+
ShareFileHelper.init(this, directFileRoot)
202+
app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this)
203+
Request.setApp(app)
204+
Notifier.setApp(app)
205+
Notifier.start(applicationScope)
206+
}
207+
198208
private fun initViewModels() {
199209
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
200210
}
@@ -237,6 +247,11 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
237247
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
238248
}
239249

250+
fun getStoredDirectoryUri(): Uri? {
251+
val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null)
252+
return uriString?.let { Uri.parse(it) }
253+
}
254+
240255
/*
241256
* setAbleToStartVPN remembers whether or not we're able to start the VPN
242257
* by storing this in a shared preference. This allows us to check this
@@ -300,29 +315,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
300315
return sb.toString()
301316
}
302317

303-
private fun prepareDownloadsFolder(): File {
304-
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
305-
306-
try {
307-
if (!downloads.exists()) {
308-
downloads.mkdirs()
309-
}
310-
} catch (e: Exception) {
311-
TSLog.e(TAG, "Failed to create downloads folder: $e")
312-
downloads = File(this.filesDir, "Taildrop")
313-
try {
314-
if (!downloads.exists()) {
315-
downloads.mkdirs()
316-
}
317-
} catch (e: Exception) {
318-
TSLog.e(TAG, "Failed to create Taildrop folder: $e")
319-
downloads = File("")
320-
}
321-
}
322-
323-
return downloads
324-
}
325-
326318
@Throws(
327319
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
328320
override fun getSyspolicyBooleanValue(key: String): Boolean {

android/src/main/java/com/tailscale/ipn/MainActivity.kt

+49-19
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package com.tailscale.ipn
66
import android.annotation.SuppressLint
77
import android.app.Activity
88
import android.app.AlertDialog
9+
import android.content.BroadcastReceiver
910
import android.content.Context
1011
import android.content.Intent
1112
import android.content.RestrictionsManager
@@ -14,14 +15,15 @@ import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
1415
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
1516
import android.net.ConnectivityManager
1617
import android.net.NetworkCapabilities
18+
import android.net.Uri
1719
import android.os.Build
1820
import android.os.Bundle
1921
import android.provider.Settings
20-
import android.util.Log
2122
import androidx.activity.ComponentActivity
2223
import androidx.activity.compose.setContent
2324
import androidx.activity.result.ActivityResultLauncher
2425
import androidx.activity.result.contract.ActivityResultContract
26+
import androidx.activity.result.contract.ActivityResultContracts
2527
import androidx.annotation.RequiresApi
2628
import androidx.browser.customtabs.CustomTabsIntent
2729
import androidx.compose.animation.core.LinearOutSlowInEasing
@@ -89,8 +91,14 @@ import kotlinx.coroutines.cancel
8991
import kotlinx.coroutines.flow.MutableStateFlow
9092
import kotlinx.coroutines.flow.StateFlow
9193
import kotlinx.coroutines.launch
94+
import libtailscale.Libtailscale
95+
import java.io.IOException
96+
import java.security.GeneralSecurityException
9297

9398
class MainActivity : ComponentActivity() {
99+
// Key to store the SAF URI in EncryptedSharedPreferences.
100+
private val PREF_KEY_SAF_URI = "saf_directory_uri"
101+
lateinit var safUriReceiver: BroadcastReceiver
94102
private lateinit var navController: NavHostController
95103
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
96104
private val viewModel: MainViewModel by lazy {
@@ -150,6 +158,24 @@ class MainActivity : ComponentActivity() {
150158
}
151159
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
152160

161+
val directoryPickerLauncher =
162+
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
163+
if (uri != null) {
164+
// Persist permissions for future access.
165+
contentResolver.takePersistableUriPermission(
166+
uri,
167+
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
168+
// Set the directory to download files to directly.
169+
Libtailscale.setDirectFileRoot(uri.toString())
170+
saveFileDirectory(uri)
171+
} else {
172+
TSLog.d(
173+
"MainActivity", "Taildrop directory not saved. Will fall back to internal storage.")
174+
}
175+
}
176+
177+
viewModel.setDirectoryPickerLauncher(directoryPickerLauncher)
178+
153179
setContent {
154180
navController = rememberNavController()
155181

@@ -198,7 +224,7 @@ class MainActivity : ComponentActivity() {
198224
onNavigateToSearch = {
199225
viewModel.enableSearchAutoFocus()
200226
navController.navigate("search")
201-
})
227+
})
202228

203229
val settingsNav =
204230
SettingsNav(
@@ -245,9 +271,8 @@ class MainActivity : ComponentActivity() {
245271
viewModel = viewModel,
246272
navController = navController,
247273
onNavigateBack = { navController.popBackStack() },
248-
autoFocus = autoFocus
249-
)
250-
}
274+
autoFocus = autoFocus)
275+
}
251276
composable("settings") { SettingsView(settingsNav) }
252277
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
253278
composable("health") { HealthView(backTo("main")) }
@@ -365,23 +390,28 @@ class MainActivity : ComponentActivity() {
365390
override fun onNewIntent(intent: Intent) {
366391
super.onNewIntent(intent)
367392
if (intent.getBooleanExtra(START_AT_ROOT, false)) {
368-
if (this::navController.isInitialized) {
369-
val previousEntry = navController.previousBackStackEntry
370-
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
371-
372-
if (previousEntry != null) {
373-
navController.popBackStack(route = "main", inclusive = false)
374-
} else {
375-
TSLog.e("MainActivity", "onNewIntent: No previous back stack entry, navigating directly to 'main'")
376-
navController.navigate("main") {
377-
popUpTo("main") { inclusive = true }
378-
}
379-
}
393+
if (this::navController.isInitialized) {
394+
val previousEntry = navController.previousBackStackEntry
395+
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
396+
397+
if (previousEntry != null) {
398+
navController.popBackStack(route = "main", inclusive = false)
399+
} else {
400+
TSLog.e(
401+
"MainActivity",
402+
"onNewIntent: No previous back stack entry, navigating directly to 'main'")
403+
navController.navigate("main") { popUpTo("main") { inclusive = true } }
380404
}
405+
}
381406
}
382-
}
383-
407+
}
384408

409+
@Throws(IOException::class, GeneralSecurityException::class)
410+
fun saveFileDirectory(directoryUri: Uri) {
411+
val prefs = App.get().getEncryptedPrefs()
412+
prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).apply()
413+
App.get().startLibtailscale(directoryUri.toString())
414+
}
385415

386416
private fun login(urlString: String) {
387417
// Launch coroutine to listen for state changes. When the user completes login, relaunch

android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt

+6-3
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ fun MainView(
209209
PromptPermissionsIfNecessary()
210210

211211
viewModel.showVPNPermissionLauncherIfUnauthorized()
212+
viewModel.showDirectoryPickerLauncher()
212213

213214
if (showKeyExpiry) {
214215
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
@@ -239,7 +240,9 @@ fun MainView(
239240
{ viewModel.login() },
240241
loginAtUrl,
241242
netmap?.SelfNode,
242-
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
243+
{ viewModel.showVPNPermissionLauncherIfUnauthorized()
244+
viewModel.showDirectoryPickerLauncher()
245+
} )
243246
}
244247
}
245248
}
@@ -415,11 +418,11 @@ fun ConnectView(
415418
loginAction: () -> Unit,
416419
loginAtUrlAction: (String) -> Unit,
417420
selfNode: Tailcfg.Node?,
418-
showVPNPermissionLauncherIfUnauthorized: () -> Unit
421+
showVPNPermissionAndDirectoryPickerLaunchers: () -> Unit
419422
) {
420423
LaunchedEffect(isPrepared) {
421424
if (!isPrepared && shouldStartAutomatically) {
422-
showVPNPermissionLauncherIfUnauthorized()
425+
showVPNPermissionAndDirectoryPickerLaunchers()
423426
}
424427
}
425428
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {

android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt

+31
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
package com.tailscale.ipn.ui.viewModel
55

66
import android.content.Intent
7+
import android.net.Uri
78
import android.net.VpnService
89
import androidx.activity.result.ActivityResultLauncher
910
import androidx.compose.runtime.getValue
1011
import androidx.compose.runtime.mutableStateOf
1112
import androidx.compose.runtime.setValue
1213
import androidx.compose.ui.platform.ClipboardManager
1314
import androidx.compose.ui.text.AnnotatedString
15+
import androidx.documentfile.provider.DocumentFile
1416
import androidx.lifecycle.ViewModel
1517
import androidx.lifecycle.ViewModelProvider
1618
import androidx.lifecycle.viewModelScope
@@ -25,6 +27,7 @@ import com.tailscale.ipn.ui.util.PeerCategorizer
2527
import com.tailscale.ipn.ui.util.PeerSet
2628
import com.tailscale.ipn.ui.util.TimeUtil
2729
import com.tailscale.ipn.ui.util.set
30+
import com.tailscale.ipn.util.TSLog
2831
import kotlinx.coroutines.Dispatchers
2932
import kotlinx.coroutines.FlowPreview
3033
import kotlinx.coroutines.Job
@@ -61,6 +64,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
6164
// Permission to prepare VPN
6265
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
6366

67+
// Select Taildrop directory
68+
private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
69+
6470
// The list of peers
6571
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
6672
val peers: StateFlow<List<PeerSet>> = _peers
@@ -197,13 +203,34 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
197203
}
198204
}
199205

206+
fun showDirectoryPickerLauncher() {
207+
val app = App.get() // Get the application instance
208+
val storedUri = app.getStoredDirectoryUri()
209+
if (storedUri == null) {
210+
// No stored URI, so launch the directory picker.
211+
directoryPickerLauncher?.launch(null)
212+
return
213+
}
214+
215+
val documentFile = DocumentFile.fromTreeUri(app, storedUri)
216+
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
217+
TSLog.d(
218+
"MainViewModel",
219+
"Stored directory URI is invalid or inaccessible; launching directory picker.")
220+
directoryPickerLauncher?.launch(null)
221+
} else {
222+
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
223+
}
224+
}
225+
200226
fun toggleVpn(desiredState: Boolean) {
201227
if (isToggleInProgress.value) {
202228
// Prevent toggling while a previous toggle is in progress
203229
return
204230
}
205231

206232
viewModelScope.launch {
233+
showDirectoryPickerLauncher()
207234
isToggleInProgress.value = true
208235
try {
209236
val currentState = Notifier.state.value
@@ -243,6 +270,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
243270
// No intent means we're already authorized
244271
vpnPermissionLauncher = launcher
245272
}
273+
274+
fun setDirectoryPickerLauncher(launcher: ActivityResultLauncher<Uri?>) {
275+
directoryPickerLauncher = launcher
276+
}
246277
}
247278

248279
private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int {

0 commit comments

Comments
 (0)