Skip to content

Commit 3d78bbd

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 <[email protected]>
1 parent 9a69bc3 commit 3d78bbd

File tree

11 files changed

+296
-64
lines changed

11 files changed

+296
-64
lines changed

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

Lines changed: 26 additions & 36 deletions
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,16 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
195193
FeatureFlags.initialize(mapOf("enable_new_search" to true))
196194
}
197195

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

248+
fun getStoredDirectoryUri(): Uri? {
249+
val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null)
250+
return uriString?.let { Uri.parse(it) }
251+
}
252+
240253
/*
241254
* setAbleToStartVPN remembers whether or not we're able to start the VPN
242255
* by storing this in a shared preference. This allows us to check this
@@ -300,29 +313,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
300313
return sb.toString()
301314
}
302315

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-
326316
@Throws(
327317
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
328318
override fun getSyspolicyBooleanValue(key: String): Boolean {

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

Lines changed: 49 additions & 19 deletions
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

Lines changed: 6 additions & 3 deletions
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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
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
@@ -25,6 +26,7 @@ import com.tailscale.ipn.ui.util.PeerCategorizer
2526
import com.tailscale.ipn.ui.util.PeerSet
2627
import com.tailscale.ipn.ui.util.TimeUtil
2728
import com.tailscale.ipn.ui.util.set
29+
import com.tailscale.ipn.util.TSLog
2830
import kotlinx.coroutines.Dispatchers
2931
import kotlinx.coroutines.FlowPreview
3032
import kotlinx.coroutines.Job
@@ -61,6 +63,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
6163
// Permission to prepare VPN
6264
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
6365

66+
// Select Taildrop directory
67+
private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
68+
6469
// The list of peers
6570
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
6671
val peers: StateFlow<List<PeerSet>> = _peers
@@ -197,13 +202,22 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
197202
}
198203
}
199204

205+
fun showDirectoryPickerLauncher() {
206+
if (App.get().getStoredDirectoryUri() == null) {
207+
directoryPickerLauncher?.launch(null)
208+
} else {
209+
TSLog.d("MainViewModel", "Directory picker not shown. Fall back to using internal storage.")
210+
}
211+
}
212+
200213
fun toggleVpn(desiredState: Boolean) {
201214
if (isToggleInProgress.value) {
202215
// Prevent toggling while a previous toggle is in progress
203216
return
204217
}
205218

206219
viewModelScope.launch {
220+
showDirectoryPickerLauncher()
207221
isToggleInProgress.value = true
208222
try {
209223
val currentState = Notifier.state.value
@@ -243,6 +257,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
243257
// No intent means we're already authorized
244258
vpnPermissionLauncher = launcher
245259
}
260+
261+
fun setDirectoryPickerLauncher(launcher: ActivityResultLauncher<Uri?>) {
262+
directoryPickerLauncher = launcher
263+
}
246264
}
247265

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

0 commit comments

Comments
 (0)