Skip to content

Commit 0f5fbf0

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 460736a commit 0f5fbf0

File tree

15 files changed

+305
-228
lines changed

15 files changed

+305
-228
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)
@@ -400,7 +402,7 @@ open class UninitializedApp : Application() {
400402
private lateinit var appInstance: UninitializedApp
401403
lateinit var notificationManager: NotificationManagerCompat
402404

403-
lateinit var vpnViewModel: VpnViewModel
405+
lateinit var appViewModel: AppViewModel
404406

405407
@JvmStatic
406408
fun get(): UninitializedApp {
@@ -587,8 +589,8 @@ open class UninitializedApp : Application() {
587589
return builtInDisallowedPackageNames + userDisallowed
588590
}
589591

590-
fun getAppScopedViewModel(): VpnViewModel {
591-
return vpnViewModel
592+
fun getAppScopedViewModel(): AppViewModel {
593+
return appViewModel
592594
}
593595

594596
val builtInDisallowedPackageNames: List<String> =

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

Lines changed: 65 additions & 12 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,42 @@ 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
98+
import com.tailscale.ipn.ui.viewModel.AppViewModelFactory
8799
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
88100
import com.tailscale.ipn.ui.viewModel.MainViewModel
89101
import com.tailscale.ipn.ui.viewModel.MainViewModelFactory
90102
import com.tailscale.ipn.ui.viewModel.PermissionsViewModel
91103
import com.tailscale.ipn.ui.viewModel.PingViewModel
92104
import com.tailscale.ipn.ui.viewModel.SettingsNav
93-
import com.tailscale.ipn.ui.viewModel.VpnViewModel
105+
import com.tailscale.ipn.util.ShareFileHelper
94106
import com.tailscale.ipn.util.TSLog
95107
import kotlinx.coroutines.Dispatchers
96108
import kotlinx.coroutines.cancel
97109
import kotlinx.coroutines.flow.MutableStateFlow
98110
import kotlinx.coroutines.flow.StateFlow
99111
import kotlinx.coroutines.launch
100-
import libtailscale.Libtailscale
101112

102113
class MainActivity : ComponentActivity() {
103114
private lateinit var navController: NavHostController
104115
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
105116
private val viewModel: MainViewModel by lazy {
106117
val app = App.get()
107-
vpnViewModel = app.getAppScopedViewModel()
108-
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
118+
appViewModel = app.getAppScopedViewModel()
119+
ViewModelProvider(this, MainViewModelFactory(appViewModel)).get(MainViewModel::class.java)
109120
}
110-
private lateinit var vpnViewModel: VpnViewModel
121+
private lateinit var appViewModel: AppViewModel
111122
val permissionsViewModel: PermissionsViewModel by viewModels()
112123

113124
companion object {
@@ -132,7 +143,11 @@ class MainActivity : ComponentActivity() {
132143

133144
// grab app to make sure it initializes
134145
App.get()
135-
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java)
146+
val appVmFactory =
147+
AppViewModelFactory(
148+
application = this.application, taildropPrompt = ShareFileHelper.taildropPrompt)
149+
150+
appViewModel = ViewModelProvider(this, appVmFactory).get(AppViewModel::class.java)
136151

137152
val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
138153
MDMSettings.update(App.get(), rm)
@@ -154,15 +169,15 @@ class MainActivity : ComponentActivity() {
154169
registerForActivityResult(VpnPermissionContract()) { granted ->
155170
if (granted) {
156171
TSLog.d("VpnPermission", "VPN permission granted")
157-
vpnViewModel.setVpnPrepared(true)
172+
appViewModel.setVpnPrepared(true)
158173
App.get().startVPN()
159174
} else {
160175
if (isAnotherVpnActive(this)) {
161176
TSLog.d("VpnPermission", "Another VPN is likely active")
162177
showOtherVPNConflictDialog()
163178
} else {
164179
TSLog.d("VpnPermission", "Permission was denied by the user")
165-
vpnViewModel.setVpnPrepared(false)
180+
appViewModel.setVpnPrepared(false)
166181

167182
AlertDialog.Builder(this)
168183
.setTitle(R.string.vpn_permission_needed)
@@ -198,9 +213,10 @@ class MainActivity : ComponentActivity() {
198213

199214
lifecycleScope.launch(Dispatchers.IO) {
200215
try {
201-
Libtailscale.setDirectFileRoot(uri.toString())
202216
TaildropDirectoryStore.saveFileDirectory(uri)
203217
permissionsViewModel.refreshCurrentDir()
218+
ShareFileHelper.notifyDirectoryReady()
219+
ShareFileHelper.setUri(uri.toString())
204220
} catch (e: Exception) {
205221
TSLog.e("MainActivity", "Failed to set Taildrop root: $e")
206222
}
@@ -219,9 +235,38 @@ class MainActivity : ComponentActivity() {
219235
}
220236
}
221237

222-
viewModel.setDirectoryPickerLauncher(directoryPickerLauncher)
238+
appViewModel.directoryPickerLauncher = directoryPickerLauncher
223239

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

227272
AppTheme {
@@ -308,7 +353,11 @@ class MainActivity : ComponentActivity() {
308353
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
309354

310355
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
311-
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
356+
MainView(
357+
loginAtUrl = ::login,
358+
navigation = mainViewNav,
359+
viewModel = viewModel,
360+
appViewModel = appViewModel)
312361
}
313362
composable("search") {
314363
val autoFocus = viewModel.autoFocusSearch
@@ -318,7 +367,11 @@ class MainActivity : ComponentActivity() {
318367
onNavigateBack = { navController.popBackStack() },
319368
autoFocus = autoFocus)
320369
}
321-
composable("settings") { SettingsView(settingsNav) }
370+
composable("settings") {
371+
SettingsView(
372+
settingsNav = settingsNav, appViewModel = appViewModel
373+
)
374+
}
322375
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
323376
composable("health") { HealthView(backTo("main")) }
324377
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }

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: 7 additions & 33 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()
@@ -151,8 +153,6 @@ fun MainView(
151153
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
152154
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState()
153155
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
154-
val showDirectoryPickerInterstitial by
155-
viewModel.showDirectoryPickerInterstitial.collectAsState()
156156

157157
// Hide the header only on Android TV when the user needs to login
158158
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
@@ -219,14 +219,6 @@ fun MainView(
219219
LaunchVpnPermissionIfNeeded(viewModel)
220220
PromptForMissingPermissions(viewModel)
221221

222-
if (!viewModel.skipPromptsForAuthKeyLogin()) {
223-
LaunchedEffect(state) {
224-
if (state == Ipn.State.Running && !isAndroidTV()) {
225-
viewModel.checkIfTaildropDirectorySelected()
226-
}
227-
}
228-
}
229-
230222
if (showKeyExpiry) {
231223
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
232224
}
@@ -259,25 +251,6 @@ fun MainView(
259251
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
260252
}
261253
}
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-
}
281254
}
282255
currentPingDevice?.let { _ ->
283256
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
@@ -869,8 +842,8 @@ fun Search(
869842
@Preview
870843
@Composable
871844
fun MainViewPreview() {
872-
val vpnViewModel = VpnViewModel(App.get())
873-
val vm = MainViewModel(vpnViewModel)
845+
val appViewModel = AppViewModel(App.get(), ShareFileHelper.taildropPrompt)
846+
val vm = MainViewModel(appViewModel)
874847

875848
MainView(
876849
{},
@@ -880,5 +853,6 @@ fun MainViewPreview() {
880853
onNavigateToExitNodes = {},
881854
onNavigateToHealth = {},
882855
onNavigateToSearch = {}),
883-
vm)
856+
vm,
857+
appViewModel)
884858
}

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)