android: defer taildrop selector until first taildrop attempt (#684)

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>
pull/688/head
kari-ts 4 months ago committed by GitHub
parent 66aae86d40
commit e68e64014e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn package com.tailscale.ipn
import android.Manifest import android.Manifest
import android.app.Application import android.app.Application
import android.app.Notification import android.app.Notification
@ -33,8 +32,8 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.notifier.HealthNotifier import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory import com.tailscale.ipn.ui.viewModel.AppViewModelFactory
import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
@ -53,17 +52,14 @@ import java.io.IOException
import java.net.NetworkInterface import java.net.NetworkInterface
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import java.util.Locale import java.util.Locale
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object { companion object {
private const val FILE_CHANNEL_ID = "tailscale-files" private const val FILE_CHANNEL_ID = "tailscale-files"
// Key to store the SAF URI in EncryptedSharedPreferences. // Key to store the SAF URI in EncryptedSharedPreferences.
private val PREF_KEY_SAF_URI = "saf_directory_uri" private val PREF_KEY_SAF_URI = "saf_directory_uri"
private const val TAG = "App" private const val TAG = "App"
private lateinit var appInstance: App private lateinit var appInstance: App
/** /**
* Initializes the app (if necessary) and returns the singleton app instance. Always use this * Initializes the app (if necessary) and returns the singleton app instance. Always use this
* function to obtain an App reference to make sure the app initializes. * function to obtain an App reference to make sure the app initializes.
@ -74,45 +70,33 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
return appInstance return appInstance
} }
} }
val dns = DnsConfig() val dns = DnsConfig()
private lateinit var connectivityManager: ConnectivityManager private lateinit var connectivityManager: ConnectivityManager
private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver
private lateinit var app: libtailscale.Application private lateinit var app: libtailscale.Application
override val viewModelStore: ViewModelStore override val viewModelStore: ViewModelStore
get() = appViewModelStore get() = appViewModelStore
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() } private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
var healthNotifier: HealthNotifier? = null var healthNotifier: HealthNotifier? = null
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this) override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this)
override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK
override fun log(s: String, s1: String) { override fun log(s: String, s1: String) {
Log.d(s, s1) Log.d(s, s1)
} }
fun getLibtailscaleApp(): libtailscale.Application { fun getLibtailscaleApp(): libtailscale.Application {
if (!isInitialized) { if (!isInitialized) {
initOnce() // Calls the synchronized initialization logic initOnce() // Calls the synchronized initialization logic
} }
return app return app
} }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
appInstance = this appInstance = this
setUnprotectedInstance(this) setUnprotectedInstance(this)
mdmChangeReceiver = MDMSettingsChangedReceiver() mdmChangeReceiver = MDMSettingsChangedReceiver()
val filter = IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) val filter = IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED)
registerReceiver(mdmChangeReceiver, filter) registerReceiver(mdmChangeReceiver, filter)
createNotificationChannel( createNotificationChannel(
STATUS_CHANNEL_ID, STATUS_CHANNEL_ID,
getString(R.string.vpn_status), getString(R.string.vpn_status),
@ -129,7 +113,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
getString(R.string.health_channel_description), getString(R.string.health_channel_description),
NotificationManagerCompat.IMPORTANCE_HIGH) NotificationManagerCompat.IMPORTANCE_HIGH)
} }
override fun onTerminate() { override fun onTerminate() {
super.onTerminate() super.onTerminate()
Notifier.stop() Notifier.stop()
@ -138,19 +121,15 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
viewModelStore.clear() viewModelStore.clear()
unregisterReceiver(mdmChangeReceiver) unregisterReceiver(mdmChangeReceiver)
} }
@Volatile private var isInitialized = false @Volatile private var isInitialized = false
@Synchronized @Synchronized
private fun initOnce() { private fun initOnce() {
if (isInitialized) { if (isInitialized) {
return return
} }
initializeApp() initializeApp()
isInitialized = true isInitialized = true
} }
private fun initializeApp() { private fun initializeApp() {
// Check if a directory URI has already been stored. // Check if a directory URI has already been stored.
val storedUri = getStoredDirectoryUri() val storedUri = getStoredDirectoryUri()
@ -166,7 +145,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
applicationScope.launch { applicationScope.launch {
val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
MDMSettings.update(get(), rm) MDMSettings.update(get(), rm)
Notifier.state.collect { _ -> Notifier.state.collect { _ ->
combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) { combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) {
state, state,
@ -184,11 +162,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
if (state == Ipn.State.Stopped) { if (state == Ipn.State.Stopped) {
notifyStatus(vpnRunning = false, hideDisconnectAction = hideDisconnectAction.value) notifyStatus(vpnRunning = false, hideDisconnectAction = hideDisconnectAction.value)
} }
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
updateConnStatus(ableToStartVPN) updateConnStatus(ableToStartVPN)
QuickToggleService.setVPNRunning(vpnRunning) QuickToggleService.setVPNRunning(vpnRunning)
// Update notification status when VPN is running // Update notification status when VPN is running
if (vpnRunning) { if (vpnRunning) {
notifyStatus( notifyStatus(
@ -205,21 +181,22 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
TSLog.init(this) TSLog.init(this)
FeatureFlags.initialize(mapOf("enable_new_search" to true)) FeatureFlags.initialize(mapOf("enable_new_search" to true))
} }
/** /**
* Called when a SAF directory URI is available (either already stored or chosen). We must restart * Called when a SAF directory URI is available (either already stored or chosen). We must restart
* Tailscale because directFileRoot must be set before LocalBackend starts being used. * Tailscale because directFileRoot must be set before LocalBackend starts being used.
*/ */
fun startLibtailscale(directFileRoot: String) { fun startLibtailscale(directFileRoot: String) {
ShareFileHelper.init(this, directFileRoot)
app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this) app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this)
ShareFileHelper.init(this, app, directFileRoot, applicationScope)
Request.setApp(app) Request.setApp(app)
Notifier.setApp(app) Notifier.setApp(app)
Notifier.start(applicationScope) Notifier.start(applicationScope)
} }
private fun initViewModels() { private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java) appViewModel =
ViewModelProvider(this, AppViewModelFactory(this, ShareFileHelper.observeTaildropPrompt()))
.get(AppViewModel::class.java)
} }
fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) { fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
@ -233,14 +210,12 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
Client(applicationScope) Client(applicationScope)
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback) .editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback)
} }
// encryptToPref a byte array of data using the Jetpack Security // encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store. // library and writes it to a global encrypted preference store.
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
override fun encryptToPref(prefKey: String?, plaintext: String?) { override fun encryptToPref(prefKey: String?, plaintext: String?) {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit() getEncryptedPrefs().edit().putString(prefKey, plaintext).commit()
} }
// decryptFromPref decrypts a encrypted preference using the Jetpack Security // decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext. // library and returns the plaintext.
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
@ -250,18 +225,18 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
override fun getStateStoreKeysJSON(): String { override fun getStateStoreKeysJSON(): String {
val prefix = "statestore-" val prefix = "statestore-"
val keys = getEncryptedPrefs() val keys =
.getAll() getEncryptedPrefs()
.keys .getAll()
.filter { it.startsWith(prefix) } .keys
.map { it.removePrefix(prefix) } .filter { it.startsWith(prefix) }
.map { it.removePrefix(prefix) }
return org.json.JSONArray(keys).toString() return org.json.JSONArray(keys).toString()
} }
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
fun getEncryptedPrefs(): SharedPreferences { fun getEncryptedPrefs(): SharedPreferences {
val key = MasterKey.Builder(this).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() val key = MasterKey.Builder(this).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
return EncryptedSharedPreferences.create( return EncryptedSharedPreferences.create(
this, this,
"secret_shared_prefs", "secret_shared_prefs",
@ -269,12 +244,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
} }
fun getStoredDirectoryUri(): Uri? { fun getStoredDirectoryUri(): Uri? {
val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null) val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null)
return uriString?.let { Uri.parse(it) } return uriString?.let { Uri.parse(it) }
} }
/* /*
* setAbleToStartVPN remembers whether or not we're able to start the VPN * setAbleToStartVPN remembers whether or not we're able to start the VPN
* by storing this in a shared preference. This allows us to check this * by storing this in a shared preference. This allows us to check this
@ -285,7 +258,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
QuickToggleService.updateTile() QuickToggleService.updateTile()
TSLog.d("App", "Set Tile Ready: $ableToStartVPN") TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
} }
override fun getModelName(): String { override fun getModelName(): String {
val manu = Build.MANUFACTURER val manu = Build.MANUFACTURER
var model = Build.MODEL var model = Build.MODEL
@ -296,17 +268,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
} }
return "$manu $model" return "$manu $model"
} }
override fun getOSVersion(): String = Build.VERSION.RELEASE override fun getOSVersion(): String = Build.VERSION.RELEASE
override fun isChromeOS(): Boolean { override fun isChromeOS(): Boolean {
return packageManager.hasSystemFeature("android.hardware.type.pc") return packageManager.hasSystemFeature("android.hardware.type.pc")
} }
override fun getInterfacesAsString(): String { override fun getInterfacesAsString(): String {
val interfaces: ArrayList<NetworkInterface> = val interfaces: ArrayList<NetworkInterface> =
java.util.Collections.list(NetworkInterface.getNetworkInterfaces()) java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
val sb = StringBuilder() val sb = StringBuilder()
for (nif in interfaces) { for (nif in interfaces) {
try { try {
@ -322,7 +290,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
nif.isLoopback, nif.isLoopback,
nif.isPointToPoint, nif.isPointToPoint,
nif.supportsMulticast())) nif.supportsMulticast()))
for (ia in nif.interfaceAddresses) { for (ia in nif.interfaceAddresses) {
val parts = ia.toString().split("/", limit = 0) val parts = ia.toString().split("/", limit = 0)
if (parts.size > 1) { if (parts.size > 1) {
@ -334,16 +301,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
} }
sb.append("\n") sb.append("\n")
} }
return sb.toString() return sb.toString()
} }
@Throws( @Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean { override fun getSyspolicyBooleanValue(key: String): Boolean {
return getSyspolicyStringValue(key) == "true" return getSyspolicyStringValue(key) == "true"
} }
@Throws( @Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringValue(key: String): String { override fun getSyspolicyStringValue(key: String): String {
@ -353,7 +317,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
} }
return setting.value?.toString() ?: "" return setting.value?.toString() ?: ""
} }
@Throws( @Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringArrayJSONValue(key: String): String { override fun getSyspolicyStringArrayJSONValue(key: String): String {
@ -369,12 +332,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
throw MDMSettings.NoSuchKeyException() throw MDMSettings.NoSuchKeyException()
} }
} }
fun notifyPolicyChanged() { fun notifyPolicyChanged() {
app.notifyPolicyChanged() app.notifyPolicyChanged()
} }
} }
/** /**
* UninitializedApp contains all of the methods of App that can be used without having to initialize * UninitializedApp contains all of the methods of App that can be used without having to initialize
* the Go backend. This is useful when you want to access functions on the App without creating side * the Go backend. This is useful when you want to access functions on the App without creating side
@ -383,30 +344,24 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
open class UninitializedApp : Application() { open class UninitializedApp : Application() {
companion object { companion object {
const val TAG = "UninitializedApp" const val TAG = "UninitializedApp"
const val STATUS_NOTIFICATION_ID = 1 const val STATUS_NOTIFICATION_ID = 1
const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2 const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2
const val STATUS_CHANNEL_ID = "tailscale-status" const val STATUS_CHANNEL_ID = "tailscale-status"
// Key for shared preference that tracks whether or not we're able to start // Key for shared preference that tracks whether or not we're able to start
// the VPN (i.e. we're logged in and machine is authorized). // the VPN (i.e. we're logged in and machine is authorized).
private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN" private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN"
private const val DISALLOWED_APPS_KEY = "disallowedApps" private const val DISALLOWED_APPS_KEY = "disallowedApps"
// File for shared preferences that are not encrypted. // File for shared preferences that are not encrypted.
private const val UNENCRYPTED_PREFERENCES = "unencrypted" private const val UNENCRYPTED_PREFERENCES = "unencrypted"
private lateinit var appInstance: UninitializedApp private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat lateinit var notificationManager: NotificationManagerCompat
lateinit var vpnViewModel: VpnViewModel lateinit var appViewModel: AppViewModel
@JvmStatic @JvmStatic
fun get(): UninitializedApp { fun get(): UninitializedApp {
return appInstance return appInstance
} }
/** /**
* Return the name of the active (but not the selected/prior one) exit node based on the * Return the name of the active (but not the selected/prior one) exit node based on the
* provided [Ipn.Prefs] and [Netmap.NetworkMap]. * provided [Ipn.Prefs] and [Netmap.NetworkMap].
@ -419,24 +374,19 @@ open class UninitializedApp : Application() {
} }
} }
} }
protected fun setUnprotectedInstance(instance: UninitializedApp) { protected fun setUnprotectedInstance(instance: UninitializedApp) {
appInstance = instance appInstance = instance
} }
protected fun setAbleToStartVPN(rdy: Boolean) { protected fun setAbleToStartVPN(rdy: Boolean) {
getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply() getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply()
} }
/** This function can be called without initializing the App. */ /** This function can be called without initializing the App. */
fun isAbleToStartVPN(): Boolean { fun isAbleToStartVPN(): Boolean {
return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false) return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false)
} }
private fun getUnencryptedPrefs(): SharedPreferences { private fun getUnencryptedPrefs(): SharedPreferences {
return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE) return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE)
} }
fun startVPN() { fun startVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN } val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN }
// FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will // FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will
@ -449,7 +399,6 @@ open class UninitializedApp : Application() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+ PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+
) )
try { try {
pendingIntent.send() pendingIntent.send()
} catch (foregroundServiceStartException: IllegalStateException) { } catch (foregroundServiceStartException: IllegalStateException) {
@ -462,7 +411,6 @@ open class UninitializedApp : Application() {
TSLog.e(TAG, "startVPN hit exception: $e") TSLog.e(TAG, "startVPN hit exception: $e")
} }
} }
fun stopVPN() { fun stopVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN } val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
try { try {
@ -473,7 +421,6 @@ open class UninitializedApp : Application() {
TSLog.e(TAG, "stopVPN hit exception in startService(): $e") TSLog.e(TAG, "stopVPN hit exception in startService(): $e")
} }
} }
fun restartVPN() { fun restartVPN() {
val intent = val intent =
Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN } Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN }
@ -485,14 +432,12 @@ open class UninitializedApp : Application() {
TSLog.e(TAG, "restartVPN hit exception in startService(): $e") TSLog.e(TAG, "restartVPN hit exception in startService(): $e")
} }
} }
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) { fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
val channel = NotificationChannel(id, name, importance) val channel = NotificationChannel(id, name, importance)
channel.description = description channel.description = description
notificationManager = NotificationManagerCompat.from(this) notificationManager = NotificationManagerCompat.from(this)
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
fun notifyStatus( fun notifyStatus(
vpnRunning: Boolean, vpnRunning: Boolean,
hideDisconnectAction: Boolean, hideDisconnectAction: Boolean,
@ -500,7 +445,6 @@ open class UninitializedApp : Application() {
) { ) {
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName)) notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName))
} }
fun notifyStatus(notification: Notification) { fun notifyStatus(notification: Notification) {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) { PackageManager.PERMISSION_GRANTED) {
@ -515,7 +459,6 @@ open class UninitializedApp : Application() {
} }
notificationManager.notify(STATUS_NOTIFICATION_ID, notification) notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
} }
fun buildStatusNotification( fun buildStatusNotification(
vpnRunning: Boolean, vpnRunning: Boolean,
hideDisconnectAction: Boolean, hideDisconnectAction: Boolean,
@ -537,7 +480,6 @@ open class UninitializedApp : Application() {
0, 0,
buttonIntent, buttonIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val intent = val intent =
Intent(this, MainActivity::class.java).apply { Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
@ -545,7 +487,6 @@ open class UninitializedApp : Application() {
val pendingIntent: PendingIntent = val pendingIntent: PendingIntent =
PendingIntent.getActivity( PendingIntent.getActivity(
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder = val builder =
NotificationCompat.Builder(this, STATUS_CHANNEL_ID) NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(icon) .setSmallIcon(icon)
@ -563,18 +504,14 @@ open class UninitializedApp : Application() {
} }
return builder.build() return builder.build()
} }
fun updateUserDisallowedPackageNames(packageNames: List<String>) { fun updateUserDisallowedPackageNames(packageNames: List<String>) {
if (packageNames.any { it.isEmpty() }) { if (packageNames.any { it.isEmpty() }) {
TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)") TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)")
return return
} }
getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply() getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply()
this.restartVPN() this.restartVPN()
} }
fun disallowedPackageNames(): List<String> { fun disallowedPackageNames(): List<String> {
val mdmDisallowed = val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
@ -587,8 +524,8 @@ open class UninitializedApp : Application() {
return builtInDisallowedPackageNames + userDisallowed return builtInDisallowedPackageNames + userDisallowed
} }
fun getAppScopedViewModel(): VpnViewModel { fun getAppScopedViewModel(): AppViewModel {
return vpnViewModel return appViewModel
} }
val builtInDisallowedPackageNames: List<String> = val builtInDisallowedPackageNames: List<String> =
@ -616,4 +553,4 @@ open class UninitializedApp : Application() {
// Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128 // Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128
"com.google.android.apps.scone", "com.google.android.apps.scone",
) )
} }

@ -34,10 +34,18 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.ViewModelProvider
@ -75,39 +83,38 @@ import com.tailscale.ipn.ui.view.MullvadInfoView
import com.tailscale.ipn.ui.view.NotificationsView import com.tailscale.ipn.ui.view.NotificationsView
import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.PermissionsView import com.tailscale.ipn.ui.view.PermissionsView
import com.tailscale.ipn.ui.view.PrimaryActionButton
import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.SearchView import com.tailscale.ipn.ui.view.SearchView
import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
import com.tailscale.ipn.ui.view.SubnetRoutingView import com.tailscale.ipn.ui.view.SubnetRoutingView
import com.tailscale.ipn.ui.view.TaildropDirView import com.tailscale.ipn.ui.view.TaildropDirView
import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt
import com.tailscale.ipn.ui.view.TailnetLockSetupView 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.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
import com.tailscale.ipn.ui.viewModel.PermissionsViewModel import com.tailscale.ipn.ui.viewModel.PermissionsViewModel
import com.tailscale.ipn.ui.viewModel.PingViewModel import com.tailscale.ipn.ui.viewModel.PingViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import libtailscale.Libtailscale
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 viewModel: MainViewModel by lazy { private lateinit var appViewModel: AppViewModel
val app = App.get() private lateinit var viewModel: MainViewModel
vpnViewModel = app.getAppScopedViewModel()
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
}
private lateinit var vpnViewModel: VpnViewModel
val permissionsViewModel: PermissionsViewModel by viewModels() val permissionsViewModel: PermissionsViewModel by viewModels()
companion object { companion object {
@ -119,7 +126,6 @@ class MainActivity : ComponentActivity() {
return (resources.configuration.screenLayout and SCREENLAYOUT_SIZE_MASK) >= return (resources.configuration.screenLayout and SCREENLAYOUT_SIZE_MASK) >=
SCREENLAYOUT_SIZE_LARGE SCREENLAYOUT_SIZE_LARGE
} }
// The loginQRCode is used to track whether or not we should be rendering a QR code // The loginQRCode is used to track whether or not we should be rendering a QR code
// to the user. This is used only on TV platforms with no browser in lieu of // to the user. This is used only on TV platforms with no browser in lieu of
// simply opening the URL. This should be consumed once it has been handled. // simply opening the URL. This should be consumed once it has been handled.
@ -132,29 +138,27 @@ class MainActivity : ComponentActivity() {
// grab app to make sure it initializes // grab app to make sure it initializes
App.get() App.get()
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java) 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)
if (MDMSettings.onboardingFlow.flow.value.value == ShowHide.Hide || if (MDMSettings.onboardingFlow.flow.value.value == ShowHide.Hide ||
MDMSettings.authKey.flow.value.value != null) { MDMSettings.authKey.flow.value.value != null) {
setIntroScreenViewed(true) setIntroScreenViewed(true)
} }
// (jonathan) TODO: Force the app to be portrait on small screens until we have // (jonathan) TODO: Force the app to be portrait on small screens until we have
// proper landscape layout support // proper landscape layout support
if (!isLandscapeCapable()) { if (!isLandscapeCapable()) {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} }
installSplashScreen() installSplashScreen()
vpnPermissionLauncher = vpnPermissionLauncher =
registerForActivityResult(VpnPermissionContract()) { granted -> registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) { if (granted) {
TSLog.d("VpnPermission", "VPN permission granted") TSLog.d("VpnPermission", "VPN permission granted")
vpnViewModel.setVpnPrepared(true) appViewModel.setVpnPrepared(true)
App.get().startVPN() App.get().startVPN()
} else { } else {
if (isAnotherVpnActive(this)) { if (isAnotherVpnActive(this)) {
@ -162,7 +166,7 @@ class MainActivity : ComponentActivity() {
showOtherVPNConflictDialog() showOtherVPNConflictDialog()
} else { } else {
TSLog.d("VpnPermission", "Permission was denied by the user") TSLog.d("VpnPermission", "Permission was denied by the user")
vpnViewModel.setVpnPrepared(false) appViewModel.setVpnPrepared(false)
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.vpn_permission_needed) .setTitle(R.string.vpn_permission_needed)
@ -176,7 +180,6 @@ class MainActivity : ComponentActivity() {
} }
} }
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher) viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
val directoryPickerLauncher = val directoryPickerLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
if (uri != null) { if (uri != null) {
@ -188,7 +191,6 @@ class MainActivity : ComponentActivity() {
} catch (e: SecurityException) { } catch (e: SecurityException) {
TSLog.e("MainActivity", "Failed to persist permissions: $e") TSLog.e("MainActivity", "Failed to persist permissions: $e")
} }
// Check if write permission is actually granted. // Check if write permission is actually granted.
val writePermission = val writePermission =
this.checkUriPermission( this.checkUriPermission(
@ -198,9 +200,10 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
Libtailscale.setDirectFileRoot(uri.toString())
TaildropDirectoryStore.saveFileDirectory(uri) TaildropDirectoryStore.saveFileDirectory(uri)
permissionsViewModel.refreshCurrentDir() permissionsViewModel.refreshCurrentDir()
ShareFileHelper.notifyDirectoryReady()
ShareFileHelper.setUri(uri.toString())
} catch (e: Exception) { } catch (e: Exception) {
TSLog.e("MainActivity", "Failed to set Taildrop root: $e") TSLog.e("MainActivity", "Failed to set Taildrop root: $e")
} }
@ -214,14 +217,40 @@ class MainActivity : ComponentActivity() {
} else { } else {
TSLog.d( TSLog.d(
"MainActivity", "Taildrop directory not saved. Will fall back to internal storage.") "MainActivity", "Taildrop directory not saved. Will fall back to internal storage.")
// Fall back to internal storage. // Fall back to internal storage.
} }
} }
viewModel.setDirectoryPickerLauncher(directoryPickerLauncher) appViewModel.directoryPickerLauncher = directoryPickerLauncher
setContent { setContent {
var showDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { appViewModel.triggerDirectoryPicker.collect { showDialog = true } }
if (showDialog) {
AppTheme {
AlertDialog(
onDismissRequest = {
showDialog = false
appViewModel.directoryPickerLauncher?.launch(null)
},
title = {
Text(text = stringResource(id = R.string.taildrop_directory_picker_title))
},
text = { TaildropDirectoryPickerPrompt() },
confirmButton = {
PrimaryActionButton(
onClick = {
showDialog = false
appViewModel.directoryPickerLauncher?.launch(null)
}) {
Text(text = stringResource(id = R.string.taildrop_directory_picker_button))
}
})
}
}
navController = rememberNavController() navController = rememberNavController()
AppTheme { AppTheme {
@ -257,7 +286,6 @@ class MainActivity : ComponentActivity() {
fun backTo(route: String): () -> Unit = { fun backTo(route: String): () -> Unit = {
navController.popBackStack(route = route, inclusive = false) navController.popBackStack(route = route, inclusive = false)
} }
val mainViewNav = val mainViewNav =
MainViewNavigation( MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") }, onNavigateToSettings = { navController.navigate("settings") },
@ -270,7 +298,6 @@ class MainActivity : ComponentActivity() {
viewModel.enableSearchAutoFocus() viewModel.enableSearchAutoFocus()
navController.navigate("search") navController.navigate("search")
}) })
val settingsNav = val settingsNav =
SettingsNav( SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") }, onNavigateToBugReport = { navController.navigate("bugReport") },
@ -285,7 +312,6 @@ class MainActivity : ComponentActivity() {
onNavigateToPermissions = { navController.navigate("permissions") }, onNavigateToPermissions = { navController.navigate("permissions") },
onBackToSettings = backTo("settings"), onBackToSettings = backTo("settings"),
onNavigateBackHome = backTo("main")) onNavigateBackHome = backTo("main"))
val exitNodePickerNav = val exitNodePickerNav =
ExitNodePickerNav( ExitNodePickerNav(
onNavigateBackHome = { onNavigateBackHome = {
@ -297,7 +323,6 @@ class MainActivity : ComponentActivity() {
onNavigateBackToMullvad = backTo("mullvad"), onNavigateBackToMullvad = backTo("mullvad"),
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
val userSwitcherNav = val userSwitcherNav =
UserSwitcherNav( UserSwitcherNav(
backToSettings = backTo("settings"), backToSettings = backTo("settings"),
@ -308,7 +333,11 @@ class MainActivity : ComponentActivity() {
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") }) onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) { composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel) MainView(
loginAtUrl = ::login,
navigation = mainViewNav,
viewModel = viewModel,
appViewModel = appViewModel)
} }
composable("search") { composable("search") {
val autoFocus = viewModel.autoFocusSearch val autoFocus = viewModel.autoFocusSearch
@ -318,7 +347,9 @@ class MainActivity : ComponentActivity() {
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
autoFocus = autoFocus) autoFocus = autoFocus)
} }
composable("settings") { SettingsView(settingsNav) } composable("settings") {
SettingsView(settingsNav = settingsNav, appViewModel = appViewModel)
}
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("health") { HealthView(backTo("main")) } composable("health") { HealthView(backTo("main")) }
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
@ -378,7 +409,6 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
// Login actions are app wide. If we are told about a browse-to-url, we should render it // Login actions are app wide. If we are told about a browse-to-url, we should render it
// over whatever screen we happen to be on. // over whatever screen we happen to be on.
loginQRCode.collectAsState().value?.let { loginQRCode.collectAsState().value?.let {
@ -401,7 +431,6 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
// Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog. // Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog.
lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } } lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } }
} }
@ -422,7 +451,6 @@ class MainActivity : ComponentActivity() {
fun isAnotherVpnActive(context: Context): Boolean { fun isAnotherVpnActive(context: Context): Boolean {
val connectivityManager = val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = connectivityManager.activeNetwork val activeNetwork = connectivityManager.activeNetwork
if (activeNetwork != null) { if (activeNetwork != null) {
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
@ -433,7 +461,6 @@ class MainActivity : ComponentActivity() {
} }
return false return false
} }
// Returns true if we should render a QR code instead of launching a browser // Returns true if we should render a QR code instead of launching a browser
// for login requests // for login requests
private fun useQRCodeLogin(): Boolean { private fun useQRCodeLogin(): Boolean {
@ -449,7 +476,6 @@ class MainActivity : ComponentActivity() {
if (this::navController.isInitialized) { if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
if (previousEntry != null) { if (previousEntry != null) {
navController.popBackStack(route = "main", inclusive = false) navController.popBackStack(route = "main", inclusive = false)
} else { } else {
@ -478,7 +504,6 @@ class MainActivity : ComponentActivity() {
putExtra(START_AT_ROOT, true) putExtra(START_AT_ROOT, true)
} }
startActivity(intent) startActivity(intent)
// Cancel coroutine once we've logged in // Cancel coroutine once we've logged in
this@launch.cancel() this@launch.cancel()
} }
@ -487,7 +512,6 @@ class MainActivity : ComponentActivity() {
TSLog.e(TAG, "Login: failed to start MainActivity: $e") TSLog.e(TAG, "Login: failed to start MainActivity: $e")
} }
} }
val url = urlString.toUri() val url = urlString.toUri()
try { try {
val customTabsIntent = CustomTabsIntent.Builder().build() val customTabsIntent = CustomTabsIntent.Builder().build()

@ -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)

@ -1,6 +1,5 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import android.os.Build import android.os.Build
@ -32,7 +31,6 @@ import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@ -87,7 +85,6 @@ import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Permissions import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.theme.customErrorContainer import com.tailscale.ipn.ui.theme.customErrorContainer
import com.tailscale.ipn.ui.theme.disabled import com.tailscale.ipn.ui.theme.disabled
import com.tailscale.ipn.ui.theme.errorButton import com.tailscale.ipn.ui.theme.errorButton
@ -109,10 +106,11 @@ import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
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.ui.viewModel.VpnViewModel
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(
@ -129,6 +127,7 @@ fun MainView(
loginAtUrl: (String) -> Unit, loginAtUrl: (String) -> Unit,
navigation: MainViewNavigation, navigation: MainViewNavigation,
viewModel: MainViewModel, viewModel: MainViewModel,
appViewModel: AppViewModel
) { ) {
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
val healthIcon by viewModel.healthIcon.collectAsState() val healthIcon by viewModel.healthIcon.collectAsState()
@ -151,12 +150,9 @@ fun MainView(
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState() val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState() val disableToggle by MDMSettings.forceEnabled.flow.collectAsState()
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
val showDirectoryPickerInterstitial by
viewModel.showDirectoryPickerInterstitial.collectAsState()
// Hide the header only on Android TV when the user needs to login // Hide the header only on Android TV when the user needs to login
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin) val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
ListItem( ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem, colors = MaterialTheme.colorScheme.surfaceContainerListItem,
leadingContent = { leadingContent = {
@ -212,30 +208,19 @@ fun MainView(
} }
} }
}) })
when (state) { when (state) {
Ipn.State.Running -> { Ipn.State.Running -> {
viewModel.maybeRequestVpnPermission() viewModel.maybeRequestVpnPermission()
LaunchVpnPermissionIfNeeded(viewModel) LaunchVpnPermissionIfNeeded(viewModel)
PromptForMissingPermissions(viewModel) PromptForMissingPermissions(viewModel)
if (!viewModel.skipPromptsForAuthKeyLogin()) {
LaunchedEffect(state) {
if (state == Ipn.State.Running && !isAndroidTV()) {
viewModel.checkIfTaildropDirectorySelected()
}
}
}
if (showKeyExpiry) { if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() }) ExpiryNotification(netmap = netmap, action = { viewModel.login() })
} }
if (showExitNodePicker.value == ShowHide.Show) { if (showExitNodePicker.value == ShowHide.Show) {
ExitNodeStatus( ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
} }
PeerList( PeerList(
viewModel = viewModel, viewModel = viewModel,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
@ -259,25 +244,6 @@ fun MainView(
{ viewModel.showVPNPermissionLauncherIfUnauthorized() }) { viewModel.showVPNPermissionLauncherIfUnauthorized() })
} }
} }
showDirectoryPickerInterstitial.let { show ->
if (show) {
AppTheme {
AlertDialog(
onDismissRequest = { viewModel.showDirectoryPickerLauncher() },
title = {
Text(text = stringResource(id = R.string.taildrop_directory_picker_title))
},
text = { TaildropDirectoryPickerPrompt() },
confirmButton = {
PrimaryActionButton(onClick = { viewModel.showDirectoryPickerLauncher() }) {
Text(
text = stringResource(id = R.string.taildrop_directory_picker_button))
}
})
}
}
}
} }
currentPingDevice?.let { _ -> currentPingDevice?.let { _ ->
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) { ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
@ -291,7 +257,6 @@ fun MainView(
@Composable @Composable
fun TaildropDirectoryPickerPrompt() { fun TaildropDirectoryPickerPrompt() {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.Start) { Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.Start) {
Text(text = stringResource(id = R.string.taildrop_directory_picker_body)) Text(text = stringResource(id = R.string.taildrop_directory_picker_body))
Text( Text(
@ -306,10 +271,8 @@ fun TaildropDirectoryPickerPrompt() {
fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) {
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val shouldRequest by viewModel.requestVpnPermission.collectAsState() val shouldRequest by viewModel.requestVpnPermission.collectAsState()
LaunchedEffect(shouldRequest) { LaunchedEffect(shouldRequest) {
if (!shouldRequest) return@LaunchedEffect if (!shouldRequest) return@LaunchedEffect
// Defer showing permission launcher until activity is resumed to avoid silent RESULT_CANCELED // Defer showing permission launcher until activity is resumed to avoid silent RESULT_CANCELED
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.showVPNPermissionLauncherIfUnauthorized() viewModel.showVPNPermissionLauncherIfUnauthorized()
@ -322,19 +285,14 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val nodeState by viewModel.nodeState.collectAsState() val nodeState by viewModel.nodeState.collectAsState()
val maybePrefs by viewModel.prefs.collectAsState() val maybePrefs by viewModel.prefs.collectAsState()
val netmap by viewModel.netmap.collectAsState() val netmap by viewModel.netmap.collectAsState()
// There's nothing to render if we haven't loaded the prefs yet // There's nothing to render if we haven't loaded the prefs yet
val prefs = maybePrefs ?: return val prefs = maybePrefs ?: return
// The activeExitNode is the source of truth. The selectedExitNode is only relevant if we // The activeExitNode is the source of truth. The selectedExitNode is only relevant if we
// don't have an active node. // don't have an active node.
val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID
val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } } val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } }
val name = exitNodePeer?.exitNodeName val name = exitNodePeer?.exitNodeName
val managedByOrganization by viewModel.managedByOrganization.collectAsState() val managedByOrganization by viewModel.managedByOrganization.collectAsState()
Box( Box(
modifier = modifier =
Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) { Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) {
@ -359,7 +317,6 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
} }
} }
} }
Box( Box(
modifier = modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp)
@ -597,7 +554,6 @@ fun PeerList(
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
val showNoResults = val showNoResults =
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
val netmap = viewModel.netmap.collectAsState() val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
var isSearchFocussed by remember { mutableStateOf(false) } var isSearchFocussed by remember { mutableStateOf(false) }
@ -606,7 +562,6 @@ fun PeerList(
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
// Restrict search to devices running API 33+ (see https://github.com/tailscale/corp/issues/27375) // Restrict search to devices running API 33+ (see https://github.com/tailscale/corp/issues/27375)
val enableSearch = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU val enableSearch = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) { if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) {
Search(onSearchBarClick) Search(onSearchBarClick)
@ -653,7 +608,6 @@ fun PeerList(
} }
} }
} }
// Peers display // Peers display
LazyColumn( LazyColumn(
modifier = modifier =
@ -661,7 +615,6 @@ fun PeerList(
.weight(1f) // LazyColumn gets the remaining vertical space .weight(1f) // LazyColumn gets the remaining vertical space
.onFocusChanged { isListFocussed = it.isFocused } .onFocusChanged { isListFocussed = it.isFocused }
.background(color = MaterialTheme.colorScheme.surface)) { .background(color = MaterialTheme.colorScheme.surface)) {
// Handle case when no results are found // Handle case when no results are found
if (showNoResults) { if (showNoResults) {
item { item {
@ -677,7 +630,6 @@ fun PeerList(
fontWeight = FontWeight.Light) fontWeight = FontWeight.Light)
} }
} }
// Iterate over peer sets to display them // Iterate over peer sets to display them
var first = true var first = true
peerList.forEach { peerSet -> peerList.forEach { peerSet ->
@ -685,13 +637,11 @@ fun PeerList(
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() } item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
} }
first = false first = false
if (isAndroidTV()) { if (isAndroidTV()) {
item { NodesSectionHeader(peerSet = peerSet) } item { NodesSectionHeader(peerSet = peerSet) }
} else { } else {
stickyHeader { NodesSectionHeader(peerSet = peerSet) } stickyHeader { NodesSectionHeader(peerSet = peerSet) }
} }
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
ListItem( ListItem(
modifier = modifier =
@ -758,7 +708,6 @@ fun PeerList(
@Composable @Composable
fun NodesSectionHeader(peerSet: PeerSet) { fun NodesSectionHeader(peerSet: PeerSet) {
Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface)) Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle( Lists.LargeTitle(
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user), peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
bottomPadding = 8.dp, bottomPadding = 8.dp,
@ -770,7 +719,6 @@ fun NodesSectionHeader(peerSet: PeerSet) {
@Composable @Composable
fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
if (netmap == null) return if (netmap == null) return
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) { Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box( Box(
modifier = modifier =
@ -801,7 +749,6 @@ fun PromptForMissingPermissions(viewModel: MainViewModel) {
if (viewModel.skipPromptsForAuthKeyLogin()) { if (viewModel.skipPromptsForAuthKeyLogin()) {
return return
} }
Permissions.prompt.forEach { (permission, state) -> Permissions.prompt.forEach { (permission, state) ->
ErrorDialog( ErrorDialog(
title = permission.title, title = permission.title,
@ -820,7 +767,6 @@ fun Search(
) { ) {
// Prevent multiple taps // Prevent multiple taps
var isNavigating by remember { mutableStateOf(false) } var isNavigating by remember { mutableStateOf(false) }
Box( Box(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
@ -851,7 +797,6 @@ fun Search(
Modifier.padding(start = 0.dp) // Optional start padding for alignment Modifier.padding(start = 0.dp) // Optional start padding for alignment
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
// Placeholder Text // Placeholder Text
Text( Text(
text = stringResource(R.string.search_ellipsis), text = stringResource(R.string.search_ellipsis),
@ -869,9 +814,9 @@ fun Search(
@Preview @Preview
@Composable @Composable
fun MainViewPreview() { fun MainViewPreview() {
val vpnViewModel = VpnViewModel(App.get()) val fakePrompt = emptyFlow<Unit>()
val vm = MainViewModel(vpnViewModel) val appViewModel = AppViewModel(App.get(), fakePrompt)
val vm = MainViewModel(appViewModel)
MainView( MainView(
{}, {},
MainViewNavigation( MainViewNavigation(
@ -880,5 +825,6 @@ fun MainViewPreview() {
onNavigateToExitNodes = {}, onNavigateToExitNodes = {},
onNavigateToHealth = {}, onNavigateToHealth = {},
onNavigateToSearch = {}), onNavigateToSearch = {}),
vm) vm,
appViewModel)
} }

@ -40,13 +40,13 @@ import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.AppViewModel
@Composable @Composable
fun SettingsView( fun SettingsView(
settingsNav: SettingsNav, settingsNav: SettingsNav,
viewModel: SettingsViewModel = viewModel(), viewModel: SettingsViewModel = viewModel(),
vpnViewModel: VpnViewModel = viewModel() appViewModel: AppViewModel = viewModel()
) { ) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
@ -55,7 +55,7 @@ fun SettingsView(
val managedByOrganization by viewModel.managedByOrganization.collectAsState() val managedByOrganization by viewModel.managedByOrganization.collectAsState()
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState() val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState() val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState() val isVPNPrepared by appViewModel.vpnPrepared.collectAsState()
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState() val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState() val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState()

@ -0,0 +1,119 @@
// 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 _triggerDirectoryPicker = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val triggerDirectoryPicker: SharedFlow<Unit> = _triggerDirectoryPicker
val TAG = "AppViewModel"
init {
observeIncomingTaildrop()
prepareVpn()
}
private fun observeIncomingTaildrop() {
viewModelScope.launch {
taildropPrompt.collect {
TSLog.d(TAG, "Taildrop event received, checking directory")
checkIfTaildropDirectorySelected()
}
}
}
fun requestDirectoryPicker() {
_triggerDirectoryPicker.tryEmit(Unit)
}
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 { requestDirectoryPicker() }
} else {
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
}
}
fun setVpnActive(isActive: Boolean) {
_vpnActive.value = isActive
}
fun setVpnPrepared(isPrepared: Boolean) {
_vpnPrepared.value = isPrepared
}
}

@ -1,8 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.net.VpnService import android.net.VpnService
@ -39,112 +37,89 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Duration import java.time.Duration
class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory { class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) { if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(vpnViewModel) as T return MainViewModel(appViewModel) as T
} }
throw IllegalArgumentException("Unknown ViewModel class") throw IllegalArgumentException("Unknown ViewModel class")
} }
} }
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() {
// The user readable state of the system // The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true)) val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
// The expected state of the VPN toggle // The expected state of the VPN toggle
private val _vpnToggleState = MutableStateFlow(false) private val _vpnToggleState = MutableStateFlow(false)
val vpnToggleState: StateFlow<Boolean> = _vpnToggleState val vpnToggleState: StateFlow<Boolean> = _vpnToggleState
// Keeps track of whether a toggle operation is in progress. This ensures that toggleVpn cannot be // Keeps track of whether a toggle operation is in progress. This ensures that toggleVpn cannot be
// invoked until the current operation is complete. // invoked until the current operation is complete.
var isToggleInProgress = MutableStateFlow(false) var isToggleInProgress = MutableStateFlow(false)
// Permission to prepare VPN // Permission to prepare VPN
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
private val _requestVpnPermission = MutableStateFlow(false) private val _requestVpnPermission = MutableStateFlow(false)
val requestVpnPermission: StateFlow<Boolean> = _requestVpnPermission val requestVpnPermission: StateFlow<Boolean> = _requestVpnPermission
// Select Taildrop directory // Select Taildrop directory
private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
private val _showDirectoryPickerInterstitial = MutableStateFlow(false)
val showDirectoryPickerInterstitial: StateFlow<Boolean> = _showDirectoryPickerInterstitial
// The list of peers // The list of peers
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList()) private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
val peers: StateFlow<List<PeerSet>> = _peers val peers: StateFlow<List<PeerSet>> = _peers
// The list of peers // The list of peers
private val _searchViewPeers = MutableStateFlow<List<PeerSet>>(emptyList()) private val _searchViewPeers = MutableStateFlow<List<PeerSet>>(emptyList())
val searchViewPeers: StateFlow<List<PeerSet>> = _searchViewPeers val searchViewPeers: StateFlow<List<PeerSet>> = _searchViewPeers
// The current state of the IPN for determining view visibility // The current state of the IPN for determining view visibility
val ipnState = Notifier.state val ipnState = Notifier.state
// The active search term for filtering peers // The active search term for filtering peers
private val _searchTerm = MutableStateFlow("") private val _searchTerm = MutableStateFlow("")
val searchTerm: StateFlow<String> = _searchTerm val searchTerm: StateFlow<String> = _searchTerm
var autoFocusSearch by mutableStateOf(true) var autoFocusSearch by mutableStateOf(true)
private set private set
// True if we should render the key expiry bannder // True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false) val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
// The peer for which the dropdown menu is currently expanded. Null if no menu is expanded // The peer for which the dropdown menu is currently expanded. Null if no menu is expanded
var expandedMenuPeer: StateFlow<Tailcfg.Node?> = MutableStateFlow(null) var expandedMenuPeer: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
var pingViewModel: PingViewModel = PingViewModel() var pingViewModel: PingViewModel = PingViewModel()
val isVpnPrepared: StateFlow<Boolean> = vpnViewModel.vpnPrepared val isVpnPrepared: StateFlow<Boolean> = appViewModel.vpnPrepared
val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive val isVpnActive: StateFlow<Boolean> = appViewModel.vpnActive
var searchJob: Job? = null var searchJob: Job? = null
// Icon displayed in the button to present the health view // Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = MutableStateFlow(null) val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
fun updateSearchTerm(term: String) { fun updateSearchTerm(term: String) {
_searchTerm.value = term _searchTerm.value = term
} }
fun hidePeerDropdownMenu() { fun hidePeerDropdownMenu() {
expandedMenuPeer.set(null) expandedMenuPeer.set(null)
} }
fun copyIpAddress(peer: Tailcfg.Node, clipboardManager: ClipboardManager) { fun copyIpAddress(peer: Tailcfg.Node, clipboardManager: ClipboardManager) {
clipboardManager.setText(AnnotatedString(peer.primaryIPv4Address ?: "")) clipboardManager.setText(AnnotatedString(peer.primaryIPv4Address ?: ""))
} }
fun startPing(peer: Tailcfg.Node) { fun startPing(peer: Tailcfg.Node) {
this.pingViewModel.startPing(peer) this.pingViewModel.startPing(peer)
} }
fun onPingDismissal() { fun onPingDismissal() {
this.pingViewModel.handleDismissal() this.pingViewModel.handleDismissal()
} }
// Returns true if we should skip all of the user-interactive permissions prompts // Returns true if we should skip all of the user-interactive permissions prompts
// (with the exception of the VPN permission prompt) // (with the exception of the VPN permission prompt)
fun skipPromptsForAuthKeyLogin(): Boolean { fun skipPromptsForAuthKeyLogin(): Boolean {
val v = MDMSettings.authKey.flow.value.value val v = MDMSettings.authKey.flow.value.value
return v != null && v != "" return v != null && v != ""
} }
private val peerCategorizer = PeerCategorizer() private val peerCategorizer = PeerCategorizer()
init { init {
viewModelScope.launch { viewModelScope.launch {
var previousState: State? = null var previousState: State? = null
combine(Notifier.state, isVpnActive) { state, active -> state to active } combine(Notifier.state, isVpnActive) { state, active -> state to active }
.collect { (currentState, active) -> .collect { (currentState, active) ->
// Determine the correct state resource string // Determine the correct state resource string
stateRes.set(userStringRes(currentState, previousState, active)) stateRes.set(userStringRes(currentState, previousState, active))
// Determine if the VPN toggle should be on // Determine if the VPN toggle should be on
val isOn = val isOn =
when { when {
@ -153,15 +128,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
previousState == State.NoState && currentState == State.Starting -> true previousState == State.NoState && currentState == State.Starting -> true
else -> false else -> false
} }
// Update the VPN toggle state // Update the VPN toggle state
_vpnToggleState.value = isOn _vpnToggleState.value = isOn
// Update the previous state // Update the previous state
previousState = currentState previousState = currentState
} }
} }
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
@ -173,7 +145,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
} }
} }
} }
viewModelScope.launch { viewModelScope.launch {
Notifier.netmap.collect { it -> Notifier.netmap.collect { it ->
it?.let { netmap -> it?.let { netmap ->
@ -184,7 +155,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
_peers.value = peerCategorizer.peerSets _peers.value = peerCategorizer.peerSets
_searchViewPeers.value = filteredPeers _searchViewPeers.value = filteredPeers
} }
if (netmap.SelfNode.keyDoesNotExpire) { if (netmap.SelfNode.keyDoesNotExpire) {
showExpiry.set(false) showExpiry.set(false)
return@let return@let
@ -199,57 +169,25 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
} }
} }
} }
viewModelScope.launch { viewModelScope.launch {
App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) }
} }
} }
fun maybeRequestVpnPermission() { fun maybeRequestVpnPermission() {
_requestVpnPermission.value = true _requestVpnPermission.value = true
} }
fun showVPNPermissionLauncherIfUnauthorized() { fun showVPNPermissionLauncherIfUnauthorized() {
val vpnIntent = VpnService.prepare(App.get()) val vpnIntent = VpnService.prepare(App.get())
TSLog.d("VpnPermissions", "vpnIntent=$vpnIntent") TSLog.d("VpnPermissions", "vpnIntent=$vpnIntent")
if (vpnIntent != null) { if (vpnIntent != null) {
vpnPermissionLauncher?.launch(vpnIntent) vpnPermissionLauncher?.launch(vpnIntent)
} else { } else {
vpnViewModel.setVpnPrepared(true) appViewModel.setVpnPrepared(true)
startVPN() startVPN()
} }
_requestVpnPermission.value = false // reset _requestVpnPermission.value = false // reset
} }
fun showDirectoryPickerLauncher() {
_showDirectoryPickerInterstitial.set(false)
directoryPickerLauncher?.launch(null)
}
fun checkIfTaildropDirectorySelected() {
if (skipPromptsForAuthKeyLogin() || AndroidTVUtil.isAndroidTV()) {
return
}
val app = App.get()
val storedUri = app.getStoredDirectoryUri()
if (storedUri == null) {
// No stored URI, so launch the directory picker.
_showDirectoryPickerInterstitial.set(true)
return
}
val documentFile = DocumentFile.fromTreeUri(app, storedUri)
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
TSLog.d(
"MainViewModel",
"Stored directory URI is invalid or inaccessible; launching directory picker.")
_showDirectoryPickerInterstitial.set(true)
} else {
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
}
}
fun toggleVpn(desiredState: Boolean) { fun toggleVpn(desiredState: Boolean) {
if (isToggleInProgress.value) { if (isToggleInProgress.value) {
// Prevent toggling while a previous toggle is in progress // Prevent toggling while a previous toggle is in progress
@ -257,16 +195,13 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
checkIfTaildropDirectorySelected()
isToggleInProgress.value = true isToggleInProgress.value = true
try { try {
val currentState = Notifier.state.value val currentState = Notifier.state.value
val isPrepared = vpnViewModel.vpnPrepared.value
if (desiredState) { if (desiredState) {
// User wants to turn ON the VPN // User wants to turn ON the VPN
when { when {
!isPrepared -> showVPNPermissionLauncherIfUnauthorized()
currentState != Ipn.State.Running -> startVPN() currentState != Ipn.State.Running -> startVPN()
} }
} else { } else {
@ -280,27 +215,19 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
} }
} }
} }
fun searchPeers(searchTerm: String) { fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm) this.searchTerm.set(searchTerm)
} }
fun enableSearchAutoFocus() { fun enableSearchAutoFocus() {
autoFocusSearch = true autoFocusSearch = true
} }
fun disableSearchAutoFocus() { fun disableSearchAutoFocus() {
autoFocusSearch = false autoFocusSearch = false
} }
fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) { fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
// No intent means we're already authorized // No intent means we're already authorized
vpnPermissionLauncher = launcher vpnPermissionLauncher = launcher
} }
fun setDirectoryPickerLauncher(launcher: ActivityResultLauncher<Uri?>) {
directoryPickerLauncher = launcher
}
} }
private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int { private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int {
@ -316,4 +243,4 @@ private fun userStringRes(currentState: State?, previousState: State?, vpnActive
currentState == State.Running -> if (vpnActive) R.string.connected else R.string.placeholder currentState == State.Running -> if (vpnActive) R.string.connected else R.string.placeholder
else -> R.string.placeholder else -> R.string.placeholder
} }
} }

@ -1,15 +1,20 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.util package com.tailscale.ipn.util
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.tailscale.ipn.TaildropDirectoryStore
import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.InputStreamAdapter
import com.tailscale.ipn.ui.util.OutputStreamAdapter import com.tailscale.ipn.ui.util.OutputStreamAdapter
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import libtailscale.Libtailscale import libtailscale.Libtailscale
import org.json.JSONObject import org.json.JSONObject
import java.io.FileOutputStream import java.io.FileOutputStream
@ -17,38 +22,80 @@ import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
data class SafFile(val fd: Int, val uri: String) data class SafFile(val fd: Int, val uri: String)
object ShareFileHelper : libtailscale.ShareFileHelper { object ShareFileHelper : libtailscale.ShareFileHelper {
private var appContext: Context? = null private var appContext: Context? = null
private var app: libtailscale.Application? = null
private var savedUri: String? = null private var savedUri: String? = null
private var scope: CoroutineScope? = null
@JvmStatic @JvmStatic
fun init(context: Context, uri: String) { fun init(context: Context, app: libtailscale.Application, uri: String, appScope: CoroutineScope) {
appContext = context.applicationContext appContext = context.applicationContext
this.app = app
savedUri = uri savedUri = uri
scope = appScope
Libtailscale.setShareFileHelper(this) Libtailscale.setShareFileHelper(this)
TSLog.d("ShareFileHelper", "init ShareFileHelper with savedUri: $savedUri")
} }
// A simple data class that holds a SAF OutputStream along with its URI. // A simple data class that holds a SAF OutputStream along with its URI.
data class SafStream(val uri: String, val stream: OutputStream) data class SafStream(val uri: String, val stream: OutputStream)
val taildropPrompt = MutableSharedFlow<Unit>(replay = 1)
fun observeTaildropPrompt(): Flow<Unit> = taildropPrompt
@Volatile private var directoryReady: CompletableDeferred<Unit>? = null
fun hasValidTaildropDir(): Boolean {
val uri = TaildropDirectoryStore.loadSavedDir()
if (uri == null) return false
// Only SAF tree URIs are supported
if (uri.scheme != "content") {
TSLog.w("ShareFileHelper", "Invalid URI scheme for taildrop dir: ${uri.scheme}")
return false
}
val context = appContext ?: return false
val docFile = DocumentFile.fromTreeUri(context, uri)
if (docFile == null || !docFile.exists() || !docFile.canWrite()) {
TSLog.w("ShareFileHelper", "Stored taildrop URI is invalid or inaccessible: $uri")
return false
}
return true
}
private suspend fun waitUntilTaildropDirReady() {
if (!hasValidTaildropDir()) {
if (directoryReady?.isActive != true) {
directoryReady = CompletableDeferred()
scope?.launch { taildropPrompt.emit(Unit) }
}
directoryReady?.await()
}
}
fun notifyDirectoryReady() {
directoryReady?.takeIf { !it.isCompleted }?.complete(Unit)
}
// A helper function that opens or creates a SafStream for a given file. // A helper function that opens or creates a SafStream for a given file.
private fun openSafFileOutputStream(fileName: String): Pair<String, OutputStream?> { private fun openSafFileOutputStream(fileName: String): Pair<String, OutputStream?> {
val context = appContext ?: return "" to null val context = appContext ?: return "" to null
val dirUri = savedUri ?: return "" to null val dirUri = savedUri ?: return "" to null
val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null
val file = val file =
dir.findFile(fileName) dir.findFile(fileName)
?: dir.createFile("application/octet-stream", fileName) ?: dir.createFile("application/octet-stream", fileName)
?: return "" to null ?: return "" to null
val os = context.contentResolver.openOutputStream(file.uri, "rw") val os = context.contentResolver.openOutputStream(file.uri, "rw")
return file.uri.toString() to os return file.uri.toString() to os
} }
@Throws(IOException::class) @Throws(IOException::class)
private fun openWriterFD(fileName: String, offset: Long): Pair<String, SeekableOutputStream> { private fun openWriterFD(fileName: String, offset: Long): Pair<String, SeekableOutputStream> {
val ctx = appContext ?: throw IOException("App context not initialized") val ctx = appContext ?: throw IOException("App context not initialized")
@ -60,20 +107,18 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
dir.findFile(fileName) dir.findFile(fileName)
?: dir.createFile("application/octet-stream", fileName) ?: dir.createFile("application/octet-stream", fileName)
?: throw IOException("Failed to create file: $fileName") ?: throw IOException("Failed to create file: $fileName")
val pfd = val pfd =
ctx.contentResolver.openFileDescriptor(file.uri, "rw") ctx.contentResolver.openFileDescriptor(file.uri, "rw")
?: throw IOException("Failed to open file descriptor for ${file.uri}") ?: throw IOException("Failed to open file descriptor for ${file.uri}")
val fos = FileOutputStream(pfd.fileDescriptor) val fos = FileOutputStream(pfd.fileDescriptor)
if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0) if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0)
return file.uri.toString() to SeekableOutputStream(fos, pfd) return file.uri.toString() to SeekableOutputStream(fos, pfd)
} }
private val currentUri = ConcurrentHashMap<String, String>() private val currentUri = ConcurrentHashMap<String, String>()
@Throws(IOException::class) @Throws(IOException::class)
override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream { override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream {
runBlocking { waitUntilTaildropDirReady() }
val (uri, stream) = openWriterFD(fileName, offset) val (uri, stream) = openWriterFD(fileName, offset)
if (stream == null) { if (stream == null) {
throw IOException("Failed to open file writer for $fileName") throw IOException("Failed to open file writer for $fileName")
@ -84,22 +129,20 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
@Throws(IOException::class) @Throws(IOException::class)
override fun getFileURI(fileName: String): String { override fun getFileURI(fileName: String): String {
runBlocking { waitUntilTaildropDirReady() }
currentUri[fileName]?.let { currentUri[fileName]?.let {
return it return it
} }
val ctx = appContext ?: throw IOException("App context not initialized") val ctx = appContext ?: throw IOException("App context not initialized")
val dirStr = savedUri ?: throw IOException("No saved directory URI") val dirStr = savedUri ?: throw IOException("No saved directory URI")
val dir = val dir =
DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr)) DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr))
?: throw IOException("Invalid tree URI: $dirStr") ?: throw IOException("Invalid tree URI: $dirStr")
val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName") val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName")
val uri = file.uri.toString() val uri = file.uri.toString()
currentUri[fileName] = uri currentUri[fileName] = uri
return uri return uri
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun renameFile(oldPath: String, targetName: String): String { override fun renameFile(oldPath: String, targetName: String): String {
val ctx = appContext ?: throw IOException("not initialized") val ctx = appContext ?: throw IOException("not initialized")
@ -108,7 +151,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
val dir = val dir =
DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri))
?: throw IOException("cannot open dir $dirUri") ?: throw IOException("cannot open dir $dirUri")
var finalName = targetName var finalName = targetName
dir.findFile(finalName)?.let { existing -> dir.findFile(finalName)?.let { existing ->
if (lengthOfUri(ctx, existing.uri) == 0L) { if (lengthOfUri(ctx, existing.uri) == 0L) {
@ -117,7 +160,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
finalName = generateNewFilename(finalName) finalName = generateNewFilename(finalName)
} }
} }
try { try {
DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri -> DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri ->
runCatching { ctx.contentResolver.delete(srcUri, null, null) } runCatching { ctx.contentResolver.delete(srcUri, null, null) }
@ -125,14 +168,14 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
return newUri.toString() return newUri.toString()
} }
} catch (e: Exception) { } catch (e: Exception) {
TSLog.w("renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}") TSLog.w(
"renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}")
} }
val dest = val dest =
dir.createFile("application/octet-stream", finalName) dir.createFile("application/octet-stream", finalName)
?: throw IOException("createFile failed for $finalName") ?: throw IOException("createFile failed for $finalName")
ctx.contentResolver.openInputStream(srcUri).use { inp -> ctx.contentResolver.openInputStream(srcUri).use { inp ->
ctx.contentResolver.openOutputStream(dest.uri, "w").use { out -> ctx.contentResolver.openOutputStream(dest.uri, "w").use { out ->
if (inp == null || out == null) { if (inp == null || out == null) {
@ -142,15 +185,13 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
inp.copyTo(out) inp.copyTo(out)
} }
} }
ctx.contentResolver.delete(srcUri, null, null) ctx.contentResolver.delete(srcUri, null, null)
cleanupPartials(dir, targetName) cleanupPartials(dir, targetName)
return dest.uri.toString() return dest.uri.toString()
} }
private fun lengthOfUri(ctx: Context, uri: Uri): Long = private fun lengthOfUri(ctx: Context, uri: Uri): Long =
ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 } ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 }
// delete any stray “.partial” files for this base name // delete any stray “.partial” files for this base name
private fun cleanupPartials(dir: DocumentFile, base: String) { private fun cleanupPartials(dir: DocumentFile, base: String) {
for (child in dir.listFiles()) { for (child in dir.listFiles()) {
@ -160,21 +201,18 @@ 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)
val doc = val doc =
DocumentFile.fromSingleUri(ctx, uri) DocumentFile.fromSingleUri(ctx, uri)
?: throw IOException("DeleteFile: cannot resolve URI $uri") ?: throw IOException("DeleteFile: cannot resolve URI $uri")
if (!doc.delete()) { if (!doc.delete()) {
throw IOException("DeleteFile: delete() returned false for $uri") throw IOException("DeleteFile: delete() returned false for $uri")
} }
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getFileInfo(fileName: String): String { override fun getFileInfo(fileName: String): String {
val context = appContext ?: throw IOException("app context not initialized") val context = appContext ?: throw IOException("app context not initialized")
@ -182,41 +220,32 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
val dir = val dir =
DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) DocumentFile.fromTreeUri(context, Uri.parse(dirUri))
?: throw IOException("could not resolve SAF root") ?: throw IOException("could not resolve SAF root")
val file = val file =
dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory") dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory")
val name = file.name ?: throw IOException("file name missing for $fileName") val name = file.name ?: throw IOException("file name missing for $fileName")
val size = file.length() val size = file.length()
val modTime = file.lastModified() val modTime = file.lastModified()
return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}""" return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}"""
} }
private fun jsonEscape(s: String): String { private fun jsonEscape(s: String): String {
return JSONObject.quote(s) return JSONObject.quote(s)
} }
fun generateNewFilename(filename: String): String { fun generateNewFilename(filename: String): String {
val dotIndex = filename.lastIndexOf('.') val dotIndex = filename.lastIndexOf('.')
val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename
val extension = if (dotIndex != -1) filename.substring(dotIndex) else "" val extension = if (dotIndex != -1) filename.substring(dotIndex) else ""
val uuid = UUID.randomUUID() val uuid = UUID.randomUUID()
return "$baseName-$uuid$extension" return "$baseName-$uuid$extension"
} }
fun listPartialFiles(suffix: String): Array<String> { fun listPartialFiles(suffix: String): Array<String> {
val context = appContext ?: return emptyArray() val context = appContext ?: return emptyArray()
val rootUri = savedUri ?: return emptyArray() val rootUri = savedUri ?: return emptyArray()
val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray() val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray()
return dir.listFiles() return dir.listFiles()
.filter { it.name?.endsWith(suffix) == true } .filter { it.name?.endsWith(suffix) == true }
.mapNotNull { it.name } .mapNotNull { it.name }
.toTypedArray() .toTypedArray()
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun listFilesJSON(suffix: String): String { override fun listFilesJSON(suffix: String): String {
val list = listPartialFiles(suffix) val list = listPartialFiles(suffix)
@ -225,7 +254,6 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
} }
return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]") return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]")
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun openFileReader(name: String): libtailscale.InputStream { override fun openFileReader(name: String): libtailscale.InputStream {
val context = appContext ?: throw IOException("app context not initialized") val context = appContext ?: throw IOException("app context not initialized")
@ -233,37 +261,32 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
val dir = val dir =
DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) DocumentFile.fromTreeUri(context, Uri.parse(rootUri))
?: throw IOException("could not open SAF root") ?: throw IOException("could not open SAF root")
val suffix = name.substringAfterLast('.', ".$name") val suffix = name.substringAfterLast('.', ".$name")
val file = val file =
dir.listFiles().firstOrNull { dir.listFiles().firstOrNull {
val fname = it.name ?: return@firstOrNull false val fname = it.name ?: return@firstOrNull false
fname.endsWith(suffix, ignoreCase = false) fname.endsWith(suffix, ignoreCase = false)
} ?: throw IOException("no file ending with \"$suffix\" in SAF directory") } ?: throw IOException("no file ending with \"$suffix\" in SAF directory")
val inStream = val inStream =
context.contentResolver.openInputStream(file.uri) context.contentResolver.openInputStream(file.uri)
?: throw IOException("openInputStream returned null for ${file.uri}") ?: throw IOException("openInputStream returned null for ${file.uri}")
return InputStreamAdapter(inStream) return InputStreamAdapter(inStream)
} }
fun setUri(uri: String) {
savedUri = uri
}
private class SeekableOutputStream( private class SeekableOutputStream(
private val fos: FileOutputStream, private val fos: FileOutputStream,
private val pfd: ParcelFileDescriptor private val pfd: ParcelFileDescriptor
) : OutputStream() { ) : OutputStream() {
private var closed = false private var closed = false
override fun write(b: Int) = fos.write(b) override fun write(b: Int) = fos.write(b)
override fun write(b: ByteArray) = fos.write(b) override fun write(b: ByteArray) = fos.write(b)
override fun write(b: ByteArray, off: Int, len: Int) { override fun write(b: ByteArray, off: Int, len: Int) {
fos.write(b, off, len) fos.write(b, off, len)
} }
override fun close() { override fun close() {
if (!closed) { if (!closed) {
closed = true closed = true
@ -276,7 +299,6 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
} }
} }
} }
override fun flush() = fos.flush() override fun flush() = fos.flush()
} }
} }

@ -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 {
@ -114,23 +112,6 @@ type backend struct {
type settingsFunc func(*router.Config, *dns.OSConfig) error type settingsFunc func(*router.Config, *dns.OSConfig) error
func (a *App) runBackend(ctx context.Context) error { func (a *App) runBackend(ctx context.Context) error {
for {
err := a.runBackendOnce(ctx)
if err != nil {
log.Printf("runBackendOnce error: %v", err)
}
// Wait for a restart trigger
<-a.backendRestartCh
}
}
func (a *App) runBackendOnce(ctx context.Context) error {
select {
case <-a.backendRestartCh:
default:
}
paths.AppSharedDir.Store(a.dataDir) paths.AppSharedDir.Store(a.dataDir)
hostinfo.SetOSVersion(a.osVersion()) hostinfo.SetOSVersion(a.osVersion())
hostinfo.SetPackage(a.appCtx.GetInstallSource()) hostinfo.SetPackage(a.appCtx.GetInstallSource())
@ -338,7 +319,6 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore,
lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0) lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0)
if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok { if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok {
ext.SetFileOps(newAndroidFileOps(a.shareFileHelper)) ext.SetFileOps(newAndroidFileOps(a.shareFileHelper))
ext.SetDirectFileRoot(a.directFileRoot)
} }
if err != nil { if err != nil {
@ -368,14 +348,9 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore,
func (a *App) watchFileOpsChanges() { func (a *App) watchFileOpsChanges() {
for { for {
select { select {
case newPath := <-onFilePath:
log.Printf("Got new directFileRoot")
a.directFileRoot = newPath
a.backendRestartCh <- struct{}{}
case helper := <-onShareFileHelper: case helper := <-onShareFileHelper:
log.Printf("Got shareFIleHelper") log.Printf("Got ShareFileHelper")
a.shareFileHelper = helper a.shareFileHelper = helper
a.backendRestartCh <- struct{}{}
} }
} }
} }

@ -26,9 +26,6 @@ var (
// onShareFileHelper receives ShareFileHelper references when the app is initialized so that files can be received via Storage Access Framework // onShareFileHelper receives ShareFileHelper references when the app is initialized so that files can be received via Storage Access Framework
onShareFileHelper = make(chan ShareFileHelper, 1) onShareFileHelper = make(chan ShareFileHelper, 1)
// onFilePath receives the SAF path used for Taildrop
onFilePath = make(chan string)
) )
// ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available. // ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available.

@ -243,7 +243,3 @@ func SetShareFileHelper(fileHelper ShareFileHelper) {
onShareFileHelper <- fileHelper onShareFileHelper <- fileHelper
} }
} }
func SetDirectFileRoot(filePath string) {
onFilePath <- filePath
}

@ -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