android: allow users to update taildrop directory (#658)

-Modify Permissions view to navigate to Taildrop dir view and Notifications view, and to reflect state
-Add Taildrop dir view which navigates to directory selector
-Add Notifications view which navigates to Taildrop notifications setting

Updates tailscale/tailscale#15263

Signed-off-by: kari-ts <kari@tailscale.com>
pull/654/head
kari-ts 6 months ago committed by GitHub
parent a14d4c7184
commit 87f0e9754b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -70,6 +70,7 @@ import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.MullvadExitNodePicker import com.tailscale.ipn.ui.view.MullvadExitNodePicker
import com.tailscale.ipn.ui.view.MullvadExitNodePickerList import com.tailscale.ipn.ui.view.MullvadExitNodePickerList
import com.tailscale.ipn.ui.view.MullvadInfoView import com.tailscale.ipn.ui.view.MullvadInfoView
import com.tailscale.ipn.ui.view.NotificationsView
import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.PermissionsView import com.tailscale.ipn.ui.view.PermissionsView
import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.RunExitNodeView
@ -77,6 +78,7 @@ import com.tailscale.ipn.ui.view.SearchView
import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
import com.tailscale.ipn.ui.view.SubnetRoutingView import com.tailscale.ipn.ui.view.SubnetRoutingView
import com.tailscale.ipn.ui.view.TaildropDirView
import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.TailnetLockSetupView
import com.tailscale.ipn.ui.view.UserSwitcherNav import com.tailscale.ipn.ui.view.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView import com.tailscale.ipn.ui.view.UserSwitcherView
@ -93,12 +95,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.io.IOException
import java.security.GeneralSecurityException
class MainActivity : ComponentActivity() { 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 navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent> private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by lazy { private val viewModel: MainViewModel by lazy {
@ -180,7 +178,7 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
Libtailscale.setDirectFileRoot(uri.toString()) Libtailscale.setDirectFileRoot(uri.toString())
saveFileDirectory(uri) TaildropDirectoryStore.saveFileDirectory(uri)
} catch (e: Exception) { } catch (e: Exception) {
TSLog.e("MainActivity", "Failed to set Taildrop root: $e") TSLog.e("MainActivity", "Failed to set Taildrop root: $e")
} }
@ -329,7 +327,16 @@ class MainActivity : ComponentActivity() {
composable("managedBy") { ManagedByView(backTo("settings")) } composable("managedBy") { ManagedByView(backTo("settings")) }
composable("userSwitcher") { UserSwitcherView(userSwitcherNav) } composable("userSwitcher") { UserSwitcherView(userSwitcherNav) }
composable("permissions") { composable("permissions") {
PermissionsView(backTo("settings"), ::openApplicationSettings) PermissionsView(
backTo("settings"),
{ navController.navigate("taildropDir") },
{ navController.navigate("notifications") })
}
composable("taildropDir") {
TaildropDirView(backTo("permissions"), directoryPickerLauncher)
}
composable("notifications") {
NotificationsView(backTo("permissions"), ::openApplicationSettings)
} }
composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) { composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) {
IntroView(backTo("main")) IntroView(backTo("main"))
@ -435,20 +442,6 @@ class MainActivity : ComponentActivity() {
} }
} }
@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) { private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch // Launch coroutine to listen for state changes. When the user completes login, relaunch
// MainActivity to bring the app back to focus. // MainActivity to bring the app back to focus.

@ -0,0 +1,43 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.net.Uri
import com.tailscale.ipn.util.TSLog
import java.io.IOException
import java.security.GeneralSecurityException
object TaildropDirectoryStore {
// Key to store the SAF URI in EncryptedSharedPreferences.
val PREF_KEY_SAF_URI = "saf_directory_uri"
@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(
"TaildropDirectoryStore",
"saveFileDirectory: Failed to restart Libtailscale with the new directory: $e")
}
}
@Throws(IOException::class, GeneralSecurityException::class)
fun loadSavedDir(): Uri? {
val prefs = App.get().getEncryptedPrefs()
val uriString = prefs.getString(PREF_KEY_SAF_URI, null) ?: return null
return try {
Uri.parse(uriString)
} catch (e: Exception) {
// Malformed URI in prefs log and wipe the bad value
TSLog.w("MainActivity", "loadSavedDir: invalid URI in prefs: $uriString; clearing")
prefs.edit().remove(PREF_KEY_SAF_URI).apply()
null
}
}
}

@ -0,0 +1,21 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import android.net.Uri
/** Converts a SAF URI string to a more human-friendly folder display name. */
fun friendlyDirName(uriStr: String): String {
val uri = Uri.parse(uriStr)
val segment = uri.lastPathSegment ?: return uriStr
return when {
segment.startsWith("primary:") -> "Internal storage " + segment.removePrefix("primary:")
segment.contains(":") -> {
val folder = segment.substringAfter(":")
"SD card $folder"
}
else -> segment
}
}

@ -0,0 +1,93 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
@Composable
fun NotificationsView(backToPermissionsView: BackNavigation, openApplicationSettings: () -> Unit) {
val permissions = Permissions.withGrantedStatus
// Find the notification permission
val notificationPermission =
permissions.find { (permission, _) ->
permission.title == R.string.permission_post_notifications
}
val granted = notificationPermission?.second ?: false
val permission = notificationPermission?.first
Scaffold(
topBar = {
Header(titleRes = R.string.permission_post_notifications, onBack = backToPermissionsView)
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
if (permission != null) {
ListItem(
headlineContent = {
Text(
stringResource(permission.title),
style = MaterialTheme.typography.titleMedium)
},
supportingContent = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(permission.description),
style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.notification_settings_explanation),
style = MaterialTheme.typography.bodyMedium)
}
})
}
}
item("spacer") {
Spacer(modifier = Modifier.height(16.dp)) // soft break instead of divider
}
item {
ListItem(
headlineContent = {
Text(
text = stringResource(R.string.permission_post_notifications),
style = MaterialTheme.typography.titleMedium)
},
supportingContent = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text =
if (granted) stringResource(R.string.on)
else stringResource(R.string.off),
style = MaterialTheme.typography.bodyMedium)
Button(
colors = MaterialTheme.colorScheme.exitNodeToggleButton,
onClick = openApplicationSettings,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
Text(stringResource(R.string.open_notification_settings))
}
}
})
}
}
}
}

@ -17,29 +17,33 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Permissions import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.theme.success import com.tailscale.ipn.ui.util.friendlyDirName
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.PermissionsViewModel
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () -> Unit) { fun PermissionsView(
backToSettings: BackNavigation,
navToTaildropDirView: () -> Unit,
navToNotificationsView: () -> Unit,
permissionsViewModel: PermissionsViewModel = viewModel()
) {
val permissions = Permissions.withGrantedStatus val permissions = Permissions.withGrantedStatus
Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) { Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) {
innerPadding -> innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
// Existing Android runtime permissions
itemsWithDividers(permissions) { (permission, granted) -> itemsWithDividers(permissions) { (permission, granted) ->
ListItem( ListItem(
modifier = Modifier.clickable { openApplicationSettings() }, modifier = Modifier.clickable { navToNotificationsView() },
leadingContent = { leadingContent = {
Icon( Icon(
if (granted) painterResource(R.drawable.check_circle) painterResource(R.drawable.baseline_notifications_none_24),
else painterResource(R.drawable.xmark_circle), tint = MaterialTheme.colorScheme.onSurfaceVariant,
tint =
if (granted) MaterialTheme.colorScheme.success
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
contentDescription = contentDescription =
stringResource(if (granted) R.string.ok else R.string.warning)) stringResource(if (granted) R.string.ok else R.string.warning))
@ -47,8 +51,32 @@ fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: ()
headlineContent = { headlineContent = {
Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium) Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium)
}, },
supportingContent = { Text(stringResource(permission.description)) }, supportingContent = {
) if (granted) Text(stringResource(R.string.on)) else Text(stringResource(R.string.off))
})
}
item {
ListItem(
modifier = Modifier.clickable { navToTaildropDirView() },
leadingContent = {
Icon(
painterResource(R.drawable.baseline_drive_folder_upload_24),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
contentDescription = stringResource(R.string.taildrop_dir))
},
headlineContent = {
Text(
stringResource(R.string.taildrop_dir_access),
style = MaterialTheme.typography.titleMedium)
},
supportingContent = {
val displayPath =
permissionsViewModel.currentDir.value?.let { friendlyDirName(it) } ?: "No access"
Text(displayPath)
})
} }
} }
} }

@ -0,0 +1,79 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import android.net.Uri
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.friendlyDirName
import com.tailscale.ipn.ui.viewModel.PermissionsViewModel
@Composable
fun TaildropDirView(
backToPermissionsView: BackNavigation,
openDirectoryLauncher: ActivityResultLauncher<Uri?>,
permissionsViewModel: PermissionsViewModel = viewModel()
) {
Scaffold(
topBar = {
Header(titleRes = R.string.taildrop_dir_access, onBack = backToPermissionsView)
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
ListItem(
headlineContent = {
Text(
stringResource(R.string.taildrop_dir_access),
style = MaterialTheme.typography.titleMedium)
},
supportingContent = {
Text(
text = stringResource(R.string.permission_taildrop_dir),
style = MaterialTheme.typography.bodyMedium)
})
}
item("divider0") { Lists.SectionDivider() }
item {
val currentDir = permissionsViewModel.currentDir.value
val displayPath = currentDir?.let { friendlyDirName(it) } ?: "No access"
ListItem(
headlineContent = {
Text(
text = stringResource(R.string.dir_access),
style = MaterialTheme.typography.titleMedium)
},
supportingContent = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(text = displayPath, style = MaterialTheme.typography.bodyMedium)
Button(
colors = MaterialTheme.colorScheme.exitNodeToggleButton,
onClick = { openDirectoryLauncher.launch(null) },
modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
Text(stringResource(R.string.pick_dir))
}
}
})
}
}
}
}

@ -0,0 +1,39 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.tailscale.ipn.TaildropDirectoryStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import libtailscale.Libtailscale
class PermissionsViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
private val _currentDir =
MutableStateFlow<String?>(TaildropDirectoryStore.loadSavedDir().toString())
val currentDir: StateFlow<String?> = _currentDir
fun onDirectoryPicked(uri: Uri?, context: Context) {
if (uri == null) return
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
val cr = context.contentResolver
// Revoke previous grant so you dont leak one
_currentDir.value?.let { old ->
runCatching { cr.releasePersistableUriPermission(Uri.parse(old), flags) }
}
cr.takePersistableUriPermission(uri, flags) // may throw SecurityException
Libtailscale.setDirectFileRoot(uri.toString())
TaildropDirectoryStore.saveFileDirectory(uri)
_currentDir.value = uri.toString()
}
}

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:tint="#000000">
<!-- Folder outline -->
<path
android:fillColor="@android:color/white"
android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,8h16v10z" />
<!-- Flipped arrow, shifted downward by 1dp (in viewport units) -->
<group
android:translateY="2">
<path
android:fillColor="@android:color/white"
android:pathData="M16,10.99l-1.41,-1.41L13,11.16V7h-2v4.16L9.41,9.58 8,10.99 12.01,15 16,10.99z" />
</group>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,8h16v10z"/>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.63,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2zM16,17L8,17v-6c0,-2.48 1.51,-4.5 4,-4.5s4,2.02 4,4.5v6z"/>
</vector>

@ -24,6 +24,8 @@
<string name="no_results">No results</string> <string name="no_results">No results</string>
<string name="back">Back</string> <string name="back">Back</string>
<string name="clear_search">Clear search</string> <string name="clear_search">Clear search</string>
<string name="off">Off</string>
<string name="on">On</string>
<!-- Strings for the about screen --> <!-- Strings for the about screen -->
<string name="app_name" translatable="false">Tailscale</string> <string name="app_name" translatable="false">Tailscale</string>
@ -221,7 +223,13 @@
<string name="permission_write_external_storage_needed">We use storage in order to receive files with Taildrop.</string> <string name="permission_write_external_storage_needed">We use storage in order to receive files with Taildrop.</string>
<string name="permission_post_notifications">Notifications</string> <string name="permission_post_notifications">Notifications</string>
<string name="permission_post_notifications_needed">We use notifications to help you troubleshoot broken connections, or notify you before you need to reauthenticate to your network. Persistent status notifications are off by default and can be enabled in system settings. </string> <string name="permission_post_notifications_needed">We use notifications to help you troubleshoot broken connections, or notify you before you need to reauthenticate to your network. Persistent status notifications are off by default and can be enabled in system settings. </string>
<string name="open_notification_settings">Go to notification settings</string>
<string name="notification_settings_explanation">Persistent status notifications are off by default and can be enabled in system settings.</string>
<string name="taildrop_dir">Taildrop directory</string>
<string name="taildrop_dir_access">Taildrop directory access</string>
<string name="permission_taildrop_dir">Give Tailscale access to a folder in order to be able to download incoming files sent to you via Taildrop.</string>
<string name="dir_access">Directory access</string>
<string name="pick_dir">Pick a different directory</string>
<!-- Strings for the share activity --> <!-- Strings for the share activity -->
<string name="share">Send with Taildrop</string> <string name="share">Send with Taildrop</string>

Loading…
Cancel
Save