diff --git a/android/build.gradle b/android/build.gradle index 4d1bcfe428..d7b8ae5af2 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -139,7 +139,7 @@ dependencies { implementation "androidx.compose.animation:animation:1.7.4" // Navigation dependencies. - def nav_version = "2.8.2" + def nav_version = "2.8.5" implementation "androidx.navigation:navigation-compose:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version" diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index a449bf0adb..249a42dd05 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -7,15 +7,14 @@ import android.app.Application import android.app.Notification import android.app.NotificationChannel import android.app.PendingIntent -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences import android.content.pm.PackageManager import android.net.ConnectivityManager +import android.net.Uri import android.os.Build -import android.os.Environment import android.util.Log import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat @@ -30,17 +29,20 @@ import com.tailscale.ipn.mdm.MDMSettingsChangedReceiver import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Request import com.tailscale.ipn.ui.model.Ipn +import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.notifier.HealthNotifier import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory import com.tailscale.ipn.util.FeatureFlags +import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString @@ -57,6 +59,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { companion object { private const val FILE_CHANNEL_ID = "tailscale-files" + // Key to store the SAF URI in EncryptedSharedPreferences. + private val PREF_KEY_SAF_URI = "saf_directory_uri" private const val TAG = "App" private lateinit var appInstance: App @@ -148,27 +152,28 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } private fun initializeApp() { - val dataDir = this.filesDir.absolutePath - - // Set this to enable direct mode for taildrop whereby downloads will be saved directly - // to the given folder. We will preferentially use /Downloads and fallback to - // an app local directory "Taildrop" if we cannot create that. This mode does not support - // user notifications for incoming files. - val directFileDir = this.prepareDownloadsFolder() - app = Libtailscale.start(dataDir, directFileDir.absolutePath, this) - Request.setApp(app) - Notifier.setApp(app) - Notifier.start(applicationScope) + // Check if a directory URI has already been stored. + val storedUri = getStoredDirectoryUri() + if (storedUri != null && storedUri.toString().startsWith("content://")) { + startLibtailscale(storedUri.toString()) + } else { + startLibtailscale(this.getFilesDir().absolutePath) + } healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope) connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns) initViewModels() applicationScope.launch { Notifier.state.collect { _ -> - combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled -> - Pair(state, forceEnabled) + combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) { + state, + forceEnabled, + prefs, + netmap -> + Triple(state, forceEnabled, getExitNodeName(prefs, netmap)) } - .collect { (state, hideDisconnectAction) -> + .distinctUntilChanged() + .collect { (state, hideDisconnectAction, exitNodeName) -> val ableToStartVPN = state > Ipn.State.NeedsMachineAuth // If VPN is stopped, show a disconnected notification. If it is running as a // foreground @@ -183,7 +188,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { // Update notification status when VPN is running if (vpnRunning) { - notifyStatus(vpnRunning = true, hideDisconnectAction = hideDisconnectAction.value) + notifyStatus( + vpnRunning = true, + hideDisconnectAction = hideDisconnectAction.value, + exitNodeName = exitNodeName) } } } @@ -195,6 +203,18 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { FeatureFlags.initialize(mapOf("enable_new_search" to true)) } + /** + * 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. + */ + fun startLibtailscale(directFileRoot: String) { + ShareFileHelper.init(this, directFileRoot) + app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this) + Request.setApp(app) + Notifier.setApp(app) + Notifier.start(applicationScope) + } + private fun initViewModels() { vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java) } @@ -237,6 +257,11 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) } + fun getStoredDirectoryUri(): Uri? { + val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null) + return uriString?.let { Uri.parse(it) } + } + /* * 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 @@ -300,29 +325,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { return sb.toString() } - private fun prepareDownloadsFolder(): File { - var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - - try { - if (!downloads.exists()) { - downloads.mkdirs() - } - } catch (e: Exception) { - TSLog.e(TAG, "Failed to create downloads folder: $e") - downloads = File(this.filesDir, "Taildrop") - try { - if (!downloads.exists()) { - downloads.mkdirs() - } - } catch (e: Exception) { - TSLog.e(TAG, "Failed to create Taildrop folder: $e") - downloads = File("") - } - } - - return downloads - } - @Throws( IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) override fun getSyspolicyBooleanValue(key: String): Boolean { @@ -391,6 +393,18 @@ open class UninitializedApp : Application() { fun get(): UninitializedApp { return appInstance } + + /** + * Return the name of the active (but not the selected/prior one) exit node based on the + * provided [Ipn.Prefs] and [Netmap.NetworkMap]. + * + * @return The name of the exit node or `null` if there isn't one. + */ + fun getExitNodeName(prefs: Ipn.Prefs?, netmap: Netmap.NetworkMap?): String? { + return prefs?.activeExitNodeID?.let { exitNodeID -> + netmap?.Peers?.find { it.StableID == exitNodeID }?.exitNodeName + } + } } protected fun setUnprotectedInstance(instance: UninitializedApp) { @@ -448,25 +462,15 @@ open class UninitializedApp : Application() { } fun restartVPN() { - // Register a receiver to listen for the completion of stopVPN - val stopReceiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - // Ensure stop intent is complete - if (intent?.action == IPNService.ACTION_STOP_VPN) { - // Unregister receiver after receiving the broadcast - context?.unregisterReceiver(this) - // Now start the VPN - startVPN() - } - } - } - - // Register the receiver before stopping VPN - val intentFilter = IntentFilter(IPNService.ACTION_STOP_VPN) - this.registerReceiver(stopReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED) - - stopVPN() + val intent = + Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN } + try { + startService(intent) + } catch (illegalStateException: IllegalStateException) { + TSLog.e(TAG, "restartVPN hit IllegalStateException in startService(): $illegalStateException") + } catch (e: Exception) { + TSLog.e(TAG, "restartVPN hit exception in startService(): $e") + } } fun createNotificationChannel(id: String, name: String, description: String, importance: Int) { @@ -476,8 +480,12 @@ open class UninitializedApp : Application() { notificationManager.createNotificationChannel(channel) } - fun notifyStatus(vpnRunning: Boolean, hideDisconnectAction: Boolean) { - notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction)) + fun notifyStatus( + vpnRunning: Boolean, + hideDisconnectAction: Boolean, + exitNodeName: String? = null + ) { + notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName)) } fun notifyStatus(notification: Notification) { @@ -495,8 +503,16 @@ open class UninitializedApp : Application() { notificationManager.notify(STATUS_NOTIFICATION_ID, notification) } - fun buildStatusNotification(vpnRunning: Boolean, hideDisconnectAction: Boolean): Notification { - val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected) + fun buildStatusNotification( + vpnRunning: Boolean, + hideDisconnectAction: Boolean, + exitNodeName: String? = null + ): Notification { + val title = getString(if (vpnRunning) R.string.connected else R.string.not_connected) + val message = + if (vpnRunning && exitNodeName != null) { + getString(R.string.using_exit_node, exitNodeName) + } else null val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled val action = if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN @@ -520,7 +536,7 @@ open class UninitializedApp : Application() { val builder = NotificationCompat.Builder(this, STATUS_CHANNEL_ID) .setSmallIcon(icon) - .setContentTitle(getString(R.string.app_name)) + .setContentTitle(title) .setContentText(message) .setAutoCancel(!vpnRunning) .setOnlyAlertOnce(!vpnRunning) @@ -535,33 +551,13 @@ open class UninitializedApp : Application() { return builder.build() } - fun addUserDisallowedPackageName(packageName: String) { - if (packageName.isEmpty()) { - TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName") - return - } - - getUnencryptedPrefs() - .edit() - .putStringSet( - DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName))) - .apply() - - this.restartVPN() - } - - fun removeUserDisallowedPackageName(packageName: String) { - if (packageName.isEmpty()) { - TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName") + fun updateUserDisallowedPackageNames(packageNames: List) { + if (packageNames.any { it.isEmpty() }) { + TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)") return } - getUnencryptedPrefs() - .edit() - .putStringSet( - DISALLOWED_APPS_KEY, - disallowedPackageNames().toMutableSet().subtract(setOf(packageName))) - .apply() + getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply() this.restartVPN() } diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index 920d08d7d7..e861d9c533 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -46,12 +46,15 @@ open class IPNService : VpnService(), libtailscale.IPNService { close() START_NOT_STICKY } - ACTION_START_VPN -> { - scope.launch { - // Collect the first value of hideDisconnectAction asynchronously. - val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() - showForegroundNotification(hideDisconnectAction.value) + ACTION_RESTART_VPN -> { + app.setWantRunning(false){ + close() + app.startVPN() } + START_NOT_STICKY + } + ACTION_START_VPN -> { + scope.launch { showForegroundNotification() } app.setWantRunning(true) Libtailscale.requestVPN(this) START_STICKY @@ -63,7 +66,9 @@ open class IPNService : VpnService(), libtailscale.IPNService { scope.launch { // Collect the first value of hideDisconnectAction asynchronously. val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() - app.notifyStatus(true, hideDisconnectAction.value) + val exitNodeName = + UninitializedApp.getExitNodeName(Notifier.prefs.value, Notifier.netmap.value) + app.notifyStatus(true, hideDisconnectAction.value, exitNodeName) } app.setWantRunning(true) Libtailscale.requestVPN(this) @@ -73,11 +78,7 @@ open class IPNService : VpnService(), libtailscale.IPNService { // This means that we were restarted after the service was killed // (potentially due to OOM). if (UninitializedApp.get().isAbleToStartVPN()) { - scope.launch { - // Collect the first value of hideDisconnectAction asynchronously. - val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() - showForegroundNotification(hideDisconnectAction.value) - } + scope.launch { showForegroundNotification() } App.get() Libtailscale.requestVPN(this) START_STICKY @@ -88,7 +89,6 @@ open class IPNService : VpnService(), libtailscale.IPNService { } override fun close() { - app.setWantRunning(false) {} Notifier.setState(Ipn.State.Stopping) disconnectVPN() Libtailscale.serviceDisconnect(this) @@ -114,16 +114,25 @@ open class IPNService : VpnService(), libtailscale.IPNService { app.getAppScopedViewModel().setVpnPrepared(isPrepared) } - private fun showForegroundNotification(hideDisconnectAction: Boolean) { + private fun showForegroundNotification( + hideDisconnectAction: Boolean, + exitNodeName: String? = null + ) { try { startForeground( UninitializedApp.STATUS_NOTIFICATION_ID, - UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction)) + UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction, exitNodeName)) } catch (e: Exception) { TSLog.e(TAG, "Failed to start foreground service: $e") } } + private fun showForegroundNotification() { + val hideDisconnectAction = MDMSettings.forceEnabled.flow.value.value + val exitNodeName = UninitializedApp.getExitNodeName(Notifier.prefs.value, Notifier.netmap.value) + showForegroundNotification(hideDisconnectAction, exitNodeName) + } + private fun configIntent(): PendingIntent { return PendingIntent.getActivity( this, @@ -177,5 +186,6 @@ open class IPNService : VpnService(), libtailscale.IPNService { companion object { const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN" const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN" + const val ACTION_RESTART_VPN = "com.tailscale.ipn.RESTART_VPN" } } diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 7a68160dab..98f591ed2d 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -10,18 +10,21 @@ import android.content.Context import android.content.Intent import android.content.RestrictionsManager import android.content.pm.ActivityInfo +import android.content.pm.PackageManager import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK import android.net.ConnectivityManager import android.net.NetworkCapabilities +import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.Process import android.provider.Settings -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.animation.core.LinearOutSlowInEasing @@ -89,8 +92,13 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import libtailscale.Libtailscale +import java.io.IOException +import java.security.GeneralSecurityException class MainActivity : ComponentActivity() { + // Key to store the SAF URI in EncryptedSharedPreferences. + val PREF_KEY_SAF_URI = "saf_directory_uri" private lateinit var navController: NavHostController private lateinit var vpnPermissionLauncher: ActivityResultLauncher private val viewModel: MainViewModel by lazy { @@ -150,6 +158,49 @@ class MainActivity : ComponentActivity() { } viewModel.setVpnPermissionLauncher(vpnPermissionLauncher) + val directoryPickerLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> + if (uri != null) { + try { + // Try to take persistable permissions for both read and write. + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } catch (e: SecurityException) { + TSLog.e("MainActivity", "Failed to persist permissions: $e") + } + + // Check if write permission is actually granted. + val writePermission = + this.checkUriPermission( + uri, Process.myPid(), Process.myUid(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + if (writePermission == PackageManager.PERMISSION_GRANTED) { + TSLog.d("MainActivity", "Write permission granted for $uri") + + lifecycleScope.launch(Dispatchers.IO) { + try { + Libtailscale.setDirectFileRoot(uri.toString()) + saveFileDirectory(uri) + } catch (e: Exception) { + TSLog.e("MainActivity", "Failed to set Taildrop root: $e") + } + } + } else { + TSLog.d( + "MainActivity", + "Write access not granted for $uri. Falling back to internal storage.") + // Don't save directory URI and fall back to internal storage. + } + } else { + TSLog.d( + "MainActivity", "Taildrop directory not saved. Will fall back to internal storage.") + + // Fall back to internal storage. + } + } + + viewModel.setDirectoryPickerLauncher(directoryPickerLauncher) + setContent { navController = rememberNavController() @@ -198,7 +249,7 @@ class MainActivity : ComponentActivity() { onNavigateToSearch = { viewModel.enableSearchAutoFocus() navController.navigate("search") - }) + }) val settingsNav = SettingsNav( @@ -245,9 +296,8 @@ class MainActivity : ComponentActivity() { viewModel = viewModel, navController = navController, onNavigateBack = { navController.popBackStack() }, - autoFocus = autoFocus - ) - } + autoFocus = autoFocus) + } composable("settings") { SettingsView(settingsNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("health") { HealthView(backTo("main")) } @@ -365,23 +415,39 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) if (intent.getBooleanExtra(START_AT_ROOT, false)) { + if (this::navController.isInitialized) { + val previousEntry = navController.previousBackStackEntry + TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") if (this::navController.isInitialized) { - val previousEntry = navController.previousBackStackEntry - TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") + val previousEntry = navController.previousBackStackEntry + TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") - if (previousEntry != null) { - navController.popBackStack(route = "main", inclusive = false) - } else { - TSLog.e("MainActivity", "onNewIntent: No previous back stack entry, navigating directly to 'main'") - navController.navigate("main") { - popUpTo("main") { inclusive = true } - } - } + if (previousEntry != null) { + navController.popBackStack(route = "main", inclusive = false) + } else { + TSLog.e( + "MainActivity", + "onNewIntent: No previous back stack entry, navigating directly to 'main'") + navController.navigate("main") { popUpTo("main") { inclusive = true } } + } } + } } -} - + } + @Throws(IOException::class, GeneralSecurityException::class) + fun saveFileDirectory(directoryUri: Uri) { + val prefs = App.get().getEncryptedPrefs() + prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).apply() + 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( + "MainActivity", + "saveFileDirectory: Failed to restart Libtailscale with the new directory: $e") + } + } private fun login(urlString: String) { // Launch coroutine to listen for state changes. When the user completes login, relaunch diff --git a/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt b/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt index 9b6f9df025..a8f5d828a1 100644 --- a/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt +++ b/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt @@ -9,9 +9,9 @@ import android.net.NetworkCapabilities import android.net.NetworkRequest import android.util.Log import com.tailscale.ipn.util.TSLog -import libtailscale.Libtailscale import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock +import libtailscale.Libtailscale object NetworkChangeCallback { diff --git a/android/src/main/java/com/tailscale/ipn/QuickToggleService.java b/android/src/main/java/com/tailscale/ipn/QuickToggleService.java index 0ac3bd0ab6..f2374ccbf0 100644 --- a/android/src/main/java/com/tailscale/ipn/QuickToggleService.java +++ b/android/src/main/java/com/tailscale/ipn/QuickToggleService.java @@ -60,9 +60,13 @@ public void onStopListening() { } } - @SuppressWarnings("deprecation") @Override public void onClick() { + unlockAndRun(this::secureOnClick); + } + + @SuppressWarnings("deprecation") + private void secureOnClick() { boolean r; synchronized (lock) { r = UninitializedApp.get().isAbleToStartVPN(); diff --git a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt index 3e121f13e4..09d9665ee3 100644 --- a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt @@ -21,12 +21,12 @@ import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.universalFit import com.tailscale.ipn.ui.view.TaildropView import com.tailscale.ipn.util.TSLog +import kotlin.random.Random import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlin.random.Random // ShareActivity is the entry point for Taildrop share intents class ShareActivity : ComponentActivity() { @@ -92,25 +92,22 @@ class ShareActivity : ComponentActivity() { } } - val pendingFiles: List = + val pendingFiles: List = uris?.filterNotNull()?.mapNotNull { uri -> - contentResolver?.query(uri, null, null, null, null)?.use { cursor -> - val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE) - - if (cursor.moveToFirst()) { - val name: String = cursor.getString(nameCol) - ?: generateFallbackName(uri) - val size: Long = cursor.getLong(sizeCol) - Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { - this.uri = uri - } - } else { - TSLog.e(TAG, "Cursor is empty for URI: $uri") - null - } + contentResolver?.query(uri, null, null, null, null)?.use { cursor -> + val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE) + + if (cursor.moveToFirst()) { + val name: String = cursor.getString(nameCol) ?: generateFallbackName(uri) + val size: Long = cursor.getLong(sizeCol) + Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { this.uri = uri } + } else { + TSLog.e(TAG, "Cursor is empty for URI: $uri") + null } - } ?: emptyList() + } + } ?: emptyList() if (pendingFiles.isEmpty()) { TSLog.e(TAG, "Share failure - no files extracted from intent") @@ -124,5 +121,5 @@ class ShareActivity : ComponentActivity() { val mimeType = contentResolver?.getType(uri) val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } return if (extension != null) "$randomId.$extension" else randomId.toString() -} + } } diff --git a/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt b/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt index ff48dc3530..e2b2bbc0d1 100644 --- a/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt +++ b/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt @@ -10,7 +10,6 @@ import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.WorkerParameters import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID -import com.tailscale.ipn.UninitializedApp.Companion.STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.notifier.Notifier @@ -18,95 +17,96 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -class UseExitNodeWorker( - appContext: Context, - workerParams: WorkerParameters -) : CoroutineWorker(appContext, workerParams) { - override suspend fun doWork(): Result { - val app = UninitializedApp.get() - suspend fun runAndGetResult(): String? { - val exitNodeName = inputData.getString(EXIT_NODE_NAME) - - val exitNodeId = if (exitNodeName.isNullOrEmpty()) { - null - } else { - if (!app.isAbleToStartVPN()) { - return app.getString(R.string.vpn_is_not_ready_to_start) - } - - val peers = - (Notifier.netmap.value - ?: run { return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) }) - .Peers ?: run { return@runAndGetResult app.getString(R.string.no_peers_found) } - - val filteredPeers = peers.filter { - it.displayName == exitNodeName - }.toList() - - if (filteredPeers.isEmpty()) { - return app.getString(R.string.no_peers_with_name_found, exitNodeName) - } else if (filteredPeers.size > 1) { - return app.getString(R.string.multiple_peers_with_name_found, exitNodeName) - } else if (!filteredPeers[0].isExitNode) { - return app.getString( - R.string.peer_with_name_is_not_an_exit_node, - exitNodeName - ) - } - - filteredPeers[0].StableID +class UseExitNodeWorker(appContext: Context, workerParams: WorkerParameters) : + CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + val app = UninitializedApp.get() + suspend fun runAndGetResult(): String? { + val exitNodeName = inputData.getString(EXIT_NODE_NAME) + + val exitNodeId = + if (exitNodeName.isNullOrEmpty()) { + null + } else { + if (!app.isAbleToStartVPN()) { + return app.getString(R.string.vpn_is_not_ready_to_start) } - val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false) - val prefsOut = Ipn.MaskedPrefs() - prefsOut.ExitNodeID = exitNodeId - prefsOut.ExitNodeAllowLANAccess = allowLanAccess - - val scope = CoroutineScope(Dispatchers.Default + Job()) - var result: String? = null - Client(scope).editPrefs(prefsOut) { - result = if (it.isFailure) { - it.exceptionOrNull()?.message - } else { - null - } + val peers = + (Notifier.netmap.value + ?: run { + return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) + }) + .Peers + ?: run { + return@runAndGetResult app.getString(R.string.no_peers_found) + } + + val filteredPeers = peers.filter { it.displayName == exitNodeName }.toList() + + if (filteredPeers.isEmpty()) { + return app.getString(R.string.no_peers_with_name_found, exitNodeName) + } else if (filteredPeers.size > 1) { + return app.getString(R.string.multiple_peers_with_name_found, exitNodeName) + } else if (!filteredPeers[0].isExitNode) { + return app.getString(R.string.peer_with_name_is_not_an_exit_node, exitNodeName) } - scope.coroutineContext[Job]?.join() - - return result - } - - val result = runAndGetResult() + filteredPeers[0].StableID + } - return if (result != null) { - val intent = - Intent(app, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - } - val pendingIntent: PendingIntent = - PendingIntent.getActivity( - app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false) + val prefsOut = Ipn.MaskedPrefs() + prefsOut.ExitNodeID = exitNodeId + prefsOut.ExitNodeAllowLANAccess = allowLanAccess - val notification = NotificationCompat.Builder(app, STATUS_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(app.getString(R.string.use_exit_node_intent_failed)) - .setContentText(result) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setContentIntent(pendingIntent) - .build() + val scope = CoroutineScope(Dispatchers.Default + Job()) + var result: String? = null + Client(scope).editPrefs(prefsOut) { + result = + if (it.isFailure) { + it.exceptionOrNull()?.message + } else { + null + } + } - app.notifyStatus(notification) + scope.coroutineContext[Job]?.join() - Result.failure(Data.Builder().putString(ERROR_KEY, result).build()) - } else { - Result.success() - } + return result } - companion object { - const val EXIT_NODE_NAME = "EXIT_NODE_NAME" - const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS" - const val ERROR_KEY = "error" + val result = runAndGetResult() + + return if (result != null) { + val intent = + Intent(app, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent: PendingIntent = + PendingIntent.getActivity( + app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + val notification = + NotificationCompat.Builder(app, STATUS_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(app.getString(R.string.use_exit_node_intent_failed)) + .setContentText(result) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .build() + + app.notifyStatus(notification) + + Result.failure(Data.Builder().putString(ERROR_KEY, result).build()) + } else { + Result.success() } + } + + companion object { + const val EXIT_NODE_NAME = "EXIT_NODE_NAME" + const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS" + const val ERROR_KEY = "error" + } } diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsChangedReceiver.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsChangedReceiver.kt index 5584fde8f7..d54129d831 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsChangedReceiver.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsChangedReceiver.kt @@ -11,11 +11,12 @@ import com.tailscale.ipn.App import com.tailscale.ipn.util.TSLog class MDMSettingsChangedReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) { - TSLog.d("syspolicy", "MDM settings changed") - val restrictionsManager = context?.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager - MDMSettings.update(App.get(), restrictionsManager) - } + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) { + TSLog.d("syspolicy", "MDM settings changed") + val restrictionsManager = + context?.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager + MDMSettings.update(App.get(), restrictionsManager) } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt index 173159fbcb..d704aebd4c 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt @@ -5,10 +5,8 @@ package com.tailscale.ipn.mdm import android.content.SharedPreferences import android.os.Bundle -import com.tailscale.ipn.App import com.tailscale.ipn.ui.util.set import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow data class SettingState(val value: T, val isSet: Boolean) @@ -29,18 +27,21 @@ abstract class MDMSetting(defaultValue: T, val key: String, val localizedTitl } protected abstract fun getFromBundle(bundle: Bundle): T + protected abstract fun getFromPrefs(prefs: SharedPreferences): T } class BooleanMDMSetting(key: String, localizedTitle: String) : MDMSetting(false, key, localizedTitle) { override fun getFromBundle(bundle: Bundle) = bundle.getBoolean(key) + override fun getFromPrefs(prefs: SharedPreferences) = prefs.getBoolean(key, false) } class StringMDMSetting(key: String, localizedTitle: String) : MDMSetting(null, key, localizedTitle) { override fun getFromBundle(bundle: Bundle) = bundle.getString(key) + override fun getFromPrefs(prefs: SharedPreferences) = prefs.getString(key, null) } @@ -50,13 +51,13 @@ class StringArrayListMDMSetting(key: String, localizedTitle: String) : // Try to retrieve the value as a String[] first val stringArray = bundle.getStringArray(key) if (stringArray != null) { - return stringArray.toList() + return stringArray.toList() } - + // Optionally, handle other types if necessary val stringArrayList = bundle.getStringArrayList(key) if (stringArrayList != null) { - return stringArrayList + return stringArrayList } // If neither String[] nor ArrayList is found, return null @@ -64,7 +65,7 @@ class StringArrayListMDMSetting(key: String, localizedTitle: String) : } override fun getFromPrefs(prefs: SharedPreferences): List? { - return prefs.getStringSet(key, HashSet())?.toList() + return prefs.getStringSet(key, HashSet())?.toList() } } @@ -72,14 +73,15 @@ class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) : MDMSetting(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) { override fun getFromBundle(bundle: Bundle) = AlwaysNeverUserDecides.fromString(bundle.getString(key)) + override fun getFromPrefs(prefs: SharedPreferences) = AlwaysNeverUserDecides.fromString(prefs.getString(key, null)) } class ShowHideMDMSetting(key: String, localizedTitle: String) : MDMSetting(ShowHide.Show, key, localizedTitle) { - override fun getFromBundle(bundle: Bundle) = - ShowHide.fromString(bundle.getString(key)) + override fun getFromBundle(bundle: Bundle) = ShowHide.fromString(bundle.getString(key)) + override fun getFromPrefs(prefs: SharedPreferences) = ShowHide.fromString(prefs.getString(key, null)) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index 6b32007816..338b7a9dd2 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -4,9 +4,9 @@ package com.tailscale.ipn.ui.model import android.net.Uri +import java.util.UUID import kotlinx.serialization.Serializable import kotlinx.serialization.Transient -import java.util.UUID class Ipn { diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt index af36f21080..5c6ac0e5e4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt @@ -3,8 +3,8 @@ package com.tailscale.ipn.ui.model -import kotlinx.serialization.Serializable import java.net.URL +import kotlinx.serialization.Serializable class IpnState { @Serializable diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index 51cb9176a4..2e9be750e2 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -105,10 +105,15 @@ class Tailcfg { // isExitNode reproduces the Go logic in local.go peerStatusFromNode val isExitNode: Boolean = - AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false + (AllowedIPs?.contains("0.0.0.0/0") ?: false) && (AllowedIPs?.contains("::/0") ?: false) + // mullvad nodes are exit nodes with a mullvad.ts.net domain *or* Location Info. + // These checks are intentionally redundant to avoid false negatives. val isMullvadNode: Boolean - get() = Name.endsWith(".mullvad.ts.net.") + get() = + Name.endsWith(".mullvad.ts.net") || + ComputedName?.endsWith(".mullvad.ts.net") == true || + Hostinfo.Location != null val displayName: String get() = ComputedName ?: Name @@ -116,9 +121,9 @@ class Tailcfg { val exitNodeName: String get() { if (isMullvadNode && - Hostinfo.Location?.Country != null && - Hostinfo.Location?.City != null && - Hostinfo.Location?.CountryCode != null) { + Hostinfo.Location?.Country != null && + Hostinfo.Location?.City != null && + Hostinfo.Location?.CountryCode != null) { return "${Hostinfo.Location!!.CountryCode!!.flag()} ${Hostinfo.Location!!.Country!!}: ${Hostinfo.Location!!.City!!}" } return displayName diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt index 8386bcb289..17d86a92d7 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt @@ -56,7 +56,9 @@ class HealthNotifier( // When the client is Stopped, no warnings should get added, and any warnings added // previously should be removed. if (ipnState == Ipn.State.Stopped) { - TSLog.d(TAG, "Ignoring and dropping all pre-existing health messages in the Stopped state") + TSLog.d( + TAG, + "Ignoring and dropping all pre-existing health messages in the Stopped state") dropAllWarnings() return@collect } else { @@ -131,9 +133,8 @@ class HealthNotifier( /** * Sets the icon displayed to represent the overall health state. - * - * - If there are any high severity warnings, or warnings that affect internet connectivity, - * a warning icon is displayed. + * - If there are any high severity warnings, or warnings that affect internet connectivity, a + * warning icon is displayed. * - If there are any other kind of warnings, an info icon is displayed. * - If there are no warnings at all, no icon is set. */ @@ -171,8 +172,8 @@ class HealthNotifier( } /** - * Removes all warnings currently displayed, including any system notifications, and - * updates the icon (causing it to be set to null since the set of warnings is empty). + * Removes all warnings currently displayed, including any system notifications, and updates the + * icon (causing it to be set to null since the set of warnings is empty). */ private fun dropAllWarnings() { removeNotifications(this.currentWarnings.value) diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/AdvertisedRoutesHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/AdvertisedRoutesHelper.kt index d97da48045..418dd915aa 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/AdvertisedRoutesHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/AdvertisedRoutesHelper.kt @@ -6,19 +6,19 @@ package com.tailscale.ipn.ui.util import com.tailscale.ipn.ui.model.Ipn class AdvertisedRoutesHelper { - companion object { - fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean { - var v4 = false - var v6 = false - prefs.AdvertiseRoutes?.forEach { - if (it == "0.0.0.0/0") { - v4 = true - } - if (it == "::/0") { - v6 = true - } - } - return v4 && v6 + companion object { + fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean { + var v4 = false + var v6 = false + prefs.AdvertiseRoutes?.forEach { + if (it == "0.0.0.0/0") { + v4 = true } + if (it == "::/0") { + v6 = true + } + } + return v4 && v6 } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt b/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt index 865282f77b..54db4d9187 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt @@ -29,33 +29,36 @@ import com.tailscale.ipn.R @Composable fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) { - val isFocused = remember { mutableStateOf(false) } - val localClipboardManager = LocalClipboardManager.current - val interactionSource = remember { MutableInteractionSource() } + val isFocused = remember { mutableStateOf(false) } + val localClipboardManager = LocalClipboardManager.current + val interactionSource = remember { MutableInteractionSource() } - ListItem( - modifier = Modifier - .focusable(interactionSource = interactionSource) - .onFocusChanged { focusState -> isFocused.value = focusState.isFocused } - .clickable( - interactionSource = interactionSource, - indication = LocalIndication.current - ) { localClipboardManager.setText(AnnotatedString(value)) } - .background( - if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - else Color.Transparent - ), - overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } }, - headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) }, - supportingContent = subtitle?.let { - { Text(it, modifier = Modifier.padding(top = 8.dp), style = MaterialTheme.typography.bodyMedium) } - }, - trailingContent = { - Icon( - painterResource(R.drawable.clipboard), - contentDescription = stringResource(R.string.copy_to_clipboard), - modifier = Modifier.size(24.dp) - ) - } - ) -} \ No newline at end of file + ListItem( + modifier = + Modifier.focusable(interactionSource = interactionSource) + .onFocusChanged { focusState -> isFocused.value = focusState.isFocused } + .clickable( + interactionSource = interactionSource, indication = LocalIndication.current) { + localClipboardManager.setText(AnnotatedString(value)) + } + .background( + if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + else Color.Transparent), + overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } }, + headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) }, + supportingContent = + subtitle?.let { + { + Text( + it, + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.bodyMedium) + } + }, + trailingContent = { + Icon( + painterResource(R.drawable.clipboard), + contentDescription = stringResource(R.string.copy_to_clipboard), + modifier = Modifier.size(24.dp)) + }) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/ConnectionMode.kt b/android/src/main/java/com/tailscale/ipn/ui/util/ConnectionMode.kt index 870541df00..eb36ea1721 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/ConnectionMode.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/ConnectionMode.kt @@ -11,43 +11,43 @@ import com.tailscale.ipn.R import com.tailscale.ipn.ui.theme.on sealed class ConnectionMode { - class NotConnected : ConnectionMode() + class NotConnected : ConnectionMode() - class Derp(val relayName: String) : ConnectionMode() + class Derp(val relayName: String) : ConnectionMode() - class Direct : ConnectionMode() + class Direct : ConnectionMode() - @Composable - fun titleString(): String { - return when (this) { - is NotConnected -> stringResource(id = R.string.not_connected) - is Derp -> stringResource(R.string.relayed_connection, relayName) - is Direct -> stringResource(R.string.direct_connection) - } + @Composable + fun titleString(): String { + return when (this) { + is NotConnected -> stringResource(id = R.string.not_connected) + is Derp -> stringResource(R.string.relayed_connection, relayName) + is Direct -> stringResource(R.string.direct_connection) } + } - fun contentKey(): String { - return when (this) { - is NotConnected -> "NotConnected" - is Derp -> "Derp($relayName)" - is Direct -> "Direct" - } + fun contentKey(): String { + return when (this) { + is NotConnected -> "NotConnected" + is Derp -> "Derp($relayName)" + is Direct -> "Direct" } + } - fun iconDrawable(): Int { - return when (this) { - is NotConnected -> R.drawable.xmark_circle - is Derp -> R.drawable.link_off - is Direct -> R.drawable.link - } + fun iconDrawable(): Int { + return when (this) { + is NotConnected -> R.drawable.xmark_circle + is Derp -> R.drawable.link_off + is Direct -> R.drawable.link } - - @Composable - fun color(): Color { - return when (this) { - is NotConnected -> MaterialTheme.colorScheme.onPrimary - is Derp -> MaterialTheme.colorScheme.error - is Direct -> MaterialTheme.colorScheme.on - } + } + + @Composable + fun color(): Color { + return when (this) { + is NotConnected -> MaterialTheme.colorScheme.onPrimary + is Derp -> MaterialTheme.colorScheme.error + is Direct -> MaterialTheme.colorScheme.on } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt b/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt index 696158c204..71415c0c3b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt @@ -47,34 +47,30 @@ object Lists { fontWeight: FontWeight? = null, focusable: Boolean = false, backgroundColor: Color = MaterialTheme.colorScheme.surface, - fontColor: Color? = null + fontColor: Color? = null ) { - Box( - modifier = Modifier - .fillMaxWidth() - .background(color = backgroundColor, shape = RectangleShape) - ) { + Box( + modifier = + Modifier.fillMaxWidth().background(color = backgroundColor, shape = RectangleShape)) { if (fontColor != null) { - Text( - text = title, - modifier = Modifier - .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) - .focusable(focusable), - style = style, - fontWeight = fontWeight, - color = fontColor - ) + Text( + text = title, + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) + .focusable(focusable), + style = style, + fontWeight = fontWeight, + color = fontColor) } else { - Text( - text = title, - modifier = Modifier - .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) - .focusable(focusable), - style = style, - fontWeight = fontWeight - ) + Text( + text = title, + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) + .focusable(focusable), + style = style, + fontWeight = fontWeight) } - } + } } @Composable diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt new file mode 100644 index 0000000000..9e73a42837 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt @@ -0,0 +1,26 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import com.tailscale.ipn.util.TSLog +import java.io.OutputStream + +// This class adapts a Java OutputStream to the libtailscale.OutputStream interface. +class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale.OutputStream { + // writes data to the outputStream in its entirety. Returns -1 on error. + override fun write(data: ByteArray): Long { + return try { + outputStream.write(data) + outputStream.flush() + data.size.toLong() + } catch (e: Exception) { + TSLog.d("OutputStreamAdapter", "write exception: $e") + -1L + } + } + + override fun close() { + outputStream.close() + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt index 1d59d913d3..54adf27480 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt @@ -51,7 +51,7 @@ fun Avatar( modifier = Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) }) .conditional( - AndroidTVUtil.isAndroidTV() && isFocusable, + AndroidTVUtil.isAndroidTV() && isFocusable, { size((size * 1.5f).dp) // Focusable area is larger than the avatar }) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt b/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt index 82d2e37d2e..83ae0b8cc3 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ListItem @@ -26,6 +27,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import com.tailscale.ipn.R @@ -140,11 +142,14 @@ fun LoginView( placeholder = { Text(strings.placeholder, style = MaterialTheme.typography.bodySmall) }, - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None) - ) + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Go), + keyboardActions = + KeyboardActions(onGo = { onSubmitAction(textVal) })) }) - ListItem( + ListItem( colors = MaterialTheme.colorScheme.listItem, headlineContent = { Box(modifier = Modifier.fillMaxWidth()) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/EditSubnetRouteDialogView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/EditSubnetRouteDialogView.kt index dfd7629f61..93e5600090 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/EditSubnetRouteDialogView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/EditSubnetRouteDialogView.kt @@ -9,12 +9,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -36,7 +32,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow /** - * EditSubnetRouteDialogView is the content of the dialog that allows the user to add or edit a subnet route. + * EditSubnetRouteDialogView is the content of the dialog that allows the user to add or edit a + * subnet route. */ @Composable fun EditSubnetRouteDialogView( @@ -46,59 +43,52 @@ fun EditSubnetRouteDialogView( onCommit: (String) -> Unit, onCancel: () -> Unit ) { - val value by valueFlow.collectAsState() - val isValueValid by isValueValidFlow.collectAsState() - val focusRequester = remember { FocusRequester() } + val value by valueFlow.collectAsState() + val isValueValid by isValueValidFlow.collectAsState() + val focusRequester = remember { FocusRequester() } - Column( - modifier = Modifier.padding(16.dp), - ) { - Text(text = stringResource(R.string.enter_valid_route)) + Column( + modifier = Modifier.padding(16.dp), + ) { + Text(text = stringResource(R.string.enter_valid_route)) - Text( - text = stringResource(R.string.route_help_text), - color = MaterialTheme.colorScheme.secondary, - fontSize = MaterialTheme.typography.bodySmall.fontSize - ) + Text( + text = stringResource(R.string.route_help_text), + color = MaterialTheme.colorScheme.secondary, + fontSize = MaterialTheme.typography.bodySmall.fontSize) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - TextField( - value = value, - onValueChange = { onValueChange(it) }, - singleLine = true, - isError = !isValueValid, - modifier = Modifier.focusRequester(focusRequester) - ) + TextField( + value = value, + onValueChange = { onValueChange(it) }, + singleLine = true, + isError = !isValueValid, + modifier = Modifier.focusRequester(focusRequester)) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.align(Alignment.End) - ) { - Button(colors = ButtonDefaults.outlinedButtonColors(), onClick = { - onCancel() - }) { - Text(stringResource(R.string.cancel)) - } + Row(modifier = Modifier.align(Alignment.End)) { + Button(colors = ButtonDefaults.outlinedButtonColors(), onClick = { onCancel() }) { + Text(stringResource(R.string.cancel)) + } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(8.dp)) - Button(onClick = { - onCommit(value) - }, enabled = value.isNotEmpty() && isValueValid) { - Text(stringResource(R.string.ok)) - } - } + Button(onClick = { onCommit(value) }, enabled = value.isNotEmpty() && isValueValid) { + Text(stringResource(R.string.ok)) + } } + } - // When the dialog is opened, focus on the text field to present the keyboard auto-magically. - val windowInfo = LocalWindowInfo.current - LaunchedEffect(windowInfo) { - snapshotFlow { windowInfo.isWindowFocused }.collect { isWindowFocused -> - if (isWindowFocused) { - focusRequester.requestFocus() - } + // When the dialog is opened, focus on the text field to present the keyboard auto-magically. + val windowInfo = LocalWindowInfo.current + LaunchedEffect(windowInfo) { + snapshotFlow { windowInfo.isWindowFocused } + .collect { isWindowFocused -> + if (isWindowFocused) { + focusRequester.requestFocus() + } } - } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt index c79d5bf33a..f57a71e2f5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.tooling.preview.Preview import com.tailscale.ipn.R import com.tailscale.ipn.ui.theme.AppTheme - enum class ErrorDialogType { INVALID_CUSTOM_URL, LOGOUT_FAILED, @@ -54,11 +53,10 @@ enum class ErrorDialogType { @Composable fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) { ErrorDialog( - title = type.title, - message = stringResource(id = type.message), - buttonText = type.buttonText, - onDismiss = action - ) + title = type.title, + message = stringResource(id = type.message), + buttonText = type.buttonText, + onDismiss = action) } @Composable @@ -69,11 +67,10 @@ fun ErrorDialog( onDismiss: () -> Unit = {} ) { ErrorDialog( - title = title, - message = stringResource(id = message), - buttonText = buttonText, - onDismiss = onDismiss - ) + title = title, + message = stringResource(id = message), + buttonText = buttonText, + onDismiss = onDismiss) } @Composable @@ -83,15 +80,15 @@ fun ErrorDialog( @StringRes buttonText: Int = R.string.ok, onDismiss: () -> Unit = {} ) { - AppTheme { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = stringResource(id = title)) }, - text = { Text(text = message) }, - confirmButton = { - PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) } - }) - } + AppTheme { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(id = title)) }, + text = { Text(text = message) }, + confirmButton = { + PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) } + }) + } } @Preview diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt index 0395e2ebdb..827925b40e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -3,6 +3,7 @@ package com.tailscale.ipn.ui.view +import android.os.Build import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -36,14 +37,13 @@ import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory import com.tailscale.ipn.ui.viewModel.selected import kotlinx.coroutines.flow.MutableStateFlow -import android.os.Build @Composable fun ExitNodePicker( nav: ExitNodePickerNav, model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav)) ) { - LoadingIndicator.Wrap { + LoadingIndicator.Wrap { Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBackHome) }) { innerPadding -> val tailnetExitNodes by model.tailnetExitNodes.collectAsState() @@ -101,7 +101,8 @@ fun ExitNodePicker( } // https://developer.android.com/reference/android/net/VpnService.Builder#excludeRoute(android.net.IpPrefix) - excludeRoute is only supported in API 33+, so don't show the option if allow LAN access is not enabled. - if (!allowLanAccessMDMDisposition.value.hiddenFromUser && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (!allowLanAccessMDMDisposition.value.hiddenFromUser && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { item(key = "allowLANAccess") { Lists.SectionDivider() diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt index 022e47144e..d79ad27d9b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -57,9 +56,7 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel( textAlign = TextAlign.Center) Box( - modifier = - Modifier.size(200.dp) - .background(MaterialTheme.colorScheme.onSurface), + modifier = Modifier.size(200.dp).background(MaterialTheme.colorScheme.onSurface), contentAlignment = Alignment.Center) { image?.let { Image( @@ -76,12 +73,11 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel( numCode?.let { Box( modifier = - Modifier - .clip(RoundedCornerShape(6.dp)) + Modifier.clip(RoundedCornerShape(6.dp)) .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), contentAlignment = Alignment.Center) { Text( - text =it, + text = it, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.onSurface) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 90e99e6c8e..96b0491f4b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -70,6 +70,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.tailscale.ipn.App import com.tailscale.ipn.R @@ -94,6 +97,7 @@ import com.tailscale.ipn.ui.theme.short import com.tailscale.ipn.ui.theme.surfaceContainerListItem import com.tailscale.ipn.ui.theme.warningButton import com.tailscale.ipn.ui.theme.warningListItem +import com.tailscale.ipn.ui.util.AndroidTVUtil import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV import com.tailscale.ipn.ui.util.AutoResizingText import com.tailscale.ipn.ui.util.Lists @@ -207,8 +211,13 @@ fun MainView( Ipn.State.Running -> { PromptPermissionsIfNecessary() - - viewModel.showVPNPermissionLauncherIfUnauthorized() + viewModel.maybeRequestVpnPermission() + LaunchVpnPermissionIfNeeded(viewModel) + LaunchedEffect(state) { + if (state == Ipn.State.Running && !AndroidTVUtil.isAndroidTV()) { + viewModel.showDirectoryPickerLauncher() + } + } if (showKeyExpiry) { ExpiryNotification(netmap = netmap, action = { viewModel.login() }) @@ -239,7 +248,9 @@ fun MainView( { viewModel.login() }, loginAtUrl, netmap?.SelfNode, - { viewModel.showVPNPermissionLauncherIfUnauthorized() }) + { + viewModel.showVPNPermissionLauncherIfUnauthorized() + }) } } } @@ -253,6 +264,21 @@ fun MainView( } } +@Composable +fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { + val lifecycleOwner = LocalLifecycleOwner.current + val shouldRequest by viewModel.requestVpnPermission.collectAsState() + + LaunchedEffect(shouldRequest) { + if (!shouldRequest) return@LaunchedEffect + + // Defer showing permission launcher until activity is resumed to avoid silent RESULT_CANCELED + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.showVPNPermissionLauncherIfUnauthorized() + } + } +} + @Composable fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { val nodeState by viewModel.nodeState.collectAsState() @@ -415,11 +441,11 @@ fun ConnectView( loginAction: () -> Unit, loginAtUrlAction: (String) -> Unit, selfNode: Tailcfg.Node?, - showVPNPermissionLauncherIfUnauthorized: () -> Unit + showVPNPermissionLauncher: () -> Unit ) { LaunchedEffect(isPrepared) { if (!isPrepared && shouldStartAutomatically) { - showVPNPermissionLauncherIfUnauthorized() + showVPNPermissionLauncher() } } Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt index 3b192a1ccd..c3d5349e4c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt @@ -45,9 +45,7 @@ fun MullvadExitNodePickerList( LazyColumn(modifier = Modifier.padding(innerPadding)) { val sortedCountries = - mullvadExitNodes.entries.toList().sortedBy { - it.value.first().country.lowercase() - } + mullvadExitNodes.entries.toList().sortedBy { it.value.first().country.lowercase() } itemsWithDividers(sortedCountries) { (countryCode, nodes) -> val first = nodes.first() diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt index 01801b9e53..1490e2bb17 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt @@ -47,7 +47,7 @@ import com.tailscale.ipn.ui.viewModel.PingViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun PeerDetails( - onNavigateBack: () -> Unit, + onNavigateBack: () -> Unit, nodeId: String, pingViewModel: PingViewModel, model: PeerDetailsViewModel = diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt index 399c7c04c6..3cc69fbc0b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt @@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.view import android.app.Activity import android.os.Build -import android.util.Log import android.window.OnBackInvokedCallback import android.window.OnBackInvokedDispatcher import androidx.annotation.RequiresApi diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index 52609f7932..e29e9882af 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -33,15 +33,14 @@ import com.tailscale.ipn.mdm.ShowHide import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.theme.link import com.tailscale.ipn.ui.theme.listItem +import com.tailscale.ipn.ui.util.AndroidTVUtil import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV +import com.tailscale.ipn.ui.util.AppVersion import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModel -import com.tailscale.ipn.ui.notifier.Notifier -import com.tailscale.ipn.ui.util.AndroidTVUtil -import com.tailscale.ipn.ui.util.AppVersion @Composable fun SettingsView( @@ -49,180 +48,176 @@ fun SettingsView( viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel() ) { - val handler = LocalUriHandler.current - - val user by viewModel.loggedInUser.collectAsState() - val isAdmin by viewModel.isAdmin.collectAsState() - val managedByOrganization by viewModel.managedByOrganization.collectAsState() - val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState() - val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState() - val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState() - val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState() - val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState() - - Scaffold(topBar = { + val handler = LocalUriHandler.current + + val user by viewModel.loggedInUser.collectAsState() + val isAdmin by viewModel.isAdmin.collectAsState() + val managedByOrganization by viewModel.managedByOrganization.collectAsState() + val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState() + val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState() + val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState() + val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState() + val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState() + + Scaffold( + topBar = { Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome) - }) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .verticalScroll(rememberScrollState()) - ) { - if (isVPNPrepared) { - UserView( - profile = user, - actionState = UserActionState.NAV, - onClick = settingsNav.onNavigateToUserSwitcher - ) - } - - if (isAdmin && !isAndroidTV()) { - Lists.ItemDivider() - AdminTextView { handler.openUri(Links.ADMIN_URL) } - } - - Lists.SectionDivider() - Setting.Text( - R.string.dns_settings, subtitle = corpDNSEnabled?.let { + }) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) { + if (isVPNPrepared) { + UserView( + profile = user, + actionState = UserActionState.NAV, + onClick = settingsNav.onNavigateToUserSwitcher) + } + + if (isAdmin && !isAndroidTV()) { + Lists.ItemDivider() + AdminTextView { handler.openUri(Links.ADMIN_URL) } + } + + Lists.SectionDivider() + Setting.Text( + R.string.dns_settings, + subtitle = + corpDNSEnabled?.let { stringResource( - if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns - ) - }, onClick = settingsNav.onNavigateToDNSSettings - ) + if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns) + }, + onClick = settingsNav.onNavigateToDNSSettings) + + Lists.ItemDivider() + Setting.Text( + R.string.split_tunneling, + subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), + onClick = settingsNav.onNavigateToSplitTunneling) + if (showTailnetLock.value == ShowHide.Show) { Lists.ItemDivider() Setting.Text( - R.string.split_tunneling, - subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), - onClick = settingsNav.onNavigateToSplitTunneling - ) - - if (showTailnetLock.value == ShowHide.Show) { - Lists.ItemDivider() - Setting.Text( - R.string.tailnet_lock, subtitle = tailnetLockEnabled?.let { - stringResource(if (it) R.string.enabled else R.string.disabled) - }, onClick = settingsNav.onNavigateToTailnetLock - ) - } - if (useTailscaleSubnets.value == AlwaysNeverUserDecides.UserDecides) { - Lists.ItemDivider() - Setting.Text( - R.string.subnet_routing, - onClick = settingsNav.onNavigateToSubnetRouting - ) - } - if (!AndroidTVUtil.isAndroidTV()) { - Lists.ItemDivider() - Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) - } - - managedByOrganization.value?.let { - Lists.ItemDivider() - Setting.Text( - title = stringResource(R.string.managed_by_orgName, it), - onClick = settingsNav.onNavigateToManagedBy - ) - } - - Lists.SectionDivider() - Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport) + R.string.tailnet_lock, + subtitle = + tailnetLockEnabled?.let { + stringResource(if (it) R.string.enabled else R.string.disabled) + }, + onClick = settingsNav.onNavigateToTailnetLock) + } + if (useTailscaleSubnets.value == AlwaysNeverUserDecides.UserDecides) { + Lists.ItemDivider() + Setting.Text(R.string.subnet_routing, onClick = settingsNav.onNavigateToSubnetRouting) + } + if (!AndroidTVUtil.isAndroidTV()) { + Lists.ItemDivider() + Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) + } + managedByOrganization.value?.let { Lists.ItemDivider() Setting.Text( - R.string.about_tailscale, - subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}", - onClick = settingsNav.onNavigateToAbout - ) - - // TODO: put a heading for the debug section - if (BuildConfig.DEBUG) { - Lists.SectionDivider() - Lists.MutedHeader(text = stringResource(R.string.internal_debug_options)) - Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings) - } + title = stringResource(R.string.managed_by_orgName, it), + onClick = settingsNav.onNavigateToManagedBy) + } + + Lists.SectionDivider() + Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport) + + Lists.ItemDivider() + Setting.Text( + R.string.about_tailscale, + subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}", + onClick = settingsNav.onNavigateToAbout) + + // TODO: put a heading for the debug section + if (BuildConfig.DEBUG) { + Lists.SectionDivider() + Lists.MutedHeader(text = stringResource(R.string.internal_debug_options)) + Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings) + } } - } + } } object Setting { - @Composable - fun Text( - titleRes: Int = 0, - title: String? = null, - subtitle: String? = null, - destructive: Boolean = false, - enabled: Boolean = true, - onClick: (() -> Unit)? = null - ) { - var modifier: Modifier = Modifier - if (enabled) { - onClick?.let { modifier = modifier.clickable(onClick = it) } - } - ListItem(modifier = modifier, - colors = MaterialTheme.colorScheme.listItem, - headlineContent = { + @Composable + fun Text( + titleRes: Int = 0, + title: String? = null, + subtitle: String? = null, + destructive: Boolean = false, + enabled: Boolean = true, + onClick: (() -> Unit)? = null + ) { + var modifier: Modifier = Modifier + if (enabled) { + onClick?.let { modifier = modifier.clickable(onClick = it) } + } + ListItem( + modifier = modifier, + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { + Text( + title ?: stringResource(titleRes), + style = MaterialTheme.typography.bodyMedium, + color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified) + }, + supportingContent = + subtitle?.let { + { Text( - title ?: stringResource(titleRes), - style = MaterialTheme.typography.bodyMedium, - color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified - ) - }, - supportingContent = subtitle?.let { - { - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } }) - } - - @Composable - fun Switch( - titleRes: Int = 0, - title: String? = null, - isOn: Boolean, - enabled: Boolean = true, - onToggle: (Boolean) -> Unit = {} - ) { - ListItem(colors = MaterialTheme.colorScheme.listItem, headlineContent = { - Text( - title ?: stringResource(titleRes), - style = MaterialTheme.typography.bodyMedium, - ) - }, trailingContent = { - TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled) + } + + @Composable + fun Switch( + titleRes: Int = 0, + title: String? = null, + isOn: Boolean, + enabled: Boolean = true, + onToggle: (Boolean) -> Unit = {} + ) { + ListItem( + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { + Text( + title ?: stringResource(titleRes), + style = MaterialTheme.typography.bodyMedium, + ) + }, + trailingContent = { + TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled) }) - } + } } @Composable fun AdminTextView(onNavigateToAdminConsole: () -> Unit) { - val adminStr = buildAnnotatedString { - append(stringResource(id = R.string.settings_admin_prefix)) - - pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL) - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.link, textDecoration = TextDecoration.Underline - ) - ) { - append(stringResource(id = R.string.settings_admin_link)) + val adminStr = buildAnnotatedString { + append(stringResource(id = R.string.settings_admin_prefix)) + + pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL) + withStyle( + style = + SpanStyle( + color = MaterialTheme.colorScheme.link, + textDecoration = TextDecoration.Underline)) { + append(stringResource(id = R.string.settings_admin_link)) } - } + } - Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole) + Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole) } @Preview @Composable fun SettingsPreview() { - val vm = SettingsViewModel() - vm.corpDNSEnabled.set(true) - vm.tailNetLockEnabled.set(true) - vm.isAdmin.set(true) - vm.managedByOrganization.set("Tails and Scales Inc.") - SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm) + val vm = SettingsViewModel() + vm.corpDNSEnabled.set(true) + vm.tailNetLockEnabled.set(true) + vm.isAdmin.set(true) + vm.managedByOrganization.set("Tails and Scales Inc.") + SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRouteRowView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRouteRowView.kt index b6e797f784..0b26e905cb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRouteRowView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRouteRowView.kt @@ -19,8 +19,8 @@ import androidx.compose.ui.unit.dp import com.tailscale.ipn.R /** - * SubnetRouteRowView is a row in RunSubnetRouterView, representing a subnet route. - * It provides options to edit or delete the route. + * SubnetRouteRowView is a row in RunSubnetRouterView, representing a subnet route. It provides + * options to edit or delete the route. * * @param route The subnet route itself (e.g., "192.168.1.0/24"). * @param onEdit A callback invoked when the edit icon is clicked. @@ -28,31 +28,32 @@ import com.tailscale.ipn.R */ @Composable fun SubnetRouteRowView( - route: String, onEdit: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier + route: String, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier ) { - ListItem( - headlineContent = { Text(text = route, style = MaterialTheme.typography.bodyMedium) }, - trailingContent = { - Row { - IconButton(onClick = onEdit) { - Icon( - painterResource(R.drawable.pencil), - contentDescription = stringResource(R.string.edit_route), - modifier = Modifier.size(24.dp) - ) - } - IconButton( - onClick = onDelete, - colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { - Icon( - painterResource(R.drawable.xmark), - contentDescription = stringResource(R.string.delete_route), - modifier = Modifier.size(24.dp) - ) - } - } - }, - modifier = modifier - ) -} \ No newline at end of file + ListItem( + headlineContent = { Text(text = route, style = MaterialTheme.typography.bodyMedium) }, + trailingContent = { + Row { + IconButton(onClick = onEdit) { + Icon( + painterResource(R.drawable.pencil), + contentDescription = stringResource(R.string.edit_route), + modifier = Modifier.size(24.dp)) + } + IconButton( + onClick = onDelete, + colors = + IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.error)) { + Icon( + painterResource(R.drawable.xmark), + contentDescription = stringResource(R.string.delete_route), + modifier = Modifier.size(24.dp)) + } + } + }, + modifier = modifier) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt index bf3cb7944e..e8e851aa1c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt @@ -22,7 +22,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -63,12 +62,9 @@ fun TailnetLockSetupView( val interactionSource = remember { MutableInteractionSource() } ListItem( modifier = - Modifier.focusable( - interactionSource = interactionSource) - .clickable( - interactionSource = interactionSource, - indication = LocalIndication.current - ) {}, + Modifier.focusable(interactionSource = interactionSource).clickable( + interactionSource = interactionSource, + indication = LocalIndication.current) {}, leadingContent = { Icon( painter = painterResource(id = statusItem.icon), diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt index bffc6024e2..b9b5c23793 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt @@ -19,9 +19,9 @@ import com.tailscale.ipn.ui.theme.onBackgroundLogoDotEnabled import com.tailscale.ipn.ui.theme.standaloneLogoDotDisabled import com.tailscale.ipn.ui.theme.standaloneLogoDotEnabled import com.tailscale.ipn.ui.util.set +import kotlin.concurrent.timer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlin.concurrent.timer // DotsMatrix represents the state of the progress indicator. typealias DotsMatrix = List> diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt index 5ba489af8a..7c5d3c4600 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt @@ -18,12 +18,12 @@ import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.theme.off import com.tailscale.ipn.ui.theme.success import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import com.tailscale.ipn.util.TSLog class DNSSettingsViewModelFactory : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt index c24b4e59de..360d30d5c9 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt @@ -12,12 +12,12 @@ import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.set +import java.util.TreeMap import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import java.util.TreeMap data class ExitNodePickerNav( val onNavigateBackHome: () -> Unit, diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 8ff53531a9..7190764759 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -4,6 +4,7 @@ package com.tailscale.ipn.ui.viewModel import android.content.Intent +import android.net.Uri import android.net.VpnService import androidx.activity.result.ActivityResultLauncher import androidx.compose.runtime.getValue @@ -11,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.text.AnnotatedString +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -25,6 +27,7 @@ import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -60,6 +63,11 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { // Permission to prepare VPN private var vpnPermissionLauncher: ActivityResultLauncher? = null + private val _requestVpnPermission = MutableStateFlow(false) + val requestVpnPermission: StateFlow = _requestVpnPermission + + // Select Taildrop directory + private var directoryPickerLauncher: ActivityResultLauncher? = null // The list of peers private val _peers = MutableStateFlow>(emptyList()) @@ -187,6 +195,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } + fun maybeRequestVpnPermission() { + _requestVpnPermission.value = true + } + fun showVPNPermissionLauncherIfUnauthorized() { val vpnIntent = VpnService.prepare(App.get()) if (vpnIntent != null) { @@ -195,6 +207,27 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { vpnViewModel.setVpnPrepared(true) startVPN() } + _requestVpnPermission.value = false // reset + } + + fun showDirectoryPickerLauncher() { + val app = App.get() + val storedUri = app.getStoredDirectoryUri() + if (storedUri == null) { + // No stored URI, so launch the directory picker. + directoryPickerLauncher?.launch(null) + 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.") + directoryPickerLauncher?.launch(null) + } else { + TSLog.d("MainViewModel", "Using stored directory URI: $storedUri") + } } fun toggleVpn(desiredState: Boolean) { @@ -204,6 +237,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } viewModelScope.launch { + showDirectoryPickerLauncher() isToggleInProgress.value = true try { val currentState = Notifier.state.value @@ -243,6 +277,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { // No intent means we're already authorized vpnPermissionLauncher = launcher } + + fun setDirectoryPickerLauncher(launcher: ActivityResultLauncher) { + directoryPickerLauncher = launcher + } } private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt index e46eb1c438..b0531c13f7 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt @@ -11,10 +11,10 @@ import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.util.ComposableStringFormatter import com.tailscale.ipn.ui.util.set +import java.io.File import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import java.io.File data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter) diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt index d00efb6759..7611f0516e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt @@ -4,14 +4,18 @@ package com.tailscale.ipn.ui.viewModel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.tailscale.ipn.App import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.SettingState import com.tailscale.ipn.ui.util.InstalledApp import com.tailscale.ipn.ui.util.InstalledAppsManager import com.tailscale.ipn.ui.util.set +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch class SplitTunnelAppPickerViewModel : ViewModel() { val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) @@ -20,6 +24,8 @@ class SplitTunnelAppPickerViewModel : ViewModel() { val mdmExcludedPackages: StateFlow> = MDMSettings.excludedPackages.flow val mdmIncludedPackages: StateFlow> = MDMSettings.includedPackages.flow + private var saveJob: Job? = null + init { installedApps.set(installedAppsManager.fetchInstalledApps()) excludedPackageNames.set( @@ -30,15 +36,22 @@ class SplitTunnelAppPickerViewModel : ViewModel() { } fun exclude(packageName: String) { - if (excludedPackageNames.value.contains(packageName)) { - return - } + if (excludedPackageNames.value.contains(packageName)) return excludedPackageNames.set(excludedPackageNames.value + packageName) - App.get().addUserDisallowedPackageName(packageName) + debounceSave() } fun unexclude(packageName: String) { excludedPackageNames.set(excludedPackageNames.value - packageName) - App.get().removeUserDisallowedPackageName(packageName) + debounceSave() + } + + private fun debounceSave() { + saveJob?.cancel() + saveJob = + viewModelScope.launch { + delay(500) // Wait to batch multiple rapid updates + App.get().updateUserDisallowedPackageNames(excludedPackageNames.value) + } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SubnetRoutingViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SubnetRoutingViewModel.kt index 0bee1bbf2e..a7b1853b00 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SubnetRoutingViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SubnetRoutingViewModel.kt @@ -19,251 +19,243 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch /** - * SubnetRoutingViewModel is responsible for managing the content of the subnet router management view. - * This class watches the backend preferences and updates the UI accordingly whenever the advertised routes - * change. It also handles the state of the editing dialog, and updates the preferences stored in - * the backend when the routes are edited in the UI. + * SubnetRoutingViewModel is responsible for managing the content of the subnet router management + * view. This class watches the backend preferences and updates the UI accordingly whenever the + * advertised routes change. It also handles the state of the editing dialog, and updates the + * preferences stored in the backend when the routes are edited in the UI. */ class SubnetRoutingViewModel : ViewModel() { - private val TAG = "SubnetRoutingViewModel" + private val TAG = "SubnetRoutingViewModel" - /** - * Matches the value of the "RouteAll" backend preference. - */ - val routeAll: StateFlow = MutableStateFlow(true) + /** Matches the value of the "RouteAll" backend preference. */ + val routeAll: StateFlow = MutableStateFlow(true) - /** - * The advertised routes displayed at any point in time in the UI. The class observes - * this value for changes, and updates the backend preferences accordingly. - */ - val advertisedRoutes: StateFlow> = MutableStateFlow(listOf()) + /** + * The advertised routes displayed at any point in time in the UI. The class observes this value + * for changes, and updates the backend preferences accordingly. + */ + val advertisedRoutes: StateFlow> = MutableStateFlow(listOf()) - /** - * Whether we are presenting the add/edit dialog to set/change the value of a route. - */ - val isPresentingDialog: StateFlow = MutableStateFlow(false) + /** Whether we are presenting the add/edit dialog to set/change the value of a route. */ + val isPresentingDialog: StateFlow = MutableStateFlow(false) - /** - * When editing a route, this stores the initial value. It is used to determine which - * of the previously existing routes needs to be updated. This starts as empty, and dismissing - * the edit dialog should reset it to empty as well. - * If the user is adding a new route, this will be empty despite isPresentingDialog being true. - */ - private val editingRoute: StateFlow = MutableStateFlow("") + /** + * When editing a route, this stores the initial value. It is used to determine which of the + * previously existing routes needs to be updated. This starts as empty, and dismissing the edit + * dialog should reset it to empty as well. If the user is adding a new route, this will be empty + * despite isPresentingDialog being true. + */ + private val editingRoute: StateFlow = MutableStateFlow("") - /** - * The value currently entered in the add/edit dialog text field. - */ - val dialogTextFieldValue: MutableStateFlow = MutableStateFlow("") + /** The value currently entered in the add/edit dialog text field. */ + val dialogTextFieldValue: MutableStateFlow = MutableStateFlow("") - /** - * True if the value currently entered in the dialog text field is valid, false otherwise. - * If the text field is empty, this returns true as we don't want to display an error state - * when the user hasn't entered anything. - */ - val isTextFieldValueValid: StateFlow = MutableStateFlow(true) + /** + * True if the value currently entered in the dialog text field is valid, false otherwise. If the + * text field is empty, this returns true as we don't want to display an error state when the user + * hasn't entered anything. + */ + val isTextFieldValueValid: StateFlow = MutableStateFlow(true) - /** - * If an error occurred while saving the ipn.Prefs to the backend this value is - * non-null. Subsequent successful attempts to save will clear it. - */ - val currentError: MutableStateFlow = MutableStateFlow(null) + /** + * If an error occurred while saving the ipn.Prefs to the backend this value is non-null. + * Subsequent successful attempts to save will clear it. + */ + val currentError: MutableStateFlow = MutableStateFlow(null) - init { - viewModelScope.launch { - // Any time the value entered by the user in the add/edit dialog changes, we determine - // whether it is valid or invalid, and set isTextFieldValueValid accordingly. - dialogTextFieldValue - .collect { newValue -> - if (newValue.isEmpty()) { - isTextFieldValueValid.set(true) - return@collect - } - val isValid = isValidCIDR(newValue) - Log.v(TAG, "isValidCIDR($newValue): $isValid") - isTextFieldValueValid.set(isValid) - return@collect - } + init { + viewModelScope.launch { + // Any time the value entered by the user in the add/edit dialog changes, we determine + // whether it is valid or invalid, and set isTextFieldValueValid accordingly. + dialogTextFieldValue.collect { newValue -> + if (newValue.isEmpty()) { + isTextFieldValueValid.set(true) + return@collect } + val isValid = isValidCIDR(newValue) + Log.v(TAG, "isValidCIDR($newValue): $isValid") + isTextFieldValueValid.set(isValid) + return@collect + } + } - viewModelScope.launch { - // Similarly, if the routes change in the backend at any time, we should also reflect - // that change in the UI. - Notifier.prefs - // Ignore any prefs updates without AdvertiseRoutes - .mapNotNull { it?.AdvertiseRoutes } - // Ignore duplicate values to prevent an unnecessary UI update - .distinctUntilChanged() - // Ignore any value that matches the current value in UI, - // to prevent an unnecessary UI update - .filter { it != advertisedRoutes }.collect { newRoutesFromBackend -> - Log.d( - TAG, "AdvertiseRoutes changed in the backend: $newRoutesFromBackend" - ) - advertisedRoutes.set(newRoutesFromBackend) - } - } + viewModelScope.launch { + // Similarly, if the routes change in the backend at any time, we should also reflect + // that change in the UI. + Notifier.prefs + // Ignore any prefs updates without AdvertiseRoutes + .mapNotNull { it?.AdvertiseRoutes } + // Ignore duplicate values to prevent an unnecessary UI update + .distinctUntilChanged() + // Ignore any value that matches the current value in UI, + // to prevent an unnecessary UI update + .filter { it != advertisedRoutes } + .collect { newRoutesFromBackend -> + Log.d(TAG, "AdvertiseRoutes changed in the backend: $newRoutesFromBackend") + advertisedRoutes.set(newRoutesFromBackend) + } + } - viewModelScope.launch { - Notifier.prefs.map { it?.RouteAll }.distinctUntilChanged().collect { - Log.d(TAG, "RouteAll changed in the backend: $it") - routeAll.set(it) - } - } + viewModelScope.launch { + Notifier.prefs + .map { it?.RouteAll } + .distinctUntilChanged() + .collect { + Log.d(TAG, "RouteAll changed in the backend: $it") + routeAll.set(it) + } + } - viewModelScope.launch { - routeAll.collect { - val prefsOut = Ipn.MaskedPrefs() - prefsOut.RouteAll = it - Log.d(TAG, "Will save RouteAll in the backend: $it") - Client(viewModelScope).editPrefs(prefsOut, responseHandler = { result -> - if (result.isFailure) { - Log.e(TAG, "Error saving RouteAll: ${result.exceptionOrNull()}") - currentError.set(result.exceptionOrNull()?.localizedMessage) - return@editPrefs - } else { - Log.d( - TAG, - "RouteAll set in backend. New value: ${result.getOrNull()?.RouteAll}" - ) - currentError.set(null) - } + viewModelScope.launch { + routeAll.collect { + val prefsOut = Ipn.MaskedPrefs() + prefsOut.RouteAll = it + Log.d(TAG, "Will save RouteAll in the backend: $it") + Client(viewModelScope) + .editPrefs( + prefsOut, + responseHandler = { result -> + if (result.isFailure) { + Log.e(TAG, "Error saving RouteAll: ${result.exceptionOrNull()}") + currentError.set(result.exceptionOrNull()?.localizedMessage) + return@editPrefs + } else { + Log.d( + TAG, "RouteAll set in backend. New value: ${result.getOrNull()?.RouteAll}") + currentError.set(null) + } }) - } - } + } } + } - // Public functions + // Public functions - fun toggleUseSubnets(onDone: () -> Unit) { - routeAll.set(!routeAll.value) - onDone() - } + fun toggleUseSubnets(onDone: () -> Unit) { + routeAll.set(!routeAll.value) + onDone() + } - /** - * Deletes the given subnet route from the list of advertised routes. - * Calling this function will cause the backend preferences to be updated in the background. - * - * @param route The route string to be deleted from the list of advertised routes. - * If the route does not exist in the list, no changes are made. - */ - fun deleteRoute(route: String) { - val currentRoutes = advertisedRoutes.value.toMutableList() - if (!currentRoutes.contains(route)) { - Log.e(TAG, "Attempted to delete route, but it does not exist: $route") - return - } - currentRoutes.remove(route) - advertisedRoutes.set(currentRoutes) - saveRoutesToPrefs() + /** + * Deletes the given subnet route from the list of advertised routes. Calling this function will + * cause the backend preferences to be updated in the background. + * + * @param route The route string to be deleted from the list of advertised routes. If the route + * does not exist in the list, no changes are made. + */ + fun deleteRoute(route: String) { + val currentRoutes = advertisedRoutes.value.toMutableList() + if (!currentRoutes.contains(route)) { + Log.e(TAG, "Attempted to delete route, but it does not exist: $route") + return } + currentRoutes.remove(route) + advertisedRoutes.set(currentRoutes) + saveRoutesToPrefs() + } - /** - * Starts editing the given subnet route. Called when the user taps the 'pencil' button - * on a route in the list. - */ - fun startEditingRoute(route: String) { - Log.d(TAG, "startEditingRoute: $route") - editingRoute.set(route) - dialogTextFieldValue.set(route) - isPresentingDialog.set(true) - } + /** + * Starts editing the given subnet route. Called when the user taps the 'pencil' button on a route + * in the list. + */ + fun startEditingRoute(route: String) { + Log.d(TAG, "startEditingRoute: $route") + editingRoute.set(route) + dialogTextFieldValue.set(route) + isPresentingDialog.set(true) + } - /** - * Commits the changes made so far in the editing dialog. - */ - fun doneEditingRoute(newValue: String) { - Log.d(TAG, "doneEditingRoute: $newValue") - editRoute(editingRoute.value, newValue) - stopEditingRoute() - } + /** Commits the changes made so far in the editing dialog. */ + fun doneEditingRoute(newValue: String) { + Log.d(TAG, "doneEditingRoute: $newValue") + editRoute(editingRoute.value, newValue) + stopEditingRoute() + } - /** - * Cancels any current editing session and closes the dialog. - */ - fun stopEditingRoute() { - Log.d(TAG, "stopEditingRoute") - isPresentingDialog.set(false) - dialogTextFieldValue.set("") - editingRoute.set("") - } + /** Cancels any current editing session and closes the dialog. */ + fun stopEditingRoute() { + Log.d(TAG, "stopEditingRoute") + isPresentingDialog.set(false) + dialogTextFieldValue.set("") + editingRoute.set("") + } - /** - * This makes the actual changes whenever adding or editing a route. - * If adding a new route, oldRoute will be empty. - * This function validates the input before making any changes. If newRoute - * is not a valid CIDR IPv4/IPv6 range, this function does nothing. - */ - private fun editRoute(oldRoute: String, newRoute: String) { - val currentRoutes = advertisedRoutes.value.toMutableList() - if (oldRoute == newRoute) { - Log.v(TAG, "Attempted to call editRoute with the same route: $newRoute") - return - } - if (currentRoutes.contains(newRoute)) { - Log.e(TAG, "Attempted to call editRoute with a duplicate route: $newRoute") - return - } - // Verify the newRoute is a valid IPv4 or IPv6 CIDR range. - val isValid = isValidCIDR(newRoute) - if (!isValid) { - Log.e(TAG, "Attempted to call editRoute with an invalid route: $newRoute") - return - } - val index = currentRoutes.indexOf(oldRoute) - if (index == -1) { - Log.v(TAG, "Adding new route: $newRoute") - currentRoutes.add(newRoute) - } else { - Log.v(TAG, "Updating route at index $index: $newRoute") - currentRoutes[index] = newRoute - } - advertisedRoutes.set(currentRoutes) - saveRoutesToPrefs() + /** + * This makes the actual changes whenever adding or editing a route. If adding a new route, + * oldRoute will be empty. This function validates the input before making any changes. If + * newRoute is not a valid CIDR IPv4/IPv6 range, this function does nothing. + */ + private fun editRoute(oldRoute: String, newRoute: String) { + val currentRoutes = advertisedRoutes.value.toMutableList() + if (oldRoute == newRoute) { + Log.v(TAG, "Attempted to call editRoute with the same route: $newRoute") + return + } + if (currentRoutes.contains(newRoute)) { + Log.e(TAG, "Attempted to call editRoute with a duplicate route: $newRoute") + return + } + // Verify the newRoute is a valid IPv4 or IPv6 CIDR range. + val isValid = isValidCIDR(newRoute) + if (!isValid) { + Log.e(TAG, "Attempted to call editRoute with an invalid route: $newRoute") + return } + val index = currentRoutes.indexOf(oldRoute) + if (index == -1) { + Log.v(TAG, "Adding new route: $newRoute") + currentRoutes.add(newRoute) + } else { + Log.v(TAG, "Updating route at index $index: $newRoute") + currentRoutes[index] = newRoute + } + advertisedRoutes.set(currentRoutes) + saveRoutesToPrefs() + } - private fun saveRoutesToPrefs() { - val prefsOut = Ipn.MaskedPrefs() - prefsOut.AdvertiseRoutes = advertisedRoutes.value - Log.d(TAG, "Will save AdvertiseRoutes in the backend: $(advertisedRoutes.value)") - Client(viewModelScope).editPrefs(prefsOut, responseHandler = { result -> - if (result.isFailure) { + private fun saveRoutesToPrefs() { + val prefsOut = Ipn.MaskedPrefs() + prefsOut.AdvertiseRoutes = advertisedRoutes.value + Log.d(TAG, "Will save AdvertiseRoutes in the backend: $(advertisedRoutes.value)") + Client(viewModelScope) + .editPrefs( + prefsOut, + responseHandler = { result -> + if (result.isFailure) { Log.e(TAG, "Error saving AdvertiseRoutes: ${result.exceptionOrNull()}") currentError.set(result.exceptionOrNull()?.localizedMessage) return@editPrefs - } else { + } else { Log.d( TAG, - "AdvertiseRoutes set in backend. New value: ${result.getOrNull()?.AdvertiseRoutes}" - ) + "AdvertiseRoutes set in backend. New value: ${result.getOrNull()?.AdvertiseRoutes}") currentError.set(null) - } - }) - } + } + }) + } - /** - * Clears the current error message and reloads the routes currently saved in the backend - * to the UI. We call this when dismissing an error upon saving the routes. - */ - fun onErrorDismissed() { - currentError.set(null) - Client(viewModelScope).prefs { response -> - Log.d(TAG, "Reloading routes from backend due to failed save: $response") - this.advertisedRoutes.set(response.getOrNull()?.AdvertiseRoutes ?: emptyList()) - } + /** + * Clears the current error message and reloads the routes currently saved in the backend to the + * UI. We call this when dismissing an error upon saving the routes. + */ + fun onErrorDismissed() { + currentError.set(null) + Client(viewModelScope).prefs { response -> + Log.d(TAG, "Reloading routes from backend due to failed save: $response") + this.advertisedRoutes.set(response.getOrNull()?.AdvertiseRoutes ?: emptyList()) } + } - companion object RouteValidation { - /** - * Returns true if the given String is a valid IPv4 or IPv6 CIDR range, false otherwise. - */ - fun isValidCIDR(newRoute: String): Boolean { - val cidrPattern = - Regex("(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(\\d+)") // IPv4 CIDR - val ipv6CidrPattern = - Regex("(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/(\\d+)") // IPv6 CIDR - return cidrPattern.matches(newRoute) || ipv6CidrPattern.matches(newRoute) - } + companion object RouteValidation { + /** Returns true if the given String is a valid IPv4 or IPv6 CIDR range, false otherwise. */ + fun isValidCIDR(newRoute: String): Boolean { + val cidrPattern = + Regex( + "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(\\d+)") // IPv4 CIDR + val ipv6CidrPattern = + Regex( + "(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/(\\d+)") // IPv6 CIDR + return cidrPattern.matches(newRoute) || ipv6CidrPattern.matches(newRoute) } + } } - diff --git a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt new file mode 100644 index 0000000000..fed568d095 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -0,0 +1,134 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.util + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import com.tailscale.ipn.ui.util.OutputStreamAdapter +import libtailscale.Libtailscale +import java.io.IOException +import java.io.OutputStream +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +data class SafFile(val fd: Int, val uri: String) + +object ShareFileHelper : libtailscale.ShareFileHelper { + private var appContext: Context? = null + private var savedUri: String? = null + + @JvmStatic + fun init(context: Context, uri: String) { + appContext = context.applicationContext + savedUri = uri + Libtailscale.setShareFileHelper(this) + } + + // A simple data class that holds a SAF OutputStream along with its URI. + data class SafStream(val uri: String, val stream: OutputStream) + + // Cache for streams; keyed by file name and savedUri. + private val streamCache = ConcurrentHashMap() + + // A helper function that creates (or reuses) a SafStream for a given file. + private fun createStreamCached(fileName: String): SafStream { + val key = "$fileName|$savedUri" + return streamCache.getOrPut(key) { + val context: Context = + appContext + ?: run { + TSLog.e("ShareFileHelper", "appContext is null, cannot create file: $fileName") + return SafStream("", OutputStream.nullOutputStream()) + } + val directoryUriString = + savedUri + ?: run { + TSLog.e("ShareFileHelper", "savedUri is null, cannot create file: $fileName") + return SafStream("", OutputStream.nullOutputStream()) + } + val dirUri = Uri.parse(directoryUriString) + val pickedDir: DocumentFile = + DocumentFile.fromTreeUri(context, dirUri) + ?: run { + TSLog.e("ShareFileHelper", "Could not access directory for URI: $dirUri") + return SafStream("", OutputStream.nullOutputStream()) + } + val newFile: DocumentFile = + pickedDir.createFile("application/octet-stream", fileName) + ?: run { + TSLog.e("ShareFileHelper", "Failed to create file: $fileName in directory: $dirUri") + return SafStream("", OutputStream.nullOutputStream()) + } + // Attempt to open an OutputStream for writing. + val os: OutputStream? = context.contentResolver.openOutputStream(newFile.uri) + if (os == null) { + TSLog.e("ShareFileHelper", "openOutputStream returned null for URI: ${newFile.uri}") + SafStream(newFile.uri.toString(), OutputStream.nullOutputStream()) + } else { + TSLog.d("ShareFileHelper", "Opened OutputStream for file: $fileName") + SafStream(newFile.uri.toString(), os) + } + } + } + + // This method returns a SafStream containing the SAF URI and its corresponding OutputStream. + override fun openFileWriter(fileName: String): libtailscale.OutputStream { + val stream = createStreamCached(fileName) + return OutputStreamAdapter(stream.stream) + } + + override fun openFileURI(fileName: String): String { + val safFile = createStreamCached(fileName) + return safFile.uri + } + + override fun renamePartialFile( + partialUri: String, + targetDirUri: String, + targetName: String + ): String { + try { + val context = appContext ?: throw IllegalStateException("appContext is null") + val partialUriObj = Uri.parse(partialUri) + val targetDirUriObj = Uri.parse(targetDirUri) + val targetDir = + DocumentFile.fromTreeUri(context, targetDirUriObj) + ?: throw IllegalStateException( + "Unable to get target directory from URI: $targetDirUri") + var finalTargetName = targetName + + var destFile = targetDir.findFile(finalTargetName) + if (destFile != null) { + finalTargetName = generateNewFilename(finalTargetName) + } + + destFile = + targetDir.createFile("application/octet-stream", finalTargetName) + ?: throw IOException("Failed to create new file with name: $finalTargetName") + + context.contentResolver.openInputStream(partialUriObj)?.use { input -> + context.contentResolver.openOutputStream(destFile.uri)?.use { output -> + input.copyTo(output) + } ?: throw IOException("Unable to open output stream for URI: ${destFile.uri}") + } ?: throw IOException("Unable to open input stream for URI: $partialUri") + + DocumentFile.fromSingleUri(context, partialUriObj)?.delete() + return destFile.uri.toString() + } catch (e: Exception) { + throw IOException( + "Failed to rename partial file from URI $partialUri to final file in $targetDirUri with name $targetName: ${e.message}", + e) + } + } + + fun generateNewFilename(filename: String): String { + val dotIndex = filename.lastIndexOf('.') + val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename + val extension = if (dotIndex != -1) filename.substring(dotIndex) else "" + + val uuid = UUID.randomUUID() + return "$baseName-$uuid$extension" + } +} diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index c1c539ac0d..5626e583b0 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ Disconnect Unknown user Connected + Using exit node (%s) Not connected %s diff --git a/android/src/test/kotlin/com/tailcale/ipn/ui/util/TimeUtilTest.kt b/android/src/test/kotlin/com/tailcale/ipn/ui/util/TimeUtilTest.kt index 743e574fd5..26f5553303 100644 --- a/android/src/test/kotlin/com/tailcale/ipn/ui/util/TimeUtilTest.kt +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/util/TimeUtilTest.kt @@ -1,13 +1,12 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause - package com.tailcale.ipn.ui.util - import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog.LibtailscaleWrapper +import java.time.Duration import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -16,92 +15,76 @@ import org.junit.Test import org.mockito.ArgumentMatchers.anyString import org.mockito.Mockito.doNothing import org.mockito.Mockito.mock -import java.time.Duration - class TimeUtilTest { - - private lateinit var libtailscaleWrapperMock: LibtailscaleWrapper - private lateinit var originalWrapper: LibtailscaleWrapper - - - @Before - fun setUp() { - libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) - doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) - - - // Store the original wrapper so we can reset it later - originalWrapper = TSLog.libtailscaleWrapper - // Inject mock into TSLog - TSLog.libtailscaleWrapper = libtailscaleWrapperMock - } - - - @After - fun tearDown() { - // Reset TSLog after each test to avoid side effects - TSLog.libtailscaleWrapper = originalWrapper - } - - - @Test - fun durationInvalidMsUnits() { - val input = "5s10ms" - val actual = TimeUtil.duration(input) - assertNull("Should return null", actual) - } - - - @Test - fun durationInvalidUsUnits() { - val input = "5s10us" - val actual = TimeUtil.duration(input) - assertNull("Should return null", actual) - } - - - @Test - fun durationTestHappyPath() { - val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y") - val expectedSeconds = - arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000) - val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) } - val actual = input.map { TimeUtil.duration(it) } - assertEquals("Incorrect conversion", expected, actual) - } - - - @Test - fun testBadDurationString() { - val input = "1..0y1.0w1.0d1.0h1.0m1.0s" - val actual = TimeUtil.duration(input) - assertNull("Should return null", actual) - } - - - @Test - fun testBadDInputString() { - val libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) - doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) - - - val input = "1.0yy1.0w1.0d1.0h1.0m1.0s" - val actual = TimeUtil.duration(input) - assertNull("Should return null", actual) - } - - - @Test - fun testIgnoreFractionalSeconds() { - val input = "10.9s" - val expectedSeconds = 10 - val expected = Duration.ofSeconds(expectedSeconds.toLong()) - val actual = TimeUtil.duration(input) - assertEquals("Should return $expectedSeconds seconds", expected, actual) - } + private lateinit var libtailscaleWrapperMock: LibtailscaleWrapper + private lateinit var originalWrapper: LibtailscaleWrapper + + @Before + fun setUp() { + libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) + doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) + + // Store the original wrapper so we can reset it later + originalWrapper = TSLog.libtailscaleWrapper + // Inject mock into TSLog + TSLog.libtailscaleWrapper = libtailscaleWrapperMock + } + + @After + fun tearDown() { + // Reset TSLog after each test to avoid side effects + TSLog.libtailscaleWrapper = originalWrapper + } + + @Test + fun durationInvalidMsUnits() { + val input = "5s10ms" + val actual = TimeUtil.duration(input) + assertNull("Should return null", actual) + } + + @Test + fun durationInvalidUsUnits() { + val input = "5s10us" + val actual = TimeUtil.duration(input) + assertNull("Should return null", actual) + } + + @Test + fun durationTestHappyPath() { + val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y") + val expectedSeconds = + arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000) + val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) } + val actual = input.map { TimeUtil.duration(it) } + assertEquals("Incorrect conversion", expected, actual) + } + + @Test + fun testBadDurationString() { + val input = "1..0y1.0w1.0d1.0h1.0m1.0s" + val actual = TimeUtil.duration(input) + assertNull("Should return null", actual) + } + + @Test + fun testBadDInputString() { + val libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) + doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) + + val input = "1.0yy1.0w1.0d1.0h1.0m1.0s" + val actual = TimeUtil.duration(input) + assertNull("Should return null", actual) + } + + @Test + fun testIgnoreFractionalSeconds() { + val input = "10.9s" + val expectedSeconds = 10 + val expected = Duration.ofSeconds(expectedSeconds.toLong()) + val actual = TimeUtil.duration(input) + assertEquals("Should return $expectedSeconds seconds", expected, actual) + } } - - - diff --git a/docker/DockerFile.amd64-build b/docker/DockerFile.amd64-build index 2b79c70ad9..a08af109b5 100644 --- a/docker/DockerFile.amd64-build +++ b/docker/DockerFile.amd64-build @@ -36,7 +36,7 @@ COPY Makefile Makefile RUN make androidsdk # Preload Gradle -COPY android/gradlew android/build.gradle android +COPY android/gradlew android/build.gradle android/ COPY android/gradle android/gradle RUN chmod 755 android/gradlew && \ diff --git a/go.mod b/go.mod index 94b6204e39..b6ddb86121 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.0 require ( github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.83.0-pre.0.20250414201714-10fd61f1bb6b + tailscale.com v1.85.0-pre.0.20250524221629-09582bdc009f ) require ( @@ -39,10 +39,9 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/btree v1.1.2 // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-tpm v0.9.4 // indirect github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/illarion/gonotify/v3 v3.0.2 // indirect github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect diff --git a/go.sum b/go.sum index f5c65876be..cd21a9d40f 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,17 @@ +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk= github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM= github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k= @@ -40,6 +46,10 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= +github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= +github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= +github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -63,6 +73,8 @@ github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6 github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -71,16 +83,14 @@ github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= +github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M= -github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= @@ -99,6 +109,8 @@ github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IX github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -119,6 +131,8 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -149,12 +163,16 @@ github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+y github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 h1:h/41LFTrwMxB9Xvvug0kRdQCU5TlV1+pAMQw0ZtDE3U= github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= +github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= @@ -172,6 +190,8 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab h1:KONOFF8Uy3b60HEzOsGnNghORNhY4ImyOx0PGm73K9k= @@ -211,9 +231,11 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k= gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM= +honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I= +honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.83.0-pre.0.20250414201714-10fd61f1bb6b h1:KfTHlSLdZd2XhZQ0t6C2AXu9/QxmZR/eXfHOzOnQ3V8= -tailscale.com v1.83.0-pre.0.20250414201714-10fd61f1bb6b/go.mod h1:CCR2Ti9anln7NMAbpSoECkd5P4N80OhjneOQ/GarSBE= +tailscale.com v1.85.0-pre.0.20250524221629-09582bdc009f h1:ZXXlCnDLlDA/Hkt4TZ3QpCsOKiuZzhrd0htEOnn89rM= +tailscale.com v1.85.0-pre.0.20250524221629-09582bdc009f/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= diff --git a/libtailscale/backend.go b/libtailscale/backend.go index 21eea06a4e..e8dbc4c37d 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -18,6 +18,7 @@ import ( "tailscale.com/drive/driveimpl" _ "tailscale.com/feature/condregister" + "tailscale.com/feature/taildrop" "tailscale.com/hostinfo" "tailscale.com/ipn" "tailscale.com/ipn/ipnauth" @@ -33,6 +34,7 @@ import ( "tailscale.com/types/logger" "tailscale.com/types/logid" "tailscale.com/types/netmap" + "tailscale.com/util/eventbus" "tailscale.com/wgengine" "tailscale.com/wgengine/netstack" "tailscale.com/wgengine/router" @@ -41,8 +43,9 @@ import ( type App struct { dataDir string - // enables direct file mode for the taildrop manager - directFileRoot string + // passes along SAF file information for the taildrop manager + directFileRoot string + shareFileHelper ShareFileHelper // appCtx is a global reference to the com.tailscale.ipn.App instance. appCtx AppContext @@ -54,6 +57,9 @@ type App struct { localAPIHandler http.Handler backend *ipnlocal.LocalBackend ready sync.WaitGroup + backendMu sync.Mutex + + backendRestartCh chan struct{} } func start(dataDir, directFileRoot string, appCtx AppContext) Application { @@ -96,6 +102,8 @@ type backend struct { logIDPublic logid.PublicID logger *logtail.Logger + bus *eventbus.Bus + // avoidEmptyDNS controls whether to use fallback nameservers // when no nameservers are provided by Tailscale. avoidEmptyDNS bool @@ -106,6 +114,23 @@ type backend struct { type settingsFunc func(*router.Config, *dns.OSConfig) 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) hostinfo.SetOSVersion(a.osVersion()) hostinfo.SetPackage(a.appCtx.GetInstallSource()) @@ -121,7 +146,7 @@ func (a *App) runBackend(ctx context.Context) error { } configs := make(chan configPair) configErrs := make(chan error) - b, err := a.newBackend(a.dataDir, a.directFileRoot, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error { + b, err := a.newBackend(a.dataDir, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error { if rcfg == nil { return nil } @@ -238,7 +263,7 @@ func (a *App) runBackend(ctx context.Context) error { } } -func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, store *stateStore, +func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore, settings settingsFunc) (*backend, error) { sys := new(tsd.System) @@ -249,7 +274,9 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor devices: newTUNDevices(), settings: settings, appCtx: appCtx, + bus: eventbus.New(), } + var logID logid.PrivateID logID.UnmarshalText([]byte("dead0000dead0000dead0000dead0000dead0000dead0000dead0000dead0000")) storedLogID, err := store.read(logPrefKey) @@ -268,7 +295,7 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor logID.UnmarshalText([]byte(storedLogID)) } - netMon, err := netmon.New(logf) + netMon, err := netmon.New(b.bus, logf) if err != nil { log.Printf("netmon.New: %w", err) } @@ -308,12 +335,15 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor w.Start() } lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0) + if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok { + ext.SetFileOps(NewAndroidFileOps(a.shareFileHelper)) + ext.SetDirectFileRoot(a.directFileRoot) + } + if err != nil { engine.Close() return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err) } - lb.SetDirectFileRoot(directFileRoot) - if err := ns.Start(lb); err != nil { return nil, fmt.Errorf("startNetstack: %w", err) } @@ -334,6 +364,21 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor return b, nil } +func (a *App) watchFileOpsChanges() { + for { + select { + case newPath := <-onFilePath: + log.Printf("Got new directFileRoot") + a.directFileRoot = newPath + a.backendRestartCh <- struct{}{} + case helper := <-onShareFileHelper: + log.Printf("Got shareFIleHelper") + a.shareFileHelper = helper + a.backendRestartCh <- struct{}{} + } + } +} + func (b *backend) isConfigNonNilAndDifferent(rcfg *router.Config, dcfg *dns.OSConfig) bool { if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) { b.logger.Logf("isConfigNonNilAndDifferent: no change to Routes or DNS, ignore") diff --git a/libtailscale/callbacks.go b/libtailscale/callbacks.go index 2ee022a060..3e1a88fcc1 100644 --- a/libtailscale/callbacks.go +++ b/libtailscale/callbacks.go @@ -23,6 +23,12 @@ var ( // onLog receives Android logs to be sent to the logger onLog = make(chan string, 10) + + // onShareFileHelper receives ShareFileHelper references when the app is initialized so that files can be received via Storage Access Framework + 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. diff --git a/libtailscale/fileops.go b/libtailscale/fileops.go new file mode 100644 index 0000000000..241097c6b4 --- /dev/null +++ b/libtailscale/fileops.go @@ -0,0 +1,38 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package libtailscale + +import ( + "fmt" + "io" +) + +// AndroidFileOps implements the ShareFileHelper interface using the Android helper. +type AndroidFileOps struct { + helper ShareFileHelper +} + +func NewAndroidFileOps(helper ShareFileHelper) *AndroidFileOps { + return &AndroidFileOps{helper: helper} +} + +func (ops *AndroidFileOps) OpenFileURI(filename string) string { + return ops.helper.OpenFileURI(filename) +} + +func (ops *AndroidFileOps) OpenFileWriter(filename string) (io.WriteCloser, string, error) { + uri := ops.helper.OpenFileURI(filename) + outputStream := ops.helper.OpenFileWriter(filename) + if outputStream == nil { + return nil, uri, fmt.Errorf("failed to open SAF output stream for %s", filename) + } + return outputStream, uri, nil +} + +func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error) { + newURI := ops.helper.RenamePartialFile(partialUri, targetDirUri, targetName) + if newURI == "" { + return "", fmt.Errorf("failed to rename partial file via SAF") + } + return newURI, nil +} diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index 6460c9f3c2..56636987fe 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -162,6 +162,25 @@ type InputStream interface { Close() error } +// OutputStream provides an adapter between Java's OutputStream and Go's +// io.WriteCloser. +type OutputStream interface { + Write([]byte) (int, error) + Close() error +} + +// ShareFileHelper corresponds to the Kotlin ShareFileHelper class +type ShareFileHelper interface { + OpenFileWriter(fileName string) OutputStream + + // OpenFileURI opens the file and returns its SAF URI. + OpenFileURI(filename string) string + + // RenamePartialFile takes SAF URIs and a target file name, + // and returns the new SAF URI and an error. + RenamePartialFile(partialUri string, targetDirUri string, targetName string) string +} + // The below are global callbacks that allow the Java application to notify Go // of various state changes. @@ -182,3 +201,23 @@ func SendLog(logstr []byte) { log.Printf("Log %v not sent", logstr) // missing argument in original code } } + +func SetShareFileHelper(fileHelper ShareFileHelper) { + // Drain the channel if there's an old value. + select { + case <-onShareFileHelper: + default: + // Channel was already empty. + } + select { + case onShareFileHelper <- fileHelper: + default: + // In the unlikely case the channel is still full, drain it and try again. + <-onShareFileHelper + onShareFileHelper <- fileHelper + } +} + +func SetDirectFileRoot(filePath string) { + onFilePath <- filePath +} diff --git a/libtailscale/tailscale.go b/libtailscale/tailscale.go index 6ae913142f..3a785fabb9 100644 --- a/libtailscale/tailscale.go +++ b/libtailscale/tailscale.go @@ -32,9 +32,10 @@ const ( func newApp(dataDir, directFileRoot string, appCtx AppContext) Application { a := &App{ - directFileRoot: directFileRoot, - dataDir: dataDir, - appCtx: appCtx, + directFileRoot: directFileRoot, + dataDir: dataDir, + appCtx: appCtx, + backendRestartCh: make(chan struct{}, 1), } a.ready.Add(2) @@ -42,6 +43,8 @@ func newApp(dataDir, directFileRoot string, appCtx AppContext) Application { a.policyStore = &syspolicyHandler{a: a} netmon.RegisterInterfaceGetter(a.getInterfaces) syspolicy.RegisterHandler(a.policyStore) + go a.watchFileOpsChanges() + go func() { defer func() { if p := recover(); p != nil {