Skip to content

Commit 64c378d

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 d3f34c5 commit 64c378d

File tree

11 files changed

+403
-56
lines changed

11 files changed

+403
-56
lines changed

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

Lines changed: 32 additions & 40 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
@@ -36,12 +36,8 @@ import com.tailscale.ipn.ui.notifier.Notifier
3636
import com.tailscale.ipn.ui.viewModel.VpnViewModel
3737
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
3838
import com.tailscale.ipn.util.FeatureFlags
39+
import com.tailscale.ipn.util.ShareFileHelper
3940
import com.tailscale.ipn.util.TSLog
40-
import java.io.File
41-
import java.io.IOException
42-
import java.net.NetworkInterface
43-
import java.security.GeneralSecurityException
44-
import java.util.Locale
4541
import kotlinx.coroutines.CoroutineScope
4642
import kotlinx.coroutines.Dispatchers
4743
import kotlinx.coroutines.SupervisorJob
@@ -53,12 +49,18 @@ import kotlinx.coroutines.launch
5349
import kotlinx.serialization.encodeToString
5450
import kotlinx.serialization.json.Json
5551
import libtailscale.Libtailscale
52+
import java.io.IOException
53+
import java.net.NetworkInterface
54+
import java.security.GeneralSecurityException
55+
import java.util.Locale
5656

5757
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
5858
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
5959

6060
companion object {
6161
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"
6264
private const val TAG = "App"
6365
private lateinit var appInstance: App
6466

@@ -150,17 +152,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
150152
}
151153

152154
private fun initializeApp() {
153-
val dataDir = this.filesDir.absolutePath
154-
155-
// Set this to enable direct mode for taildrop whereby downloads will be saved directly
156-
// to the given folder. We will preferentially use <shared>/Downloads and fallback to
157-
// an app local directory "Taildrop" if we cannot create that. This mode does not support
158-
// user notifications for incoming files.
159-
val directFileDir = this.prepareDownloadsFolder()
160-
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
161-
Request.setApp(app)
162-
Notifier.setApp(app)
163-
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+
}
164162
healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope)
165163
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
166164
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
@@ -205,6 +203,18 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
205203
FeatureFlags.initialize(mapOf("enable_new_search" to true))
206204
}
207205

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+
208218
private fun initViewModels() {
209219
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
210220
}
@@ -247,6 +257,11 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
247257
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
248258
}
249259

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

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

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

Lines changed: 69 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,41 @@ 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+
Libtailscale.setDirectFileRoot(uri.toString())
180+
saveFileDirectory(uri)
181+
} else {
182+
TSLog.d(
183+
"MainActivity",
184+
"Write access not granted for $uri. Falling back to internal storage.")
185+
// Don't save directory URI and fall back to internal storage.
186+
}
187+
} else {
188+
TSLog.d(
189+
"MainActivity", "Taildrop directory not saved. Will fall back to internal storage.")
190+
// Fall back to internal storage.
191+
}
192+
}
193+
194+
viewModel.setDirectoryPickerLauncher(directoryPickerLauncher)
195+
152196
setContent {
153197
navController = rememberNavController()
154198

@@ -366,19 +410,37 @@ class MainActivity : ComponentActivity() {
366410
if (this::navController.isInitialized) {
367411
val previousEntry = navController.previousBackStackEntry
368412
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
413+
if (this::navController.isInitialized) {
414+
val previousEntry = navController.previousBackStackEntry
415+
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
369416

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 } }
417+
if (previousEntry != null) {
418+
navController.popBackStack(route = "main", inclusive = false)
419+
} else {
420+
TSLog.e(
421+
"MainActivity",
422+
"onNewIntent: No previous back stack entry, navigating directly to 'main'")
423+
navController.navigate("main") { popUpTo("main") { inclusive = true } }
424+
}
377425
}
378426
}
379427
}
380428
}
381429

430+
@Throws(IOException::class, GeneralSecurityException::class)
431+
fun saveFileDirectory(directoryUri: Uri) {
432+
val prefs = App.get().getEncryptedPrefs()
433+
prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).apply()
434+
try {
435+
// Must restart Tailscale because a new LocalBackend with the new directory must be created.
436+
App.get().startLibtailscale(directoryUri.toString())
437+
} catch (e: Exception) {
438+
TSLog.d(
439+
"MainActivity",
440+
"saveFileDirectory: Failed to restart Libtailscale with the new directory: $e")
441+
}
442+
}
443+
382444
private fun login(urlString: String) {
383445
// Launch coroutine to listen for state changes. When the user completes login, relaunch
384446
// 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
@@ -98,6 +98,7 @@ import com.tailscale.ipn.ui.theme.surfaceContainerListItem
9898
import com.tailscale.ipn.ui.theme.warningButton
9999
import com.tailscale.ipn.ui.theme.warningListItem
100100
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
101+
import com.tailscale.ipn.ui.util.AndroidTVUtil
101102
import com.tailscale.ipn.ui.util.AutoResizingText
102103
import com.tailscale.ipn.ui.util.Lists
103104
import com.tailscale.ipn.ui.util.LoadingIndicator
@@ -212,6 +213,9 @@ fun MainView(
212213
PromptPermissionsIfNecessary()
213214
viewModel.maybeRequestVpnPermission()
214215
LaunchVpnPermissionIfNeeded(viewModel)
216+
if (AndroidTVUtil.isAndroidTV()){
217+
viewModel.showDirectoryPickerLauncher()
218+
}
215219

216220
if (showKeyExpiry) {
217221
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
@@ -242,7 +246,11 @@ fun MainView(
242246
{ viewModel.login() },
243247
loginAtUrl,
244248
netmap?.SelfNode,
245-
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
249+
{ viewModel.showVPNPermissionLauncherIfUnauthorized()
250+
if (!AndroidTVUtil.isAndroidTV()){
251+
viewModel.showDirectoryPickerLauncher()
252+
}
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+
showVPNPermissionAndDirectoryPickerLaunchers: () -> Unit
437445
) {
438446
LaunchedEffect(isPrepared) {
439447
if (!isPrepared && shouldStartAutomatically) {
440-
showVPNPermissionLauncherIfUnauthorized()
448+
showVPNPermissionAndDirectoryPickerLaunchers()
441449
}
442450
}
443451
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {

0 commit comments

Comments
 (0)