android: defer taildrop selector until first taildrop attempt

Move Taildrop directory selector out of onboarding
-Listen for Taildrop, and show selector if a directory has not been set

Remove LocalBackend re-initialization
-This is no longer necessary since the directory is set in FileOps

Updates tailscale/corp#29211

Signed-off-by: kari-ts <kari@tailscale.com>
kari/dirselmove
kari-ts 4 months ago
parent c887c926da
commit 36a867d2d0

@ -48,6 +48,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
@ -94,7 +95,6 @@ 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
import com.tailscale.ipn.ui.viewModel.AppViewModel import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.AppViewModelFactory
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.MainViewModelFactory import com.tailscale.ipn.ui.viewModel.MainViewModelFactory
@ -112,12 +112,9 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var navController: NavHostController private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent> private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val appViewModel: AppViewModel by viewModels {
AppViewModelFactory(
application = this.application, taildropPrompt = ShareFileHelper.taildropPrompt)
}
private val viewModel: MainViewModel by viewModels { MainViewModelFactory(appViewModel) } private lateinit var appViewModel: AppViewModel
private lateinit var viewModel: MainViewModel
val permissionsViewModel: PermissionsViewModel by viewModels() val permissionsViewModel: PermissionsViewModel by viewModels()
@ -144,6 +141,11 @@ class MainActivity : ComponentActivity() {
// grab app to make sure it initializes // grab app to make sure it initializes
App.get() App.get()
appViewModel = (application as App).getAppScopedViewModel()
viewModel =
ViewModelProvider(this, MainViewModelFactory(appViewModel)).get(MainViewModel::class.java)
val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
MDMSettings.update(App.get(), rm) MDMSettings.update(App.get(), rm)
@ -232,6 +234,19 @@ class MainActivity : ComponentActivity() {
appViewModel.directoryPickerLauncher = directoryPickerLauncher appViewModel.directoryPickerLauncher = directoryPickerLauncher
lifecycleScope.launchWhenStarted {
appViewModel.triggerDirectoryPicker.collect {
AlertDialog.Builder(this@MainActivity)
.setTitle(R.string.taildrop_directory_picker_title)
.setMessage(R.string.taildrop_directory_picker_body)
.setPositiveButton(R.string.taildrop_directory_picker_button) { _, _ ->
appViewModel.directoryPickerLauncher?.launch(null)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
setContent { setContent {
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
@ -242,10 +257,7 @@ class MainActivity : ComponentActivity() {
if (showDialog) { if (showDialog) {
AppTheme { AppTheme {
AlertDialog( AlertDialog(
onDismissRequest = { onDismissRequest = { showDialog = false },
showDialog = false
appViewModel.directoryPickerLauncher?.launch(null)
},
title = { title = {
Text(text = stringResource(id = R.string.taildrop_directory_picker_title)) Text(text = stringResource(id = R.string.taildrop_directory_picker_title))
}, },

@ -16,14 +16,6 @@ object TaildropDirectoryStore {
fun saveFileDirectory(directoryUri: Uri) { fun saveFileDirectory(directoryUri: Uri) {
val prefs = App.get().getEncryptedPrefs() val prefs = App.get().getEncryptedPrefs()
prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).commit() prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).commit()
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) @Throws(IOException::class, GeneralSecurityException::class)

@ -70,11 +70,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.tailscale.ipn.App
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide import com.tailscale.ipn.mdm.ShowHide
@ -109,6 +111,7 @@ import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.FeatureFlags
import kotlinx.coroutines.flow.emptyFlow
// Navigation actions for the MainView // Navigation actions for the MainView
data class MainViewNavigation( data class MainViewNavigation(
@ -131,11 +134,11 @@ fun MainView(
val healthIcon by viewModel.healthIcon.collectAsState() val healthIcon by viewModel.healthIcon.collectAsState()
val showDirectoryPicker = appViewModel.showDirectoryPickerInterstitial.collectAsState(null) val showDirectoryPicker = appViewModel.showDirectoryPickerInterstitial.collectAsState(null)
LaunchedEffect(showDirectoryPicker.value) { LaunchedEffect(showDirectoryPicker.value) {
if (showDirectoryPicker.value != null) { if (showDirectoryPicker.value != null) {
appViewModel.directoryPickerLauncher?.launch(null) appViewModel.directoryPickerLauncher?.launch(null)
}
} }
}
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
@ -221,14 +224,6 @@ fun MainView(
LaunchVpnPermissionIfNeeded(viewModel) LaunchVpnPermissionIfNeeded(viewModel)
PromptForMissingPermissions(viewModel) PromptForMissingPermissions(viewModel)
if (!viewModel.skipPromptsForAuthKeyLogin()) {
LaunchedEffect(state) {
if (state == Ipn.State.Running && !isAndroidTV()) {
appViewModel.checkIfTaildropDirectorySelected()
}
}
}
if (showKeyExpiry) { if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() }) ExpiryNotification(netmap = netmap, action = { viewModel.login() })
} }
@ -848,11 +843,12 @@ fun Search(
} }
} }
} }
/*
@Preview @Preview
@Composable @Composable
fun MainViewPreview() { fun MainViewPreview() {
val appViewModel = AppViewModel(App.get()) val fakePrompt = emptyFlow<Unit>()
val appViewModel = AppViewModel(App.get(), fakePrompt)
val vm = MainViewModel(appViewModel) val vm = MainViewModel(appViewModel)
MainView( MainView(
@ -866,4 +862,3 @@ fun MainViewPreview() {
vm, vm,
appViewModel) appViewModel)
} }
*/

@ -57,6 +57,8 @@ class AppViewModel(application: Application, private val taildropPrompt: Flow<Un
var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
private val _showDirectoryPickerInterstitial = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _showDirectoryPickerInterstitial = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val showDirectoryPickerInterstitial: SharedFlow<Unit> = _showDirectoryPickerInterstitial val showDirectoryPickerInterstitial: SharedFlow<Unit> = _showDirectoryPickerInterstitial
private val _triggerDirectoryPicker = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val triggerDirectoryPicker: SharedFlow<Unit> = _triggerDirectoryPicker
val TAG = "AppViewModel" val TAG = "AppViewModel"
init { init {
@ -73,6 +75,10 @@ class AppViewModel(application: Application, private val taildropPrompt: Flow<Un
} }
} }
fun requestDirectoryPicker() {
_triggerDirectoryPicker.tryEmit(Unit)
}
private fun prepareVpn() { private fun prepareVpn() {
// Check if the user has granted permission yet. // Check if the user has granted permission yet.
if (!vpnPrepared.value) { if (!vpnPrepared.value) {
@ -99,7 +105,7 @@ class AppViewModel(application: Application, private val taildropPrompt: Flow<Un
TSLog.d( TSLog.d(
"MainViewModel", "MainViewModel",
"Stored directory URI is invalid or inaccessible; launching directory picker.") "Stored directory URI is invalid or inaccessible; launching directory picker.")
viewModelScope.launch { _showDirectoryPickerInterstitial.tryEmit(Unit) } viewModelScope.launch { requestDirectoryPicker() }
} else { } else {
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri") TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
} }

@ -12,7 +12,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -23,7 +22,6 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.TimeUtil
@ -162,6 +160,10 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() {
} }
} }
viewModelScope.launch {
isVpnActive.collect { active -> TSLog.d("KARI", "🏷️ isVpnActive changed: $active") }
}
viewModelScope.launch { viewModelScope.launch {
_searchTerm.debounce(250L).collect { term -> _searchTerm.debounce(250L).collect { term ->
// run the search as a background task // run the search as a background task

@ -127,6 +127,8 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
@Throws(IOException::class) @Throws(IOException::class)
override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream { override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream {
TSLog.d("ShareFileHelper", "openFileWriter called for $fileName\n" +
Throwable().stackTraceToString())
runBlocking { waitUntilTaildropDirReady() } runBlocking { waitUntilTaildropDirReady() }
val (uri, stream) = openWriterFD(fileName, offset) val (uri, stream) = openWriterFD(fileName, offset)
if (stream == null) { if (stream == null) {
@ -218,6 +220,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
@Throws(IOException::class) @Throws(IOException::class)
override fun deleteFile(uri: String) { override fun deleteFile(uri: String) {
runBlocking { waitUntilTaildropDirReady() }
val ctx = appContext ?: throw IOException("DeleteFile: not initialized") val ctx = appContext ?: throw IOException("DeleteFile: not initialized")
val uri = Uri.parse(uri) val uri = Uri.parse(uri)
@ -283,6 +286,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
@Throws(IOException::class) @Throws(IOException::class)
override fun openFileReader(name: String): libtailscale.InputStream { override fun openFileReader(name: String): libtailscale.InputStream {
runBlocking { waitUntilTaildropDirReady() }
val context = appContext ?: throw IOException("app context not initialized") val context = appContext ?: throw IOException("app context not initialized")
val rootUri = savedUri ?: throw IOException("SAF URI not initialized") val rootUri = savedUri ?: throw IOException("SAF URI not initialized")
val dir = val dir =

@ -58,8 +58,6 @@ type App struct {
backend *ipnlocal.LocalBackend backend *ipnlocal.LocalBackend
ready sync.WaitGroup ready sync.WaitGroup
backendMu sync.Mutex backendMu sync.Mutex
backendRestartCh chan struct{}
} }
func start(dataDir, directFileRoot string, appCtx AppContext) Application { func start(dataDir, directFileRoot string, appCtx AppContext) Application {

@ -32,10 +32,9 @@ const (
func newApp(dataDir, directFileRoot string, appCtx AppContext) Application { func newApp(dataDir, directFileRoot string, appCtx AppContext) Application {
a := &App{ a := &App{
directFileRoot: directFileRoot, directFileRoot: directFileRoot,
dataDir: dataDir, dataDir: dataDir,
appCtx: appCtx, appCtx: appCtx,
backendRestartCh: make(chan struct{}, 1),
} }
a.ready.Add(2) a.ready.Add(2)

Loading…
Cancel
Save