Skip to content

Commit 620ad22

Browse files
committed
android: move taildrop directory selector out of onboarding
-ShareFileHelper manages directory readiness; when a file is being shared to the device, it emits a signal to prompt the user to pick a directory -Remove MDM auth key check; there is no longer any need to make assumptions about Taildrop usage, and we only show the directory selector when they are receiving a Taildropped file -Listen for Taildrop receipt in application view model (formerly VpnViewModel, now renamed due to its expanded scope), since Taildrop can occur even without MainActivity, and move dir picker out of MainView -Switch from StateFlow to SharedFlow since this is an event that only needs to be handled once rather than a persistent UI state. -ShareFileHelper keeps track of Taildrop dir rather than the Taildrop extension managerOptions; this allows the correct directory to be used without having to send a new request or restart LocalBackend -Don't restart LocalBackend on Taildrop dir selection because this is no longer necessary Follow-up: implement resume Taildrop in SAF Updates tailscale/corp#29211 Signed-off-by: kari-ts <[email protected]>
1 parent 28f1931 commit 620ad22

File tree

15 files changed

+297
-227
lines changed

15 files changed

+297
-227
lines changed

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

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ import com.tailscale.ipn.ui.model.Ipn
3333
import com.tailscale.ipn.ui.model.Netmap
3434
import com.tailscale.ipn.ui.notifier.HealthNotifier
3535
import com.tailscale.ipn.ui.notifier.Notifier
36-
import com.tailscale.ipn.ui.viewModel.VpnViewModel
37-
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
36+
import com.tailscale.ipn.ui.viewModel.AppViewModel
37+
import com.tailscale.ipn.ui.viewModel.AppViewModelFactory
3838
import com.tailscale.ipn.util.FeatureFlags
3939
import com.tailscale.ipn.util.ShareFileHelper
4040
import com.tailscale.ipn.util.TSLog
@@ -211,23 +211,25 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
211211
* Tailscale because directFileRoot must be set before LocalBackend starts being used.
212212
*/
213213
fun startLibtailscale(directFileRoot: String) {
214-
ShareFileHelper.init(this, directFileRoot)
215214
app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this)
215+
ShareFileHelper.init(this, app, directFileRoot, applicationScope)
216216
Request.setApp(app)
217217
Notifier.setApp(app)
218218
Notifier.start(applicationScope)
219219
}
220220

221221
private fun initViewModels() {
222-
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
222+
appViewModel =
223+
ViewModelProvider(this, AppViewModelFactory(this, ShareFileHelper.observeTaildropPrompt()))
224+
.get(AppViewModel::class.java)
223225
}
224226

225227
fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
226228
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
227229
result.fold(
228230
onSuccess = { onSuccess?.invoke() },
229231
onFailure = { error ->
230-
TSLog.d("TAG", "Set want running: failed to update preferences: ${error.message}")
232+
TSLog.d(TAG, "Set want running: failed to update preferences: ${error.message}")
231233
})
232234
}
233235
Client(applicationScope)
@@ -390,7 +392,7 @@ open class UninitializedApp : Application() {
390392
private lateinit var appInstance: UninitializedApp
391393
lateinit var notificationManager: NotificationManagerCompat
392394

393-
lateinit var vpnViewModel: VpnViewModel
395+
lateinit var appViewModel: AppViewModel
394396

395397
@JvmStatic
396398
fun get(): UninitializedApp {
@@ -577,8 +579,8 @@ open class UninitializedApp : Application() {
577579
return builtInDisallowedPackageNames + userDisallowed
578580
}
579581

580-
fun getAppScopedViewModel(): VpnViewModel {
581-
return vpnViewModel
582+
fun getAppScopedViewModel(): AppViewModel {
583+
return appViewModel
582584
}
583585

584586
val builtInDisallowedPackageNames: List<String> =

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

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,18 @@ import androidx.compose.animation.fadeIn
3434
import androidx.compose.animation.fadeOut
3535
import androidx.compose.animation.slideInHorizontally
3636
import androidx.compose.animation.slideOutHorizontally
37+
import androidx.compose.material3.AlertDialog
3738
import androidx.compose.material3.MaterialTheme
3839
import androidx.compose.material3.Surface
40+
import androidx.compose.material3.Text
41+
import androidx.compose.runtime.LaunchedEffect
3942
import androidx.compose.runtime.collectAsState
43+
import androidx.compose.runtime.getValue
44+
import androidx.compose.runtime.mutableStateOf
45+
import androidx.compose.runtime.remember
46+
import androidx.compose.runtime.setValue
4047
import androidx.compose.ui.Modifier
48+
import androidx.compose.ui.res.stringResource
4149
import androidx.core.net.toUri
4250
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
4351
import androidx.lifecycle.ViewModelProvider
@@ -75,39 +83,41 @@ import com.tailscale.ipn.ui.view.MullvadInfoView
7583
import com.tailscale.ipn.ui.view.NotificationsView
7684
import com.tailscale.ipn.ui.view.PeerDetails
7785
import com.tailscale.ipn.ui.view.PermissionsView
86+
import com.tailscale.ipn.ui.view.PrimaryActionButton
7887
import com.tailscale.ipn.ui.view.RunExitNodeView
7988
import com.tailscale.ipn.ui.view.SearchView
8089
import com.tailscale.ipn.ui.view.SettingsView
8190
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
8291
import com.tailscale.ipn.ui.view.SubnetRoutingView
8392
import com.tailscale.ipn.ui.view.TaildropDirView
93+
import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt
8494
import com.tailscale.ipn.ui.view.TailnetLockSetupView
8595
import com.tailscale.ipn.ui.view.UserSwitcherNav
8696
import com.tailscale.ipn.ui.view.UserSwitcherView
97+
import com.tailscale.ipn.ui.viewModel.AppViewModel
8798
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
8899
import com.tailscale.ipn.ui.viewModel.MainViewModel
89100
import com.tailscale.ipn.ui.viewModel.MainViewModelFactory
90101
import com.tailscale.ipn.ui.viewModel.PermissionsViewModel
91102
import com.tailscale.ipn.ui.viewModel.PingViewModel
92103
import com.tailscale.ipn.ui.viewModel.SettingsNav
93-
import com.tailscale.ipn.ui.viewModel.VpnViewModel
104+
import com.tailscale.ipn.util.ShareFileHelper
94105
import com.tailscale.ipn.util.TSLog
95106
import kotlinx.coroutines.Dispatchers
96107
import kotlinx.coroutines.cancel
97108
import kotlinx.coroutines.flow.MutableStateFlow
98109
import kotlinx.coroutines.flow.StateFlow
99110
import kotlinx.coroutines.launch
100-
import libtailscale.Libtailscale
101111

102112
class MainActivity : ComponentActivity() {
103113
private lateinit var navController: NavHostController
104114
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
105115
private val viewModel: MainViewModel by lazy {
106116
val app = App.get()
107-
vpnViewModel = app.getAppScopedViewModel()
108-
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
117+
appViewModel = app.getAppScopedViewModel()
118+
ViewModelProvider(this, MainViewModelFactory(appViewModel)).get(MainViewModel::class.java)
109119
}
110-
private lateinit var vpnViewModel: VpnViewModel
120+
private lateinit var appViewModel: AppViewModel
111121
val permissionsViewModel: PermissionsViewModel by viewModels()
112122

113123
companion object {
@@ -132,7 +142,7 @@ class MainActivity : ComponentActivity() {
132142

133143
// grab app to make sure it initializes
134144
App.get()
135-
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java)
145+
appViewModel = ViewModelProvider(App.get()).get(AppViewModel::class.java)
136146

137147
val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
138148
MDMSettings.update(App.get(), rm)
@@ -154,15 +164,15 @@ class MainActivity : ComponentActivity() {
154164
registerForActivityResult(VpnPermissionContract()) { granted ->
155165
if (granted) {
156166
TSLog.d("VpnPermission", "VPN permission granted")
157-
vpnViewModel.setVpnPrepared(true)
167+
appViewModel.setVpnPrepared(true)
158168
App.get().startVPN()
159169
} else {
160170
if (isAnotherVpnActive(this)) {
161171
TSLog.d("VpnPermission", "Another VPN is likely active")
162172
showOtherVPNConflictDialog()
163173
} else {
164174
TSLog.d("VpnPermission", "Permission was denied by the user")
165-
vpnViewModel.setVpnPrepared(false)
175+
appViewModel.setVpnPrepared(false)
166176

167177
AlertDialog.Builder(this)
168178
.setTitle(R.string.vpn_permission_needed)
@@ -198,9 +208,10 @@ class MainActivity : ComponentActivity() {
198208

199209
lifecycleScope.launch(Dispatchers.IO) {
200210
try {
201-
Libtailscale.setDirectFileRoot(uri.toString())
202211
TaildropDirectoryStore.saveFileDirectory(uri)
203212
permissionsViewModel.refreshCurrentDir()
213+
ShareFileHelper.notifyDirectoryReady()
214+
ShareFileHelper.setUri(uri.toString())
204215
} catch (e: Exception) {
205216
TSLog.e("MainActivity", "Failed to set Taildrop root: $e")
206217
}
@@ -219,9 +230,38 @@ class MainActivity : ComponentActivity() {
219230
}
220231
}
221232

222-
viewModel.setDirectoryPickerLauncher(directoryPickerLauncher)
233+
appViewModel.directoryPickerLauncher = directoryPickerLauncher
223234

224235
setContent {
236+
var showDialog by remember { mutableStateOf(false) }
237+
238+
LaunchedEffect(Unit) {
239+
appViewModel.showDirectoryPickerInterstitial.collect { showDialog = true }
240+
}
241+
242+
if (showDialog) {
243+
AppTheme {
244+
AlertDialog(
245+
onDismissRequest = {
246+
showDialog = false
247+
appViewModel.directoryPickerLauncher?.launch(null)
248+
},
249+
title = {
250+
Text(text = stringResource(id = R.string.taildrop_directory_picker_title))
251+
},
252+
text = { TaildropDirectoryPickerPrompt() },
253+
confirmButton = {
254+
PrimaryActionButton(
255+
onClick = {
256+
showDialog = false
257+
appViewModel.directoryPickerLauncher?.launch(null)
258+
}) {
259+
Text(text = stringResource(id = R.string.taildrop_directory_picker_button))
260+
}
261+
})
262+
}
263+
}
264+
225265
navController = rememberNavController()
226266

227267
AppTheme {
@@ -308,7 +348,11 @@ class MainActivity : ComponentActivity() {
308348
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
309349

310350
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
311-
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
351+
MainView(
352+
loginAtUrl = ::login,
353+
navigation = mainViewNav,
354+
viewModel = viewModel,
355+
appViewModel = appViewModel)
312356
}
313357
composable("search") {
314358
val autoFocus = viewModel.autoFocusSearch

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

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,6 @@ object TaildropDirectoryStore {
1616
fun saveFileDirectory(directoryUri: Uri) {
1717
val prefs = App.get().getEncryptedPrefs()
1818
prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).commit()
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-
}
2719
}
2820

2921
@Throws(IOException::class, GeneralSecurityException::class)

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

Lines changed: 8 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,11 @@ import com.tailscale.ipn.ui.util.LoadingIndicator
109109
import com.tailscale.ipn.ui.util.PeerSet
110110
import com.tailscale.ipn.ui.util.itemsWithDividers
111111
import com.tailscale.ipn.ui.util.set
112+
import com.tailscale.ipn.ui.viewModel.AppViewModel
112113
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
113114
import com.tailscale.ipn.ui.viewModel.MainViewModel
114-
import com.tailscale.ipn.ui.viewModel.VpnViewModel
115115
import com.tailscale.ipn.util.FeatureFlags
116+
import com.tailscale.ipn.util.ShareFileHelper
116117

117118
// Navigation actions for the MainView
118119
data class MainViewNavigation(
@@ -129,6 +130,7 @@ fun MainView(
129130
loginAtUrl: (String) -> Unit,
130131
navigation: MainViewNavigation,
131132
viewModel: MainViewModel,
133+
appViewModel: AppViewModel
132134
) {
133135
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
134136
val healthIcon by viewModel.healthIcon.collectAsState()
@@ -152,7 +154,7 @@ fun MainView(
152154
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState()
153155
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
154156
val showDirectoryPickerInterstitial by
155-
viewModel.showDirectoryPickerInterstitial.collectAsState()
157+
appViewModel.showDirectoryPickerInterstitial.collectAsState()
156158

157159
// Hide the header only on Android TV when the user needs to login
158160
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
@@ -219,14 +221,6 @@ fun MainView(
219221
LaunchVpnPermissionIfNeeded(viewModel)
220222
PromptForMissingPermissions(viewModel)
221223

222-
if (!viewModel.skipPromptsForAuthKeyLogin()) {
223-
LaunchedEffect(state) {
224-
if (state == Ipn.State.Running && !isAndroidTV()) {
225-
viewModel.checkIfTaildropDirectorySelected()
226-
}
227-
}
228-
}
229-
230224
if (showKeyExpiry) {
231225
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
232226
}
@@ -259,25 +253,6 @@ fun MainView(
259253
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
260254
}
261255
}
262-
263-
showDirectoryPickerInterstitial.let { show ->
264-
if (show) {
265-
AppTheme {
266-
AlertDialog(
267-
onDismissRequest = { viewModel.showDirectoryPickerLauncher() },
268-
title = {
269-
Text(text = stringResource(id = R.string.taildrop_directory_picker_title))
270-
},
271-
text = { TaildropDirectoryPickerPrompt() },
272-
confirmButton = {
273-
PrimaryActionButton(onClick = { viewModel.showDirectoryPickerLauncher() }) {
274-
Text(
275-
text = stringResource(id = R.string.taildrop_directory_picker_button))
276-
}
277-
})
278-
}
279-
}
280-
}
281256
}
282257
currentPingDevice?.let { _ ->
283258
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
@@ -869,8 +844,8 @@ fun Search(
869844
@Preview
870845
@Composable
871846
fun MainViewPreview() {
872-
val vpnViewModel = VpnViewModel(App.get())
873-
val vm = MainViewModel(vpnViewModel)
847+
val appViewModel = AppViewModel(App.get(), ShareFileHelper.taildropPrompt)
848+
val vm = MainViewModel(appViewModel)
874849

875850
MainView(
876851
{},
@@ -880,5 +855,6 @@ fun MainViewPreview() {
880855
onNavigateToExitNodes = {},
881856
onNavigateToHealth = {},
882857
onNavigateToSearch = {}),
883-
vm)
858+
vm,
859+
appViewModel)
884860
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ import com.tailscale.ipn.ui.util.Lists
4040
import com.tailscale.ipn.ui.util.set
4141
import com.tailscale.ipn.ui.viewModel.SettingsNav
4242
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
43-
import com.tailscale.ipn.ui.viewModel.VpnViewModel
43+
import com.tailscale.ipn.ui.viewModel.AppViewModel
4444

4545
@Composable
4646
fun SettingsView(
4747
settingsNav: SettingsNav,
4848
viewModel: SettingsViewModel = viewModel(),
49-
vpnViewModel: VpnViewModel = viewModel()
49+
appViewModel: AppViewModel = viewModel()
5050
) {
5151
val handler = LocalUriHandler.current
5252

@@ -55,7 +55,7 @@ fun SettingsView(
5555
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
5656
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
5757
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
58-
val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState()
58+
val isVPNPrepared by appViewModel.vpnPrepared.collectAsState()
5959
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
6060
val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState()
6161

0 commit comments

Comments
 (0)