Skip to content
Open
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
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ dependencies {
implementation "androidx.compose.animation:animation:1.7.4"

// Navigation dependencies.
def nav_version = "2.8.2"
def nav_version = "2.8.5"
implementation "androidx.navigation:navigation-compose:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"

Expand Down
172 changes: 84 additions & 88 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@ import android.app.Application
import android.app.Notification
import android.app.NotificationChannel
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
Expand All @@ -30,17 +29,20 @@ import com.tailscale.ipn.mdm.MDMSettingsChangedReceiver
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.localapi.Request
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
Expand All @@ -57,6 +59,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {

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

Expand Down Expand Up @@ -148,27 +152,28 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
}

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

// Set this to enable direct mode for taildrop whereby downloads will be saved directly
// to the given folder. We will preferentially use <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)
initViewModels()
applicationScope.launch {
Notifier.state.collect { _ ->
combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled ->
Pair(state, forceEnabled)
combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) {
state,
forceEnabled,
prefs,
netmap ->
Triple(state, forceEnabled, getExitNodeName(prefs, netmap))
}
.collect { (state, hideDisconnectAction) ->
.distinctUntilChanged()
.collect { (state, hideDisconnectAction, exitNodeName) ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
// If VPN is stopped, show a disconnected notification. If it is running as a
// foreground
Expand All @@ -183,7 +188,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {

// Update notification status when VPN is running
if (vpnRunning) {
notifyStatus(vpnRunning = true, hideDisconnectAction = hideDisconnectAction.value)
notifyStatus(
vpnRunning = true,
hideDisconnectAction = hideDisconnectAction.value,
exitNodeName = exitNodeName)
}
}
}
Expand All @@ -195,6 +203,18 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
FeatureFlags.initialize(mapOf("enable_new_search" to true))
}

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

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

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

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

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

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

return downloads
}

@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean {
Expand Down Expand Up @@ -391,6 +393,18 @@ open class UninitializedApp : Application() {
fun get(): UninitializedApp {
return appInstance
}

/**
* Return the name of the active (but not the selected/prior one) exit node based on the
* provided [Ipn.Prefs] and [Netmap.NetworkMap].
*
* @return The name of the exit node or `null` if there isn't one.
*/
fun getExitNodeName(prefs: Ipn.Prefs?, netmap: Netmap.NetworkMap?): String? {
return prefs?.activeExitNodeID?.let { exitNodeID ->
netmap?.Peers?.find { it.StableID == exitNodeID }?.exitNodeName
}
}
}

protected fun setUnprotectedInstance(instance: UninitializedApp) {
Expand Down Expand Up @@ -448,25 +462,15 @@ open class UninitializedApp : Application() {
}

fun restartVPN() {
// Register a receiver to listen for the completion of stopVPN
val stopReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
// Ensure stop intent is complete
if (intent?.action == IPNService.ACTION_STOP_VPN) {
// Unregister receiver after receiving the broadcast
context?.unregisterReceiver(this)
// Now start the VPN
startVPN()
}
}
}

// Register the receiver before stopping VPN
val intentFilter = IntentFilter(IPNService.ACTION_STOP_VPN)
this.registerReceiver(stopReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)

stopVPN()
val intent =
Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN }
try {
startService(intent)
} catch (illegalStateException: IllegalStateException) {
TSLog.e(TAG, "restartVPN hit IllegalStateException in startService(): $illegalStateException")
} catch (e: Exception) {
TSLog.e(TAG, "restartVPN hit exception in startService(): $e")
}
}

fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
Expand All @@ -476,8 +480,12 @@ open class UninitializedApp : Application() {
notificationManager.createNotificationChannel(channel)
}

fun notifyStatus(vpnRunning: Boolean, hideDisconnectAction: Boolean) {
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction))
fun notifyStatus(
vpnRunning: Boolean,
hideDisconnectAction: Boolean,
exitNodeName: String? = null
) {
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName))
}

fun notifyStatus(notification: Notification) {
Expand All @@ -495,8 +503,16 @@ open class UninitializedApp : Application() {
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
}

fun buildStatusNotification(vpnRunning: Boolean, hideDisconnectAction: Boolean): Notification {
val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
fun buildStatusNotification(
vpnRunning: Boolean,
hideDisconnectAction: Boolean,
exitNodeName: String? = null
): Notification {
val title = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
val message =
if (vpnRunning && exitNodeName != null) {
getString(R.string.using_exit_node, exitNodeName)
} else null
val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
val action =
if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN
Expand All @@ -520,7 +536,7 @@ open class UninitializedApp : Application() {
val builder =
NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(icon)
.setContentTitle(getString(R.string.app_name))
.setContentTitle(title)
.setContentText(message)
.setAutoCancel(!vpnRunning)
.setOnlyAlertOnce(!vpnRunning)
Expand All @@ -535,33 +551,13 @@ open class UninitializedApp : Application() {
return builder.build()
}

fun addUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) {
TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName")
return
}

getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName)))
.apply()

this.restartVPN()
}

fun removeUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) {
TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
fun updateUserDisallowedPackageNames(packageNames: List<String>) {
if (packageNames.any { it.isEmpty() }) {
TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)")
return
}

getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY,
disallowedPackageNames().toMutableSet().subtract(setOf(packageName)))
.apply()
getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply()

this.restartVPN()
}
Expand Down
38 changes: 24 additions & 14 deletions android/src/main/java/com/tailscale/ipn/IPNService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,15 @@ open class IPNService : VpnService(), libtailscale.IPNService {
close()
START_NOT_STICKY
}
ACTION_START_VPN -> {
scope.launch {
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
showForegroundNotification(hideDisconnectAction.value)
ACTION_RESTART_VPN -> {
app.setWantRunning(false){
close()
app.startVPN()
}
START_NOT_STICKY
}
ACTION_START_VPN -> {
scope.launch { showForegroundNotification() }
app.setWantRunning(true)
Libtailscale.requestVPN(this)
START_STICKY
Expand All @@ -63,7 +66,9 @@ open class IPNService : VpnService(), libtailscale.IPNService {
scope.launch {
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
app.notifyStatus(true, hideDisconnectAction.value)
val exitNodeName =
UninitializedApp.getExitNodeName(Notifier.prefs.value, Notifier.netmap.value)
app.notifyStatus(true, hideDisconnectAction.value, exitNodeName)
}
app.setWantRunning(true)
Libtailscale.requestVPN(this)
Expand All @@ -73,11 +78,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
// This means that we were restarted after the service was killed
// (potentially due to OOM).
if (UninitializedApp.get().isAbleToStartVPN()) {
scope.launch {
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
showForegroundNotification(hideDisconnectAction.value)
}
scope.launch { showForegroundNotification() }
App.get()
Libtailscale.requestVPN(this)
START_STICKY
Expand All @@ -88,7 +89,6 @@ open class IPNService : VpnService(), libtailscale.IPNService {
}

override fun close() {
app.setWantRunning(false) {}
Notifier.setState(Ipn.State.Stopping)
disconnectVPN()
Libtailscale.serviceDisconnect(this)
Expand All @@ -114,16 +114,25 @@ open class IPNService : VpnService(), libtailscale.IPNService {
app.getAppScopedViewModel().setVpnPrepared(isPrepared)
}

private fun showForegroundNotification(hideDisconnectAction: Boolean) {
private fun showForegroundNotification(
hideDisconnectAction: Boolean,
exitNodeName: String? = null
) {
try {
startForeground(
UninitializedApp.STATUS_NOTIFICATION_ID,
UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction))
UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction, exitNodeName))
} catch (e: Exception) {
TSLog.e(TAG, "Failed to start foreground service: $e")
}
}

private fun showForegroundNotification() {
val hideDisconnectAction = MDMSettings.forceEnabled.flow.value.value
val exitNodeName = UninitializedApp.getExitNodeName(Notifier.prefs.value, Notifier.netmap.value)
showForegroundNotification(hideDisconnectAction, exitNodeName)
}

private fun configIntent(): PendingIntent {
return PendingIntent.getActivity(
this,
Expand Down Expand Up @@ -177,5 +186,6 @@ open class IPNService : VpnService(), libtailscale.IPNService {
companion object {
const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"
const val ACTION_RESTART_VPN = "com.tailscale.ipn.RESTART_VPN"
}
}
Loading