Skip to content

Commit 87f0e97

Browse files
authored
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 <[email protected]>
1 parent a14d4c7 commit 87f0e97

11 files changed

+368
-34
lines changed

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

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,15 @@ import com.tailscale.ipn.ui.view.ManagedByView
7070
import com.tailscale.ipn.ui.view.MullvadExitNodePicker
7171
import com.tailscale.ipn.ui.view.MullvadExitNodePickerList
7272
import com.tailscale.ipn.ui.view.MullvadInfoView
73+
import com.tailscale.ipn.ui.view.NotificationsView
7374
import com.tailscale.ipn.ui.view.PeerDetails
7475
import com.tailscale.ipn.ui.view.PermissionsView
7576
import com.tailscale.ipn.ui.view.RunExitNodeView
7677
import com.tailscale.ipn.ui.view.SearchView
7778
import com.tailscale.ipn.ui.view.SettingsView
7879
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
7980
import com.tailscale.ipn.ui.view.SubnetRoutingView
81+
import com.tailscale.ipn.ui.view.TaildropDirView
8082
import com.tailscale.ipn.ui.view.TailnetLockSetupView
8183
import com.tailscale.ipn.ui.view.UserSwitcherNav
8284
import com.tailscale.ipn.ui.view.UserSwitcherView
@@ -93,12 +95,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
9395
import kotlinx.coroutines.flow.StateFlow
9496
import kotlinx.coroutines.launch
9597
import libtailscale.Libtailscale
96-
import java.io.IOException
97-
import java.security.GeneralSecurityException
9898

9999
class MainActivity : ComponentActivity() {
100-
// Key to store the SAF URI in EncryptedSharedPreferences.
101-
val PREF_KEY_SAF_URI = "saf_directory_uri"
102100
private lateinit var navController: NavHostController
103101
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
104102
private val viewModel: MainViewModel by lazy {
@@ -180,7 +178,7 @@ class MainActivity : ComponentActivity() {
180178
lifecycleScope.launch(Dispatchers.IO) {
181179
try {
182180
Libtailscale.setDirectFileRoot(uri.toString())
183-
saveFileDirectory(uri)
181+
TaildropDirectoryStore.saveFileDirectory(uri)
184182
} catch (e: Exception) {
185183
TSLog.e("MainActivity", "Failed to set Taildrop root: $e")
186184
}
@@ -190,7 +188,7 @@ class MainActivity : ComponentActivity() {
190188
"MainActivity",
191189
"Write access not granted for $uri. Falling back to internal storage.")
192190
// Don't save directory URI and fall back to internal storage.
193-
}
191+
}
194192
} else {
195193
TSLog.d(
196194
"MainActivity", "Taildrop directory not saved. Will fall back to internal storage.")
@@ -329,7 +327,16 @@ class MainActivity : ComponentActivity() {
329327
composable("managedBy") { ManagedByView(backTo("settings")) }
330328
composable("userSwitcher") { UserSwitcherView(userSwitcherNav) }
331329
composable("permissions") {
332-
PermissionsView(backTo("settings"), ::openApplicationSettings)
330+
PermissionsView(
331+
backTo("settings"),
332+
{ navController.navigate("taildropDir") },
333+
{ navController.navigate("notifications") })
334+
}
335+
composable("taildropDir") {
336+
TaildropDirView(backTo("permissions"), directoryPickerLauncher)
337+
}
338+
composable("notifications") {
339+
NotificationsView(backTo("permissions"), ::openApplicationSettings)
333340
}
334341
composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) {
335342
IntroView(backTo("main"))
@@ -435,20 +442,6 @@ class MainActivity : ComponentActivity() {
435442
}
436443
}
437444

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-
452445
private fun login(urlString: String) {
453446
// Launch coroutine to listen for state changes. When the user completes login, relaunch
454447
// MainActivity to bring the app back to focus.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package com.tailscale.ipn
5+
6+
import android.net.Uri
7+
import com.tailscale.ipn.util.TSLog
8+
import java.io.IOException
9+
import java.security.GeneralSecurityException
10+
11+
object TaildropDirectoryStore {
12+
// Key to store the SAF URI in EncryptedSharedPreferences.
13+
val PREF_KEY_SAF_URI = "saf_directory_uri"
14+
15+
@Throws(IOException::class, GeneralSecurityException::class)
16+
fun saveFileDirectory(directoryUri: Uri) {
17+
val prefs = App.get().getEncryptedPrefs()
18+
prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).apply()
19+
try {
20+
// Must restart Tailscale because a new LocalBackend with the new directory must be created.
21+
App.get().startLibtailscale(directoryUri.toString())
22+
} catch (e: Exception) {
23+
TSLog.d(
24+
"TaildropDirectoryStore",
25+
"saveFileDirectory: Failed to restart Libtailscale with the new directory: $e")
26+
}
27+
}
28+
29+
@Throws(IOException::class, GeneralSecurityException::class)
30+
fun loadSavedDir(): Uri? {
31+
val prefs = App.get().getEncryptedPrefs()
32+
val uriString = prefs.getString(PREF_KEY_SAF_URI, null) ?: return null
33+
34+
return try {
35+
Uri.parse(uriString)
36+
} catch (e: Exception) {
37+
// Malformed URI in prefs ‑‑ log and wipe the bad value
38+
TSLog.w("MainActivity", "loadSavedDir: invalid URI in prefs: $uriString; clearing")
39+
prefs.edit().remove(PREF_KEY_SAF_URI).apply()
40+
null
41+
}
42+
}
43+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package com.tailscale.ipn.ui.util
5+
6+
import android.net.Uri
7+
8+
/** Converts a SAF URI string to a more human-friendly folder display name. */
9+
fun friendlyDirName(uriStr: String): String {
10+
val uri = Uri.parse(uriStr)
11+
val segment = uri.lastPathSegment ?: return uriStr
12+
13+
return when {
14+
segment.startsWith("primary:") -> "Internal storage › " + segment.removePrefix("primary:")
15+
segment.contains(":") -> {
16+
val folder = segment.substringAfter(":")
17+
"SD card › $folder"
18+
}
19+
else -> segment
20+
}
21+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package com.tailscale.ipn.ui.view
5+
6+
import androidx.compose.foundation.layout.Column
7+
import androidx.compose.foundation.layout.Spacer
8+
import androidx.compose.foundation.layout.fillMaxWidth
9+
import androidx.compose.foundation.layout.height
10+
import androidx.compose.foundation.layout.padding
11+
import androidx.compose.foundation.lazy.LazyColumn
12+
import androidx.compose.material3.Button
13+
import androidx.compose.material3.ListItem
14+
import androidx.compose.material3.MaterialTheme
15+
import androidx.compose.material3.Scaffold
16+
import androidx.compose.material3.Text
17+
import androidx.compose.runtime.Composable
18+
import androidx.compose.ui.Modifier
19+
import androidx.compose.ui.res.stringResource
20+
import androidx.compose.ui.unit.dp
21+
import com.tailscale.ipn.R
22+
import com.tailscale.ipn.ui.model.Permissions
23+
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
24+
25+
@Composable
26+
fun NotificationsView(backToPermissionsView: BackNavigation, openApplicationSettings: () -> Unit) {
27+
val permissions = Permissions.withGrantedStatus
28+
29+
// Find the notification permission
30+
val notificationPermission =
31+
permissions.find { (permission, _) ->
32+
permission.title == R.string.permission_post_notifications
33+
}
34+
val granted = notificationPermission?.second ?: false
35+
val permission = notificationPermission?.first
36+
37+
Scaffold(
38+
topBar = {
39+
Header(titleRes = R.string.permission_post_notifications, onBack = backToPermissionsView)
40+
}) { innerPadding ->
41+
LazyColumn(modifier = Modifier.padding(innerPadding)) {
42+
item {
43+
if (permission != null) {
44+
ListItem(
45+
headlineContent = {
46+
Text(
47+
stringResource(permission.title),
48+
style = MaterialTheme.typography.titleMedium)
49+
},
50+
supportingContent = {
51+
Column(modifier = Modifier.fillMaxWidth()) {
52+
Text(
53+
text = stringResource(permission.description),
54+
style = MaterialTheme.typography.bodyMedium)
55+
Spacer(modifier = Modifier.height(12.dp))
56+
Text(
57+
text = stringResource(R.string.notification_settings_explanation),
58+
style = MaterialTheme.typography.bodyMedium)
59+
}
60+
})
61+
}
62+
}
63+
64+
item("spacer") {
65+
Spacer(modifier = Modifier.height(16.dp)) // soft break instead of divider
66+
}
67+
68+
item {
69+
ListItem(
70+
headlineContent = {
71+
Text(
72+
text = stringResource(R.string.permission_post_notifications),
73+
style = MaterialTheme.typography.titleMedium)
74+
},
75+
supportingContent = {
76+
Column(modifier = Modifier.fillMaxWidth()) {
77+
Text(
78+
text =
79+
if (granted) stringResource(R.string.on)
80+
else stringResource(R.string.off),
81+
style = MaterialTheme.typography.bodyMedium)
82+
Button(
83+
colors = MaterialTheme.colorScheme.exitNodeToggleButton,
84+
onClick = openApplicationSettings,
85+
modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
86+
Text(stringResource(R.string.open_notification_settings))
87+
}
88+
}
89+
})
90+
}
91+
}
92+
}
93+
}

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

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,38 +17,66 @@ import androidx.compose.ui.Modifier
1717
import androidx.compose.ui.res.painterResource
1818
import androidx.compose.ui.res.stringResource
1919
import androidx.compose.ui.unit.dp
20-
import com.google.accompanist.permissions.ExperimentalPermissionsApi
20+
import androidx.lifecycle.viewmodel.compose.viewModel
2121
import com.tailscale.ipn.R
2222
import com.tailscale.ipn.ui.model.Permissions
23-
import com.tailscale.ipn.ui.theme.success
23+
import com.tailscale.ipn.ui.util.friendlyDirName
2424
import com.tailscale.ipn.ui.util.itemsWithDividers
25+
import com.tailscale.ipn.ui.viewModel.PermissionsViewModel
2526

26-
@OptIn(ExperimentalPermissionsApi::class)
2727
@Composable
28-
fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () -> Unit) {
28+
fun PermissionsView(
29+
backToSettings: BackNavigation,
30+
navToTaildropDirView: () -> Unit,
31+
navToNotificationsView: () -> Unit,
32+
permissionsViewModel: PermissionsViewModel = viewModel()
33+
) {
2934
val permissions = Permissions.withGrantedStatus
35+
3036
Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) {
3137
innerPadding ->
3238
LazyColumn(modifier = Modifier.padding(innerPadding)) {
39+
// Existing Android runtime permissions
3340
itemsWithDividers(permissions) { (permission, granted) ->
3441
ListItem(
35-
modifier = Modifier.clickable { openApplicationSettings() },
42+
modifier = Modifier.clickable { navToNotificationsView() },
3643
leadingContent = {
3744
Icon(
38-
if (granted) painterResource(R.drawable.check_circle)
39-
else painterResource(R.drawable.xmark_circle),
40-
tint =
41-
if (granted) MaterialTheme.colorScheme.success
42-
else MaterialTheme.colorScheme.onSurfaceVariant,
45+
painterResource(R.drawable.baseline_notifications_none_24),
46+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
4347
modifier = Modifier.size(24.dp),
4448
contentDescription =
4549
stringResource(if (granted) R.string.ok else R.string.warning))
4650
},
4751
headlineContent = {
4852
Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium)
4953
},
50-
supportingContent = { Text(stringResource(permission.description)) },
51-
)
54+
supportingContent = {
55+
if (granted) Text(stringResource(R.string.on)) else Text(stringResource(R.string.off))
56+
})
57+
}
58+
59+
item {
60+
ListItem(
61+
modifier = Modifier.clickable { navToTaildropDirView() },
62+
leadingContent = {
63+
Icon(
64+
painterResource(R.drawable.baseline_drive_folder_upload_24),
65+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
66+
modifier = Modifier.size(24.dp),
67+
contentDescription = stringResource(R.string.taildrop_dir))
68+
},
69+
headlineContent = {
70+
Text(
71+
stringResource(R.string.taildrop_dir_access),
72+
style = MaterialTheme.typography.titleMedium)
73+
},
74+
supportingContent = {
75+
val displayPath =
76+
permissionsViewModel.currentDir.value?.let { friendlyDirName(it) } ?: "No access"
77+
78+
Text(displayPath)
79+
})
5280
}
5381
}
5482
}

0 commit comments

Comments
 (0)