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
62 changes: 48 additions & 14 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,29 @@ 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.TSLog
import java.io.File
import java.io.IOException
import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
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
import kotlinx.serialization.json.Json
import libtailscale.Libtailscale
import java.io.File
import java.io.IOException
import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale

class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
Expand Down Expand Up @@ -165,10 +167,15 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
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 +190,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 Down Expand Up @@ -391,6 +401,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 @@ -476,8 +498,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 +521,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 +554,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 Down
31 changes: 17 additions & 14 deletions android/src/main/java/com/tailscale/ipn/IPNService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.util.TSLog
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import libtailscale.Libtailscale
import java.util.UUID

open class IPNService : VpnService(), libtailscale.IPNService {
private val TAG = "IPNService"
Expand Down Expand Up @@ -47,11 +47,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
START_NOT_STICKY
}
ACTION_START_VPN -> {
scope.launch {
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
showForegroundNotification(hideDisconnectAction.value)
}
scope.launch { showForegroundNotification() }
app.setWantRunning(true)
Libtailscale.requestVPN(this)
START_STICKY
Expand All @@ -63,7 +59,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 +71,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 Down Expand Up @@ -114,16 +108,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
36 changes: 16 additions & 20 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import android.net.NetworkCapabilities
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
Expand Down Expand Up @@ -198,7 +197,7 @@ class MainActivity : ComponentActivity() {
onNavigateToSearch = {
viewModel.enableSearchAutoFocus()
navController.navigate("search")
})
})

val settingsNav =
SettingsNav(
Expand Down Expand Up @@ -245,9 +244,8 @@ class MainActivity : ComponentActivity() {
viewModel = viewModel,
navController = navController,
onNavigateBack = { navController.popBackStack() },
autoFocus = autoFocus
)
}
autoFocus = autoFocus)
}
composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("health") { HealthView(backTo("main")) }
Expand Down Expand Up @@ -365,23 +363,21 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.getBooleanExtra(START_AT_ROOT, false)) {
if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")

if (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 } }
}
}
}
}


}

private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.util.Log
import com.tailscale.ipn.util.TSLog
import libtailscale.Libtailscale
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import libtailscale.Libtailscale

object NetworkChangeCallback {

Expand Down
35 changes: 16 additions & 19 deletions android/src/main/java/com/tailscale/ipn/ShareActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.TaildropView
import com.tailscale.ipn.util.TSLog
import kotlin.random.Random
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.random.Random

// ShareActivity is the entry point for Taildrop share intents
class ShareActivity : ComponentActivity() {
Expand Down Expand Up @@ -92,25 +92,22 @@ class ShareActivity : ComponentActivity() {
}
}

val pendingFiles: List<Ipn.OutgoingFile> =
val pendingFiles: List<Ipn.OutgoingFile> =
uris?.filterNotNull()?.mapNotNull { uri ->
contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)

if (cursor.moveToFirst()) {
val name: String = cursor.getString(nameCol)
?: generateFallbackName(uri)
val size: Long = cursor.getLong(sizeCol)
Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply {
this.uri = uri
}
} else {
TSLog.e(TAG, "Cursor is empty for URI: $uri")
null
}
contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)

if (cursor.moveToFirst()) {
val name: String = cursor.getString(nameCol) ?: generateFallbackName(uri)
val size: Long = cursor.getLong(sizeCol)
Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { this.uri = uri }
} else {
TSLog.e(TAG, "Cursor is empty for URI: $uri")
null
}
} ?: emptyList()
}
} ?: emptyList()

if (pendingFiles.isEmpty()) {
TSLog.e(TAG, "Share failure - no files extracted from intent")
Expand All @@ -124,5 +121,5 @@ class ShareActivity : ComponentActivity() {
val mimeType = contentResolver?.getType(uri)
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
return if (extension != null) "$randomId.$extension" else randomId.toString()
}
}
}
Loading