Skip to content

Commit b5b31c3

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 f01fb70 commit b5b31c3

File tree

11 files changed

+441
-57
lines changed

11 files changed

+441
-57
lines changed

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

Lines changed: 28 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import android.content.IntentFilter
1313
import android.content.SharedPreferences
1414
import android.content.pm.PackageManager
1515
import android.net.ConnectivityManager
16+
import android.net.Uri
1617
import android.os.Build
17-
import android.os.Environment
1818
import android.util.Log
1919
import androidx.core.app.ActivityCompat
2020
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
@@ -58,6 +59,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
5859

5960
companion object {
6061
private const val FILE_CHANNEL_ID = "tailscale-files"
62+
// Key to store the SAF URI in EncryptedSharedPreferences.
63+
private val PREF_KEY_SAF_URI = "saf_directory_uri"
6164
private const val TAG = "App"
6265
private lateinit var appInstance: App
6366

@@ -149,17 +152,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
149152
}
150153

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

206+
/**
207+
* Called when a SAF directory URI is available (either already stored or chosen). We must restart
208+
* Tailscale because directFileRoot must be set before LocalBackend starts being used.
209+
*/
210+
fun startLibtailscale(directFileRoot: String) {
211+
ShareFileHelper.init(this, directFileRoot)
212+
app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this)
213+
Request.setApp(app)
214+
Notifier.setApp(app)
215+
Notifier.start(applicationScope)
216+
}
217+
207218
private fun initViewModels() {
208219
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
209220
}
@@ -246,6 +257,11 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
246257
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
247258
}
248259

260+
fun getStoredDirectoryUri(): Uri? {
261+
val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null)
262+
return uriString?.let { Uri.parse(it) }
263+
}
264+
249265
/*
250266
* setAbleToStartVPN remembers whether or not we're able to start the VPN
251267
* by storing this in a shared preference. This allows us to check this
@@ -309,29 +325,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
309325
return sb.toString()
310326
}
311327

312-
private fun prepareDownloadsFolder(): File {
313-
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
314-
315-
try {
316-
if (!downloads.exists()) {
317-
downloads.mkdirs()
318-
}
319-
} catch (e: Exception) {
320-
TSLog.e(TAG, "Failed to create downloads folder: $e")
321-
downloads = File(this.filesDir, "Taildrop")
322-
try {
323-
if (!downloads.exists()) {
324-
downloads.mkdirs()
325-
}
326-
} catch (e: Exception) {
327-
TSLog.e(TAG, "Failed to create Taildrop folder: $e")
328-
downloads = File("")
329-
}
330-
}
331-
332-
return downloads
333-
}
334-
335328
@Throws(
336329
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
337330
override fun getSyspolicyBooleanValue(key: String): Boolean {

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

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,21 @@ import android.content.Context
1010
import android.content.Intent
1111
import android.content.RestrictionsManager
1212
import android.content.pm.ActivityInfo
13+
import android.content.pm.PackageManager
1314
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
21+
import android.os.Process
1922
import android.provider.Settings
2023
import androidx.activity.ComponentActivity
2124
import androidx.activity.compose.setContent
2225
import androidx.activity.result.ActivityResultLauncher
2326
import androidx.activity.result.contract.ActivityResultContract
27+
import androidx.activity.result.contract.ActivityResultContracts
2428
import androidx.annotation.RequiresApi
2529
import androidx.browser.customtabs.CustomTabsIntent
2630
import androidx.compose.animation.core.LinearOutSlowInEasing
@@ -88,8 +92,13 @@ import kotlinx.coroutines.cancel
8892
import kotlinx.coroutines.flow.MutableStateFlow
8993
import kotlinx.coroutines.flow.StateFlow
9094
import kotlinx.coroutines.launch
95+
import libtailscale.Libtailscale
96+
import java.io.IOException
97+
import java.security.GeneralSecurityException
9198

9299
class MainActivity : ComponentActivity() {
100+
// Key to store the SAF URI in EncryptedSharedPreferences.
101+
val PREF_KEY_SAF_URI = "saf_directory_uri"
93102
private lateinit var navController: NavHostController
94103
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
95104
private val viewModel: MainViewModel by lazy {
@@ -149,6 +158,49 @@ class MainActivity : ComponentActivity() {
149158
}
150159
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
151160

161+
val directoryPickerLauncher =
162+
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
163+
if (uri != null) {
164+
try {
165+
// Try to take persistable permissions for both read and write.
166+
contentResolver.takePersistableUriPermission(
167+
uri,
168+
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
169+
} catch (e: SecurityException) {
170+
TSLog.e("MainActivity", "Failed to persist permissions: $e")
171+
}
172+
173+
// Check if write permission is actually granted.
174+
val writePermission =
175+
this.checkUriPermission(
176+
uri, Process.myPid(), Process.myUid(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
177+
if (writePermission == PackageManager.PERMISSION_GRANTED) {
178+
TSLog.d("MainActivity", "Write permission granted for $uri")
179+
180+
lifecycleScope.launch(Dispatchers.IO) {
181+
try {
182+
Libtailscale.setDirectFileRoot(uri.toString())
183+
saveFileDirectory(uri)
184+
} catch (e: Exception) {
185+
TSLog.e("MainActivity", "Failed to set Taildrop root: $e")
186+
}
187+
}
188+
} else {
189+
TSLog.d(
190+
"MainActivity",
191+
"Write access not granted for $uri. Falling back to internal storage.")
192+
// Don't save directory URI and fall back to internal storage.
193+
}
194+
} else {
195+
TSLog.d(
196+
"MainActivity", "Taildrop directory not saved. Will fall back to internal storage.")
197+
198+
// Fall back to internal storage.
199+
}
200+
}
201+
202+
viewModel.setDirectoryPickerLauncher(directoryPickerLauncher)
203+
152204
setContent {
153205
navController = rememberNavController()
154206

@@ -366,19 +418,37 @@ class MainActivity : ComponentActivity() {
366418
if (this::navController.isInitialized) {
367419
val previousEntry = navController.previousBackStackEntry
368420
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
421+
if (this::navController.isInitialized) {
422+
val previousEntry = navController.previousBackStackEntry
423+
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
369424

370-
if (previousEntry != null) {
371-
navController.popBackStack(route = "main", inclusive = false)
372-
} else {
373-
TSLog.e(
374-
"MainActivity",
375-
"onNewIntent: No previous back stack entry, navigating directly to 'main'")
376-
navController.navigate("main") { popUpTo("main") { inclusive = true } }
425+
if (previousEntry != null) {
426+
navController.popBackStack(route = "main", inclusive = false)
427+
} else {
428+
TSLog.e(
429+
"MainActivity",
430+
"onNewIntent: No previous back stack entry, navigating directly to 'main'")
431+
navController.navigate("main") { popUpTo("main") { inclusive = true } }
432+
}
377433
}
378434
}
379435
}
380436
}
381437

438+
@Throws(IOException::class, GeneralSecurityException::class)
439+
fun saveFileDirectory(directoryUri: Uri) {
440+
val prefs = App.get().getEncryptedPrefs()
441+
prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).apply()
442+
try {
443+
// Must restart Tailscale because a new LocalBackend with the new directory must be created.
444+
App.get().startLibtailscale(directoryUri.toString())
445+
} catch (e: Exception) {
446+
TSLog.d(
447+
"MainActivity",
448+
"saveFileDirectory: Failed to restart Libtailscale with the new directory: $e")
449+
}
450+
}
451+
382452
private fun login(urlString: String) {
383453
// Launch coroutine to listen for state changes. When the user completes login, relaunch
384454
// MainActivity to bring the app back to focus.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package com.tailscale.ipn.ui.util
5+
6+
import com.tailscale.ipn.util.TSLog
7+
import java.io.OutputStream
8+
9+
// This class adapts a Java OutputStream to the libtailscale.OutputStream interface.
10+
class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale.OutputStream {
11+
// writes data to the outputStream in its entirety. Returns -1 on error.
12+
override fun write(data: ByteArray): Long {
13+
return try {
14+
outputStream.write(data)
15+
outputStream.flush()
16+
data.size.toLong()
17+
} catch (e: Exception) {
18+
TSLog.d("OutputStreamAdapter", "write exception: $e")
19+
-1L
20+
}
21+
}
22+
23+
override fun close() {
24+
outputStream.close()
25+
}
26+
}

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import com.tailscale.ipn.ui.theme.short
9797
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
9898
import com.tailscale.ipn.ui.theme.warningButton
9999
import com.tailscale.ipn.ui.theme.warningListItem
100+
import com.tailscale.ipn.ui.util.AndroidTVUtil
100101
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
101102
import com.tailscale.ipn.ui.util.AutoResizingText
102103
import com.tailscale.ipn.ui.util.Lists
@@ -212,6 +213,11 @@ fun MainView(
212213
PromptPermissionsIfNecessary()
213214
viewModel.maybeRequestVpnPermission()
214215
LaunchVpnPermissionIfNeeded(viewModel)
216+
LaunchedEffect(state) {
217+
if (state == Ipn.State.Running && !AndroidTVUtil.isAndroidTV()) {
218+
viewModel.showDirectoryPickerLauncher()
219+
}
220+
}
215221

216222
if (showKeyExpiry) {
217223
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
@@ -242,7 +248,9 @@ fun MainView(
242248
{ viewModel.login() },
243249
loginAtUrl,
244250
netmap?.SelfNode,
245-
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
251+
{
252+
viewModel.showVPNPermissionLauncherIfUnauthorized()
253+
})
246254
}
247255
}
248256
}
@@ -433,11 +441,11 @@ fun ConnectView(
433441
loginAction: () -> Unit,
434442
loginAtUrlAction: (String) -> Unit,
435443
selfNode: Tailcfg.Node?,
436-
showVPNPermissionLauncherIfUnauthorized: () -> Unit
444+
showVPNPermissionLauncher: () -> Unit
437445
) {
438446
LaunchedEffect(isPrepared) {
439447
if (!isPrepared && shouldStartAutomatically) {
440-
showVPNPermissionLauncherIfUnauthorized()
448+
showVPNPermissionLauncher()
441449
}
442450
}
443451
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {

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

Lines changed: 31 additions & 0 deletions
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
@@ -63,6 +66,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
6366
private val _requestVpnPermission = MutableStateFlow(false)
6467
val requestVpnPermission: StateFlow<Boolean> = _requestVpnPermission
6568

69+
// Select Taildrop directory
70+
private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
71+
6672
// The list of peers
6773
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
6874
val peers: StateFlow<List<PeerSet>> = _peers
@@ -204,13 +210,34 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
204210
_requestVpnPermission.value = false // reset
205211
}
206212

213+
fun showDirectoryPickerLauncher() {
214+
val app = App.get()
215+
val storedUri = app.getStoredDirectoryUri()
216+
if (storedUri == null) {
217+
// No stored URI, so launch the directory picker.
218+
directoryPickerLauncher?.launch(null)
219+
return
220+
}
221+
222+
val documentFile = DocumentFile.fromTreeUri(app, storedUri)
223+
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
224+
TSLog.d(
225+
"MainViewModel",
226+
"Stored directory URI is invalid or inaccessible; launching directory picker.")
227+
directoryPickerLauncher?.launch(null)
228+
} else {
229+
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
230+
}
231+
}
232+
207233
fun toggleVpn(desiredState: Boolean) {
208234
if (isToggleInProgress.value) {
209235
// Prevent toggling while a previous toggle is in progress
210236
return
211237
}
212238

213239
viewModelScope.launch {
240+
showDirectoryPickerLauncher()
214241
isToggleInProgress.value = true
215242
try {
216243
val currentState = Notifier.state.value
@@ -250,6 +277,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
250277
// No intent means we're already authorized
251278
vpnPermissionLauncher = launcher
252279
}
280+
281+
fun setDirectoryPickerLauncher(launcher: ActivityResultLauncher<Uri?>) {
282+
directoryPickerLauncher = launcher
283+
}
253284
}
254285

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

0 commit comments

Comments
 (0)