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 <kari@tailscale.com>kari/movedirsel
parent
460736a151
commit
b3c6414ad8
@ -0,0 +1,115 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.util.Log
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.App
|
||||
import com.tailscale.ipn.util.ShareFileHelper
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AppViewModelFactory(val application: Application, private val taildropPrompt: Flow<Unit>) :
|
||||
ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(AppViewModel::class.java)) {
|
||||
return AppViewModel(application, taildropPrompt) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
|
||||
// Application context-aware ViewModel used to track app-wide VPN and Taildrop state.
|
||||
// This must be application-scoped because Tailscale may be enabled, disabled, or used for
|
||||
// file transfers (Taildrop) outside the activity lifecycle.
|
||||
//
|
||||
// Responsibilities:
|
||||
// - Track VPN preparation state (e.g., whether permission has been granted) and activity state
|
||||
// - Monitor incoming Taildrop file transfers
|
||||
// - Coordinate prompts for Taildrop directory selection if not yet configured
|
||||
class AppViewModel(application: Application, private val taildropPrompt: Flow<Unit>) :
|
||||
AndroidViewModel(application) {
|
||||
// Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or
|
||||
// if the user has previously consented to the VPN application. This is used to determine whether
|
||||
// a VPN permission launcher needs to be shown.
|
||||
val _vpnPrepared = MutableStateFlow(false)
|
||||
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
|
||||
// Whether a VPN interface has been established. This is set by net.updateTUN upon
|
||||
// VpnServiceBuilder.establish, and consumed by UI to reflect VPN state.
|
||||
val _vpnActive = MutableStateFlow(false)
|
||||
val vpnActive: StateFlow<Boolean> = _vpnActive
|
||||
// Select Taildrop directory
|
||||
var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
|
||||
private val _showDirectoryPickerInterstitial = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val showDirectoryPickerInterstitial: SharedFlow<Unit> = _showDirectoryPickerInterstitial
|
||||
val TAG = "AppViewModel"
|
||||
|
||||
init {
|
||||
observeIncomingTaildrop()
|
||||
prepareVpn()
|
||||
}
|
||||
|
||||
private fun observeIncomingTaildrop() {
|
||||
viewModelScope.launch {
|
||||
taildropPrompt.collect {
|
||||
TSLog.d(TAG, "Taildrop event received, checking directory")
|
||||
checkIfTaildropDirectorySelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareVpn() {
|
||||
// Check if the user has granted permission yet.
|
||||
if (!vpnPrepared.value) {
|
||||
val vpnIntent = VpnService.prepare(getApplication())
|
||||
if (vpnIntent != null) {
|
||||
setVpnPrepared(false)
|
||||
Log.d(TAG, "VpnService.prepare returned non-null intent")
|
||||
} else {
|
||||
setVpnPrepared(true)
|
||||
Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkIfTaildropDirectorySelected() {
|
||||
val app = App.get()
|
||||
val storedUri = app.getStoredDirectoryUri()
|
||||
if (ShareFileHelper.hasValidTaildropDir()) {
|
||||
return
|
||||
}
|
||||
|
||||
val documentFile = storedUri?.let { DocumentFile.fromTreeUri(app, it) }
|
||||
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
|
||||
TSLog.d(
|
||||
"MainViewModel",
|
||||
"Stored directory URI is invalid or inaccessible; launching directory picker.")
|
||||
viewModelScope.launch { _showDirectoryPickerInterstitial.tryEmit(Unit) }
|
||||
} else {
|
||||
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
|
||||
}
|
||||
}
|
||||
|
||||
fun setVpnActive(isActive: Boolean) {
|
||||
_vpnActive.value = isActive
|
||||
}
|
||||
|
||||
fun setVpnPrepared(isPrepared: Boolean) {
|
||||
_vpnPrepared.value = isPrepared
|
||||
}
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import android.app.Application
|
||||
import android.net.VpnService
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class VpnViewModelFactory(private val application: Application) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(VpnViewModel::class.java)) {
|
||||
return VpnViewModel(application) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
|
||||
// Application context aware view model that tracks whether the VPN has been prepared. This must be
|
||||
// application scoped because Tailscale might be toggled on and off outside of the activity
|
||||
// lifecycle.
|
||||
class VpnViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or
|
||||
// if the user has previously consented to the VPN application. This is used to determine whether
|
||||
// a VPN permission launcher needs to be shown.
|
||||
val _vpnPrepared = MutableStateFlow(false)
|
||||
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
|
||||
// Whether a VPN interface has been established. This is set by net.updateTUN upon
|
||||
// VpnServiceBuilder.establish, and consumed by UI to reflect VPN state.
|
||||
val _vpnActive = MutableStateFlow(false)
|
||||
val vpnActive: StateFlow<Boolean> = _vpnActive
|
||||
val TAG = "VpnViewModel"
|
||||
|
||||
init {
|
||||
prepareVpn()
|
||||
}
|
||||
|
||||
private fun prepareVpn() {
|
||||
// Check if the user has granted permission yet.
|
||||
if (!vpnPrepared.value) {
|
||||
val vpnIntent = VpnService.prepare(getApplication())
|
||||
if (vpnIntent != null) {
|
||||
setVpnPrepared(false)
|
||||
Log.d(TAG, "VpnService.prepare returned non-null intent")
|
||||
} else {
|
||||
setVpnPrepared(true)
|
||||
Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setVpnActive(isActive: Boolean) {
|
||||
_vpnActive.value = isActive
|
||||
}
|
||||
|
||||
fun setVpnPrepared(isPrepared: Boolean) {
|
||||
_vpnPrepared.value = isPrepared
|
||||
}
|
||||
}
|
||||
@ -1 +1 @@
|
||||
1cd3bf1a6eaf559aa8c00e749289559c884cef09
|
||||
98e8c99c256a5aeaa13725d2e43fdd7f465ba200
|
||||
Loading…
Reference in New Issue