Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 28 additions & 35 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
Expand All @@ -35,6 +35,7 @@ import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand All @@ -58,6 +59,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {

companion object {
private const val FILE_CHANNEL_ID = "tailscale-files"
// Key to store the SAF URI in EncryptedSharedPreferences.
private val PREF_KEY_SAF_URI = "saf_directory_uri"
private const val TAG = "App"
private lateinit var appInstance: App

Expand Down Expand Up @@ -149,17 +152,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
}

private fun initializeApp() {
val dataDir = this.filesDir.absolutePath

// Set this to enable direct mode for taildrop whereby downloads will be saved directly
// to the given folder. We will preferentially use <shared>/Downloads and fallback to
// an app local directory "Taildrop" if we cannot create that. This mode does not support
// user notifications for incoming files.
val directFileDir = this.prepareDownloadsFolder()
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
// Check if a directory URI has already been stored.
val storedUri = getStoredDirectoryUri()
if (storedUri != null && storedUri.toString().startsWith("content://")) {
startLibtailscale(storedUri.toString())
} else {
startLibtailscale(this.getFilesDir().absolutePath)
}
healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
Expand Down Expand Up @@ -204,6 +203,18 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
FeatureFlags.initialize(mapOf("enable_new_search" to true))
}

/**
* Called when a SAF directory URI is available (either already stored or chosen). We must restart
* Tailscale because directFileRoot must be set before LocalBackend starts being used.
*/
fun startLibtailscale(directFileRoot: String) {
ShareFileHelper.init(this, directFileRoot)
app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this)
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
}

private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
}
Expand Down Expand Up @@ -246,6 +257,11 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
}

fun getStoredDirectoryUri(): Uri? {
val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null)
return uriString?.let { Uri.parse(it) }
}

/*
* setAbleToStartVPN remembers whether or not we're able to start the VPN
* by storing this in a shared preference. This allows us to check this
Expand Down Expand Up @@ -309,29 +325,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
return sb.toString()
}

private fun prepareDownloadsFolder(): File {
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)

try {
if (!downloads.exists()) {
downloads.mkdirs()
}
} catch (e: Exception) {
TSLog.e(TAG, "Failed to create downloads folder: $e")
downloads = File(this.filesDir, "Taildrop")
try {
if (!downloads.exists()) {
downloads.mkdirs()
}
} catch (e: Exception) {
TSLog.e(TAG, "Failed to create Taildrop folder: $e")
downloads = File("")
}
}

return downloads
}

@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean {
Expand Down
84 changes: 77 additions & 7 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,21 @@ import android.content.Context
import android.content.Intent
import android.content.RestrictionsManager
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Process
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.LinearOutSlowInEasing
Expand Down Expand Up @@ -88,8 +92,13 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import libtailscale.Libtailscale
import java.io.IOException
import java.security.GeneralSecurityException

class MainActivity : ComponentActivity() {
// Key to store the SAF URI in EncryptedSharedPreferences.
val PREF_KEY_SAF_URI = "saf_directory_uri"
private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by lazy {
Expand Down Expand Up @@ -149,6 +158,49 @@ class MainActivity : ComponentActivity() {
}
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)

val directoryPickerLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are incoming Taildrop files relevant on Android TV? They are not on AppleTV. If the user can actually pick a directory and use the files - all good - otherwise we should skip the dialog.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good call. gating it with an AndroidTV check

if (uri != null) {
try {
// Try to take persistable permissions for both read and write.
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
} catch (e: SecurityException) {
TSLog.e("MainActivity", "Failed to persist permissions: $e")
}

// Check if write permission is actually granted.
val writePermission =
this.checkUriPermission(
uri, Process.myPid(), Process.myUid(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
if (writePermission == PackageManager.PERMISSION_GRANTED) {
TSLog.d("MainActivity", "Write permission granted for $uri")

lifecycleScope.launch(Dispatchers.IO) {
try {
Libtailscale.setDirectFileRoot(uri.toString())
saveFileDirectory(uri)
} catch (e: Exception) {
TSLog.e("MainActivity", "Failed to set Taildrop root: $e")
}
}
} else {
TSLog.d(
"MainActivity",
"Write access not granted for $uri. Falling back to internal storage.")
// Don't save directory URI and fall back to internal storage.
}
} else {
TSLog.d(
"MainActivity", "Taildrop directory not saved. Will fall back to internal storage.")

// Fall back to internal storage.
}
}

viewModel.setDirectoryPickerLauncher(directoryPickerLauncher)

setContent {
navController = rememberNavController()

Expand Down Expand Up @@ -366,19 +418,37 @@ class MainActivity : ComponentActivity() {
if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")

if (previousEntry != null) {
navController.popBackStack(route = "main", inclusive = false)
} else {
TSLog.e(
"MainActivity",
"onNewIntent: No previous back stack entry, navigating directly to 'main'")
navController.navigate("main") { popUpTo("main") { inclusive = true } }
if (previousEntry != null) {
navController.popBackStack(route = "main", inclusive = false)
} else {
TSLog.e(
"MainActivity",
"onNewIntent: No previous back stack entry, navigating directly to 'main'")
navController.navigate("main") { popUpTo("main") { inclusive = true } }
}
}
}
}
}

@Throws(IOException::class, GeneralSecurityException::class)
fun saveFileDirectory(directoryUri: Uri) {
val prefs = App.get().getEncryptedPrefs()
prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).apply()
try {
// Must restart Tailscale because a new LocalBackend with the new directory must be created.
App.get().startLibtailscale(directoryUri.toString())
} catch (e: Exception) {
TSLog.d(
"MainActivity",
"saveFileDirectory: Failed to restart Libtailscale with the new directory: $e")
}
}

private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch
// MainActivity to bring the app back to focus.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
kari-ts marked this conversation as resolved.
return try {
outputStream.write(data)
outputStream.flush()
data.size.toLong()
} catch (e: Exception) {
Comment on lines +12 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the OutputStreamAdapter.write method, the return value is always data.size.toLong() regardless of how many bytes were actually written. Java's OutputStream.write might write fewer bytes than requested. Consider capturing the actual bytes written or ensuring the entire buffer is written (possibly with multiple calls).

TSLog.d("OutputStreamAdapter", "write exception: $e")
-1L
}
Comment thread
barnstar marked this conversation as resolved.
}

override fun close() {
outputStream.close()
}
}
14 changes: 11 additions & 3 deletions android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
import com.tailscale.ipn.ui.theme.warningButton
import com.tailscale.ipn.ui.theme.warningListItem
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AutoResizingText
import com.tailscale.ipn.ui.util.Lists
Expand Down Expand Up @@ -212,6 +213,11 @@ fun MainView(
PromptPermissionsIfNecessary()
viewModel.maybeRequestVpnPermission()
LaunchVpnPermissionIfNeeded(viewModel)
LaunchedEffect(state) {
if (state == Ipn.State.Running && !AndroidTVUtil.isAndroidTV()) {
viewModel.showDirectoryPickerLauncher()
}
}

if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
Expand Down Expand Up @@ -242,7 +248,9 @@ fun MainView(
{ viewModel.login() },
loginAtUrl,
netmap?.SelfNode,
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
{
viewModel.showVPNPermissionLauncherIfUnauthorized()
})
}
}
}
Expand Down Expand Up @@ -433,11 +441,11 @@ fun ConnectView(
loginAction: () -> Unit,
loginAtUrlAction: (String) -> Unit,
selfNode: Tailcfg.Node?,
showVPNPermissionLauncherIfUnauthorized: () -> Unit
showVPNPermissionLauncher: () -> Unit
) {
LaunchedEffect(isPrepared) {
if (!isPrepared && shouldStartAutomatically) {
showVPNPermissionLauncherIfUnauthorized()
showVPNPermissionLauncher()
}
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
Expand All @@ -25,6 +27,7 @@ import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -63,6 +66,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
private val _requestVpnPermission = MutableStateFlow(false)
val requestVpnPermission: StateFlow<Boolean> = _requestVpnPermission

// Select Taildrop directory
private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null

// The list of peers
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
val peers: StateFlow<List<PeerSet>> = _peers
Expand Down Expand Up @@ -204,13 +210,34 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
_requestVpnPermission.value = false // reset
}

fun showDirectoryPickerLauncher() {
val app = App.get()
val storedUri = app.getStoredDirectoryUri()
if (storedUri == null) {
// No stored URI, so launch the directory picker.
directoryPickerLauncher?.launch(null)
return
Comment on lines +216 to +219

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After launching the directory picker, the function returns immediately. However, there's no indication that the function will be called again after the picker completes. Consider adding a callback mechanism to ensure the flow continues appropriately after directory selection.

}

val documentFile = DocumentFile.fromTreeUri(app, storedUri)
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
TSLog.d(
"MainViewModel",
"Stored directory URI is invalid or inaccessible; launching directory picker.")
directoryPickerLauncher?.launch(null)
} else {
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
}
}

fun toggleVpn(desiredState: Boolean) {
if (isToggleInProgress.value) {
// Prevent toggling while a previous toggle is in progress
return
}

viewModelScope.launch {
showDirectoryPickerLauncher()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The showDirectoryPickerLauncher() call is made inside a viewModelScope.launch block during toggleVpn. Was this intentional? It could cause the directory picker to appear unexpectedly during VPN toggling, which might confuse users.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of agree with Skynet here. An actionable error message might be a less disruptive. Perhaps modelled after health warnings. Along with knowing where Taildrop files go (or don't go) and being able to change that in Settings after the fact.

...but this will do for now.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100%

isToggleInProgress.value = true
try {
val currentState = Notifier.state.value
Expand Down Expand Up @@ -250,6 +277,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// No intent means we're already authorized
vpnPermissionLauncher = launcher
}

fun setDirectoryPickerLauncher(launcher: ActivityResultLauncher<Uri?>) {
directoryPickerLauncher = launcher
}
}

private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int {
Expand Down
Loading