From 87f0e9754b512f7b337f68596390ee20bcc47617 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Fri, 30 May 2025 15:52:52 -0700 Subject: [PATCH] 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 --- .../java/com/tailscale/ipn/MainActivity.kt | 35 +++---- .../tailscale/ipn/TaildropDirectoryStore.kt | 43 +++++++++ .../ipn/ui/util/PermissionsDisplayUtil.kt | 21 +++++ .../ipn/ui/view/NotificationsView.kt | 93 +++++++++++++++++++ .../tailscale/ipn/ui/view/PermissionsView.kt | 52 ++++++++--- .../tailscale/ipn/ui/view/TaildropDirView.kt | 79 ++++++++++++++++ .../ipn/ui/viewModel/PermissionsViewModel.kt | 39 ++++++++ .../baseline_drive_folder_upload_24.xml | 20 ++++ .../res/drawable/baseline_folder_open_24.xml | 5 + .../baseline_notifications_none_24.xml | 5 + android/src/main/res/values/strings.xml | 10 +- 11 files changed, 368 insertions(+), 34 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/util/PermissionsDisplayUtil.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/NotificationsView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt create mode 100644 android/src/main/res/drawable/baseline_drive_folder_upload_24.xml create mode 100644 android/src/main/res/drawable/baseline_folder_open_24.xml create mode 100644 android/src/main/res/drawable/baseline_notifications_none_24.xml diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 98f591e..c7c8d60 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -70,6 +70,7 @@ import com.tailscale.ipn.ui.view.ManagedByView import com.tailscale.ipn.ui.view.MullvadExitNodePicker import com.tailscale.ipn.ui.view.MullvadExitNodePickerList 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.PermissionsView 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.SplitTunnelAppPickerView 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.UserSwitcherNav import com.tailscale.ipn.ui.view.UserSwitcherView @@ -93,12 +95,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import libtailscale.Libtailscale -import java.io.IOException -import java.security.GeneralSecurityException 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 vpnPermissionLauncher: ActivityResultLauncher private val viewModel: MainViewModel by lazy { @@ -180,7 +178,7 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch(Dispatchers.IO) { try { Libtailscale.setDirectFileRoot(uri.toString()) - saveFileDirectory(uri) + TaildropDirectoryStore.saveFileDirectory(uri) } catch (e: Exception) { TSLog.e("MainActivity", "Failed to set Taildrop root: $e") } @@ -190,7 +188,7 @@ class MainActivity : ComponentActivity() { "MainActivity", "Write access not granted for $uri. Falling back to internal storage.") // Don't save directory URI and fall back to internal storage. - } + } } else { TSLog.d( "MainActivity", "Taildrop directory not saved. Will fall back to internal storage.") @@ -329,7 +327,16 @@ class MainActivity : ComponentActivity() { composable("managedBy") { ManagedByView(backTo("settings")) } composable("userSwitcher") { UserSwitcherView(userSwitcherNav) } 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)) }) { 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) { // Launch coroutine to listen for state changes. When the user completes login, relaunch // MainActivity to bring the app back to focus. diff --git a/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt b/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt new file mode 100644 index 0000000..02bbaee --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt @@ -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 + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/PermissionsDisplayUtil.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PermissionsDisplayUtil.kt new file mode 100644 index 0000000..7ba877f --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/PermissionsDisplayUtil.kt @@ -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 + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/NotificationsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/NotificationsView.kt new file mode 100644 index 0000000..ee78959 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/NotificationsView.kt @@ -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)) + } + } + }) + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt index b033f24..6c19939 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt @@ -17,29 +17,33 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource 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.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.viewModel.PermissionsViewModel -@OptIn(ExperimentalPermissionsApi::class) @Composable -fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () -> Unit) { +fun PermissionsView( + backToSettings: BackNavigation, + navToTaildropDirView: () -> Unit, + navToNotificationsView: () -> Unit, + permissionsViewModel: PermissionsViewModel = viewModel() +) { val permissions = Permissions.withGrantedStatus + Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) { innerPadding -> LazyColumn(modifier = Modifier.padding(innerPadding)) { + // Existing Android runtime permissions itemsWithDividers(permissions) { (permission, granted) -> ListItem( - modifier = Modifier.clickable { openApplicationSettings() }, + modifier = Modifier.clickable { navToNotificationsView() }, leadingContent = { Icon( - if (granted) painterResource(R.drawable.check_circle) - else painterResource(R.drawable.xmark_circle), - tint = - if (granted) MaterialTheme.colorScheme.success - else MaterialTheme.colorScheme.onSurfaceVariant, + painterResource(R.drawable.baseline_notifications_none_24), + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(24.dp), contentDescription = stringResource(if (granted) R.string.ok else R.string.warning)) @@ -47,8 +51,32 @@ fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () headlineContent = { 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) + }) } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt new file mode 100644 index 0000000..4996b66 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt @@ -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, + 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)) + } + } + }) + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt new file mode 100644 index 0000000..46f2ea9 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt @@ -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(TaildropDirectoryStore.loadSavedDir().toString()) + val currentDir: StateFlow = _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 don’t 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() + } +} diff --git a/android/src/main/res/drawable/baseline_drive_folder_upload_24.xml b/android/src/main/res/drawable/baseline_drive_folder_upload_24.xml new file mode 100644 index 0000000..2582c88 --- /dev/null +++ b/android/src/main/res/drawable/baseline_drive_folder_upload_24.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/android/src/main/res/drawable/baseline_folder_open_24.xml b/android/src/main/res/drawable/baseline_folder_open_24.xml new file mode 100644 index 0000000..5601372 --- /dev/null +++ b/android/src/main/res/drawable/baseline_folder_open_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/src/main/res/drawable/baseline_notifications_none_24.xml b/android/src/main/res/drawable/baseline_notifications_none_24.xml new file mode 100644 index 0000000..1fb5684 --- /dev/null +++ b/android/src/main/res/drawable/baseline_notifications_none_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index d376490..c04baa8 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -24,6 +24,8 @@ No results Back Clear search + Off + On Tailscale @@ -221,7 +223,13 @@ We use storage in order to receive files with Taildrop. Notifications 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. - + Go to notification settings + Persistent status notifications are off by default and can be enabled in system settings. + Taildrop directory + Taildrop directory access + Give Tailscale access to a folder in order to be able to download incoming files sent to you via Taildrop. + Directory access + Pick a different directory Send with Taildrop