diff --git a/Makefile b/Makefile index 342e0bc..b7e969a 100644 --- a/Makefile +++ b/Makefile @@ -13,19 +13,6 @@ DOCKER_IMAGE := tailscale-android-build-amd64-041425-1 export TS_USE_TOOLCHAIN=1 -# Auto-select an NDK from ANDROID_HOME (choose highest version available) -NDK_ROOT ?= $(shell ls -1d $(ANDROID_HOME)/ndk/* 2>/dev/null | sort -V | tail -n 1) - -HOST_OS := $(shell uname | tr A-Z a-z) -ifeq ($(HOST_OS),linux) - STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy -else ifeq ($(HOST_OS),darwin) - STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objcopy -endif - -$(info Using NDK_ROOT: $(NDK_ROOT)) -$(info Using STRIP_TOOL: $(STRIP_TOOL)) - DEBUG_APK := tailscale-debug.apk RELEASE_AAB := tailscale-release.aab RELEASE_TV_AAB := tailscale-tv-release.aab @@ -64,6 +51,20 @@ ifeq ($(ANDROID_SDK_ROOT),) endif export ANDROID_HOME ?= $(ANDROID_SDK_ROOT) +# Auto-select an NDK from ANDROID_HOME (choose highest version available) +NDK_ROOT ?= $(shell ls -1d $(ANDROID_HOME)/ndk/* 2>/dev/null | sort -V | tail -n 1) + +HOST_OS := $(shell uname | tr A-Z a-z) +ifeq ($(HOST_OS),linux) + STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy +else ifeq ($(HOST_OS),darwin) + STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objcopy +endif + +$(info Using ANDROID_HOME: $(ANDROID_HOME)) +$(info Using NDK_ROOT: $(NDK_ROOT)) +$(info Using STRIP_TOOL: $(STRIP_TOOL)) + # Attempt to find Android Studio for Linux configuration, which does not have a # predetermined location. ANDROID_STUDIO_ROOT ?= $(shell find ~/android-studio /usr/local/android-studio /opt/android-studio /Applications/Android\ Studio.app $(PROGRAMFILES)/Android/Android\ Studio -type d -maxdepth 1 2>/dev/null | head -n 1) @@ -179,9 +180,11 @@ build-unstripped-aar: tailscale.version $(GOBIN)/gomobile @echo "Output file: $(ABS_UNSTRIPPED_AAR)" mkdir -p $(dir $(ABS_UNSTRIPPED_AAR)) rm -f $(ABS_UNSTRIPPED_AAR) + # The -linkmode=external -extldflags=-Wl,-z,max-page-size=16384 is specific to NDK 23 + # to support 16kb page sizes. Your mileage may vary with other NDK versions. $(GOBIN)/gomobile bind -target android -androidapi 26 \ -tags "$$(./build-tags.sh)" \ - -ldflags "$$(./version-ldflags.sh)" \ + -ldflags "-linkmode=external -extldflags=-Wl,-z,max-page-size=16384 $$(./version-ldflags.sh)" \ -o $(ABS_UNSTRIPPED_AAR) ./libtailscale || { echo "gomobile bind failed"; exit 1; } @if [ ! -f $(ABS_UNSTRIPPED_AAR) ]; then \ echo "Error: $(ABS_UNSTRIPPED_AAR) was not created"; exit 1; \ @@ -275,10 +278,10 @@ bump_version_code: .PHONY: update-oss ## Update the tailscale.com go module update-oss: + curl -f https://raw.githubusercontent.com/tailscale/tailscale/refs/heads/main/go.toolchain.rev > go.toolchain.rev.new + mv go.toolchain.rev.new go.toolchain.rev GOPROXY=direct ./tool/go get tailscale.com@main ./tool/go mod tidy -compat=1.24 - ./tool/go run tailscale.com/cmd/printdep --go > go.toolchain.rev.new - mv go.toolchain.rev.new go.toolchain.rev # Get the commandline tools package, this provides (among other things) the sdkmanager binary. $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager: @@ -286,7 +289,7 @@ $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager: mkdir -p $(ANDROID_HOME)/cmdline-tools (cd $(ANDROID_HOME)/tmp && \ curl --silent -O -L $(ANDROID_TOOLS_URL) && \ - echo $(ANDROID_TOOLS_SUM) | sha256sum -c && \ + echo $(ANDROID_TOOLS_SUM) | shasum -c - && \ unzip $(shell basename $(ANDROID_TOOLS_URL))) mv $(ANDROID_HOME)/tmp/cmdline-tools $(ANDROID_HOME)/cmdline-tools/latest rm -rf $(ANDROID_HOME)/tmp @@ -312,7 +315,15 @@ checkandroidsdk: ## Check that Android SDK is installed test: gradle-dependencies ## Run the Android tests (cd android && ./gradlew test) -.PHONY: emulator +.PHONY: fmt +fmt: gradle-dependencies ## Format the Android code + (cd android && ./gradlew ktfmtFormat) + +.PHONY: fmt-check +fmt-check: gradle-dependencies ## Check the Android code is formatted + (cd android && ./gradlew ktfmtCheck) + +.PHONY: emulator emulator: ## Start an android emulator instance @echo "Checking installed SDK packages..." @if ! $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q "$(AVD_IMAGE)"; then \ @@ -327,7 +338,7 @@ emulator: ## Start an android emulator instance @echo "Starting emulator..." @$(ANDROID_HOME)/emulator/emulator -avd "$(AVD)" -logcat-output /dev/stdout -netdelay none -netspeed full -.PHONY: install +.PHONY: install install: $(DEBUG_APK) ## Install the debug APK on a connected device adb install -r $< @@ -335,7 +346,7 @@ install: $(DEBUG_APK) ## Install the debug APK on a connected device run: install ## Run the debug APK on a connected device adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.MainActivity -.PHONY: docker-build-image +.PHONY: docker-build-image docker-build-image: ## Builds the docker image for the android build environment if it does not exist @echo "Checking if docker image $(DOCKER_IMAGE) already exists..." @if ! docker images $(DOCKER_IMAGE) -q | grep -q . ; then \ diff --git a/android/build.gradle b/android/build.gradle index d7b8ae5..5079097 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -36,7 +36,7 @@ android { compileSdkVersion 34 defaultConfig { minSdkVersion 26 - targetSdkVersion 34 + targetSdkVersion 35 versionCode 356 versionName getVersionProperty("VERSION_LONG") testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 0286733..b98e48d 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -7,15 +7,15 @@ 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.RestrictionsManager 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 @@ -33,11 +33,13 @@ 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.ui.viewModel.AppViewModel +import com.tailscale.ipn.ui.viewModel.AppViewModelFactory import com.tailscale.ipn.util.FeatureFlags +import com.tailscale.ipn.util.HardwareKeyStore +import com.tailscale.ipn.util.NoSuchKeyException +import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.TSLog -import java.io.File import java.io.IOException import java.net.NetworkInterface import java.security.GeneralSecurityException @@ -53,15 +55,16 @@ import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import libtailscale.Libtailscale - +import java.lang.UnsupportedOperationException class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) 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 - /** * Initializes the app (if necessary) and returns the singleton app instance. Always use this * function to obtain an App reference to make sure the app initializes. @@ -77,12 +80,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { private lateinit var connectivityManager: ConnectivityManager private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver private lateinit var app: libtailscale.Application - override val viewModelStore: ViewModelStore get() = appViewModelStore private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() } - var healthNotifier: HealthNotifier? = null override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString @@ -106,11 +107,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { super.onCreate() appInstance = this setUnprotectedInstance(this) - mdmChangeReceiver = MDMSettingsChangedReceiver() val filter = IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) registerReceiver(mdmChangeReceiver, filter) - createNotificationChannel( STATUS_CHANNEL_ID, getString(R.string.vpn_status), @@ -144,28 +143,25 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { if (isInitialized) { return } - initializeApp() isInitialized = true } 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.filesDir.absolutePath) + } healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope) connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns) initViewModels() applicationScope.launch { + val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager + MDMSettings.update(get(), rm) Notifier.state.collect { _ -> combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) { state, @@ -183,11 +179,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { if (state == Ipn.State.Stopped) { notifyStatus(vpnRunning = false, hideDisconnectAction = hideDisconnectAction.value) } - val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running updateConnStatus(ableToStartVPN) QuickToggleService.setVPNRunning(vpnRunning) - // Update notification status when VPN is running if (vpnRunning) { notifyStatus( @@ -204,9 +198,22 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { TSLog.init(this) 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) { + app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this) + ShareFileHelper.init(this, app, directFileRoot, applicationScope) + Request.setApp(app) + Notifier.setApp(app) + Notifier.start(applicationScope) + } private fun initViewModels() { - vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java) + appViewModel = + ViewModelProvider(this, AppViewModelFactory(this, ShareFileHelper.observeTaildropPrompt())) + .get(AppViewModel::class.java) } fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) { @@ -220,14 +227,12 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { Client(applicationScope) .editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback) } - // encryptToPref a byte array of data using the Jetpack Security // library and writes it to a global encrypted preference store. @Throws(IOException::class, GeneralSecurityException::class) override fun encryptToPref(prefKey: String?, plaintext: String?) { getEncryptedPrefs().edit().putString(prefKey, plaintext).commit() } - // decryptFromPref decrypts a encrypted preference using the Jetpack Security // library and returns the plaintext. @Throws(IOException::class, GeneralSecurityException::class) @@ -235,10 +240,20 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { return getEncryptedPrefs().getString(prefKey, null) } + override fun getStateStoreKeysJSON(): String { + val prefix = "statestore-" + val keys = + getEncryptedPrefs() + .getAll() + .keys + .filter { it.startsWith(prefix) } + .map { it.removePrefix(prefix) } + return org.json.JSONArray(keys).toString() + } + @Throws(IOException::class, GeneralSecurityException::class) fun getEncryptedPrefs(): SharedPreferences { val key = MasterKey.Builder(this).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() - return EncryptedSharedPreferences.create( this, "secret_shared_prefs", @@ -247,6 +262,10 @@ 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 @@ -278,7 +297,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { override fun getInterfacesAsString(): String { val interfaces: ArrayList = java.util.Collections.list(NetworkInterface.getNetworkInterfaces()) - val sb = StringBuilder() for (nif in interfaces) { try { @@ -294,7 +312,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { nif.isLoopback, nif.isPointToPoint, nif.supportsMulticast())) - for (ia in nif.interfaceAddresses) { val parts = ia.toString().split("/", limit = 0) if (parts.size > 1) { @@ -306,33 +323,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } sb.append("\n") } - 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 { @@ -368,8 +361,49 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { fun notifyPolicyChanged() { app.notifyPolicyChanged() } -} + override fun hardwareAttestationKeySupported(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + } else { + false + } + } + + private lateinit var keyStore: HardwareKeyStore; + + private fun getKeyStore(): HardwareKeyStore { + if (hardwareAttestationKeySupported()) { + return HardwareKeyStore() + } else { + throw UnsupportedOperationException() + } + } + + override fun hardwareAttestationKeyCreate(): String { + return getKeyStore().createKey() + } + + @Throws(NoSuchKeyException::class) + override fun hardwareAttestationKeyRelease(id: String) { + return getKeyStore().releaseKey(id) + } + + @Throws(NoSuchKeyException::class) + override fun hardwareAttestationKeySign(id: String, data: ByteArray): ByteArray { + return getKeyStore().sign(id, data) + } + + @Throws(NoSuchKeyException::class) + override fun hardwareAttestationKeyPublic(id: String): ByteArray { + return getKeyStore().public(id) + } + + @Throws(NoSuchKeyException::class) + override fun hardwareAttestationKeyLoad(id: String) { + return getKeyStore().load(id) + } +} /** * UninitializedApp contains all of the methods of App that can be used without having to initialize * the Go backend. This is useful when you want to access functions on the App without creating side @@ -378,11 +412,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { open class UninitializedApp : Application() { companion object { const val TAG = "UninitializedApp" - const val STATUS_NOTIFICATION_ID = 1 const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2 const val STATUS_CHANNEL_ID = "tailscale-status" - // Key for shared preference that tracks whether or not we're able to start // the VPN (i.e. we're logged in and machine is authorized). private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN" @@ -397,17 +429,15 @@ open class UninitializedApp : Application() { // File for shared preferences that are not encrypted. private const val UNENCRYPTED_PREFERENCES = "unencrypted" - private lateinit var appInstance: UninitializedApp lateinit var notificationManager: NotificationManagerCompat - lateinit var vpnViewModel: VpnViewModel + lateinit var appViewModel: AppViewModel @JvmStatic 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]. @@ -428,7 +458,6 @@ open class UninitializedApp : Application() { protected fun setAbleToStartVPN(rdy: Boolean) { getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply() } - /** This function can be called without initializing the App. */ fun isAbleToStartVPN(): Boolean { return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false) @@ -450,7 +479,6 @@ open class UninitializedApp : Application() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+ ) - try { pendingIntent.send() } catch (foregroundServiceStartException: IllegalStateException) { @@ -476,25 +504,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) { @@ -548,7 +566,6 @@ open class UninitializedApp : Application() { 0, buttonIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - val intent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK @@ -556,7 +573,6 @@ open class UninitializedApp : Application() { val pendingIntent: PendingIntent = PendingIntent.getActivity( this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - val builder = NotificationCompat.Builder(this, STATUS_CHANNEL_ID) .setSmallIcon(icon) @@ -575,32 +591,13 @@ open class UninitializedApp : Application() { return builder.build() } - fun addUserSelectedPackage(packageName: String) { - if (packageName.isEmpty()) { - TSLog.e(TAG, "addUserSelectedPackage called with empty packageName") - return - } - - getUnencryptedPrefs() - .edit() - .putStringSet( - SELECTED_APPS_KEY, selectedPackageNames().toMutableSet().union(setOf(packageName))) - .apply() - - this.restartVPN() - } - - fun removeUserSelectedPackage(packageName: String) { - if (packageName.isEmpty()) { - TSLog.e(TAG, "removeUserSelectedPackage called with empty packageName") + fun updateUserSelectedPackages(packageNames: List) { + if (packageNames.any { it.isEmpty() }) { + TSLog.e(TAG, "updateUserSelectedPackage called with empty packageName(s)") return } - getUnencryptedPrefs() - .edit() - .putStringSet( - SELECTED_APPS_KEY, selectedPackageNames().toMutableSet().subtract(setOf(packageName))) - .apply() + getUnencryptedPrefs().edit().putStringSet(SELECTED_APPS_KEY, packageNames.toSet()).apply() this.restartVPN() } @@ -624,8 +621,8 @@ open class UninitializedApp : Application() { return getUnencryptedPrefs().getBoolean(ALLOW_SELECTED_APPS_KEY, false) } - fun getAppScopedViewModel(): VpnViewModel { - return vpnViewModel + fun getAppScopedViewModel(): AppViewModel { + return appViewModel } val builtInDisallowedPackageNames: List = diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index 09b7682..32f927f 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -46,6 +46,13 @@ open class IPNService : VpnService(), libtailscale.IPNService { close() START_NOT_STICKY } + ACTION_RESTART_VPN -> { + app.setWantRunning(false) { + close() + app.startVPN() + } + START_NOT_STICKY + } ACTION_START_VPN -> { scope.launch { showForegroundNotification() } app.setWantRunning(true) @@ -82,7 +89,6 @@ open class IPNService : VpnService(), libtailscale.IPNService { } override fun close() { - app.setWantRunning(false) {} Notifier.setState(Ipn.State.Stopping) disconnectVPN() Libtailscale.serviceDisconnect(this) @@ -214,5 +220,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 0b3b762..2de4873 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -10,17 +10,22 @@ 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 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.activity.viewModels import androidx.annotation.RequiresApi import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.animation.core.LinearOutSlowInEasing @@ -29,10 +34,18 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.core.net.toUri import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.ViewModelProvider @@ -44,6 +57,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.tailscale.ipn.mdm.MDMSettings +import com.tailscale.ipn.mdm.ShowHide import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.theme.AppTheme @@ -66,22 +80,28 @@ import com.tailscale.ipn.ui.view.ManagedByView import com.tailscale.ipn.ui.view.MullvadExitNodePicker import com.tailscale.ipn.ui.view.MullvadExitNodePickerList import com.tailscale.ipn.ui.view.MullvadInfoView +import com.tailscale.ipn.ui.view.NotificationsView import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PermissionsView +import com.tailscale.ipn.ui.view.PrimaryActionButton import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.SearchView import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView import com.tailscale.ipn.ui.view.SubnetRoutingView +import com.tailscale.ipn.ui.view.TaildropDirView +import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.UserSwitcherNav import com.tailscale.ipn.ui.view.UserSwitcherView +import com.tailscale.ipn.ui.viewModel.AppViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModelFactory +import com.tailscale.ipn.ui.viewModel.PermissionsViewModel import com.tailscale.ipn.ui.viewModel.PingViewModel import com.tailscale.ipn.ui.viewModel.SettingsNav -import com.tailscale.ipn.ui.viewModel.VpnViewModel +import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel @@ -92,12 +112,10 @@ import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { private lateinit var navController: NavHostController private lateinit var vpnPermissionLauncher: ActivityResultLauncher - private val viewModel: MainViewModel by lazy { - val app = App.get() - vpnViewModel = app.getAppScopedViewModel() - ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java) - } - private lateinit var vpnViewModel: VpnViewModel + private lateinit var appViewModel: AppViewModel + private lateinit var viewModel: MainViewModel + + val permissionsViewModel: PermissionsViewModel by viewModels() companion object { private const val TAG = "Main Activity" @@ -108,7 +126,6 @@ class MainActivity : ComponentActivity() { return (resources.configuration.screenLayout and SCREENLAYOUT_SIZE_MASK) >= SCREENLAYOUT_SIZE_LARGE } - // The loginQRCode is used to track whether or not we should be rendering a QR code // to the user. This is used only on TV platforms with no browser in lieu of // simply opening the URL. This should be consumed once it has been handled. @@ -121,21 +138,27 @@ class MainActivity : ComponentActivity() { // grab app to make sure it initializes App.get() - vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java) - + appViewModel = (application as App).getAppScopedViewModel() + viewModel = + ViewModelProvider(this, MainViewModelFactory(appViewModel)).get(MainViewModel::class.java) + + val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager + MDMSettings.update(App.get(), rm) + if (MDMSettings.onboardingFlow.flow.value.value == ShowHide.Hide || + MDMSettings.authKey.flow.value.value != null) { + setIntroScreenViewed(true) + } // (jonathan) TODO: Force the app to be portrait on small screens until we have // proper landscape layout support if (!isLandscapeCapable()) { requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT } - installSplashScreen() - vpnPermissionLauncher = registerForActivityResult(VpnPermissionContract()) { granted -> if (granted) { TSLog.d("VpnPermission", "VPN permission granted") - vpnViewModel.setVpnPrepared(true) + appViewModel.setVpnPrepared(true) App.get().startVPN() } else { if (isAnotherVpnActive(this)) { @@ -143,13 +166,91 @@ class MainActivity : ComponentActivity() { showOtherVPNConflictDialog() } else { TSLog.d("VpnPermission", "Permission was denied by the user") - vpnViewModel.setVpnPrepared(false) + appViewModel.setVpnPrepared(false) + + AlertDialog.Builder(this) + .setTitle(R.string.vpn_permission_needed) + .setMessage(R.string.vpn_explainer) + .setPositiveButton(R.string.try_again) { _, _ -> + viewModel.showVPNPermissionLauncherIfUnauthorized() + } + .setNegativeButton(R.string.cancel, null) + .show() } } } 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 { + TaildropDirectoryStore.saveFileDirectory(uri) + permissionsViewModel.refreshCurrentDir() + ShareFileHelper.notifyDirectoryReady() + ShareFileHelper.setUri(uri.toString()) + } 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. + } + } + + appViewModel.directoryPickerLauncher = directoryPickerLauncher setContent { + var showDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { appViewModel.triggerDirectoryPicker.collect { showDialog = true } } + + if (showDialog) { + AppTheme { + AlertDialog( + onDismissRequest = { + showDialog = false + appViewModel.directoryPickerLauncher?.launch(null) + }, + title = { + Text(text = stringResource(id = R.string.taildrop_directory_picker_title)) + }, + text = { TaildropDirectoryPickerPrompt() }, + confirmButton = { + PrimaryActionButton( + onClick = { + showDialog = false + appViewModel.directoryPickerLauncher?.launch(null) + }) { + Text(text = stringResource(id = R.string.taildrop_directory_picker_button)) + } + }) + } + } + navController = rememberNavController() AppTheme { @@ -185,7 +286,6 @@ class MainActivity : ComponentActivity() { fun backTo(route: String): () -> Unit = { navController.popBackStack(route = route, inclusive = false) } - val mainViewNav = MainViewNavigation( onNavigateToSettings = { navController.navigate("settings") }, @@ -198,7 +298,6 @@ class MainActivity : ComponentActivity() { viewModel.enableSearchAutoFocus() navController.navigate("search") }) - val settingsNav = SettingsNav( onNavigateToBugReport = { navController.navigate("bugReport") }, @@ -213,7 +312,6 @@ class MainActivity : ComponentActivity() { onNavigateToPermissions = { navController.navigate("permissions") }, onBackToSettings = backTo("settings"), onNavigateBackHome = backTo("main")) - val exitNodePickerNav = ExitNodePickerNav( onNavigateBackHome = { @@ -225,7 +323,6 @@ class MainActivity : ComponentActivity() { onNavigateBackToMullvad = backTo("mullvad"), onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }, onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) - val userSwitcherNav = UserSwitcherNav( backToSettings = backTo("settings"), @@ -236,7 +333,11 @@ class MainActivity : ComponentActivity() { onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") }) composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) { - MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel) + MainView( + loginAtUrl = ::login, + navigation = mainViewNav, + viewModel = viewModel, + appViewModel = appViewModel) } composable("search") { val autoFocus = viewModel.autoFocusSearch @@ -246,7 +347,9 @@ class MainActivity : ComponentActivity() { onNavigateBack = { navController.popBackStack() }, autoFocus = autoFocus) } - composable("settings") { SettingsView(settingsNav) } + composable("settings") { + SettingsView(settingsNav = settingsNav, appViewModel = appViewModel) + } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("health") { HealthView(backTo("main")) } composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } @@ -277,7 +380,17 @@ class MainActivity : ComponentActivity() { composable("managedBy") { ManagedByView(backTo("settings")) } composable("userSwitcher") { UserSwitcherView(userSwitcherNav) } composable("permissions") { - PermissionsView(backTo("settings"), ::openApplicationSettings) + PermissionsView( + backTo("settings"), + { navController.navigate("taildropDir") }, + { navController.navigate("notifications") }) + } + composable("taildropDir") { + TaildropDirView( + backTo("permissions"), directoryPickerLauncher, permissionsViewModel) + } + composable("notifications") { + NotificationsView(backTo("permissions"), ::openApplicationSettings) } composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) { IntroView(backTo("main")) @@ -290,15 +403,12 @@ class MainActivity : ComponentActivity() { onNavigateHome = backTo("main"), backTo("userSwitcher")) } } - - // Show the intro screen one time - if (!introScreenViewed()) { + if (isIntroScreenViewedSet()) { navController.navigate("intro") setIntroScreenViewed(true) } } } - // Login actions are app wide. If we are told about a browse-to-url, we should render it // over whatever screen we happen to be on. loginQRCode.collectAsState().value?.let { @@ -321,7 +431,6 @@ class MainActivity : ComponentActivity() { } } } - // Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog. lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } } } @@ -342,7 +451,6 @@ class MainActivity : ComponentActivity() { fun isAnotherVpnActive(context: Context): Boolean { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val activeNetwork = connectivityManager.activeNetwork if (activeNetwork != null) { val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) @@ -353,7 +461,6 @@ class MainActivity : ComponentActivity() { } return false } - // Returns true if we should render a QR code instead of launching a browser // for login requests private fun useQRCodeLogin(): Boolean { @@ -366,14 +473,17 @@ class MainActivity : ComponentActivity() { if (this::navController.isInitialized) { 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 (this::navController.isInitialized) { + 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 } } + } } } } @@ -394,7 +504,6 @@ class MainActivity : ComponentActivity() { putExtra(START_AT_ROOT, true) } startActivity(intent) - // Cancel coroutine once we've logged in this@launch.cancel() } @@ -403,7 +512,6 @@ class MainActivity : ComponentActivity() { TSLog.e(TAG, "Login: failed to start MainActivity: $e") } } - val url = urlString.toUri() try { val customTabsIntent = CustomTabsIntent.Builder().build() @@ -426,10 +534,6 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) } } - override fun onStart() { - super.onStart() - } - override fun onStop() { super.onStop() val restrictionsManager = @@ -446,8 +550,8 @@ class MainActivity : ComponentActivity() { startActivity(intent) } - private fun introScreenViewed(): Boolean { - return getSharedPreferences("introScreen", Context.MODE_PRIVATE).getBoolean("seen", false) + private fun isIntroScreenViewedSet(): Boolean { + return !getSharedPreferences("introScreen", Context.MODE_PRIVATE).getBoolean("seen", false) } private fun setIntroScreenViewed(seen: Boolean) { diff --git a/android/src/main/java/com/tailscale/ipn/QuickToggleService.java b/android/src/main/java/com/tailscale/ipn/QuickToggleService.java index 0ac3bd0..f2374cc 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 class QuickToggleService extends TileService { } } - @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/TaildropDirectoryStore.kt b/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt new file mode 100644 index 0000000..c168d7d --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt @@ -0,0 +1,35 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn + +import android.net.Uri +import com.tailscale.ipn.util.TSLog +import java.io.IOException +import java.security.GeneralSecurityException + +object TaildropDirectoryStore { + // Key to store the SAF URI in EncryptedSharedPreferences. + val PREF_KEY_SAF_URI = "saf_directory_uri" + + @Throws(IOException::class, GeneralSecurityException::class) + fun saveFileDirectory(directoryUri: Uri) { + val prefs = App.get().getEncryptedPrefs() + prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).commit() + } + + @Throws(IOException::class, GeneralSecurityException::class) + fun loadSavedDir(): Uri? { + val prefs = App.get().getEncryptedPrefs() + val uriString = prefs.getString(PREF_KEY_SAF_URI, null) ?: return null + + return try { + Uri.parse(uriString) + } catch (e: Exception) { + // Malformed URI in prefs ‑‑ log and wipe the bad value + TSLog.w("MainActivity", "loadSavedDir: invalid URI in prefs: $uriString; clearing") + prefs.edit().remove(PREF_KEY_SAF_URI).apply() + null + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt index 3364ceb..34b341f 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt @@ -59,6 +59,11 @@ object MDMSettings { val postureChecking = AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking") + // Handled on the backend + val deviceSerialNumber = + StringMDMSetting( + "DeviceSerialNumber", "Serial number of the device that is running Tailscale") + val useTailscaleDNSSettings = AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings") @@ -97,6 +102,9 @@ object MDMSettings { // Overrides the value provided by os.Hostname() in Go val hostname = StringMDMSetting("Hostname", "Device Hostname") + // Allows admins to skip the get started intro screen + val onboardingFlow = ShowHideMDMSetting("OnboardingFlow", "Suppress the intro screen") + val allSettings by lazy { MDMSettings::class .declaredMemberProperties 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 338b7a9..38acc7f 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 @@ -95,11 +95,11 @@ class Ipn { var ExitNodeIDSet: Boolean? = null, var ExitNodeAllowLANAccessSet: Boolean? = null, var WantRunningSet: Boolean? = null, + var LoggedOutSet: Boolean? = null, var ShieldsUpSet: Boolean? = null, var AdvertiseRoutesSet: Boolean? = null, var ForceDaemonSet: Boolean? = null, var HostnameSet: Boolean? = null, - var InternalExitNodePriorSet: Boolean? = null, ) { var ControlURL: String? = null @@ -126,12 +126,6 @@ class Ipn { ExitNodeIDSet = true } - var InternalExitNodePrior: String? = null - set(value) { - field = value - InternalExitNodePriorSet = true - } - var ExitNodeAllowLANAccess: Boolean? = null set(value) { field = value @@ -144,6 +138,12 @@ class Ipn { WantRunningSet = true } + var LoggedOut: Boolean? = null + set(value) { + field = value + LoggedOutSet = true + } + var ShieldsUp: Boolean? = null set(value) { field = value @@ -238,3 +238,20 @@ class Persist { var Provider: String = "", ) } + +fun Ipn.MaskedPrefs.deepCopy(): Ipn.MaskedPrefs { + return Ipn.MaskedPrefs().also { + if (this.ControlURLSet == true) it.ControlURL = this.ControlURL + if (this.RouteAllSet == true) it.RouteAll = this.RouteAll + if (this.CorpDNSSet == true) it.CorpDNS = this.CorpDNS + if (this.ExitNodeIDSet == true) it.ExitNodeID = this.ExitNodeID + if (this.ExitNodeAllowLANAccessSet == true) + it.ExitNodeAllowLANAccess = this.ExitNodeAllowLANAccess + if (this.WantRunningSet == true) it.WantRunning = this.WantRunning + if (this.LoggedOutSet == true) it.LoggedOut = this.LoggedOut + if (this.ShieldsUpSet == true) it.ShieldsUp = this.ShieldsUp + if (this.AdvertiseRoutesSet == true) it.AdvertiseRoutes = this.AdvertiseRoutes + if (this.ForceDaemonSet == true) it.ForceDaemon = this.ForceDaemon + if (this.HostnameSet == true) it.Hostname = this.Hostname + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt index 77322cb..861e64c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt @@ -15,7 +15,8 @@ class Netmap { var Domain: String, var UserProfiles: Map, var TKAEnabled: Boolean, - var DNS: Tailcfg.DNSConfig? = null + var DNS: Tailcfg.DNSConfig? = null, + var AllCaps: List = emptyList() ) { // Keys are tailcfg.UserIDs thet get stringified // Helpers @@ -51,5 +52,9 @@ class Netmap { UserProfiles == other.UserProfiles && TKAEnabled == other.TKAEnabled } + + fun hasCap(capability: String): Boolean { + return AllCaps.contains(capability) + } } } 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 f79b89d..658cb06 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 @@ -183,7 +188,15 @@ class Tailcfg { data class Service(var Proto: String, var Port: Int, var Description: String? = null) @Serializable - data class NetworkProfile(var MagicDNSName: String? = null, var DomainName: String? = null) + data class NetworkProfile( + var MagicDNSName: String? = null, + var DomainName: String? = null, + var DisplayName: String? = null + ) { + fun tailnetNameForDisplay(): String? { + return DisplayName ?: DomainName + } + } @Serializable data class Location( diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt b/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt index b4265d2..4329d0f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt @@ -13,10 +13,13 @@ import com.tailscale.ipn.UninitializedApp import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV object AndroidTVUtil { + private val FEATURE_FIRETV = "amazon.hardware.fire_tv" + fun isAndroidTV(): Boolean { val pm = UninitializedApp.get().packageManager return (pm.hasSystemFeature(@Suppress("deprecation") PackageManager.FEATURE_TELEVISION) || - pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) + pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) || + pm.hasSystemFeature(FEATURE_FIRETV)) } } 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 0000000..9e73a42 --- /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/util/PermissionsDisplayUtil.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PermissionsDisplayUtil.kt new file mode 100644 index 0000000..7ba877f --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/PermissionsDisplayUtil.kt @@ -0,0 +1,21 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import android.net.Uri + +/** Converts a SAF URI string to a more human-friendly folder display name. */ +fun friendlyDirName(uriStr: String): String { + val uri = Uri.parse(uriStr) + val segment = uri.lastPathSegment ?: return uriStr + + return when { + segment.startsWith("primary:") -> "Internal storage › " + segment.removePrefix("primary:") + segment.contains(":") -> { + val folder = segment.substringAfter(":") + "SD card › $folder" + } + else -> segment + } +} 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 54adf27..e4adf8d 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 @@ -29,6 +29,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import coil.annotation.ExperimentalCoilApi import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter import com.tailscale.ipn.R import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.util.AndroidTVUtil @@ -44,6 +45,7 @@ fun Avatar( ) { val isFocused = remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current + val isIconLoaded = remember { mutableStateOf(false) } // Outer Box for the larger focusable and clickable area Box( @@ -73,20 +75,28 @@ fun Avatar( contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(CircleShape)) { // Always display the default icon as a background layer - Icon( - imageVector = Icons.Default.Person, - contentDescription = stringResource(R.string.settings_title), - modifier = - Modifier.conditional(AndroidTVUtil.isAndroidTV(), { size((size * 0.8f).dp) }) - .clip(CircleShape) // Icon size slightly smaller than the Box - ) + if (!isIconLoaded.value) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = stringResource(R.string.settings_title), + modifier = + Modifier.conditional( + AndroidTVUtil.isAndroidTV(), { size((size * 0.8f).dp) }) + .clip(CircleShape) // Icon size slightly smaller than the Box + ) + } // Overlay the profile picture if available profile?.UserProfile?.ProfilePicURL?.let { url -> AsyncImage( model = url, modifier = Modifier.size(size.dp).clip(CircleShape), - contentDescription = null) + contentDescription = null, + onState = { state -> + if (state is AsyncImagePainter.State.Success) { + isIconLoaded.value = true + } + }) } } } 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 991ec39..2f8e607 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,7 +142,10 @@ 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( 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 90e99e6..6c11cc5 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 @@ -1,6 +1,5 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause - package com.tailscale.ipn.ui.view import android.os.Build @@ -61,20 +60,26 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration 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 import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.ShowHide +import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.Netmap @@ -101,10 +106,11 @@ import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.viewModel.AppViewModel import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState import com.tailscale.ipn.ui.viewModel.MainViewModel -import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.util.FeatureFlags +import kotlinx.coroutines.flow.emptyFlow // Navigation actions for the MainView data class MainViewNavigation( @@ -120,7 +126,8 @@ data class MainViewNavigation( fun MainView( loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, - viewModel: MainViewModel + viewModel: MainViewModel, + appViewModel: AppViewModel ) { val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() val healthIcon by viewModel.healthIcon.collectAsState() @@ -146,7 +153,6 @@ fun MainView( // Hide the header only on Android TV when the user needs to login val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin) - ListItem( colors = MaterialTheme.colorScheme.surfaceContainerListItem, leadingContent = { @@ -161,7 +167,7 @@ fun MainView( } }, headlineContent = { - user?.NetworkProfile?.DomainName?.let { domain -> + user?.NetworkProfile?.tailnetNameForDisplay()?.let { domain -> AutoResizingText( text = domain, style = MaterialTheme.typography.titleMedium.short, @@ -202,23 +208,19 @@ fun MainView( } } }) - when (state) { Ipn.State.Running -> { - - PromptPermissionsIfNecessary() - - viewModel.showVPNPermissionLauncherIfUnauthorized() + viewModel.maybeRequestVpnPermission() + LaunchVpnPermissionIfNeeded(viewModel) + PromptForMissingPermissions(viewModel) if (showKeyExpiry) { ExpiryNotification(netmap = netmap, action = { viewModel.login() }) } - if (showExitNodePicker.value == ShowHide.Show) { ExitNodeStatus( navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) } - PeerList( viewModel = viewModel, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, @@ -243,7 +245,6 @@ fun MainView( } } } - currentPingDevice?.let { _ -> ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) { PingView(model = viewModel.pingViewModel) @@ -253,24 +254,45 @@ fun MainView( } } +@Composable +fun TaildropDirectoryPickerPrompt() { + val uriHandler = LocalUriHandler.current + Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.Start) { + Text(text = stringResource(id = R.string.taildrop_directory_picker_body)) + Text( + text = stringResource(id = R.string.taildrop_directory_picker_info), + modifier = Modifier.clickable { uriHandler.openUri(Links.TAILDROP_KB_URL) }, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline) + } +} + +@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() val maybePrefs by viewModel.prefs.collectAsState() val netmap by viewModel.netmap.collectAsState() - // There's nothing to render if we haven't loaded the prefs yet val prefs = maybePrefs ?: return - // The activeExitNode is the source of truth. The selectedExitNode is only relevant if we // don't have an active node. val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID - val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } } val name = exitNodePeer?.exitNodeName - val managedByOrganization by viewModel.managedByOrganization.collectAsState() - Box( modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) { @@ -295,7 +317,6 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { } } } - Box( modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) @@ -415,11 +436,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()) { @@ -479,7 +500,7 @@ fun ConnectView( fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, fontFamily = MaterialTheme.typography.titleMedium.fontFamily) - val tailnetName = user.NetworkProfile?.DomainName ?: "" + val tailnetName = user.NetworkProfile?.tailnetNameForDisplay() ?: "" Text( buildAnnotatedString { append(stringResource(id = R.string.connect_to_tailnet_prefix)) @@ -527,13 +548,12 @@ fun PeerList( viewModel: MainViewModel, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onSearchBarClick: () -> Unit, - onSearch: (String) -> Unit + onSearch: (String) -> Unit, ) { val peerList by viewModel.peers.collectAsState(initial = emptyList()) val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") val showNoResults = remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value - val netmap = viewModel.netmap.collectAsState() val focusManager = LocalFocusManager.current var isSearchFocussed by remember { mutableStateOf(false) } @@ -542,7 +562,6 @@ fun PeerList( val localClipboardManager = LocalClipboardManager.current // Restrict search to devices running API 33+ (see https://github.com/tailscale/corp/issues/27375) val enableSearch = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - Column(modifier = Modifier.fillMaxSize()) { if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) { Search(onSearchBarClick) @@ -589,7 +608,6 @@ fun PeerList( } } } - // Peers display LazyColumn( modifier = @@ -597,7 +615,6 @@ fun PeerList( .weight(1f) // LazyColumn gets the remaining vertical space .onFocusChanged { isListFocussed = it.isFocused } .background(color = MaterialTheme.colorScheme.surface)) { - // Handle case when no results are found if (showNoResults) { item { @@ -613,7 +630,6 @@ fun PeerList( fontWeight = FontWeight.Light) } } - // Iterate over peer sets to display them var first = true peerList.forEach { peerSet -> @@ -621,13 +637,11 @@ fun PeerList( item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() } } first = false - if (isAndroidTV()) { item { NodesSectionHeader(peerSet = peerSet) } } else { stickyHeader { NodesSectionHeader(peerSet = peerSet) } } - itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> ListItem( modifier = @@ -694,7 +708,6 @@ fun PeerList( @Composable fun NodesSectionHeader(peerSet: PeerSet) { Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface)) - Lists.LargeTitle( peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user), bottomPadding = 8.dp, @@ -706,7 +719,6 @@ fun NodesSectionHeader(peerSet: PeerSet) { @Composable fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { if (netmap == null) return - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) { Box( modifier = @@ -733,7 +745,10 @@ fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { @OptIn(ExperimentalPermissionsApi::class) @Composable -fun PromptPermissionsIfNecessary() { +fun PromptForMissingPermissions(viewModel: MainViewModel) { + if (viewModel.skipPromptsForAuthKeyLogin()) { + return + } Permissions.prompt.forEach { (permission, state) -> ErrorDialog( title = permission.title, @@ -748,11 +763,10 @@ fun PromptPermissionsIfNecessary() { @Composable fun Search( onSearchBarClick: () -> Unit, // Callback for navigating to SearchView - backgroundColor: Color = MaterialTheme.colorScheme.background // Default background color + backgroundColor: Color = MaterialTheme.colorScheme.background, // Default background color ) { // Prevent multiple taps var isNavigating by remember { mutableStateOf(false) } - Box( modifier = Modifier.fillMaxWidth() @@ -783,7 +797,6 @@ fun Search( Modifier.padding(start = 0.dp) // Optional start padding for alignment ) Spacer(modifier = Modifier.width(4.dp)) - // Placeholder Text Text( text = stringResource(R.string.search_ellipsis), @@ -801,9 +814,9 @@ fun Search( @Preview @Composable fun MainViewPreview() { - val vpnViewModel = VpnViewModel(App.get()) - val vm = MainViewModel(vpnViewModel) - + val fakePrompt = emptyFlow() + val appViewModel = AppViewModel(App.get(), fakePrompt) + val vm = MainViewModel(appViewModel) MainView( {}, MainViewNavigation( @@ -812,5 +825,6 @@ fun MainViewPreview() { onNavigateToExitNodes = {}, onNavigateToHealth = {}, onNavigateToSearch = {}), - vm) + vm, + appViewModel) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/NotificationsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/NotificationsView.kt new file mode 100644 index 0000000..ee78959 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/NotificationsView.kt @@ -0,0 +1,93 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.model.Permissions +import com.tailscale.ipn.ui.theme.exitNodeToggleButton + +@Composable +fun NotificationsView(backToPermissionsView: BackNavigation, openApplicationSettings: () -> Unit) { + val permissions = Permissions.withGrantedStatus + + // Find the notification permission + val notificationPermission = + permissions.find { (permission, _) -> + permission.title == R.string.permission_post_notifications + } + val granted = notificationPermission?.second ?: false + val permission = notificationPermission?.first + + Scaffold( + topBar = { + Header(titleRes = R.string.permission_post_notifications, onBack = backToPermissionsView) + }) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + item { + if (permission != null) { + ListItem( + headlineContent = { + Text( + stringResource(permission.title), + style = MaterialTheme.typography.titleMedium) + }, + supportingContent = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(permission.description), + style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.notification_settings_explanation), + style = MaterialTheme.typography.bodyMedium) + } + }) + } + } + + item("spacer") { + Spacer(modifier = Modifier.height(16.dp)) // soft break instead of divider + } + + item { + ListItem( + headlineContent = { + Text( + text = stringResource(R.string.permission_post_notifications), + style = MaterialTheme.typography.titleMedium) + }, + supportingContent = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = + if (granted) stringResource(R.string.on) + else stringResource(R.string.off), + style = MaterialTheme.typography.bodyMedium) + Button( + colors = MaterialTheme.colorScheme.exitNodeToggleButton, + onClick = openApplicationSettings, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { + Text(stringResource(R.string.open_notification_settings)) + } + } + }) + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt index b033f24..3a5a83d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt @@ -13,33 +13,38 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.google.accompanist.permissions.ExperimentalPermissionsApi +import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.R import com.tailscale.ipn.ui.model.Permissions -import com.tailscale.ipn.ui.theme.success +import com.tailscale.ipn.ui.util.friendlyDirName import com.tailscale.ipn.ui.util.itemsWithDividers +import com.tailscale.ipn.ui.viewModel.PermissionsViewModel -@OptIn(ExperimentalPermissionsApi::class) @Composable -fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () -> Unit) { +fun PermissionsView( + backToSettings: BackNavigation, + navToTaildropDirView: () -> Unit, + navToNotificationsView: () -> Unit, + permissionsViewModel: PermissionsViewModel = viewModel() +) { val permissions = Permissions.withGrantedStatus + Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) { innerPadding -> LazyColumn(modifier = Modifier.padding(innerPadding)) { + // Existing Android runtime permissions itemsWithDividers(permissions) { (permission, granted) -> ListItem( - modifier = Modifier.clickable { openApplicationSettings() }, + modifier = Modifier.clickable { navToNotificationsView() }, leadingContent = { Icon( - if (granted) painterResource(R.drawable.check_circle) - else painterResource(R.drawable.xmark_circle), - tint = - if (granted) MaterialTheme.colorScheme.success - else MaterialTheme.colorScheme.onSurfaceVariant, + painterResource(R.drawable.baseline_notifications_none_24), + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(24.dp), contentDescription = stringResource(if (granted) R.string.ok else R.string.warning)) @@ -47,8 +52,34 @@ fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () headlineContent = { Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium) }, - supportingContent = { Text(stringResource(permission.description)) }, - ) + supportingContent = { + if (granted) Text(stringResource(R.string.on)) else Text(stringResource(R.string.off)) + }) + } + + item { + ListItem( + modifier = Modifier.clickable { navToTaildropDirView() }, + leadingContent = { + Icon( + painterResource(R.drawable.baseline_drive_folder_upload_24), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + contentDescription = stringResource(R.string.taildrop_dir)) + }, + headlineContent = { + Text( + stringResource(R.string.taildrop_dir_access), + style = MaterialTheme.typography.titleMedium) + }, + supportingContent = { + val displayPath = + permissionsViewModel.currentDir.collectAsState().value?.let { + friendlyDirName(it) + } ?: "No access" + + Text(displayPath) + }) } } } 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 82e16b5..2a3ab34 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 @@ -38,15 +38,15 @@ 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.AppViewModel import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsViewModel -import com.tailscale.ipn.ui.viewModel.VpnViewModel @Composable fun SettingsView( settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), - vpnViewModel: VpnViewModel = viewModel() + appViewModel: AppViewModel = viewModel() ) { val handler = LocalUriHandler.current @@ -55,7 +55,7 @@ fun SettingsView( 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 isVPNPrepared by appViewModel.vpnPrepared.collectAsState() val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState() val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState() diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt new file mode 100644 index 0000000..ba9510e --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt @@ -0,0 +1,82 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.theme.exitNodeToggleButton +import com.tailscale.ipn.ui.util.Lists +import com.tailscale.ipn.ui.util.friendlyDirName +import com.tailscale.ipn.ui.viewModel.PermissionsViewModel +import com.tailscale.ipn.util.TSLog + +@Composable +fun TaildropDirView( + backToPermissionsView: BackNavigation, + openDirectoryLauncher: ActivityResultLauncher, + permissionsViewModel: PermissionsViewModel +) { + Scaffold( + topBar = { + Header(titleRes = R.string.taildrop_dir_access, onBack = backToPermissionsView) + }) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + item { + ListItem( + headlineContent = { + Text( + stringResource(R.string.taildrop_dir_access), + style = MaterialTheme.typography.titleMedium) + }, + supportingContent = { + Text( + text = stringResource(R.string.permission_taildrop_dir), + style = MaterialTheme.typography.bodyMedium) + }) + } + + item("divider0") { Lists.SectionDivider() } + + item { + val currentDir by permissionsViewModel.currentDir.collectAsState() + TSLog.d("TaildropDirView", "currentDir in UI: $currentDir") + val displayPath = currentDir?.let { friendlyDirName(it) } ?: "No access" + + ListItem( + headlineContent = { + Text( + text = stringResource(R.string.dir_access), + style = MaterialTheme.typography.titleMedium) + }, + supportingContent = { + Column(modifier = Modifier.fillMaxWidth()) { + Text(text = displayPath, style = MaterialTheme.typography.bodyMedium) + Button( + colors = MaterialTheme.colorScheme.exitNodeToggleButton, + onClick = { openDirectoryLauncher.launch(null) }, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { + Text(stringResource(R.string.pick_dir)) + } + } + }) + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt index 6fb76a6..64d4bd4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt @@ -3,6 +3,8 @@ package com.tailscale.ipn.ui.view +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,8 +12,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.ClickableText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -20,13 +24,19 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -46,10 +56,14 @@ data class UserSwitcherNav( @OptIn(ExperimentalMaterial3Api::class) @Composable fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = viewModel()) { - val users by viewModel.loginProfiles.collectAsState() val currentUser by viewModel.loggedInUser.collectAsState() val showHeaderMenu by viewModel.showHeaderMenu.collectAsState() + var showDeleteDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + val netmapState by viewModel.netmap.collectAsState() + val capabilityIsOwner = "https://tailscale.com/cap/is-owner" + val isOwner = netmapState?.hasCap(capabilityIsOwner) == true Scaffold( topBar = { @@ -138,10 +152,47 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi } }) } + + Lists.SectionDivider() + Setting.Text(R.string.delete_tailnet, destructive = true) { + showDeleteDialog = true + } } } } } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(text = stringResource(R.string.delete_tailnet)) }, + text = { + if (isOwner) { + OwnerDeleteDialogText { + val uri = Uri.parse("https://login.tailscale.com/admin/settings/general") + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + } + } else { + Text(stringResource(R.string.request_deletion_nonowner)) + } + }, + confirmButton = { + TextButton( + onClick = { + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse("https://tailscale.com/contact/support")) + context.startActivity(intent) + showDeleteDialog = false + }) { + Text(text = stringResource(R.string.contact_support)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(text = stringResource(R.string.cancel)) + } + }) + } } @Composable @@ -171,6 +222,41 @@ fun FusMenu( } } +@Composable +fun OwnerDeleteDialogText(onSettingsClick: () -> Unit) { + val part1 = stringResource(R.string.request_deletion_owner_part1) + val part2a = stringResource(R.string.request_deletion_owner_part2a) + val part2b = stringResource(R.string.request_deletion_owner_part2b) + + val annotatedText = buildAnnotatedString { + append(part1 + " ") + + pushStringAnnotation( + tag = "settings", annotation = "https://login.tailscale.com/admin/settings/general") + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { + append("Settings > General") + } + pop() + + append(" $part2a\n\n") // newline after "Delete tailnet." + append(part2b) + } + + val context = LocalContext.current + ClickableText( + text = annotatedText, + style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), + onClick = { offset -> + annotatedText + .getStringAnnotations(tag = "settings", start = offset, end = offset) + .firstOrNull() + ?.let { annotation -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)) + context.startActivity(intent) + } + }) +} + @Composable fun MenuItem(text: String, onClick: () -> Unit) { DropdownMenuItem( diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt index 0c2a3dc..10f52d8 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt @@ -63,7 +63,7 @@ fun UserView( supportingContent = { Column { AutoResizingText( - text = profile.NetworkProfile?.DomainName ?: "", + text = profile.NetworkProfile?.tailnetNameForDisplay() ?: "", style = MaterialTheme.typography.bodyMedium.short, minFontSize = MaterialTheme.typography.minTextSize, overflow = TextOverflow.Ellipsis) diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt new file mode 100644 index 0000000..b5913a3 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt @@ -0,0 +1,119 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.viewModel + +import android.app.Application +import android.net.Uri +import android.net.VpnService +import android.util.Log +import androidx.activity.result.ActivityResultLauncher +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.tailscale.ipn.App +import com.tailscale.ipn.util.ShareFileHelper +import com.tailscale.ipn.util.TSLog +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class AppViewModelFactory(val application: Application, private val taildropPrompt: Flow) : + ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(AppViewModel::class.java)) { + return AppViewModel(application, taildropPrompt) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} + +// Application context-aware ViewModel used to track app-wide VPN and Taildrop state. +// This must be application-scoped because Tailscale may be enabled, disabled, or used for +// file transfers (Taildrop) outside the activity lifecycle. +// +// Responsibilities: +// - Track VPN preparation state (e.g., whether permission has been granted) and activity state +// - Monitor incoming Taildrop file transfers +// - Coordinate prompts for Taildrop directory selection if not yet configured +class AppViewModel(application: Application, private val taildropPrompt: Flow) : + AndroidViewModel(application) { + // Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or + // if the user has previously consented to the VPN application. This is used to determine whether + // a VPN permission launcher needs to be shown. + val _vpnPrepared = MutableStateFlow(false) + val vpnPrepared: StateFlow = _vpnPrepared + // Whether a VPN interface has been established. This is set by net.updateTUN upon + // VpnServiceBuilder.establish, and consumed by UI to reflect VPN state. + val _vpnActive = MutableStateFlow(false) + val vpnActive: StateFlow = _vpnActive + // Select Taildrop directory + var directoryPickerLauncher: ActivityResultLauncher? = null + private val _triggerDirectoryPicker = MutableSharedFlow(extraBufferCapacity = 1) + val triggerDirectoryPicker: SharedFlow = _triggerDirectoryPicker + val TAG = "AppViewModel" + + init { + observeIncomingTaildrop() + prepareVpn() + } + + private fun observeIncomingTaildrop() { + viewModelScope.launch { + taildropPrompt.collect { + TSLog.d(TAG, "Taildrop event received, checking directory") + checkIfTaildropDirectorySelected() + } + } + } + + fun requestDirectoryPicker() { + _triggerDirectoryPicker.tryEmit(Unit) + } + + private fun prepareVpn() { + // Check if the user has granted permission yet. + if (!vpnPrepared.value) { + val vpnIntent = VpnService.prepare(getApplication()) + if (vpnIntent != null) { + setVpnPrepared(false) + Log.d(TAG, "VpnService.prepare returned non-null intent") + } else { + setVpnPrepared(true) + Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared") + } + } + } + + fun checkIfTaildropDirectorySelected() { + val app = App.get() + val storedUri = app.getStoredDirectoryUri() + if (ShareFileHelper.hasValidTaildropDir()) { + return + } + + val documentFile = storedUri?.let { DocumentFile.fromTreeUri(app, it) } + if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) { + TSLog.d( + "MainViewModel", + "Stored directory URI is invalid or inaccessible; launching directory picker.") + viewModelScope.launch { requestDirectoryPicker() } + } else { + TSLog.d("MainViewModel", "Using stored directory URI: $storedUri") + } + } + + fun setVpnActive(isActive: Boolean) { + _vpnActive.value = isActive + } + + fun setVpnPrepared(isPrepared: Boolean) { + _vpnPrepared.value = isPrepared + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index 20230f6..865481b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -11,6 +11,7 @@ import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.UserID +import com.tailscale.ipn.ui.model.deepCopy import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.util.AdvertisedRoutesHelper import com.tailscale.ipn.ui.util.LoadingIndicator @@ -144,52 +145,56 @@ open class IpnViewModel : ViewModel() { // Login/Logout + /** + * Order of operations: + * 1. editPrefs() with maskedPrefs (to allow ControlURL override), WantRunning=true, + * LoggedOut=false if AuthKey != null + * 2. start() starts the LocalBackend state machine + * 3. startLoginInteractive() is currently required for bother interactive and non-interactive + * (using auth key) login + * + * Any failure short‑circuits the chain and invokes completionHandler once. + */ fun login( maskedPrefs: Ipn.MaskedPrefs? = null, authKey: String? = null, completionHandler: (Result) -> Unit = {} ) { + val client = Client(viewModelScope) - val loginAction = { - Client(viewModelScope).startLoginInteractive { result -> - result - .onSuccess { TSLog.d(TAG, "Login started: $it") } - .onFailure { TSLog.e(TAG, "Error starting login: ${it.message}") } - completionHandler(result) - } - } - - // Need to stop running before logging in to clear routes: - // https://linear.app/tailscale/issue/ENG-3441/routesdns-is-not-cleared-when-switching-profiles-or-reauthenticating - val stopThenLogin = { - Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result -> - result - .onSuccess { loginAction() } - .onFailure { TSLog.e(TAG, "Error setting wantRunning to false: ${it.message}") } - } - } - - val startAction = { - Client(viewModelScope).start(Ipn.Options(AuthKey = authKey)) { start -> - start.onFailure { completionHandler(Result.failure(it)) }.onSuccess { stopThenLogin() } - } + val finalMaskedPrefs = maskedPrefs?.deepCopy() ?: Ipn.MaskedPrefs() + finalMaskedPrefs.WantRunning = true + if (authKey != null) { + finalMaskedPrefs.LoggedOut = false } - // If an MDM control URL is set, we will always use that in lieu of anything the user sets. - var prefs = maskedPrefs - val mdmControlURL = MDMSettings.loginURL.flow.value.value - - if (mdmControlURL != null) { - prefs = prefs ?: Ipn.MaskedPrefs() - prefs.ControlURL = mdmControlURL - TSLog.d(TAG, "Overriding control URL with MDM value: $mdmControlURL") + client.editPrefs(finalMaskedPrefs) { editResult -> + editResult + .onFailure { + TSLog.e(TAG, "editPrefs() failed: ${it.message}") + completionHandler(Result.failure(it)) + } + .onSuccess { + val opts = Ipn.Options(UpdatePrefs = editResult.getOrThrow(), AuthKey = authKey) + client.start(opts) { startResult -> + startResult + .onFailure { + TSLog.e(TAG, "start() failed: ${it.message}") + completionHandler(Result.failure(it)) + } + .onSuccess { + client.startLoginInteractive { loginResult -> + loginResult + .onFailure { + TSLog.e(TAG, "startLoginInteractive() failed: ${it.message}") + completionHandler(Result.failure(it)) + } + .onSuccess { completionHandler(Result.success(Unit)) } + } + } + } + } } - - prefs?.let { - Client(viewModelScope).editPrefs(it) { result -> - result.onFailure { completionHandler(Result.failure(it)) }.onSuccess { startAction() } - } - } ?: run { startAction() } } fun loginWithAuthKey(authKey: String, completionHandler: (Result) -> 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 2d75841..4c3f6a7 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 @@ -1,9 +1,9 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause - 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 @@ -25,6 +25,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 java.time.Duration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -35,61 +36,55 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch -class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory { +class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(MainViewModel::class.java)) { - return MainViewModel(vpnViewModel) as T + return MainViewModel(appViewModel) as T } throw IllegalArgumentException("Unknown ViewModel class") } } @OptIn(FlowPreview::class) -class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { +class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { // The user readable state of the system val stateRes: StateFlow = MutableStateFlow(userStringRes(State.NoState, State.NoState, true)) - // The expected state of the VPN toggle private val _vpnToggleState = MutableStateFlow(false) val vpnToggleState: StateFlow = _vpnToggleState - // Keeps track of whether a toggle operation is in progress. This ensures that toggleVpn cannot be // invoked until the current operation is complete. var isToggleInProgress = MutableStateFlow(false) - // 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()) val peers: StateFlow> = _peers - // The list of peers private val _searchViewPeers = MutableStateFlow>(emptyList()) val searchViewPeers: StateFlow> = _searchViewPeers - // The current state of the IPN for determining view visibility val ipnState = Notifier.state - // The active search term for filtering peers private val _searchTerm = MutableStateFlow("") val searchTerm: StateFlow = _searchTerm - var autoFocusSearch by mutableStateOf(true) private set - // True if we should render the key expiry bannder val showExpiry: StateFlow = MutableStateFlow(false) - // The peer for which the dropdown menu is currently expanded. Null if no menu is expanded var expandedMenuPeer: StateFlow = MutableStateFlow(null) var pingViewModel: PingViewModel = PingViewModel() - val isVpnPrepared: StateFlow = vpnViewModel.vpnPrepared + val isVpnPrepared: StateFlow = appViewModel.vpnPrepared - val isVpnActive: StateFlow = vpnViewModel.vpnActive + val isVpnActive: StateFlow = appViewModel.vpnActive var searchJob: Job? = null @@ -115,18 +110,22 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { fun onPingDismissal() { this.pingViewModel.handleDismissal() } + // Returns true if we should skip all of the user-interactive permissions prompts + // (with the exception of the VPN permission prompt) + fun skipPromptsForAuthKeyLogin(): Boolean { + val v = MDMSettings.authKey.flow.value.value + return v != null && v != "" + } private val peerCategorizer = PeerCategorizer() init { viewModelScope.launch { var previousState: State? = null - combine(Notifier.state, isVpnActive) { state, active -> state to active } .collect { (currentState, active) -> // Determine the correct state resource string stateRes.set(userStringRes(currentState, previousState, active)) - // Determine if the VPN toggle should be on val isOn = when { @@ -135,15 +134,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { previousState == State.NoState && currentState == State.Starting -> true else -> false } - // Update the VPN toggle state _vpnToggleState.value = isOn - // Update the previous state previousState = currentState } } - viewModelScope.launch { _searchTerm.debounce(250L).collect { term -> // run the search as a background task @@ -155,7 +151,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } } - viewModelScope.launch { Notifier.netmap.collect { it -> it?.let { netmap -> @@ -166,7 +161,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { _peers.value = peerCategorizer.peerSets _searchViewPeers.value = filteredPeers } - if (netmap.SelfNode.keyDoesNotExpire) { showExpiry.set(false) return@let @@ -181,20 +175,25 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } } - viewModelScope.launch { App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } } } + fun maybeRequestVpnPermission() { + _requestVpnPermission.value = true + } + fun showVPNPermissionLauncherIfUnauthorized() { val vpnIntent = VpnService.prepare(App.get()) + TSLog.d("VpnPermissions", "vpnIntent=$vpnIntent") if (vpnIntent != null) { vpnPermissionLauncher?.launch(vpnIntent) } else { - vpnViewModel.setVpnPrepared(true) + appViewModel.setVpnPrepared(true) startVPN() } + _requestVpnPermission.value = false // reset } fun toggleVpn(desiredState: Boolean) { @@ -207,12 +206,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { isToggleInProgress.value = true try { val currentState = Notifier.state.value - val isPrepared = vpnViewModel.vpnPrepared.value if (desiredState) { // User wants to turn ON the VPN when { - !isPrepared -> showVPNPermissionLauncherIfUnauthorized() currentState != Ipn.State.Running -> startVPN() } } else { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt new file mode 100644 index 0000000..507ccc5 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt @@ -0,0 +1,22 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.viewModel + +import androidx.lifecycle.ViewModel +import com.tailscale.ipn.TaildropDirectoryStore +import com.tailscale.ipn.util.TSLog +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class PermissionsViewModel : ViewModel() { + private val _currentDir = + MutableStateFlow(TaildropDirectoryStore.loadSavedDir()?.toString()) + val currentDir: StateFlow = _currentDir + + fun refreshCurrentDir() { + val newUri = TaildropDirectoryStore.loadSavedDir()?.toString() + TSLog.d("PermissionsViewModel", "refreshCurrentDir: $newUri") + _currentDir.value = newUri + } +} 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 89fe839..7bdf531 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) @@ -26,6 +30,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()) initSelectedPackageNames() @@ -53,15 +59,23 @@ class SplitTunnelAppPickerViewModel : ViewModel() { } fun select(packageName: String) { - if (selectedPackageNames.value.contains(packageName)) { - return - } + if (excludedPackageNames.value.contains(packageName)) return + selectedPackageNames.set(selectedPackageNames.value + packageName) - App.get().addUserSelectedPackage(packageName) + debounceSave() } fun deselect(packageName: String) { selectedPackageNames.set(selectedPackageNames.value - packageName) - App.get().removeUserSelectedPackage(packageName) + debounceSave() + } + + private fun debounceSave() { + saveJob?.cancel() + saveJob = + viewModelScope.launch { + delay(500) // Wait to batch multiple rapid updates + App.get().updateUserSelectedPackages(selectedPackageNames.value) + } } } diff --git a/android/src/main/java/com/tailscale/ipn/util/HardwareKeyStore.kt b/android/src/main/java/com/tailscale/ipn/util/HardwareKeyStore.kt new file mode 100644 index 0000000..24b9d98 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/util/HardwareKeyStore.kt @@ -0,0 +1,95 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package com.tailscale.ipn.util + +import android.content.pm.PackageManager +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.Signature +import kotlin.random.Random + +class NoSuchKeyException : Exception("no key found matching the provided ID") +class HardwareKeysNotSupported : Exception("hardware-backed keys are not supported on this device") + +// HardwareKeyStore implements the callbacks necessary to implement key.HardwareAttestationKey on +// the Go side. It uses KeyStore with a StrongBox processor. +class HardwareKeyStore() { + var keyStoreKeys = HashMap(); + val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + } + + @OptIn(ExperimentalStdlibApi::class) + fun newID(): String { + var id: String + do { + id = Random.nextBytes(4).toHexString() + } while (keyStoreKeys.containsKey(id)) + return id + } + + fun createKey(): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + throw HardwareKeysNotSupported() + } + val id = newID() + val kpg: KeyPairGenerator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore" + ) + val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder( + id, KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY + ).run { + // Use DIGEST_NONE because hashing is done on the Go side. + setDigests(KeyProperties.DIGEST_NONE) + setIsStrongBoxBacked(true) + build() + } + + kpg.initialize(parameterSpec) + + val kp = kpg.generateKeyPair() + keyStoreKeys[id] = kp + return id + } + + fun releaseKey(id: String) { + keyStoreKeys.remove(id) + } + + fun sign(id: String, data: ByteArray): ByteArray { + val key = keyStoreKeys[id] + if (key == null) { + throw NoSuchKeyException() + } + // Use NONEwithECDSA because hashing is done on the Go side. + return Signature.getInstance("NONEwithECDSA").run { + initSign(key.private) + update(data) + sign() + } + } + + fun public(id: String): ByteArray { + val key = keyStoreKeys[id] + if (key == null) { + throw NoSuchKeyException() + } + return key.public.encoded + } + + fun load(id: String) { + if (keyStoreKeys[id] != null) { + // Already loaded. + return + } + val entry: KeyStore.Entry = keyStore.getEntry(id, null) + if (entry !is KeyStore.PrivateKeyEntry) { + throw NoSuchKeyException() + } + keyStoreKeys[id] = KeyPair(entry.certificate.publicKey, entry.privateKey) + } +} \ No newline at end of file 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 0000000..d14f791 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -0,0 +1,322 @@ +// 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 android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import com.tailscale.ipn.TaildropDirectoryStore +import com.tailscale.ipn.ui.util.InputStreamAdapter +import com.tailscale.ipn.ui.util.OutputStreamAdapter +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import libtailscale.Libtailscale +import org.json.JSONObject + +data class SafFile(val fd: Int, val uri: String) + +object ShareFileHelper : libtailscale.ShareFileHelper { + private var appContext: Context? = null + private var app: libtailscale.Application? = null + private var savedUri: String? = null + private var scope: CoroutineScope? = null + + @JvmStatic + fun init(context: Context, app: libtailscale.Application, uri: String, appScope: CoroutineScope) { + appContext = context.applicationContext + this.app = app + savedUri = uri + scope = appScope + Libtailscale.setShareFileHelper(this) + TSLog.d("ShareFileHelper", "init ShareFileHelper with savedUri: $savedUri") + } + + // A simple data class that holds a SAF OutputStream along with its URI. + data class SafStream(val uri: String, val stream: OutputStream) + + val taildropPrompt = MutableSharedFlow(replay = 1) + + fun observeTaildropPrompt(): Flow = taildropPrompt + + @Volatile private var directoryReady: CompletableDeferred? = null + + fun hasValidTaildropDir(): Boolean { + val uri = TaildropDirectoryStore.loadSavedDir() + if (uri == null) return false + + // Only SAF tree URIs are supported + if (uri.scheme != "content") { + TSLog.w("ShareFileHelper", "Invalid URI scheme for taildrop dir: ${uri.scheme}") + return false + } + + val context = appContext ?: return false + val docFile = DocumentFile.fromTreeUri(context, uri) + + if (docFile == null || !docFile.exists() || !docFile.canWrite()) { + TSLog.w("ShareFileHelper", "Stored taildrop URI is invalid or inaccessible: $uri") + return false + } + + return true + } + + private suspend fun waitUntilTaildropDirReady() { + if (!hasValidTaildropDir()) { + if (directoryReady?.isActive != true) { + directoryReady = CompletableDeferred() + scope?.launch { taildropPrompt.emit(Unit) } + } + directoryReady?.await() + } + } + + fun notifyDirectoryReady() { + directoryReady?.takeIf { !it.isCompleted }?.complete(Unit) + } + + // A helper function that opens or creates a SafStream for a given file. + private fun openSafFileOutputStream(fileName: String): Pair { + val context = appContext ?: return "" to null + val dirUri = savedUri ?: return "" to null + val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null + val file = + dir.findFile(fileName) + ?: dir.createFile("application/octet-stream", fileName) + ?: return "" to null + val os = context.contentResolver.openOutputStream(file.uri, "rw") + return file.uri.toString() to os + } + + @Throws(IOException::class) + private fun openWriterFD(fileName: String, offset: Long): Pair { + val ctx = appContext ?: throw IOException("App context not initialized") + val dirUri = savedUri ?: throw IOException("No directory URI") + val dir = + DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) + ?: throw IOException("Invalid tree URI: $dirUri") + val file = + dir.findFile(fileName) + ?: dir.createFile("application/octet-stream", fileName) + ?: throw IOException("Failed to create file: $fileName") + val pfd = + ctx.contentResolver.openFileDescriptor(file.uri, "rw") + ?: throw IOException("Failed to open file descriptor for ${file.uri}") + val fos = FileOutputStream(pfd.fileDescriptor) + if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0) + return file.uri.toString() to SeekableOutputStream(fos, pfd) + } + + private val currentUri = ConcurrentHashMap() + + @Throws(IOException::class) + override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream { + runBlocking { waitUntilTaildropDirReady() } + val (uri, stream) = openWriterFD(fileName, offset) + if (stream == null) { + throw IOException("Failed to open file writer for $fileName") + } + currentUri[fileName] = uri + return OutputStreamAdapter(stream) + } + + @Throws(IOException::class) + override fun getFileURI(fileName: String): String { + runBlocking { waitUntilTaildropDirReady() } + currentUri[fileName]?.let { + return it + } + val ctx = appContext ?: throw IOException("App context not initialized") + val dirStr = savedUri ?: throw IOException("No saved directory URI") + val dir = + DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr)) + ?: throw IOException("Invalid tree URI: $dirStr") + val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName") + val uri = file.uri.toString() + currentUri[fileName] = uri + return uri + } + + @Throws(IOException::class) + override fun renameFile(oldPath: String, targetName: String): String { + val ctx = appContext ?: throw IOException("not initialized") + val dirUri = savedUri ?: throw IOException("directory not set") + val srcUri = Uri.parse(oldPath) + val dir = + DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) + ?: throw IOException("cannot open dir $dirUri") + + var finalName = targetName + dir.findFile(finalName)?.let { existing -> + if (lengthOfUri(ctx, existing.uri) == 0L) { + existing.delete() + } else { + finalName = generateNewFilename(finalName) + } + } + + try { + DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri -> + runCatching { ctx.contentResolver.delete(srcUri, null, null) } + cleanupPartials(dir, targetName) + return newUri.toString() + } + } catch (e: Exception) { + TSLog.w( + "renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}") + } + + val dest = + dir.createFile("application/octet-stream", finalName) + ?: throw IOException("createFile failed for $finalName") + + ctx.contentResolver.openInputStream(srcUri).use { inp -> + ctx.contentResolver.openOutputStream(dest.uri, "w").use { out -> + if (inp == null || out == null) { + dest.delete() + throw IOException("Unable to open output stream for URI: ${dest.uri}") + } + inp.copyTo(out) + } + } + + ctx.contentResolver.delete(srcUri, null, null) + cleanupPartials(dir, targetName) + return dest.uri.toString() + } + + private fun lengthOfUri(ctx: Context, uri: Uri): Long = + ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 } + // delete any stray “.partial” files for this base name + private fun cleanupPartials(dir: DocumentFile, base: String) { + for (child in dir.listFiles()) { + val n = child.name ?: continue + if (n.endsWith(".partial") && n.contains(base, ignoreCase = false)) { + child.delete() + } + } + } + + @Throws(IOException::class) + override fun deleteFile(uri: String) { + runBlocking { waitUntilTaildropDirReady() } + val ctx = appContext ?: throw IOException("DeleteFile: not initialized") + val uri = Uri.parse(uri) + val doc = + DocumentFile.fromSingleUri(ctx, uri) + ?: throw IOException("DeleteFile: cannot resolve URI $uri") + if (!doc.delete()) { + throw IOException("DeleteFile: delete() returned false for $uri") + } + } + + @Throws(IOException::class) + override fun getFileInfo(fileName: String): String { + val context = appContext ?: throw IOException("app context not initialized") + val dirUri = savedUri ?: throw IOException("SAF URI not initialized") + val dir = + DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) + ?: throw IOException("could not resolve SAF root") + val file = + dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory") + val name = file.name ?: throw IOException("file name missing for $fileName") + val size = file.length() + val modTime = file.lastModified() + return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}""" + } + + private fun jsonEscape(s: String): String { + return JSONObject.quote(s) + } + + 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" + } + + fun listPartialFiles(suffix: String): Array { + val context = appContext ?: return emptyArray() + val rootUri = savedUri ?: return emptyArray() + val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray() + return dir.listFiles() + .filter { it.name?.endsWith(suffix) == true } + .mapNotNull { it.name } + .toTypedArray() + } + + @Throws(IOException::class) + override fun listFilesJSON(suffix: String): String { + val list = listPartialFiles(suffix) + if (list.isEmpty()) { + throw IOException("no files found matching suffix \"$suffix\"") + } + return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]") + } + + @Throws(IOException::class) + override fun openFileReader(name: String): libtailscale.InputStream { + val context = appContext ?: throw IOException("app context not initialized") + val rootUri = savedUri ?: throw IOException("SAF URI not initialized") + val dir = + DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) + ?: throw IOException("could not open SAF root") + val suffix = name.substringAfterLast('.', ".$name") + val file = + dir.listFiles().firstOrNull { + val fname = it.name ?: return@firstOrNull false + fname.endsWith(suffix, ignoreCase = false) + } ?: throw IOException("no file ending with \"$suffix\" in SAF directory") + val inStream = + context.contentResolver.openInputStream(file.uri) + ?: throw IOException("openInputStream returned null for ${file.uri}") + return InputStreamAdapter(inStream) + } + + fun setUri(uri: String) { + savedUri = uri + } + + private class SeekableOutputStream( + private val fos: FileOutputStream, + private val pfd: ParcelFileDescriptor + ) : OutputStream() { + private var closed = false + + override fun write(b: Int) = fos.write(b) + + override fun write(b: ByteArray) = fos.write(b) + + override fun write(b: ByteArray, off: Int, len: Int) { + fos.write(b, off, len) + } + + override fun close() { + if (!closed) { + closed = true + try { + fos.flush() + fos.fd.sync() // blocks until data + metadata are durable + } finally { + fos.close() + pfd.close() + } + } + } + + override fun flush() = fos.flush() + } +} diff --git a/android/src/main/res/drawable/baseline_drive_folder_upload_24.xml b/android/src/main/res/drawable/baseline_drive_folder_upload_24.xml new file mode 100644 index 0000000..2582c88 --- /dev/null +++ b/android/src/main/res/drawable/baseline_drive_folder_upload_24.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/android/src/main/res/drawable/baseline_folder_open_24.xml b/android/src/main/res/drawable/baseline_folder_open_24.xml new file mode 100644 index 0000000..5601372 --- /dev/null +++ b/android/src/main/res/drawable/baseline_folder_open_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/src/main/res/drawable/baseline_notifications_none_24.xml b/android/src/main/res/drawable/baseline_notifications_none_24.xml new file mode 100644 index 0000000..1fb5684 --- /dev/null +++ b/android/src/main/res/drawable/baseline_notifications_none_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 4c284bc..ab7766f 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -13,7 +13,7 @@ Not connected %s - Selected + Selected Offline OK Continue @@ -24,6 +24,10 @@ No results Back Clear search + Off + On + Try again + Cancel Tailscale @@ -132,6 +136,19 @@ Invalid key Custom control server URL Auth key + Delete tailnet + Contact support + All requests related to the removal or deletion of data are handled by our Support team. To open a request, tap the Contact Support button below to be taken to our contact form in the browser. Complete the form, and a Customer Support Engineer will work with you directly to assist. + + As the owner of this tailnet, to remove yourself from the tailnet you can either reassign ownership and contact our Support team, or delete the whole tailnet through the admin console. To do the latter, go to + + + and look for “Delete tailnet”. + + + + All requests related to the removal or deletion of data are handled by our Support team. To open a request, tap the Contact Support button below to be taken to our contact form in the browser. Complete the form, and a Customer Support Engineer will work with you directly to assist. + Choose exit node @@ -203,6 +220,8 @@ Hides the specified categories of network devices from the devices list in the client. Allow LAN access when using an exit node Enable posture checking + Serial number of the device that is running Tailscale + Allows administrators to pass the serial number of the device to Tailscale client using MDM. Use Tailscale DNS settings Use Tailscale subnets Allow incoming connections @@ -214,6 +233,8 @@ Run as exit node visibility Defines an auth key that will be used for login. Auth Key + Skips the intro page shown to users that open the app for the first time + Skip the Onboarding Flow Permissions @@ -221,7 +242,13 @@ We use storage in order to receive files with Taildrop. Notifications We use notifications to help you troubleshoot broken connections, or notify you before you need to reauthenticate to your network. Persistent status notifications are off by default and can be enabled in system settings. - + Go to notification settings + Persistent status notifications are off by default and can be enabled in system settings. + Taildrop directory + Taildrop directory access + Give Tailscale access to a folder in order to be able to download incoming files sent to you via Taildrop. + Directory access + Pick a different directory Send with Taildrop @@ -311,7 +338,6 @@ VPN permission denied Only one VPN can be active, and it appears another is already running. Before starting Tailscale, disable the other VPN. Go to Settings - Cancel Subnet routes Advertise routes to machines that are not running Tailscale to make them available in your tailnet. Routes must be approved in the admin console. Open KB Article @@ -331,4 +357,14 @@ Hostname Failed to save + + VPN permission needed + Tailscale needs VPN access, but it looks like your device may not show VPN settings. If you are using another VPN app or have work policies, disable them first, then try again. + + + Taildrop Directory + You have not selected a directory for incoming taildrop transfers. Please select or create a target directory. + What is taildrop? + Open Directory Picker + diff --git a/android/src/main/res/xml/app_restrictions.xml b/android/src/main/res/xml/app_restrictions.xml index 7f5549f..b47cc58 100644 --- a/android/src/main/res/xml/app_restrictions.xml +++ b/android/src/main/res/xml/app_restrictions.xml @@ -66,6 +66,12 @@ android:restrictionType="choice" android:title="@string/enable_posture_checking" /> + + + + \ No newline at end of file diff --git a/go.mod b/go.mod index 94b6204..5500779 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/tailscale/tailscale-android -go 1.24.0 +go 1.25.1 require ( - github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 + github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.83.0-pre.0.20250414201714-10fd61f1bb6b + tailscale.com v1.87.0-pre.0.20250909160301-77250a301aee ) require ( @@ -33,16 +33,15 @@ require ( github.com/djherbis/times v1.6.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gaissmai/bart v0.18.0 // indirect - github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect 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 @@ -69,20 +68,20 @@ require ( github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect - github.com/vishvananda/netns v0.0.4 // indirect + github.com/vishvananda/netns v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.37.0 // indirect + golang.org/x/crypto v0.38.0 // indirect golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect - golang.org/x/mod v0.23.0 // indirect - golang.org/x/net v0.36.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/term v0.31.0 // indirect - golang.org/x/text v0.24.0 // indirect - golang.org/x/time v0.10.0 // indirect - golang.org/x/tools v0.30.0 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/time v0.11.0 // indirect + golang.org/x/tools v0.33.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect diff --git a/go.sum b/go.sum index f5c6587..508feb2 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= @@ -49,8 +59,6 @@ github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yez github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI= -github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= @@ -59,10 +67,12 @@ github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= -github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY= -github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/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 +81,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 +107,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 +129,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,17 +161,21 @@ 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/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/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-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw= +github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/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= -github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= -github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= @@ -168,37 +184,39 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4 go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -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/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 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/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= -golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +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.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab h1:KONOFF8Uy3b60HEzOsGnNghORNhY4ImyOx0PGm73K9k= golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab/go.mod h1:udWezQGYjqrCxz5nV321pXQTx5oGbZx+khZvFjZNOPM= -golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= -golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= -golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= -golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= -golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= -golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= @@ -211,9 +229,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.87.0-pre.0.20250909160301-77250a301aee h1:BCJ6ux5S7jSv8OkbUHUISyKso+m5VMf9zJ6mQAsBZ+s= +tailscale.com v1.87.0-pre.0.20250909160301-77250a301aee/go.mod h1:LHaTiwRgzebPDLgZ6RQQVzX+1SR5fbNl51fzm7UtMaw= diff --git a/go.toolchain.rev b/go.toolchain.rev index e8ede33..1fd4f3d 100644 --- a/go.toolchain.rev +++ b/go.toolchain.rev @@ -1 +1 @@ -982da8f24fa0504f2214f24b0d68b2febd5983f8 +aa85d1541af0921f830f053f29d91971fa5838f6 diff --git a/libtailscale/backend.go b/libtailscale/backend.go index 21eea06..8503b77 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,19 +43,21 @@ 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 store *stateStore - policyStore *syspolicyHandler + policyStore *syspolicyStore logIDPublicAtomic atomic.Pointer[logid.PublicID] localAPIHandler http.Handler backend *ipnlocal.LocalBackend ready sync.WaitGroup + backendMu sync.Mutex } func start(dataDir, directFileRoot string, appCtx AppContext) Application { @@ -96,6 +100,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 @@ -121,7 +127,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 } @@ -135,7 +141,14 @@ func (a *App) runBackend(ctx context.Context) error { a.backend = b.backend defer b.CloseTUNs() - h := localapi.NewHandler(ipnauth.Self, b.backend, log.Printf, *a.logIDPublicAtomic.Load()) + hc := localapi.HandlerConfig{ + Actor: ipnauth.Self, + Backend: b.backend, + Logf: log.Printf, + LogID: *a.logIDPublicAtomic.Load(), + EventBus: b.bus, + } + h := localapi.NewHandler(hc) h.PermitRead = true h.PermitWrite = true a.localAPIHandler = h @@ -238,10 +251,10 @@ 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) + sys := tsd.NewSystem() sys.Set(store) logf := logger.RusagePrefixLog(log.Printf) @@ -249,7 +262,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 +283,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) } @@ -290,6 +305,7 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor HealthTracker: sys.HealthTracker(), Metrics: sys.UserMetricsRegistry(), DriveForLocal: driveimpl.NewFileSystemForLocal(logf), + EventBus: sys.Bus.Get(), }) if err != nil { return nil, fmt.Errorf("runBackend: NewUserspaceEngine: %v", err) @@ -308,12 +324,14 @@ 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)) + } + 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 +352,16 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor return b, nil } +func (a *App) watchFileOpsChanges() { + for { + select { + case helper := <-onShareFileHelper: + log.Printf("Got ShareFileHelper") + a.shareFileHelper = helper + } + } +} + 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 2ee022a..9daec5c 100644 --- a/libtailscale/callbacks.go +++ b/libtailscale/callbacks.go @@ -23,6 +23,9 @@ 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) ) // 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 0000000..769cd91 --- /dev/null +++ b/libtailscale/fileops.go @@ -0,0 +1,100 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package libtailscale + +import ( + "encoding/json" + "io" + "os" + "time" + + "tailscale.com/feature/taildrop" +) + +// androidFileOps implements [taildrop.FileOps] using the Android ShareFileHelper. +type androidFileOps struct { + helper ShareFileHelper +} + +var _ taildrop.FileOps = (*androidFileOps)(nil) + +func newAndroidFileOps(helper ShareFileHelper) *androidFileOps { + return &androidFileOps{helper: helper} +} + +func (ops *androidFileOps) OpenWriter(name string, offset int64, _ os.FileMode) (io.WriteCloser, string, error) { + wc, err := ops.helper.OpenFileWriter(name, offset) + if err != nil { + return nil, "", err + } + uri, err := ops.helper.GetFileURI(name) + if err != nil { + wc.Close() + return nil, "", err + } + return wc, uri, nil +} + +func (ops *androidFileOps) Remove(baseName string) error { + uri, err := ops.helper.GetFileURI(baseName) + if err != nil { + return err + } + return ops.helper.DeleteFile(uri) +} + +func (ops *androidFileOps) Rename(oldPath, newName string) (string, error) { + return ops.helper.RenameFile(oldPath, newName) +} + +func (ops *androidFileOps) ListFiles() ([]string, error) { + namesJSON, err := ops.helper.ListFilesJSON("") + if err != nil { + return nil, err + } + var names []string + if err := json.Unmarshal([]byte(namesJSON), &names); err != nil { + return nil, err + } + return names, nil +} + +func (ops *androidFileOps) OpenReader(name string) (io.ReadCloser, error) { + in, err := ops.helper.OpenFileReader(name) + if err != nil { + return nil, err + } + return adaptInputStream(in), nil +} + +func (ops *androidFileOps) Stat(name string) (os.FileInfo, error) { + infoJSON, err := ops.helper.GetFileInfo(name) + if err != nil { + return nil, err + } + var fi androidFileInfo + if err := json.Unmarshal([]byte(infoJSON), &fi); err != nil { + return nil, err + } + return &fi, nil +} + +type androidFileInfoJSON struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModTime int64 `json:"modTime"` +} + +type androidFileInfo struct { + data androidFileInfoJSON +} + +// compile-time check +var _ os.FileInfo = (*androidFileInfo)(nil) + +func (fi *androidFileInfo) Name() string { return fi.data.Name } +func (fi *androidFileInfo) Size() int64 { return fi.data.Size } +func (fi *androidFileInfo) Mode() os.FileMode { return 0o600 } +func (fi *androidFileInfo) ModTime() time.Time { return time.UnixMilli(fi.data.ModTime) } +func (fi *androidFileInfo) IsDir() bool { return false } +func (fi *androidFileInfo) Sys() any { return nil } diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index 6460c9f..14c5694 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -29,6 +29,10 @@ type AppContext interface { // at the given key, or returns empty string if unset. DecryptFromPref(key string) (string, error) + // GetStateStoreKeysJson retrieves all keys stored in the encrypted SharedPreferences, + // strips off the "statestore-" prefix, and returns them as a JSON array. + GetStateStoreKeysJSON() string + // GetOSVersion gets the Android version. GetOSVersion() (string, error) @@ -61,6 +65,15 @@ type AppContext interface { // GetSyspolicyStringArrayValue returns the current string array value for the given system policy, // expressed as a JSON string. GetSyspolicyStringArrayJSONValue(key string) (string, error) + + // Methods used to implement key.HardwareAttestationKey using the Android + // KeyStore. + HardwareAttestationKeySupported() bool + HardwareAttestationKeyCreate() (id string, err error) + HardwareAttestationKeyRelease(id string) error + HardwareAttestationKeyPublic(id string) (pub []byte, err error) + HardwareAttestationKeySign(id string, data []byte) (sig []byte, err error) + HardwareAttestationKeyLoad(id string) error } // IPNService corresponds to our IPNService in Java. @@ -162,6 +175,47 @@ 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 creates or truncates a file named fileName at a given offset, + // returning an OutputStream for writing. Returns an error if the file cannot be opened. + OpenFileWriter(fileName string, offset int64) (stream OutputStream, err error) + + // GetFileURI returns the SAF URI string for the file named fileName, + // or an error if the file cannot be resolved. + GetFileURI(fileName string) (uri string, err error) + + // RenameFile renames the file at oldPath (a SAF URI) into the Taildrop directory, + // giving it the new targetName. Returns the SAF URI of the renamed file, or an error. + RenameFile(oldPath string, targetName string) (newURI string, err error) + + // ListFilesJSON returns a JSON-encoded list of filenames in the Taildrop directory + // that end with the specified suffix. If the suffix is empty, it returns all files. + // Returns an error if no matching files are found or the directory cannot be accessed. + ListFilesJSON(suffix string) (json string, err error) + + // OpenFileReader opens the file with the given name (typically a .partial file) + // and returns an InputStream for reading its contents. + // Returns an error if the file cannot be opened. + OpenFileReader(name string) (stream InputStream, err error) + + // DeleteFile deletes the file identified by the given SAF URI string. + // Returns an error if the file could not be deleted. + DeleteFile(uri string) error + + // GetFileInfo returns a JSON-encoded string containing metadata for fileName, + // matching the fields of androidFileInfo (name, size, modTime). + // Returns an error if the file does not exist or cannot be accessed. + GetFileInfo(fileName string) (json string, err error) +} + // The below are global callbacks that allow the Java application to notify Go // of various state changes. @@ -182,3 +236,19 @@ 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 + } +} diff --git a/libtailscale/keystore.go b/libtailscale/keystore.go new file mode 100644 index 0000000..20150dc --- /dev/null +++ b/libtailscale/keystore.go @@ -0,0 +1,91 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package libtailscale + +import ( + "crypto" + "crypto/ecdsa" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + + "tailscale.com/types/key" +) + +func emptyHardwareAttestationKey(appCtx AppContext) key.HardwareAttestationKey { + return &hardwareAttestationKey{appCtx: appCtx} +} + +func createHardwareAttestationKey(appCtx AppContext) (key.HardwareAttestationKey, error) { + id, err := appCtx.HardwareAttestationKeyCreate() + if err != nil { + return nil, err + } + k := &hardwareAttestationKey{appCtx: appCtx, id: id} + if err := k.fetchPublic(); err != nil { + return nil, err + } + return k, nil +} + +var hardwareAttestationKeyNotInitialized = errors.New("HardwareAttestationKey has not been initialized") + +type hardwareAttestationKey struct { + appCtx AppContext + id string + // public key is always initialized in createHardwareAttestationKey and + // UnmarshalJSON. It's only nil in emptyHardwareAttestationKey. + public *ecdsa.PublicKey +} + +func (k *hardwareAttestationKey) fetchPublic() error { + if k.id == "" || k.appCtx == nil { + return hardwareAttestationKeyNotInitialized + } + + pubRaw, err := k.appCtx.HardwareAttestationKeyPublic(k.id) + if err != nil { + return fmt.Errorf("loading public key from KeyStore: %w", err) + } + pubAny, err := x509.ParsePKIXPublicKey(pubRaw) + if err != nil { + return fmt.Errorf("parsing public key: %w", err) + } + pub, ok := pubAny.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("parsed key is %T, expected *ecdsa.PublicKey", pubAny) + } + k.public = pub + return nil +} + +func (k *hardwareAttestationKey) Public() crypto.PublicKey { return k.public } + +func (k *hardwareAttestationKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) { + if k.id == "" || k.appCtx == nil { + return nil, hardwareAttestationKeyNotInitialized + } + return k.appCtx.HardwareAttestationKeySign(k.id, digest) +} + +func (k *hardwareAttestationKey) MarshalJSON() ([]byte, error) { return json.Marshal(k.id) } + +func (k *hardwareAttestationKey) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, &k.id); err != nil { + return err + } + if err := k.appCtx.HardwareAttestationKeyLoad(k.id); err != nil { + return fmt.Errorf("loading key with ID %q from KeyStore: %w", k.id, err) + } + return k.fetchPublic() +} + +func (k *hardwareAttestationKey) Close() error { + if k.id == "" || k.appCtx == nil { + return hardwareAttestationKeyNotInitialized + } + return k.appCtx.HardwareAttestationKeyRelease(k.id) +} diff --git a/libtailscale/localapi.go b/libtailscale/localapi.go index 678d44c..d25312b 100644 --- a/libtailscale/localapi.go +++ b/libtailscale/localapi.go @@ -230,27 +230,6 @@ func (r *Response) Flush() { }) } -func adaptInputStream(in InputStream) io.ReadCloser { - if in == nil { - return nil - } - r, w := io.Pipe() - go func() { - defer w.Close() - for { - b, err := in.Read() - if err != nil { - log.Printf("error reading from inputstream: %s", err) - } - if b == nil { - return - } - w.Write(b) - } - }() - return r -} - // Below taken from Go stdlib var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") diff --git a/libtailscale/net.go b/libtailscale/net.go index 0405316..86becab 100644 --- a/libtailscale/net.go +++ b/libtailscale/net.go @@ -101,9 +101,20 @@ func (a *App) getInterfaces() ([]netmon.Interface, error) { addrs := strings.Trim(fields[1], " \n") for _, addr := range strings.Split(addrs, " ") { - _, ipnet, err := net.ParseCIDR(addr) + pfx, err := netip.ParsePrefix(addr) + var ip net.IP + if pfx.Addr().Is4() { + v4 := pfx.Addr().As4() + ip = net.IP(v4[:]) + } else { + v6 := pfx.Addr().As16() + ip = net.IP(v6[:]) + } if err == nil { - newIf.AltAddrs = append(newIf.AltAddrs, ipnet) + newIf.AltAddrs = append(newIf.AltAddrs, &net.IPAddr{ + IP: ip, + Zone: pfx.Addr().Zone(), + }) } } diff --git a/libtailscale/store.go b/libtailscale/store.go index 3496b5d..4cc2960 100644 --- a/libtailscale/store.go +++ b/libtailscale/store.go @@ -5,6 +5,8 @@ package libtailscale import ( "encoding/base64" + "encoding/json" + "iter" "tailscale.com/ipn" ) @@ -23,6 +25,28 @@ func newStateStore(appCtx AppContext) *stateStore { } } +func (s *stateStore) All() iter.Seq2[ipn.StateKey, []byte] { + rawJSON := s.appCtx.GetStateStoreKeysJSON() + var keys []string + if err := json.Unmarshal([]byte(rawJSON), &keys); err != nil { + return func(yield func(ipn.StateKey, []byte) bool) {} + } + return func(yield func(ipn.StateKey, []byte) bool) { + for _, k := range keys { + blob, err := s.ReadState(ipn.StateKey(k)) + if err != nil { + continue + } + if !yield(ipn.StateKey(k), blob) { + return + } + } + } +} + +// compile-time assertion that store must implement ipn.StateStore to give immediate feedback on interface drift. +var _ ipn.StateStore = (*stateStore)(nil) + func prefKeyFor(id ipn.StateKey) string { return "statestore-" + string(id) } diff --git a/libtailscale/streamutil.go b/libtailscale/streamutil.go new file mode 100644 index 0000000..a656923 --- /dev/null +++ b/libtailscale/streamutil.go @@ -0,0 +1,36 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package libtailscale + +import ( + "io" + "log" +) + +// adaptInputStream wraps an [InputStream] into an [io.ReadCloser]. +// It launches a goroutine to stream reads into a pipe. +func adaptInputStream(in InputStream) io.ReadCloser { + if in == nil { + return nil + } + r, w := io.Pipe() + go func() { + defer w.Close() + for { + b, err := in.Read() + if err != nil { + log.Printf("error reading from inputstream: %v", err) + return + } + if b == nil { + return + } + if _, err := w.Write(b); err != nil { + log.Printf("error writing to pipe: %v", err) + return + } + } + }() + return r +} diff --git a/libtailscale/syspolicy_handler.go b/libtailscale/syspolicy_handler.go index c7fc68c..086258a 100644 --- a/libtailscale/syspolicy_handler.go +++ b/libtailscale/syspolicy_handler.go @@ -10,33 +10,34 @@ import ( "tailscale.com/util/set" "tailscale.com/util/syspolicy" + "tailscale.com/util/syspolicy/pkey" ) -// syspolicyHandler is a syspolicy handler for the Android version of the Tailscale client, +// syspolicyStore is a syspolicy Store for the Android version of the Tailscale client, // which lets the main networking code read values set via the Android RestrictionsManager. -type syspolicyHandler struct { +type syspolicyStore struct { a *App mu sync.RWMutex cbs set.HandleSet[func()] } -func (h *syspolicyHandler) ReadString(key string) (string, error) { +func (h *syspolicyStore) ReadString(key pkey.Key) (string, error) { if key == "" { return "", syspolicy.ErrNoSuchKey } - retVal, err := h.a.appCtx.GetSyspolicyStringValue(key) + retVal, err := h.a.appCtx.GetSyspolicyStringValue(string(key)) return retVal, translateHandlerError(err) } -func (h *syspolicyHandler) ReadBoolean(key string) (bool, error) { +func (h *syspolicyStore) ReadBoolean(key pkey.Key) (bool, error) { if key == "" { return false, syspolicy.ErrNoSuchKey } - retVal, err := h.a.appCtx.GetSyspolicyBooleanValue(key) + retVal, err := h.a.appCtx.GetSyspolicyBooleanValue(string(key)) return retVal, translateHandlerError(err) } -func (h *syspolicyHandler) ReadUInt64(key string) (uint64, error) { +func (h *syspolicyStore) ReadUInt64(key pkey.Key) (uint64, error) { if key == "" { return 0, syspolicy.ErrNoSuchKey } @@ -44,11 +45,11 @@ func (h *syspolicyHandler) ReadUInt64(key string) (uint64, error) { return 0, errors.New("ReadUInt64 is not implemented on Android") } -func (h *syspolicyHandler) ReadStringArray(key string) ([]string, error) { +func (h *syspolicyStore) ReadStringArray(key pkey.Key) ([]string, error) { if key == "" { return nil, syspolicy.ErrNoSuchKey } - retVal, err := h.a.appCtx.GetSyspolicyStringArrayJSONValue(key) + retVal, err := h.a.appCtx.GetSyspolicyStringArrayJSONValue(string(key)) if err := translateHandlerError(err); err != nil { return nil, err } @@ -63,7 +64,7 @@ func (h *syspolicyHandler) ReadStringArray(key string) ([]string, error) { return arr, err } -func (h *syspolicyHandler) RegisterChangeCallback(cb func()) (unregister func(), err error) { +func (h *syspolicyStore) RegisterChangeCallback(cb func()) (unregister func(), err error) { h.mu.Lock() handle := h.cbs.Add(cb) h.mu.Unlock() @@ -74,7 +75,7 @@ func (h *syspolicyHandler) RegisterChangeCallback(cb func()) (unregister func(), }, nil } -func (h *syspolicyHandler) notifyChanged() { +func (h *syspolicyStore) notifyChanged() { h.mu.RLock() for _, cb := range h.cbs { go cb() diff --git a/libtailscale/tailscale.go b/libtailscale/tailscale.go index 6ae9131..76dc979 100644 --- a/libtailscale/tailscale.go +++ b/libtailscale/tailscale.go @@ -16,10 +16,12 @@ import ( "tailscale.com/logtail" "tailscale.com/logtail/filch" "tailscale.com/net/netmon" + "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/logid" "tailscale.com/util/clientmetric" - "tailscale.com/util/syspolicy" + "tailscale.com/util/syspolicy/rsop" + "tailscale.com/util/syspolicy/setting" ) const defaultMTU = 1280 // minimalMTU from wgengine/userspace.go @@ -39,9 +41,19 @@ func newApp(dataDir, directFileRoot string, appCtx AppContext) Application { a.ready.Add(2) a.store = newStateStore(a.appCtx) - a.policyStore = &syspolicyHandler{a: a} + a.policyStore = &syspolicyStore{a: a} netmon.RegisterInterfaceGetter(a.getInterfaces) - syspolicy.RegisterHandler(a.policyStore) + rsop.RegisterStore("DeviceHandler", setting.DeviceScope, a.policyStore) + if appCtx.HardwareAttestationKeySupported() { + key.RegisterHardwareAttestationKeyFns( + func() key.HardwareAttestationKey { return emptyHardwareAttestationKey(appCtx) }, + func() (key.HardwareAttestationKey, error) { return createHardwareAttestationKey(appCtx) }, + ) + } else { + log.Printf("HardwareAttestationKey is not supported on this device") + } + go a.watchFileOpsChanges() + go func() { defer func() { if p := recover(); p != nil {