android: ViewModel cleanup

- Replace IpnManager, IpnModel and PrefsEditor with IpnViewModel
- Use lazy StateFlows in Notifier
- Manage view model lifecycles using viewModel() function
- Stop watching IPN bus when MainActivity stops
- Pass IPN notifications as ByteArray instead of string

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
oxtoacart/reactive_exit_node_bak
Percy Wegmann 8 months ago committed by Percy Wegmann
parent d42329e2e2
commit a1e67ff1e9

@ -74,7 +74,6 @@ import com.tailscale.ipn.mdm.BooleanSetting;
import com.tailscale.ipn.mdm.MDMSettings; import com.tailscale.ipn.mdm.MDMSettings;
import com.tailscale.ipn.mdm.ShowHideSetting; import com.tailscale.ipn.mdm.ShowHideSetting;
import com.tailscale.ipn.mdm.StringSetting; import com.tailscale.ipn.mdm.StringSetting;
import com.tailscale.ipn.ui.service.IpnManager;
import org.gioui.Gio; import org.gioui.Gio;

@ -10,7 +10,6 @@ import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.remember
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@ -19,8 +18,9 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import androidx.navigation.navigation import androidx.navigation.navigation
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.service.IpnManager import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.AboutView import com.tailscale.ipn.ui.view.AboutView
import com.tailscale.ipn.ui.view.BugReportView import com.tailscale.ipn.ui.view.BugReportView
import com.tailscale.ipn.ui.view.ExitNodePicker import com.tailscale.ipn.ui.view.ExitNodePicker
@ -31,18 +31,16 @@ import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.MullvadExitNodePicker import com.tailscale.ipn.ui.view.MullvadExitNodePicker
import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.Settings import com.tailscale.ipn.ui.view.Settings
import com.tailscale.ipn.ui.view.SettingsNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.BugReportViewModel import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.MainViewModel import kotlinx.coroutines.CoroutineScope
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val manager = IpnManager(lifecycleScope) private var notifierScope: CoroutineScope? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -64,38 +62,29 @@ class MainActivity : ComponentActivity() {
onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") }) onNavigateToManagedBy = { navController.navigate("managedBy") })
composable("main") { val exitNodePickerNav = ExitNodePickerNav(onNavigateHome = {
MainView( navController.popBackStack(
viewModel = MainViewModel(manager.model, manager), route = "main", inclusive = false
navigation = mainViewNav
) )
}, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") })
composable("main") {
MainView(navigation = mainViewNav)
} }
composable("settings") { composable("settings") {
Settings(SettingsViewModel(manager, settingsNav)) Settings(settingsNav)
} }
navigation(startDestination = "list", route = "exitNodes") { navigation(startDestination = "list", route = "exitNodes") {
composable("list") { composable("list") {
val viewModel = remember { ExitNodePicker(exitNodePickerNav)
ExitNodePickerViewModel(manager.model) {
navController.navigate("main")
}
}
ExitNodePicker(viewModel) {
navController.navigate("mullvad/$it")
}
} }
composable( composable(
"mullvad/{countryCode}", arguments = listOf(navArgument("countryCode") { "mullvad/{countryCode}", arguments = listOf(navArgument("countryCode") {
type = NavType.StringType type = NavType.StringType
}) })
) { ) {
val viewModel = remember {
ExitNodePickerViewModel(manager.model) {
navController.navigate("main")
}
}
MullvadExitNodePicker( MullvadExitNodePicker(
viewModel, it.arguments!!.getString("countryCode")!! it.arguments!!.getString("countryCode")!!, exitNodePickerNav
) )
} }
} }
@ -103,23 +92,19 @@ class MainActivity : ComponentActivity() {
"peerDetails/{nodeId}", "peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType }) arguments = listOf(navArgument("nodeId") { type = NavType.StringType })
) { ) {
PeerDetails( PeerDetails(it.arguments?.getString("nodeId") ?: "")
PeerDetailsViewModel(
manager.model, nodeId = it.arguments?.getString("nodeId") ?: ""
)
)
} }
composable("bugReport") { composable("bugReport") {
BugReportView(BugReportViewModel()) BugReportView()
} }
composable("about") { composable("about") {
AboutView() AboutView()
} }
composable("mdmSettings") { composable("mdmSettings") {
MDMSettingsDebugView(manager.mdmSettings) MDMSettingsDebugView()
} }
composable("managedBy") { composable("managedBy") {
ManagedByView(manager.mdmSettings) ManagedByView()
} }
} }
} }
@ -130,7 +115,7 @@ class MainActivity : ComponentActivity() {
// Watch the model's browseToURL and launch the browser when it changes // Watch the model's browseToURL and launch the browser when it changes
// This will trigger the login flow // This will trigger the login flow
lifecycleScope.launch { lifecycleScope.launch {
manager.model.browseToURL.collect { url -> Notifier.browseToURL.collect { url ->
url?.let { url?.let {
Dispatchers.Main.run { Dispatchers.Main.run {
login(it) login(it)
@ -152,7 +137,19 @@ class MainActivity : ComponentActivity() {
super.onResume() super.onResume()
val restrictionsManager = val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
manager.mdmSettings = MDMSettings(restrictionsManager) IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager))
}
override fun onStart() {
super.onStart()
val scope = CoroutineScope(Dispatchers.IO)
notifierScope = scope
Notifier.start(lifecycleScope)
}
override fun onStop() {
Notifier.stop()
super.onStop()
} }
} }

@ -4,19 +4,19 @@
package com.tailscale.ipn.ui.notifier package com.tailscale.ipn.ui.notifier
import android.util.Log import android.util.Log
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.Notify import com.tailscale.ipn.ui.model.Ipn.Notify
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
typealias NotifierCallback = (Notify) -> Unit
class Watcher(
val sessionId: String, val mask: Int, val callback: NotifierCallback
)
// Notifier is a wrapper around the IPN Bus notifier. It provides a way to watch // Notifier is a wrapper around the IPN Bus notifier. It provides a way to watch
// for changes in various parts of the Tailscale engine. You will typically only use // for changes in various parts of the Tailscale engine. You will typically only use
@ -26,116 +26,72 @@ class Watcher(
// The primary entry point here is watchIPNBus which will start a watcher on the IPN bus // The primary entry point here is watchIPNBus which will start a watcher on the IPN bus
// and return you the session Id. When you are done with your watcher, you must call // and return you the session Id. When you are done with your watcher, you must call
// unwatchIPNBus with the sessionId. // unwatchIPNBus with the sessionId.
class Notifier(private val scope: CoroutineScope) { object Notifier {
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which private val TAG = Notifier::class.simpleName
// what we want to see on the Noitfy bus
enum class NotifyWatchOpt(val value: Int) {
engineUpdates(1), initialState(2), prefs(4), netmap(8), noPrivateKey(16), initialTailFSShares(
32
)
}
companion object {
private val sessionIdLock = Any()
private var sessionId: Int = 0
private val decoder = Json { ignoreUnknownKeys = true } private val decoder = Json { ignoreUnknownKeys = true }
private val isReady = CompletableDeferred<Boolean>() private val isReady = CompletableDeferred<Boolean>()
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
val tailFSShares: StateFlow<Map<String, String>?> = MutableStateFlow(null)
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null)
// Called by the backend when the localAPI is ready to accept requests. // Called by the backend when the localAPI is ready to accept requests.
@JvmStatic @JvmStatic
@Suppress("unused")
fun onReady() { fun onReady() {
isReady.complete(true) isReady.complete(true)
Log.d("Notifier", "Notifier is ready") Log.d(TAG, "Ready")
} }
private fun generateSessionId(): String { fun start(scope: CoroutineScope) {
synchronized(sessionIdLock) { Log.d(TAG, "Starting")
sessionId += 1
return sessionId.toString()
}
}
}
// Starts an IPN Bus watcher. **This is blocking** and will not return until
// the watcher is stopped and must be executed in a suitable coroutine scope such
// as Dispatchers.IO
private external fun startIPNBusWatcher(sessionId: String, mask: Int)
// Stops an IPN Bus watcher
private external fun stopIPNBusWatcher(sessionId: String)
private var watchers = HashMap<String, Watcher>()
// Callback from jni when a new notification is received
fun onNotify(notification: String, sessionId: String) {
val notify = decoder.decodeFromString<Notify>(notification)
val watcher = watchers[sessionId]
watcher?.let { watcher.callback(notify) } ?: {
Log.e(
"Notifier",
"Received notification for unknown session: ${sessionId}"
)
}
}
// Watch the IPN bus for notifications
// Notifications will be passed to the caller via the callback until
// the caller calls unwatchIPNBus with the sessionId returned from this call.
private fun watchIPNBus(mask: Int, callback: NotifierCallback): String {
val sessionId = generateSessionId()
val watcher = Watcher(sessionId, mask, callback)
watchers[sessionId] = watcher
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
// Wait for the notifier to be ready // Wait for the notifier to be ready
isReady.await() isReady.await()
Log.d("Notifier", "Starting IPN Bus watcher for sessionid: ${sessionId}") val mask =
startIPNBusWatcher(sessionId, mask) NotifyWatchOpt.Netmap.value or NotifyWatchOpt.Prefs.value or NotifyWatchOpt.InitialState.value
watchers.remove(sessionId) startIPNBusWatcher(mask)
Log.d("Notifier", "IPN Bus watcher for sessionid:${sessionId} has halted") Log.d(TAG, "Stopped")
} }
return sessionId
}
// Cancels the watcher with the given sessionId. No errors are thrown or
// indicated for invalid sessionIds.
private fun unwatchIPNBus(sessionId: String) {
stopIPNBusWatcher(sessionId)
} }
// Cancels all watchers fun stop() {
fun cancelAllWatchers() { Log.d(TAG, "Stopping")
for (sessionId in watchers.values.map({ it.sessionId })) { stopIPNBusWatcher()
unwatchIPNBus(sessionId)
} }
}
// Returns a list of all active watchers
fun watchers(): List<Watcher> {
return watchers.values.toList()
}
// Convenience methods for watching specific parts of the IPN bus
fun watchNetMap(callback: NotifierCallback): String { // Callback from jni when a new notification is received
return watchIPNBus(NotifyWatchOpt.netmap.value, callback) @OptIn(ExperimentalSerializationApi::class)
@JvmStatic
@Suppress("unused")
fun onNotify(notification: ByteArray) {
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
notify.State?.let { state.set(Ipn.State.fromInt(it)) }
notify.NetMap?.let(netmap::set)
notify.Prefs?.let(prefs::set)
notify.Engine?.let(engineStatus::set)
notify.TailFSShares?.let(tailFSShares::set)
notify.BrowseToURL?.let(browseToURL::set)
notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set)
} }
fun watchPrefs(callback: NotifierCallback): String { // Starts watching the IPN Bus. This is blocking.
return watchIPNBus(NotifyWatchOpt.prefs.value, callback) private external fun startIPNBusWatcher(mask: Int)
}
fun watchEngineUpdates(callback: NotifierCallback): String { // Stop watching the IPN Bus. This is non-blocking.
return watchIPNBus(NotifyWatchOpt.engineUpdates.value, callback) private external fun stopIPNBusWatcher()
}
fun watchAll(callback: NotifierCallback): String { // NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
return watchIPNBus( // what we want to see on the Notify bus
NotifyWatchOpt.netmap.value or NotifyWatchOpt.prefs.value or NotifyWatchOpt.initialState.value, private enum class NotifyWatchOpt(val value: Int) {
callback EngineUpdates(1), InitialState(2), Prefs(4), Netmap(8), NoPrivateKey(16), InitialTailFSShares(
32
) )
} }
init {
Log.d("Notifier", "Notifier created")
}
} }

@ -1,81 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.service
import android.content.Intent
import android.util.Log
import com.tailscale.ipn.App
import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
typealias PrefChangeCallback = (Result<Boolean>) -> Unit
// Abstracts the actions that can be taken by the UI so that the concept of an IPNManager
// itself is hidden from the viewModel implementations.
interface IpnActions {
fun startVPN()
fun stopVPN()
fun login()
fun logout()
fun updatePrefs(prefs: Ipn.MaskedPrefs, callback: PrefChangeCallback)
}
class IpnManager(private val scope: CoroutineScope) : IpnActions {
companion object {
private const val TAG = "IpnManager"
}
private var notifier = Notifier(scope)
var mdmSettings = MDMSettings()
val model = IpnModel(notifier, scope)
override fun startVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN
context.sendBroadcast(intent)
}
override fun stopVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
}
override fun login() {
Client(scope).startLoginInteractive { result ->
result.onSuccess {
Log.d(TAG, "Login started: $it")
}.onFailure {
Log.e(TAG, "Error starting login: ${it.message}")
}
}
}
override fun logout() {
Client(scope).logout { result ->
result.onSuccess {
Log.d(TAG, "Logout started: $it")
}.onFailure {
Log.e(TAG, "Error starting logout: ${it.message}")
}
}
}
override fun updatePrefs(prefs: Ipn.MaskedPrefs, callback: PrefChangeCallback) {
Client(scope).editPrefs(prefs) { result ->
result.onSuccess {
callback(Result.success(true))
}.onFailure {
callback(Result.failure(it))
}
}
}
}

@ -1,84 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.service
import android.util.Log
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.Netmap
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class IpnModel(notifier: Notifier, val scope: CoroutineScope) {
companion object {
private const val TAG = "IpnModel"
}
private var notifierSessions: MutableList<String> = mutableListOf()
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
val tailFSShares: StateFlow<Map<String, String>?> = MutableStateFlow(null)
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null)
val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)
val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null)
val isUsingExitNode: Boolean
get() {
return prefs.value != null
}
// Backend Observation
private suspend fun loadUserProfiles() {
Client(scope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure {
Log.e(TAG, "Error loading profiles: ${it.message}")
}
}
Client(scope).currentProfile { result ->
result.onSuccess(loggedInUser::set).onFailure {
Log.e(TAG, "Error loading current profile: ${it.message}")
}
}
}
private fun onNotifyChange(notify: Ipn.Notify) {
notify.State?.let { s ->
// Refresh the user profiles if we're transitioning out of the
// NeedsLogin state.
if (state.value == Ipn.State.NeedsLogin) {
scope.launch { loadUserProfiles() }
}
Log.d("IpnModel", "State changed: $s")
state.set(Ipn.State.fromInt(s))
}
notify.NetMap?.let(netmap::set)
notify.Prefs?.let(prefs::set)
notify.Engine?.let(engineStatus::set)
notify.TailFSShares?.let(tailFSShares::set)
notify.BrowseToURL?.let(browseToURL::set)
notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set)
}
init {
Log.d("IpnModel", "IpnModel created")
val session = notifier.watchAll { n -> onNotifyChange(n) }
notifierSessions.add(session)
scope.launch { loadUserProfiles() }
}
}

@ -1,75 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.service
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
// Handles all types of preference modifications typically invoked by the UI.
// Callers generally shouldn't care about the returned prefs value - the source of
// truth is the IPNModel, who's prefs flow will change in value to reflect the true
// value of the pref setting in the back end (and will match the value returned here).
// Generally, you will want to inspect the returned value in the callback for errors
// to indicate why a particular setting did not change in the interface.
//
// Usage:
// - User/Interface changed to new value. Render the new value.
// - Submit the new value to the PrefsEditor
// - Observe the prefs on the IpnModel and update the UI when/if the value changes.
// For a typical flow, the changed value should reflect the value already shown.
// - Inform the user of any error which may have occurred
//
// The "toggle' functions here will attempt to set the pref value to the inverse of
// what is currently known in the IpnModel.prefs. If IpnModel.prefs is not available,
// the callback will be called with a NO_PREFS error
fun IpnModel.setWantRunning(wantRunning: Boolean, callback: (Result<Ipn.Prefs>) -> Unit) {
Ipn.MaskedPrefs().WantRunning = wantRunning
Client(scope).editPrefs(Ipn.MaskedPrefs(), callback)
}
fun IpnModel.toggleCorpDNS(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = prefs.value ?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleCorpDNS
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.CorpDNS = !prefs.CorpDNS
Client(scope).editPrefs(prefsOut, callback)
}
fun IpnModel.toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = prefs.value ?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ShieldsUp = !prefs.ShieldsUp
Client(scope).editPrefs(prefsOut, callback)
}
fun IpnModel.setExitNodeId(id: String?, callback: (Result<Ipn.Prefs>) -> Unit) {
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = id
Client(scope).editPrefs(prefsOut, callback)
}
fun IpnModel.toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = prefs.value ?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = !prefs.RouteAll
Client(scope).editPrefs(prefsOut, callback)
}

@ -33,8 +33,8 @@ object LoadingIndicator {
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
content() content()
val isLoading = loading.collectAsState() val isLoading = loading.collectAsState().value
if (isLoading.value) { if (isLoading) {
Box( Box(
Modifier Modifier
.matchParentSize() .matchParentSize()

@ -7,7 +7,7 @@ package com.tailscale.ipn.ui.util
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.model.UserID import com.tailscale.ipn.ui.model.UserID
import com.tailscale.ipn.ui.service.IpnModel import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -16,7 +16,7 @@ data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>
typealias GroupedPeers = MutableMap<UserID, MutableList<Tailcfg.Node>> typealias GroupedPeers = MutableMap<UserID, MutableList<Tailcfg.Node>>
class PeerCategorizer(val model: IpnModel, val scope: CoroutineScope) { class PeerCategorizer(scope: CoroutineScope) {
var peerSets: List<PeerSet> = emptyList() var peerSets: List<PeerSet> = emptyList()
var lastSearchResult: List<PeerSet> = emptyList() var lastSearchResult: List<PeerSet> = emptyList()
var searchTerm: String = "" var searchTerm: String = ""
@ -24,7 +24,7 @@ class PeerCategorizer(val model: IpnModel, val scope: CoroutineScope) {
// Keep the peer sets current while the model is active // Keep the peer sets current while the model is active
init { init {
scope.launch { scope.launch {
model.netmap.collect { netmap -> Notifier.netmap.collect { netmap ->
netmap?.let { netmap?.let {
peerSets = regenerateGroupedPeers(netmap) peerSets = regenerateGroupedPeers(netmap)
lastSearchResult = peerSets lastSearchResult = peerSets
@ -79,7 +79,8 @@ class PeerCategorizer(val model: IpnModel, val scope: CoroutineScope) {
} }
// We can optimize out typing... If the search term starts with the last search term, we can just search the last result // We can optimize out typing... If the search term starts with the last search term, we can just search the last result
val setsToSearch = if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets val setsToSearch =
if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets
this.searchTerm = searchTerm this.searchTerm = searchTerm
val matchingSets = setsToSearch.map { peerSet -> val matchingSets = setsToSearch.map { peerSet ->
@ -91,7 +92,8 @@ class PeerCategorizer(val model: IpnModel, val scope: CoroutineScope) {
return@map peerSet return@map peerSet
} }
val matchingPeers = peers.filter { it.ComputedName.contains(searchTerm, ignoreCase = true) } val matchingPeers =
peers.filter { it.ComputedName.contains(searchTerm, ignoreCase = true) }
if (matchingPeers.isNotEmpty()) { if (matchingPeers.isNotEmpty()) {
PeerSet(user, matchingPeers) PeerSet(user, matchingPeers)
} else { } else {

@ -34,6 +34,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.util.defaultPaddingModifier import com.tailscale.ipn.ui.util.defaultPaddingModifier
@ -43,16 +44,22 @@ import kotlinx.coroutines.flow.StateFlow
@Composable @Composable
fun BugReportView(viewModel: BugReportViewModel) { fun BugReportView(model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.surface) { Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = defaultPaddingModifier().fillMaxWidth().fillMaxHeight()) { Column(
Text(text = stringResource(id = R.string.bug_report_title), modifier = defaultPaddingModifier()
.fillMaxWidth()
.fillMaxHeight()
) {
Text(
text = stringResource(id = R.string.bug_report_title),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium) style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -65,15 +72,17 @@ fun BugReportView(viewModel: BugReportViewModel) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
ReportIdRow(bugReportIdFlow = viewModel.bugReportID) ReportIdRow(bugReportIdFlow = model.bugReportID)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text(text = stringResource(id = R.string.bug_report_id_desc), Text(
text = stringResource(id = R.string.bug_report_id_desc),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Left, textAlign = TextAlign.Left,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall) style = MaterialTheme.typography.bodySmall
)
} }
} }
} }
@ -83,17 +92,27 @@ fun ReportIdRow(bugReportIdFlow: StateFlow<String>) {
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
val bugReportId = bugReportIdFlow.collectAsState() val bugReportId = bugReportIdFlow.collectAsState()
Row(modifier = settingsRowModifier() Row(
modifier = settingsRowModifier()
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(bugReportId.value)) }), .clickable(onClick = { localClipboardManager.setText(AnnotatedString(bugReportId.value)) }),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.weight(10f)) { Box(Modifier.weight(10f)) {
Text(text = bugReportId.value, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = defaultPaddingModifier()) Text(
text = bugReportId.value,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = defaultPaddingModifier()
)
} }
Box(Modifier.weight(1f)) { Box(Modifier.weight(1f)) {
Icon(Icons.Outlined.Share, null, modifier = Modifier Icon(
Icons.Outlined.Share, null, modifier = Modifier
.width(24.dp) .width(24.dp)
.height(24.dp)) .height(24.dp)
)
} }
} }
} }

@ -30,35 +30,37 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ExitNodePicker( fun ExitNodePicker(
viewModel: ExitNodePickerViewModel, nav: ExitNodePickerNav,
onNavigateToMullvadCountry: (String) -> Unit, model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) { ) {
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(topBar = { Scaffold(topBar = {
TopAppBar(title = { Text(stringResource(R.string.choose_exit_node)) }) TopAppBar(title = { Text(stringResource(R.string.choose_exit_node)) })
}) { innerPadding -> }) { innerPadding ->
val tailnetExitNodes = viewModel.tailnetExitNodes.collectAsState() val tailnetExitNodes = model.tailnetExitNodes.collectAsState()
val mullvadExitNodes = viewModel.mullvadExitNodesByCountryCode.collectAsState() val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val anyActive = viewModel.anyActive.collectAsState() val anyActive = model.anyActive.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "none") { item(key = "none") {
ExitNodeItem( ExitNodeItem(
viewModel, model, ExitNodePickerViewModel.ExitNode(
ExitNodePickerViewModel.ExitNode(
label = stringResource(R.string.none), label = stringResource(R.string.none),
online = true, online = true,
selected = !anyActive.value, selected = !anyActive.value,
), )
) )
} }
@ -67,7 +69,7 @@ fun ExitNodePicker(
} }
items(tailnetExitNodes.value, key = { it.id!! }) { node -> items(tailnetExitNodes.value, key = { it.id!! }) { node ->
ExitNodeItem(viewModel, node, indent = 16.dp) ExitNodeItem(model, node, indent = 16.dp)
} }
item { item {
@ -89,11 +91,11 @@ fun ExitNodePicker(
.padding(start = 16.dp) .padding(start = 16.dp)
.clickable { .clickable {
if (nodes.size > 1) { if (nodes.size > 1) {
onNavigateToMullvadCountry( nav.onNavigateToMullvadCountry(
countryCode countryCode
) )
} else { } else {
viewModel.setExitNode(first) model.setExitNode(first)
} }
}, headlineContent = { }, headlineContent = {
Text("${countryCode.flag()} ${first.country}") Text("${countryCode.flag()} ${first.country}")

@ -17,35 +17,35 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting
import com.tailscale.ipn.mdm.BooleanSetting import com.tailscale.ipn.mdm.BooleanSetting
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHideSetting import com.tailscale.ipn.mdm.ShowHideSetting
import com.tailscale.ipn.mdm.StringArraySetting import com.tailscale.ipn.mdm.StringArraySetting
import com.tailscale.ipn.mdm.StringSetting import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.util.defaultPaddingModifier import com.tailscale.ipn.ui.util.defaultPaddingModifier
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MDMSettingsDebugView(mdmSettings: MDMSettings) { fun MDMSettingsDebugView(model: IpnViewModel = viewModel()) {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(colors = TopAppBarDefaults.topAppBarColors(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
titleContentColor = MaterialTheme.colorScheme.primary, titleContentColor = MaterialTheme.colorScheme.primary,
), ), title = {
title = {
Text(stringResource(R.string.current_mdm_settings)) Text(stringResource(R.string.current_mdm_settings))
} })
)
}, },
) { innerPadding -> ) { innerPadding ->
val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
items(enumValues<BooleanSetting>()) { booleanSetting -> items(enumValues<BooleanSetting>()) { booleanSetting ->
MDMSettingView( MDMSettingView(
@ -95,8 +95,7 @@ fun MDMSettingsDebugView(mdmSettings: MDMSettings) {
fun MDMSettingView(title: String, caption: String, valueDescription: String) { fun MDMSettingView(title: String, caption: String, valueDescription: String) {
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = defaultPaddingModifier() modifier = defaultPaddingModifier().fillMaxWidth()
.fillMaxWidth()
) { ) {
Column { Column {
Text(title, maxLines = 3) Text(title, maxLines = 3)

@ -45,6 +45,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
@ -62,31 +63,36 @@ import kotlinx.coroutines.flow.StateFlow
data class MainViewNavigation( data class MainViewNavigation(
val onNavigateToSettings: () -> Unit, val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit) val onNavigateToExitNodes: () -> Unit
)
@Composable @Composable
fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) { fun MainView(navigation: MainViewNavigation, model: MainViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.secondaryContainer) { Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center
) { ) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState) val state = model.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null) val user = model.loggedInUser.collectAsState(initial = null)
Row(modifier = Modifier Row(
modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically
val isOn = viewModel.vpnToggleState.collectAsState(initial = false) ) {
val isOn = model.vpnToggleState.collectAsState(initial = false)
Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value) Switch(onCheckedChange = { model.toggleVpn() }, checked = isOn.value)
Spacer(Modifier.size(3.dp)) Spacer(Modifier.size(3.dp))
StateDisplay(viewModel.stateRes, viewModel.userName) StateDisplay(model.stateRes, model.userName)
Box(modifier = Modifier Box(
modifier = Modifier
.weight(1f) .weight(1f)
.clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) { .clickable { navigation.onNavigateToSettings() },
contentAlignment = Alignment.CenterEnd
) {
Avatar(profile = user.value, size = 36) Avatar(profile = user.value, size = 36)
} }
} }
@ -94,22 +100,17 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
when (state.value) { when (state.value) {
Ipn.State.Running -> { Ipn.State.Running -> {
ExitNodeStatus(navigation.onNavigateToExitNodes, viewModel) ExitNodeStatus(navigation.onNavigateToExitNodes, model)
PeerList( PeerList(searchTerm = model.searchTerm,
searchTerm = viewModel.searchTerm, state = model.ipnState,
state = viewModel.ipnState, peers = model.peers,
peers = viewModel.peers, selfPeer = model.selfPeerId,
selfPeer = viewModel.selfPeerId,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) }) onSearch = { model.searchPeers(it) })
} }
Ipn.State.Starting -> StartingView() Ipn.State.Starting -> StartingView()
else -> else -> ConnectView(user.value, { model.toggleVpn() }, { model.login() }
ConnectView(
user.value,
{ viewModel.toggleVpn() },
{ viewModel.login() }
) )
} }
@ -119,8 +120,8 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
@Composable @Composable
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val prefs = viewModel.model.prefs.collectAsState() val prefs = viewModel.prefs.collectAsState()
val netmap = viewModel.model.netmap.collectAsState() val netmap = viewModel.netmap.collectAsState()
val exitNodeId = prefs.value?.ExitNodeID val exitNodeId = prefs.value?.ExitNodeID
val exitNode = exitNodeId?.let { id -> val exitNode = exitNodeId?.let { id ->
netmap.value?.Peers?.find { it.StableID == id }?.let { peer -> netmap.value?.Peers?.find { it.StableID == id }?.let { peer ->
@ -136,9 +137,15 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
.background(MaterialTheme.colorScheme.secondaryContainer) .background(MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) { .fillMaxWidth()) {
Column(modifier = Modifier.padding(6.dp)) { Column(modifier = Modifier.padding(6.dp)) {
Text(text = stringResource(id = R.string.exit_node), style = MaterialTheme.typography.titleMedium) Text(
text = stringResource(id = R.string.exit_node),
style = MaterialTheme.typography.titleMedium
)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = exitNode ?: stringResource(id = R.string.none), style = MaterialTheme.typography.bodyMedium) Text(
text = exitNode ?: stringResource(id = R.string.none),
style = MaterialTheme.typography.bodyMedium
)
Icon( Icon(
Icons.Outlined.ArrowDropDown, Icons.Outlined.ArrowDropDown,
null, null,
@ -155,17 +162,18 @@ fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
Column(modifier = Modifier.padding(7.dp)) { Column(modifier = Modifier.padding(7.dp)) {
Text(text = tailnet, style = MaterialTheme.typography.titleMedium) Text(text = tailnet, style = MaterialTheme.typography.titleMedium)
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary) Text(
text = stateStr,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary
)
} }
} }
@Composable @Composable
fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) { fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
// (jonathan) TODO: On iOS this is the users avatar or a letter avatar. // (jonathan) TODO: On iOS this is the users avatar or a letter avatar.
IconButton( IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
modifier = Modifier.size(24.dp),
onClick = { action() }
) {
Icon( Icon(
Icons.Outlined.Settings, Icons.Outlined.Settings,
null, null,
@ -177,14 +185,14 @@ fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
fun StartingView() { fun StartingView() {
// (jonathan) TODO: On iOS this is the game-of-life Tailscale animation. // (jonathan) TODO: On iOS this is the game-of-life Tailscale animation.
Column( Column(
modifier = modifier = Modifier
Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text(text = stringResource(id = R.string.starting), Text(
text = stringResource(id = R.string.starting),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@ -201,8 +209,7 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
.fillMaxWidth(0.7f) .fillMaxWidth(0.7f)
.fillMaxHeight(), .fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy( verticalArrangement = Arrangement.spacedBy(
8.dp, 8.dp, alignment = Alignment.CenterVertically
alignment = Alignment.CenterVertically
), ),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@ -265,12 +272,14 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PeerList(searchTerm: StateFlow<String>, fun PeerList(
searchTerm: StateFlow<String>,
peers: StateFlow<List<PeerSet>>, peers: StateFlow<List<PeerSet>>,
state: StateFlow<Ipn.State>, state: StateFlow<Ipn.State>,
selfPeer: StableNodeID, selfPeer: StableNodeID,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit) { onSearch: (String) -> Unit
) {
val peerList = peers.collectAsState(initial = emptyList<PeerSet>()) val peerList = peers.collectAsState(initial = emptyList<PeerSet>())
var searching = false var searching = false
val searchTermStr by searchTerm.collectAsState(initial = "") val searchTermStr by searchTerm.collectAsState(initial = "")
@ -291,51 +300,55 @@ fun PeerList(searchTerm: StateFlow<String>,
) { ) {
LazyColumn( LazyColumn(
modifier = modifier = Modifier
Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer), .background(MaterialTheme.colorScheme.secondaryContainer),
) { ) {
peerList.value.forEach { peerSet -> peerList.value.forEach { peerSet ->
item { item {
ListItem(headlineContent = { ListItem(headlineContent = {
Text(text = peerSet.user?.DisplayName Text(
?: stringResource(id = R.string.unknown_user), style = MaterialTheme.typography.titleLarge) text = peerSet.user?.DisplayName
?: stringResource(id = R.string.unknown_user),
style = MaterialTheme.typography.titleLarge
)
}) })
} }
peerSet.peers.forEach { peer -> peerSet.peers.forEach { peer ->
item { item {
ListItem( ListItem(modifier = Modifier.clickable {
modifier = Modifier.clickable {
onNavigateToPeerDetails(peer) onNavigateToPeerDetails(peer)
}, }, headlineContent = {
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
// By definition, SelfPeer is online since we will not show the peer list unless you're connected. // By definition, SelfPeer is online since we will not show the peer list unless you're connected.
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running) val isSelfAndRunning =
(peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
val color: Color = if ((peer.Online == true) || isSelfAndRunning) { val color: Color = if ((peer.Online == true) || isSelfAndRunning) {
ts_color_light_green ts_color_light_green
} else { } else {
Color.Gray Color.Gray
} }
Box(modifier = Modifier Box(
modifier = Modifier
.size(8.dp) .size(8.dp)
.background(color = color, shape = RoundedCornerShape(percent = 50))) {} .background(
color = color, shape = RoundedCornerShape(percent = 50)
)
) {}
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium) Text(
text = peer.ComputedName,
style = MaterialTheme.typography.titleMedium
)
} }
}, }, supportingContent = {
supportingContent = {
Text( Text(
text = peer.Addresses?.first()?.split("/")?.first() text = peer.Addresses?.first()?.split("/")?.first() ?: "",
?: "",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
}, }, trailingContent = {
trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
} })
)
} }
} }
} }

@ -11,16 +11,18 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.StringSetting import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Composable @Composable
fun ManagedByView(mdmSettings: MDMSettings) { fun ManagedByView(model: IpnViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.surface) { Surface(color = MaterialTheme.colorScheme.surface) {
Column( Column(
verticalArrangement = Arrangement.spacedBy( verticalArrangement = Arrangement.spacedBy(
@ -31,6 +33,7 @@ fun ManagedByView(mdmSettings: MDMSettings) {
.fillMaxWidth() .fillMaxWidth()
.safeContentPadding() .safeContentPadding()
) { ) {
val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value
mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let { mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Text(stringResource(R.string.managed_by_explainer_orgName, it)) Text(stringResource(R.string.managed_by_explainer_orgName, it))
} ?: run { } ?: run {

@ -15,17 +15,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MullvadExitNodePicker(viewModel: ExitNodePickerViewModel, countryCode: String) { fun MullvadExitNodePicker(
val mullvadExitNodes = viewModel.mullvadExitNodesByCountryCode.collectAsState() countryCode: String,
val bestAvailableByCountry = viewModel.mullvadBestAvailableByCountry.collectAsState() nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val bestAvailableByCountry = model.mullvadBestAvailableByCountry.collectAsState()
mullvadExitNodes.value[countryCode]?.toList()?.let { nodes -> mullvadExitNodes.value[countryCode]?.toList()?.let { nodes ->
val any = nodes.first() val any = nodes.first()
@ -39,7 +46,7 @@ fun MullvadExitNodePicker(viewModel: ExitNodePickerViewModel, countryCode: Strin
val bestAvailableNode = bestAvailableByCountry.value[countryCode]!! val bestAvailableNode = bestAvailableByCountry.value[countryCode]!!
item { item {
ExitNodeItem( ExitNodeItem(
viewModel, ExitNodePickerViewModel.ExitNode( model, ExitNodePickerViewModel.ExitNode(
id = bestAvailableNode.id, id = bestAvailableNode.id,
label = stringResource(R.string.best_available), label = stringResource(R.string.best_available),
online = bestAvailableNode.online, online = bestAvailableNode.online,
@ -50,7 +57,7 @@ fun MullvadExitNodePicker(viewModel: ExitNodePickerViewModel, countryCode: Strin
} }
items(nodes) { node -> items(nodes) { node ->
ExitNodeItem(viewModel, node) ExitNodeItem(model, node)
} }
} }
} }

@ -28,32 +28,49 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.settingsRowModifier import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
@Composable @Composable
fun PeerDetails(viewModel: PeerDetailsViewModel) { fun PeerDetails(
nodeId: String, model: PeerDetailsViewModel = viewModel(
factory = PeerDetailsViewModelFactory(nodeId)
)
) {
Surface(color = MaterialTheme.colorScheme.surface) { Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier Column(
modifier = Modifier
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
.fillMaxHeight()) { .fillMaxHeight()
Column(modifier = Modifier ) {
Column(
modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally) { horizontalAlignment = Alignment.CenterHorizontally
Text(text = viewModel.nodeName, ) {
Text(
text = model.nodeName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier Box(
modifier = Modifier
.size(8.dp) .size(8.dp)
.background(color = viewModel.connectedColor, shape = RoundedCornerShape(percent = 50))) {} .background(
color = model.connectedColor,
shape = RoundedCornerShape(percent = 50)
)
) {}
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text(text = stringResource(id = viewModel.connectedStrRes), Text(
text = stringResource(id = model.connectedStrRes),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@ -62,13 +79,14 @@ fun PeerDetails(viewModel: PeerDetailsViewModel) {
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text(text = stringResource(id = R.string.addresses_section), Text(
text = stringResource(id = R.string.addresses_section),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Column(modifier = settingsRowModifier()) { Column(modifier = settingsRowModifier()) {
viewModel.addresses.forEach { model.addresses.forEach {
AddressRow(address = it.address, type = it.typeString) AddressRow(address = it.address, type = it.typeString)
} }
} }
@ -76,7 +94,7 @@ fun PeerDetails(viewModel: PeerDetailsViewModel) {
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Column(modifier = settingsRowModifier()) { Column(modifier = settingsRowModifier()) {
viewModel.info.forEach { model.info.forEach {
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString()) ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
} }
} }
@ -88,9 +106,11 @@ fun PeerDetails(viewModel: PeerDetailsViewModel) {
fun AddressRow(address: String, type: String) { fun AddressRow(address: String, type: String) {
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
Row(modifier = Modifier Row(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })) { .clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })
) {
Column { Column {
Text(text = address, style = MaterialTheme.typography.titleMedium) Text(text = address, style = MaterialTheme.typography.titleMedium)
Text(text = type, style = MaterialTheme.typography.bodyMedium) Text(text = type, style = MaterialTheme.typography.bodyMedium)
@ -103,9 +123,11 @@ fun AddressRow(address: String, type: String) {
@Composable @Composable
fun ValueRow(title: String, value: String) { fun ValueRow(title: String, value: String) {
Row(modifier = Modifier Row(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth()) { .fillMaxWidth()
) {
Text(text = title, style = MaterialTheme.typography.titleMedium) Text(text = title, style = MaterialTheme.typography.titleMedium)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = value, style = MaterialTheme.typography.bodyMedium) Text(text = value, style = MaterialTheme.typography.bodyMedium)

@ -36,6 +36,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
@ -44,18 +45,16 @@ import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.Setting import com.tailscale.ipn.ui.viewModel.Setting
import com.tailscale.ipn.ui.viewModel.SettingType import com.tailscale.ipn.ui.viewModel.SettingType
import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.SettingsViewModelFactory
data class SettingsNav(
val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit,
val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit,
)
@Composable @Composable
fun Settings(viewModel: SettingsViewModel) { fun Settings(
settingsNav: SettingsNav,
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModelFactory(settingsNav))
) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxHeight()) { Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxHeight()) {
@ -82,18 +81,19 @@ fun Settings(viewModel: SettingsViewModel) {
handler.openUri(Links.ADMIN_URL) handler.openUri(Links.ADMIN_URL)
}) })
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
PrimaryActionButton(onClick = { viewModel.ipnManager.logout() }) { PrimaryActionButton(onClick = { viewModel.logout() }) {
Text(text = stringResource(id = R.string.log_out)) Text(text = stringResource(id = R.string.log_out))
} }
} ?: run { } ?: run {
Button(onClick = { viewModel.ipnManager.login() }) { Button(onClick = { viewModel.login() }) {
Text(text = stringResource(id = R.string.log_in)) Text(text = stringResource(id = R.string.log_in))
} }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
viewModel.settings.forEach { settingBundle -> val settings = viewModel.settings.collectAsState().value
settings.forEach { settingBundle ->
Column(modifier = settingsRowModifier()) { Column(modifier = settingsRowModifier()) {
settingBundle.title?.let { settingBundle.title?.let {
Text( Text(
@ -140,8 +140,8 @@ fun UserView(
Column(verticalArrangement = Arrangement.Center) { Column(verticalArrangement = Arrangement.Center) {
Text( Text(
text = profile?.UserProfile?.DisplayName text = profile?.UserProfile?.DisplayName ?: "",
?: "", style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium) Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium)
} }
@ -180,7 +180,10 @@ fun SettingsSwitchRow(setting: Setting) {
val swVal = setting.isOn?.collectAsState()?.value ?: false val swVal = setting.isOn?.collectAsState()?.value ?: false
val enabled = setting.enabled.collectAsState().value val enabled = setting.enabled.collectAsState().value
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) { Row(
modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() },
verticalAlignment = Alignment.CenterVertically
) {
Text(setting.title.getString()) Text(setting.title.getString())
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled) Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)

@ -6,23 +6,30 @@ package com.tailscale.ipn.ui.viewModel
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.service.setExitNodeId
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.util.TreeMap import java.util.TreeMap
class ExitNodePickerViewModel(private val model: IpnModel, private val onNavigateHome: () -> Unit) : data class ExitNodePickerNav(
ViewModel() { val onNavigateHome: () -> Unit,
companion object { val onNavigateToMullvadCountry: (String) -> Unit,
const val TAG = "ExitNodePickerViewModel" )
class ExitNodePickerViewModelFactory(private val nav: ExitNodePickerNav) :
ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ExitNodePickerViewModel(nav) as T
} }
}
class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel() {
data class ExitNode( data class ExitNode(
val id: StableNodeID? = null, val id: StableNodeID? = null,
val label: String, val label: String,
@ -110,8 +117,10 @@ class ExitNodePickerViewModel(private val model: IpnModel, private val onNavigat
fun setExitNode(node: ExitNode) { fun setExitNode(node: ExitNode) {
LoadingIndicator.start() LoadingIndicator.start()
model.setExitNodeId(node.id) { val prefsOut = Ipn.MaskedPrefs()
onNavigateHome() prefsOut.ExitNodeID = node.id
Client(viewModelScope).editPrefs(prefsOut) {
nav.onNavigateHome()
LoadingIndicator.stop() LoadingIndicator.stop()
} }
} }

@ -0,0 +1,134 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.mdm.MDMSettings
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.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* Base model for most models in this application. Provides common facilities for watching IPN
* notifications, managing login/logout, updating preferences, etc.
*/
open class IpnViewModel : ViewModel() {
companion object {
val mdmSettings: StateFlow<MDMSettings> = MutableStateFlow(MDMSettings())
}
protected val TAG = this::class.simpleName
val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)
val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null)
init {
viewModelScope.launch {
Notifier.state.collect {
// Refresh the user profiles if we're transitioning out of the
// NeedsLogin state.
if (it == Ipn.State.NeedsLogin) {
viewModelScope.launch { loadUserProfiles() }
}
}
}
viewModelScope.launch { loadUserProfiles() }
Log.d(TAG, "Created")
}
private fun loadUserProfiles() {
Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure {
Log.e(TAG, "Error loading profiles: ${it.message}")
}
}
Client(viewModelScope).currentProfile { result ->
result.onSuccess(loggedInUser::set).onFailure {
Log.e(TAG, "Error loading current profile: ${it.message}")
}
}
}
fun login() {
Client(viewModelScope).startLoginInteractive { result ->
result.onSuccess {
Log.d(TAG, "Login started: $it")
}.onFailure {
Log.e(TAG, "Error starting login: ${it.message}")
}
}
}
fun logout() {
Client(viewModelScope).logout { result ->
result.onSuccess {
Log.d(TAG, "Logout started: $it")
}.onFailure {
Log.e(TAG, "Error starting logout: ${it.message}")
}
}
}
// The below handle all types of preference modifications typically invoked by the UI.
// Callers generally shouldn't care about the returned prefs value - the source of
// truth is the IPNModel, who's prefs flow will change in value to reflect the true
// value of the pref setting in the back end (and will match the value returned here).
// Generally, you will want to inspect the returned value in the callback for errors
// to indicate why a particular setting did not change in the interface.
//
// Usage:
// - User/Interface changed to new value. Render the new value.
// - Submit the new value to the PrefsEditor
// - Observe the prefs on the IpnModel and update the UI when/if the value changes.
// For a typical flow, the changed value should reflect the value already shown.
// - Inform the user of any error which may have occurred
//
// The "toggle' functions here will attempt to set the pref value to the inverse of
// what is currently known in the IpnModel.prefs. If IpnModel.prefs is not available,
// the callback will be called with a NO_PREFS error
fun setWantRunning(wantRunning: Boolean, callback: (Result<Ipn.Prefs>) -> Unit) {
Ipn.MaskedPrefs().WantRunning = wantRunning
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback)
}
fun toggleCorpDNS(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = Notifier.prefs.value ?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleCorpDNS
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.CorpDNS = !prefs.CorpDNS
Client(viewModelScope).editPrefs(prefsOut, callback)
}
fun toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = Notifier.prefs.value ?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ShieldsUp = !prefs.ShieldsUp
Client(viewModelScope).editPrefs(prefsOut, callback)
}
fun toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = Notifier.prefs.value ?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = !prefs.RouteAll
Client(viewModelScope).editPrefs(prefsOut, callback)
}
}

@ -4,20 +4,21 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel import android.content.Intent
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.service.IpnActions import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel() { class MainViewModel : IpnViewModel() {
// The user readable state of the system // The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(State.NoState.userStringRes()) val stateRes: StateFlow<Int> = MutableStateFlow(State.NoState.userStringRes())
@ -29,29 +30,29 @@ class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel()
val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>()) val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>())
// The current state of the IPN for determining view visibility // The current state of the IPN for determining view visibility
val ipnState = model.state val ipnState = Notifier.state
// The logged in user val prefs = Notifier.prefs
val loggedInUser = model.loggedInUser val netmap = Notifier.netmap
// The active search term for filtering peers // The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("") val searchTerm: StateFlow<String> = MutableStateFlow("")
// The peerID of the local node // The peerID of the local node
val selfPeerId = model.netmap.value?.SelfNode?.StableID ?: "" val selfPeerId = Notifier.netmap.value?.SelfNode?.StableID ?: ""
val peerCategorizer = PeerCategorizer(model, viewModelScope) private val peerCategorizer = PeerCategorizer(viewModelScope)
init { init {
viewModelScope.launch { viewModelScope.launch {
model.state.collect { state -> Notifier.state.collect { state ->
stateRes.set(state.userStringRes()) stateRes.set(state.userStringRes())
vpnToggleState.set((state == State.Running || state == State.Starting)) vpnToggleState.set((state == State.Running || state == State.Starting))
} }
} }
viewModelScope.launch { viewModelScope.launch {
model.netmap.collect { netmap -> Notifier.netmap.collect { netmap ->
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value)) peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
} }
} }
@ -70,16 +71,25 @@ class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel()
} }
fun toggleVpn() { fun toggleVpn() {
when (model.state.value) { when (Notifier.state.value) {
State.Running -> actions.stopVPN() State.Running -> stopVPN()
else -> actions.startVPN() else -> startVPN()
} }
} }
fun login() { private fun startVPN() {
actions.login() val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN
context.sendBroadcast(intent)
} }
fun stopVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
}
} }
private fun State?.userStringRes(): Int { private fun State?.userStringRes(): Int {

@ -5,9 +5,10 @@ package com.tailscale.ipn.ui.viewModel
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.service.IpnModel import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.ts_color_light_green import com.tailscale.ipn.ui.theme.ts_color_light_green
import com.tailscale.ipn.ui.util.ComposableStringFormatter import com.tailscale.ipn.ui.util.ComposableStringFormatter
import com.tailscale.ipn.ui.util.DisplayAddress import com.tailscale.ipn.ui.util.DisplayAddress
@ -16,7 +17,13 @@ import com.tailscale.ipn.ui.util.TimeUtil
data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter) data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter)
class PeerDetailsViewModel(val model: IpnModel, val nodeId: StableNodeID) : ViewModel() { class PeerDetailsViewModelFactory(private val nodeId: StableNodeID) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PeerDetailsViewModel(nodeId) as T
}
}
class PeerDetailsViewModel(val nodeId: StableNodeID) : IpnViewModel() {
var addresses: List<DisplayAddress> = emptyList() var addresses: List<DisplayAddress> = emptyList()
var info: List<PeerSettingInfo> = emptyList() var info: List<PeerSettingInfo> = emptyList()
@ -26,7 +33,7 @@ class PeerDetailsViewModel(val model: IpnModel, val nodeId: StableNodeID) : View
val connectedColor: Color val connectedColor: Color
init { init {
val peer = model.netmap.value?.getPeer(nodeId) val peer = Notifier.netmap.value?.getPeer(nodeId)
peer?.Addresses?.let { peer?.Addresses?.let {
addresses = it.map { addr -> addresses = it.map { addr ->
DisplayAddress(addr) DisplayAddress(addr)

@ -7,14 +7,14 @@ import androidx.annotation.StringRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.BuildConfig import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.StringSetting import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.service.IpnManager import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.service.toggleCorpDNS
import com.tailscale.ipn.ui.view.SettingsNav
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -27,7 +27,7 @@ class ComposableStringFormatter(@StringRes val stringRes: Int, vararg val params
fun getString(): String = stringResource(id = stringRes, *params) fun getString(): String = stringResource(id = stringRes, *params)
} }
// Represents a bundle of settings values that should be grouped together uner a title // Represents a bundle of settings values that should be grouped together under a title
data class SettingBundle(val title: String? = null, val settings: List<Setting>) data class SettingBundle(val title: String? = null, val settings: List<Setting>)
// Represents a UI setting. // Represents a UI setting.
@ -70,62 +70,81 @@ data class Setting(
) )
} }
data class SettingsNav(
val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit,
val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit,
)
class SettingsViewModel( class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelProvider.Factory {
val ipnManager: IpnManager, override fun <T : ViewModel> create(modelClass: Class<T>): T {
val navigation: SettingsNav return SettingsViewModel(navigation) as T
) : ViewModel() { }
// The logged in user }
val model = ipnManager.model
val mdmSettings = ipnManager.mdmSettings
val user = model.loggedInUser.value class SettingsViewModel(private val navigation: SettingsNav) : IpnViewModel() {
val user = loggedInUser.value
// Display name for the logged in user // Display name for the logged in user
val isAdmin = model.netmap.value?.SelfNode?.isAdmin ?: false val isAdmin = Notifier.netmap.value?.SelfNode?.isAdmin ?: false
val useDNSSetting = Setting( val useDNSSetting = Setting(R.string.use_ts_dns,
R.string.use_ts_dns,
SettingType.SWITCH, SettingType.SWITCH,
isOn = MutableStateFlow(model.prefs.value?.CorpDNS), isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS),
onToggle = { onToggle = {
model.toggleCorpDNS { toggleCorpDNS {
// (jonathan) TODO: Error handling // (jonathan) TODO: Error handling
} }
}) })
val settings: StateFlow<List<SettingBundle>> = MutableStateFlow(emptyList())
init { init {
viewModelScope.launch { viewModelScope.launch {
// Monitor our prefs for changes and update the displayed values accordingly // Monitor our prefs for changes and update the displayed values accordingly
model.prefs.collect { prefs -> Notifier.prefs.collect { prefs ->
useDNSSetting.isOn?.set(prefs?.CorpDNS) useDNSSetting.isOn?.set(prefs?.CorpDNS)
useDNSSetting.enabled.set(prefs != null) useDNSSetting.enabled.set(prefs != null)
} }
} }
viewModelScope.launch {
IpnViewModel.mdmSettings.collect { mdmSettings ->
settings.set(
listOf(
SettingBundle(
settings = listOf(
useDNSSetting,
)
),
// General settings, always enabled
SettingBundle(settings = footerSettings(mdmSettings))
)
)
}
}
} }
private val footerSettings: List<Setting> = listOfNotNull( private fun footerSettings(mdmSettings: MDMSettings): List<Setting> = listOfNotNull(
Setting( Setting(
titleRes = R.string.about, titleRes = R.string.about,
SettingType.NAV, SettingType.NAV,
onClick = { navigation.onNavigateToAbout() }, onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true) enabled = MutableStateFlow(true)
), ), Setting(
Setting(
titleRes = R.string.bug_report, titleRes = R.string.bug_report,
SettingType.NAV, SettingType.NAV,
onClick = { navigation.onNavigateToBugReport() }, onClick = { navigation.onNavigateToBugReport() },
enabled = MutableStateFlow(true) enabled = MutableStateFlow(true)
), ), mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Setting( Setting(
ComposableStringFormatter(R.string.managed_by_orgName, it), ComposableStringFormatter(R.string.managed_by_orgName, it),
SettingType.NAV, SettingType.NAV,
onClick = { navigation.onNavigateToManagedBy() }, onClick = { navigation.onNavigateToManagedBy() },
enabled = MutableStateFlow(true) enabled = MutableStateFlow(true)
) )
}, }, if (BuildConfig.DEBUG) {
if (BuildConfig.DEBUG) {
Setting( Setting(
titleRes = R.string.mdm_settings, titleRes = R.string.mdm_settings,
SettingType.NAV, SettingType.NAV,
@ -136,14 +155,4 @@ class SettingsViewModel(
null null
} }
) )
val settings: List<SettingBundle> = listOf(
SettingBundle(
settings = listOf(
useDNSSetting,
)
),
// General settings, always enabled
SettingBundle(settings = footerSettings)
)
} }

@ -34,7 +34,7 @@ var shim struct {
backend *ipnlocal.LocalBackend backend *ipnlocal.LocalBackend
busWatchers map[string]func() cancelWatchBus func()
jvm *jni.JVM jvm *jni.JVM
} }
@ -108,7 +108,6 @@ func doLocalAPIRequest(path string, method string, body []byte) []byte {
// Assign a localAPIService to our shim for handling incoming localapi requests from the Kotlin side. // Assign a localAPIService to our shim for handling incoming localapi requests from the Kotlin side.
func ConfigureShim(jvm *jni.JVM, appCtx jni.Object, s *LocalAPIService, b *ipnlocal.LocalBackend) { func ConfigureShim(jvm *jni.JVM, appCtx jni.Object, s *LocalAPIService, b *ipnlocal.LocalBackend) {
shim.busWatchers = make(map[string]func())
shim.service = s shim.service = s
shim.backend = b shim.backend = b
@ -152,22 +151,14 @@ func configureLocalAPIJNIHandler(jvm *jni.JVM, appCtx jni.Object) error {
//export Java_com_tailscale_ipn_ui_notifier_Notifier_stopIPNBusWatcher //export Java_com_tailscale_ipn_ui_notifier_Notifier_stopIPNBusWatcher
func Java_com_tailscale_ipn_ui_notifier_Notifier_stopIPNBusWatcher( func Java_com_tailscale_ipn_ui_notifier_Notifier_stopIPNBusWatcher(
env *C.JNIEnv, env *C.JNIEnv,
cls C.jclass, cls C.jclass) {
jsessionId C.jstring) {
jenv := (*jni.Env)(unsafe.Pointer(env))
sessionIdRef := jni.NewGlobalRef(jenv, jni.Object(jsessionId)) if shim.cancelWatchBus != nil {
sessionId := jni.GoString(jenv, jni.String(sessionIdRef)) log.Printf("Stop watching IPN bus")
defer jni.DeleteGlobalRef(jenv, sessionIdRef) shim.cancelWatchBus()
shim.cancelWatchBus = nil
cancel := shim.busWatchers[sessionId]
if cancel != nil {
log.Printf("Deregistering app layer bus watcher with sessionid: %s", sessionId)
cancel()
delete(shim.busWatchers, sessionId)
} else { } else {
log.Printf("Error: Could not find bus watcher with sessionid: %s", sessionId) log.Printf("Not watching IPN bus, nothing to cancel")
} }
} }
@ -175,19 +166,14 @@ func Java_com_tailscale_ipn_ui_notifier_Notifier_stopIPNBusWatcher(
func Java_com_tailscale_ipn_ui_notifier_Notifier_startIPNBusWatcher( func Java_com_tailscale_ipn_ui_notifier_Notifier_startIPNBusWatcher(
env *C.JNIEnv, env *C.JNIEnv,
cls C.jclass, cls C.jclass,
jsessionId C.jstring,
jmask C.jint) { jmask C.jint) {
jenv := (*jni.Env)(unsafe.Pointer(env)) jenv := (*jni.Env)(unsafe.Pointer(env))
sessionIdRef := jni.NewGlobalRef(jenv, jni.Object(jsessionId)) log.Printf("Start watching IPN bus")
sessionId := jni.GoString(jenv, jni.String(sessionIdRef))
defer jni.DeleteGlobalRef(jenv, sessionIdRef)
log.Printf("Registering app layer bus watcher with sessionid: %s", sessionId)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
shim.busWatchers[sessionId] = cancel shim.cancelWatchBus = cancel
opts := ipn.NotifyWatchOpt(jmask) opts := ipn.NotifyWatchOpt(jmask)
shim.backend.WatchNotifications(ctx, opts, func() { shim.backend.WatchNotifications(ctx, opts, func() {
@ -198,9 +184,9 @@ func Java_com_tailscale_ipn_ui_notifier_Notifier_startIPNBusWatcher(
return true return true
} }
jni.Do(shim.jvm, func(env *jni.Env) error { jni.Do(shim.jvm, func(env *jni.Env) error {
jjson := jni.JavaString(env, string(js)) jjson := jni.NewByteArray(jenv, js)
onNotify := jni.GetMethodID(env, shim.notifierClass, "onNotify", "(Ljava/lang/String;Ljava/lang/String;)V") onNotify := jni.GetStaticMethodID(jenv, shim.notifierClass, "onNotify", "([B)V")
jni.CallVoidMethod(env, jni.Object(cls), onNotify, jni.Value(jjson), jni.Value(jsessionId)) jni.CallStaticVoidMethod(jenv, shim.notifierClass, onNotify, jni.Value(jjson))
return nil return nil
}) })
return true return true

Loading…
Cancel
Save