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.ShowHideSetting;
import com.tailscale.ipn.mdm.StringSetting;
import com.tailscale.ipn.ui.service.IpnManager;
import org.gioui.Gio;

@ -10,7 +10,6 @@ import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.remember
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@ -19,8 +18,9 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navigation
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.util.set
import com.tailscale.ipn.ui.view.AboutView
import com.tailscale.ipn.ui.view.BugReportView
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.PeerDetails
import com.tailscale.ipn.ui.view.Settings
import com.tailscale.ipn.ui.view.SettingsNav
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private val manager = IpnManager(lifecycleScope)
private var notifierScope: CoroutineScope? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -64,38 +62,29 @@ class MainActivity : ComponentActivity() {
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") })
composable("main") {
MainView(
viewModel = MainViewModel(manager.model, manager),
navigation = mainViewNav
val exitNodePickerNav = ExitNodePickerNav(onNavigateHome = {
navController.popBackStack(
route = "main", inclusive = false
)
}, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") })
composable("main") {
MainView(navigation = mainViewNav)
}
composable("settings") {
Settings(SettingsViewModel(manager, settingsNav))
Settings(settingsNav)
}
navigation(startDestination = "list", route = "exitNodes") {
composable("list") {
val viewModel = remember {
ExitNodePickerViewModel(manager.model) {
navController.navigate("main")
}
}
ExitNodePicker(viewModel) {
navController.navigate("mullvad/$it")
}
ExitNodePicker(exitNodePickerNav)
}
composable(
"mullvad/{countryCode}", arguments = listOf(navArgument("countryCode") {
type = NavType.StringType
})
) {
val viewModel = remember {
ExitNodePickerViewModel(manager.model) {
navController.navigate("main")
}
}
MullvadExitNodePicker(
viewModel, it.arguments!!.getString("countryCode")!!
it.arguments!!.getString("countryCode")!!, exitNodePickerNav
)
}
}
@ -103,23 +92,19 @@ class MainActivity : ComponentActivity() {
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })
) {
PeerDetails(
PeerDetailsViewModel(
manager.model, nodeId = it.arguments?.getString("nodeId") ?: ""
)
)
PeerDetails(it.arguments?.getString("nodeId") ?: "")
}
composable("bugReport") {
BugReportView(BugReportViewModel())
BugReportView()
}
composable("about") {
AboutView()
}
composable("mdmSettings") {
MDMSettingsDebugView(manager.mdmSettings)
MDMSettingsDebugView()
}
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
// This will trigger the login flow
lifecycleScope.launch {
manager.model.browseToURL.collect { url ->
Notifier.browseToURL.collect { url ->
url?.let {
Dispatchers.Main.run {
login(it)
@ -152,7 +137,19 @@ class MainActivity : ComponentActivity() {
super.onResume()
val 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
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.Netmap
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
typealias NotifierCallback = (Notify) -> Unit
class Watcher(
val sessionId: String, val mask: Int, val callback: NotifierCallback
)
import kotlinx.serialization.json.decodeFromStream
// 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
@ -26,116 +26,72 @@ class Watcher(
// 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
// unwatchIPNBus with the sessionId.
class Notifier(private val scope: CoroutineScope) {
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// 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
object Notifier {
private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true }
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.
@JvmStatic
@Suppress("unused")
fun onReady() {
isReady.complete(true)
Log.d("Notifier", "Notifier is ready")
}
private fun generateSessionId(): String {
synchronized(sessionIdLock) {
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}"
)
}
Log.d(TAG, "Ready")
}
// 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
fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting")
scope.launch(Dispatchers.IO) {
// Wait for the notifier to be ready
isReady.await()
Log.d("Notifier", "Starting IPN Bus watcher for sessionid: ${sessionId}")
startIPNBusWatcher(sessionId, mask)
watchers.remove(sessionId)
Log.d("Notifier", "IPN Bus watcher for sessionid:${sessionId} has halted")
}
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 cancelAllWatchers() {
for (sessionId in watchers.values.map({ it.sessionId })) {
unwatchIPNBus(sessionId)
val mask =
NotifyWatchOpt.Netmap.value or NotifyWatchOpt.Prefs.value or NotifyWatchOpt.InitialState.value
startIPNBusWatcher(mask)
Log.d(TAG, "Stopped")
}
}
// Returns a list of all active watchers
fun watchers(): List<Watcher> {
return watchers.values.toList()
fun stop() {
Log.d(TAG, "Stopping")
stopIPNBusWatcher()
}
// Convenience methods for watching specific parts of the IPN bus
fun watchNetMap(callback: NotifierCallback): String {
return watchIPNBus(NotifyWatchOpt.netmap.value, callback)
// Callback from jni when a new notification is received
@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 {
return watchIPNBus(NotifyWatchOpt.prefs.value, callback)
}
// Starts watching the IPN Bus. This is blocking.
private external fun startIPNBusWatcher(mask: Int)
fun watchEngineUpdates(callback: NotifierCallback): String {
return watchIPNBus(NotifyWatchOpt.engineUpdates.value, callback)
}
// Stop watching the IPN Bus. This is non-blocking.
private external fun stopIPNBusWatcher()
fun watchAll(callback: NotifierCallback): String {
return watchIPNBus(
NotifyWatchOpt.netmap.value or NotifyWatchOpt.prefs.value or NotifyWatchOpt.initialState.value,
callback
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// what we want to see on the Notify bus
private enum class NotifyWatchOpt(val value: Int) {
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,
) {
content()
val isLoading = loading.collectAsState()
if (isLoading.value) {
val isLoading = loading.collectAsState().value
if (isLoading) {
Box(
Modifier
.matchParentSize()

@ -7,7 +7,7 @@ package com.tailscale.ipn.ui.util
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Tailcfg
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.launch
@ -16,7 +16,7 @@ data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<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 lastSearchResult: List<PeerSet> = emptyList()
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
init {
scope.launch {
model.netmap.collect { netmap ->
Notifier.netmap.collect { netmap ->
netmap?.let {
peerSets = regenerateGroupedPeers(netmap)
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
val setsToSearch = if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets
val setsToSearch =
if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets
this.searchTerm = searchTerm
val matchingSets = setsToSearch.map { peerSet ->
@ -91,7 +92,8 @@ class PeerCategorizer(val model: IpnModel, val scope: CoroutineScope) {
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()) {
PeerSet(user, matchingPeers)
} else {

@ -34,6 +34,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.util.defaultPaddingModifier
@ -43,16 +44,22 @@ import kotlinx.coroutines.flow.StateFlow
@Composable
fun BugReportView(viewModel: BugReportViewModel) {
fun BugReportView(model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = defaultPaddingModifier().fillMaxWidth().fillMaxHeight()) {
Text(text = stringResource(id = R.string.bug_report_title),
Column(
modifier = defaultPaddingModifier()
.fillMaxWidth()
.fillMaxHeight()
) {
Text(
text = stringResource(id = R.string.bug_report_title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium)
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
@ -65,15 +72,17 @@ fun BugReportView(viewModel: BugReportViewModel) {
Spacer(modifier = Modifier.height(8.dp))
ReportIdRow(bugReportIdFlow = viewModel.bugReportID)
ReportIdRow(bugReportIdFlow = model.bugReportID)
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(),
textAlign = TextAlign.Left,
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 bugReportId = bugReportIdFlow.collectAsState()
Row(modifier = settingsRowModifier()
Row(
modifier = settingsRowModifier()
.fillMaxWidth()
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(bugReportId.value)) }),
verticalAlignment = Alignment.CenterVertically) {
verticalAlignment = Alignment.CenterVertically
) {
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)) {
Icon(Icons.Outlined.Share, null, modifier = Modifier
Icon(
Icons.Outlined.Share, null, modifier = Modifier
.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.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.LoadingIndicator
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.ExitNodePickerViewModelFactory
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExitNodePicker(
viewModel: ExitNodePickerViewModel,
onNavigateToMullvadCountry: (String) -> Unit,
nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
LoadingIndicator.Wrap {
Scaffold(topBar = {
TopAppBar(title = { Text(stringResource(R.string.choose_exit_node)) })
}) { innerPadding ->
val tailnetExitNodes = viewModel.tailnetExitNodes.collectAsState()
val mullvadExitNodes = viewModel.mullvadExitNodesByCountryCode.collectAsState()
val anyActive = viewModel.anyActive.collectAsState()
val tailnetExitNodes = model.tailnetExitNodes.collectAsState()
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val anyActive = model.anyActive.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "none") {
ExitNodeItem(
viewModel,
ExitNodePickerViewModel.ExitNode(
model, ExitNodePickerViewModel.ExitNode(
label = stringResource(R.string.none),
online = true,
selected = !anyActive.value,
),
)
)
}
@ -67,7 +69,7 @@ fun ExitNodePicker(
}
items(tailnetExitNodes.value, key = { it.id!! }) { node ->
ExitNodeItem(viewModel, node, indent = 16.dp)
ExitNodeItem(model, node, indent = 16.dp)
}
item {
@ -89,11 +91,11 @@ fun ExitNodePicker(
.padding(start = 16.dp)
.clickable {
if (nodes.size > 1) {
onNavigateToMullvadCountry(
nav.onNavigateToMullvadCountry(
countryCode
)
} else {
viewModel.setExitNode(first)
model.setExitNode(first)
}
}, headlineContent = {
Text("${countryCode.flag()} ${first.country}")

@ -17,35 +17,35 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting
import com.tailscale.ipn.mdm.BooleanSetting
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHideSetting
import com.tailscale.ipn.mdm.StringArraySetting
import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.util.defaultPaddingModifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MDMSettingsDebugView(mdmSettings: MDMSettings) {
fun MDMSettingsDebugView(model: IpnViewModel = viewModel()) {
Scaffold(
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
TopAppBar(colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = {
), title = {
Text(stringResource(R.string.current_mdm_settings))
}
)
})
},
) { innerPadding ->
val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value
LazyColumn(modifier = Modifier.padding(innerPadding)) {
items(enumValues<BooleanSetting>()) { booleanSetting ->
MDMSettingView(
@ -95,8 +95,7 @@ fun MDMSettingsDebugView(mdmSettings: MDMSettings) {
fun MDMSettingView(title: String, caption: String, valueDescription: String) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = defaultPaddingModifier()
.fillMaxWidth()
modifier = defaultPaddingModifier().fillMaxWidth()
) {
Column {
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.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
@ -62,31 +63,36 @@ import kotlinx.coroutines.flow.StateFlow
data class MainViewNavigation(
val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit)
val onNavigateToExitNodes: () -> Unit
)
@Composable
fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
fun MainView(navigation: MainViewNavigation, model: MainViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Center
modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center
) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null)
val state = model.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = model.loggedInUser.collectAsState(initial = null)
Row(modifier = Modifier
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically) {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
verticalAlignment = Alignment.CenterVertically
) {
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))
StateDisplay(viewModel.stateRes, viewModel.userName)
Box(modifier = Modifier
StateDisplay(model.stateRes, model.userName)
Box(
modifier = Modifier
.weight(1f)
.clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) {
.clickable { navigation.onNavigateToSettings() },
contentAlignment = Alignment.CenterEnd
) {
Avatar(profile = user.value, size = 36)
}
}
@ -94,22 +100,17 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
when (state.value) {
Ipn.State.Running -> {
ExitNodeStatus(navigation.onNavigateToExitNodes, viewModel)
PeerList(
searchTerm = viewModel.searchTerm,
state = viewModel.ipnState,
peers = viewModel.peers,
selfPeer = viewModel.selfPeerId,
ExitNodeStatus(navigation.onNavigateToExitNodes, model)
PeerList(searchTerm = model.searchTerm,
state = model.ipnState,
peers = model.peers,
selfPeer = model.selfPeerId,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
onSearch = { model.searchPeers(it) })
}
Ipn.State.Starting -> StartingView()
else ->
ConnectView(
user.value,
{ viewModel.toggleVpn() },
{ viewModel.login() }
else -> ConnectView(user.value, { model.toggleVpn() }, { model.login() }
)
}
@ -119,8 +120,8 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
@Composable
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val prefs = viewModel.model.prefs.collectAsState()
val netmap = viewModel.model.netmap.collectAsState()
val prefs = viewModel.prefs.collectAsState()
val netmap = viewModel.netmap.collectAsState()
val exitNodeId = prefs.value?.ExitNodeID
val exitNode = exitNodeId?.let { id ->
netmap.value?.Peers?.find { it.StableID == id }?.let { peer ->
@ -136,9 +137,15 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
.background(MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) {
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) {
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(
Icons.Outlined.ArrowDropDown,
null,
@ -155,17 +162,18 @@ fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
Column(modifier = Modifier.padding(7.dp)) {
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
fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
// (jonathan) TODO: On iOS this is the users avatar or a letter avatar.
IconButton(
modifier = Modifier.size(24.dp),
onClick = { action() }
) {
IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
Icon(
Icons.Outlined.Settings,
null,
@ -177,14 +185,14 @@ fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
fun StartingView() {
// (jonathan) TODO: On iOS this is the game-of-life Tailscale animation.
Column(
modifier =
Modifier
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(id = R.string.starting),
Text(
text = stringResource(id = R.string.starting),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@ -201,8 +209,7 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
.fillMaxWidth(0.7f)
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(
8.dp,
alignment = Alignment.CenterVertically
8.dp, alignment = Alignment.CenterVertically
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
@ -265,12 +272,14 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeerList(searchTerm: StateFlow<String>,
fun PeerList(
searchTerm: StateFlow<String>,
peers: StateFlow<List<PeerSet>>,
state: StateFlow<Ipn.State>,
selfPeer: StableNodeID,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit) {
onSearch: (String) -> Unit
) {
val peerList = peers.collectAsState(initial = emptyList<PeerSet>())
var searching = false
val searchTermStr by searchTerm.collectAsState(initial = "")
@ -291,51 +300,55 @@ fun PeerList(searchTerm: StateFlow<String>,
) {
LazyColumn(
modifier =
Modifier
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer),
) {
peerList.value.forEach { peerSet ->
item {
ListItem(headlineContent = {
Text(text = peerSet.user?.DisplayName
?: stringResource(id = R.string.unknown_user), style = MaterialTheme.typography.titleLarge)
Text(
text = peerSet.user?.DisplayName
?: stringResource(id = R.string.unknown_user),
style = MaterialTheme.typography.titleLarge
)
})
}
peerSet.peers.forEach { peer ->
item {
ListItem(
modifier = Modifier.clickable {
ListItem(modifier = Modifier.clickable {
onNavigateToPeerDetails(peer)
},
headlineContent = {
}, headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
// 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) {
ts_color_light_green
} else {
Color.Gray
}
Box(modifier = Modifier
Box(
modifier = Modifier
.size(8.dp)
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
.background(
color = color, shape = RoundedCornerShape(percent = 50)
)
) {}
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 = peer.Addresses?.first()?.split("/")?.first()
?: "",
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
style = MaterialTheme.typography.bodyMedium
)
},
trailingContent = {
}, trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
)
})
}
}
}

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

@ -15,17 +15,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.LoadingIndicator
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.ExitNodePickerViewModelFactory
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MullvadExitNodePicker(viewModel: ExitNodePickerViewModel, countryCode: String) {
val mullvadExitNodes = viewModel.mullvadExitNodesByCountryCode.collectAsState()
val bestAvailableByCountry = viewModel.mullvadBestAvailableByCountry.collectAsState()
fun MullvadExitNodePicker(
countryCode: String,
nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val bestAvailableByCountry = model.mullvadBestAvailableByCountry.collectAsState()
mullvadExitNodes.value[countryCode]?.toList()?.let { nodes ->
val any = nodes.first()
@ -39,7 +46,7 @@ fun MullvadExitNodePicker(viewModel: ExitNodePickerViewModel, countryCode: Strin
val bestAvailableNode = bestAvailableByCountry.value[countryCode]!!
item {
ExitNodeItem(
viewModel, ExitNodePickerViewModel.ExitNode(
model, ExitNodePickerViewModel.ExitNode(
id = bestAvailableNode.id,
label = stringResource(R.string.best_available),
online = bestAvailableNode.online,
@ -50,7 +57,7 @@ fun MullvadExitNodePicker(viewModel: ExitNodePickerViewModel, countryCode: Strin
}
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.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
@Composable
fun PeerDetails(viewModel: PeerDetailsViewModel) {
fun PeerDetails(
nodeId: String, model: PeerDetailsViewModel = viewModel(
factory = PeerDetailsViewModelFactory(nodeId)
)
) {
Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxHeight()) {
Column(modifier = Modifier
.fillMaxHeight()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = viewModel.nodeName,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = model.nodeName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier
Box(
modifier = Modifier
.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))
Text(text = stringResource(id = viewModel.connectedStrRes),
Text(
text = stringResource(id = model.connectedStrRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
@ -62,13 +79,14 @@ fun PeerDetails(viewModel: PeerDetailsViewModel) {
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,
color = MaterialTheme.colorScheme.primary
)
Column(modifier = settingsRowModifier()) {
viewModel.addresses.forEach {
model.addresses.forEach {
AddressRow(address = it.address, type = it.typeString)
}
}
@ -76,7 +94,7 @@ fun PeerDetails(viewModel: PeerDetailsViewModel) {
Spacer(modifier = Modifier.size(16.dp))
Column(modifier = settingsRowModifier()) {
viewModel.info.forEach {
model.info.forEach {
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
}
}
@ -88,9 +106,11 @@ fun PeerDetails(viewModel: PeerDetailsViewModel) {
fun AddressRow(address: String, type: String) {
val localClipboardManager = LocalClipboardManager.current
Row(modifier = Modifier
Row(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })) {
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })
) {
Column {
Text(text = address, style = MaterialTheme.typography.titleMedium)
Text(text = type, style = MaterialTheme.typography.bodyMedium)
@ -103,9 +123,11 @@ fun AddressRow(address: String, type: String) {
@Composable
fun ValueRow(title: String, value: String) {
Row(modifier = Modifier
Row(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth()) {
.fillMaxWidth()
) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
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.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
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.viewModel.Setting
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.SettingsViewModelFactory
data class SettingsNav(
val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit,
val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit,
)
@Composable
fun Settings(viewModel: SettingsViewModel) {
fun Settings(
settingsNav: SettingsNav,
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModelFactory(settingsNav))
) {
val handler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxHeight()) {
@ -82,18 +81,19 @@ fun Settings(viewModel: SettingsViewModel) {
handler.openUri(Links.ADMIN_URL)
})
Spacer(modifier = Modifier.height(8.dp))
PrimaryActionButton(onClick = { viewModel.ipnManager.logout() }) {
PrimaryActionButton(onClick = { viewModel.logout() }) {
Text(text = stringResource(id = R.string.log_out))
}
} ?: run {
Button(onClick = { viewModel.ipnManager.login() }) {
Button(onClick = { viewModel.login() }) {
Text(text = stringResource(id = R.string.log_in))
}
}
Spacer(modifier = Modifier.height(8.dp))
viewModel.settings.forEach { settingBundle ->
val settings = viewModel.settings.collectAsState().value
settings.forEach { settingBundle ->
Column(modifier = settingsRowModifier()) {
settingBundle.title?.let {
Text(
@ -140,8 +140,8 @@ fun UserView(
Column(verticalArrangement = Arrangement.Center) {
Text(
text = profile?.UserProfile?.DisplayName
?: "", style = MaterialTheme.typography.titleMedium
text = profile?.UserProfile?.DisplayName ?: "",
style = MaterialTheme.typography.titleMedium
)
Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium)
}
@ -180,7 +180,10 @@ fun SettingsSwitchRow(setting: Setting) {
val swVal = setting.isOn?.collectAsState()?.value ?: false
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())
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)

@ -6,23 +6,30 @@ package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
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.service.IpnModel
import com.tailscale.ipn.ui.service.setExitNodeId
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.TreeMap
class ExitNodePickerViewModel(private val model: IpnModel, private val onNavigateHome: () -> Unit) :
ViewModel() {
companion object {
const val TAG = "ExitNodePickerViewModel"
data class ExitNodePickerNav(
val onNavigateHome: () -> Unit,
val onNavigateToMullvadCountry: (String) -> Unit,
)
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(
val id: StableNodeID? = null,
val label: String,
@ -110,8 +117,10 @@ class ExitNodePickerViewModel(private val model: IpnModel, private val onNavigat
fun setExitNode(node: ExitNode) {
LoadingIndicator.start()
model.setExitNodeId(node.id) {
onNavigateHome()
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = node.id
Client(viewModelScope).editPrefs(prefsOut) {
nav.onNavigateHome()
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
import androidx.lifecycle.ViewModel
import android.content.Intent
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.service.IpnActions
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel() {
class MainViewModel : IpnViewModel() {
// The user readable state of the system
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>())
// The current state of the IPN for determining view visibility
val ipnState = model.state
val ipnState = Notifier.state
// The logged in user
val loggedInUser = model.loggedInUser
val prefs = Notifier.prefs
val netmap = Notifier.netmap
// The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("")
// 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 {
viewModelScope.launch {
model.state.collect { state ->
Notifier.state.collect { state ->
stateRes.set(state.userStringRes())
vpnToggleState.set((state == State.Running || state == State.Starting))
}
}
viewModelScope.launch {
model.netmap.collect { netmap ->
Notifier.netmap.collect { netmap ->
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
}
}
@ -70,16 +71,25 @@ class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel()
}
fun toggleVpn() {
when (model.state.value) {
State.Running -> actions.stopVPN()
else -> actions.startVPN()
when (Notifier.state.value) {
State.Running -> stopVPN()
else -> startVPN()
}
}
fun login() {
actions.login()
private fun startVPN() {
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 {

@ -5,9 +5,10 @@ package com.tailscale.ipn.ui.viewModel
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.tailscale.ipn.R
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.util.ComposableStringFormatter
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)
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 info: List<PeerSettingInfo> = emptyList()
@ -26,7 +33,7 @@ class PeerDetailsViewModel(val model: IpnModel, val nodeId: StableNodeID) : View
val connectedColor: Color
init {
val peer = model.netmap.value?.getPeer(nodeId)
val peer = Notifier.netmap.value?.getPeer(nodeId)
peer?.Addresses?.let {
addresses = it.map { addr ->
DisplayAddress(addr)

@ -7,14 +7,14 @@ import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
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.service.toggleCorpDNS
import com.tailscale.ipn.ui.view.SettingsNav
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@ -27,7 +27,7 @@ class ComposableStringFormatter(@StringRes val stringRes: Int, vararg val 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>)
// 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(
val ipnManager: IpnManager,
val navigation: SettingsNav
) : ViewModel() {
// The logged in user
val model = ipnManager.model
val mdmSettings = ipnManager.mdmSettings
class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return SettingsViewModel(navigation) as T
}
}
val user = model.loggedInUser.value
class SettingsViewModel(private val navigation: SettingsNav) : IpnViewModel() {
val user = loggedInUser.value
// 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(
R.string.use_ts_dns,
val useDNSSetting = Setting(R.string.use_ts_dns,
SettingType.SWITCH,
isOn = MutableStateFlow(model.prefs.value?.CorpDNS),
isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS),
onToggle = {
model.toggleCorpDNS {
toggleCorpDNS {
// (jonathan) TODO: Error handling
}
})
val settings: StateFlow<List<SettingBundle>> = MutableStateFlow(emptyList())
init {
viewModelScope.launch {
// 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.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(
titleRes = R.string.about,
SettingType.NAV,
onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true)
),
Setting(
), Setting(
titleRes = R.string.bug_report,
SettingType.NAV,
onClick = { navigation.onNavigateToBugReport() },
enabled = MutableStateFlow(true)
),
mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
), mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Setting(
ComposableStringFormatter(R.string.managed_by_orgName, it),
SettingType.NAV,
onClick = { navigation.onNavigateToManagedBy() },
enabled = MutableStateFlow(true)
)
},
if (BuildConfig.DEBUG) {
}, if (BuildConfig.DEBUG) {
Setting(
titleRes = R.string.mdm_settings,
SettingType.NAV,
@ -136,14 +155,4 @@ class SettingsViewModel(
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
busWatchers map[string]func()
cancelWatchBus func()
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.
func ConfigureShim(jvm *jni.JVM, appCtx jni.Object, s *LocalAPIService, b *ipnlocal.LocalBackend) {
shim.busWatchers = make(map[string]func())
shim.service = s
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
func Java_com_tailscale_ipn_ui_notifier_Notifier_stopIPNBusWatcher(
env *C.JNIEnv,
cls C.jclass,
jsessionId C.jstring) {
jenv := (*jni.Env)(unsafe.Pointer(env))
cls C.jclass) {
sessionIdRef := jni.NewGlobalRef(jenv, jni.Object(jsessionId))
sessionId := jni.GoString(jenv, jni.String(sessionIdRef))
defer jni.DeleteGlobalRef(jenv, sessionIdRef)
cancel := shim.busWatchers[sessionId]
if cancel != nil {
log.Printf("Deregistering app layer bus watcher with sessionid: %s", sessionId)
cancel()
delete(shim.busWatchers, sessionId)
if shim.cancelWatchBus != nil {
log.Printf("Stop watching IPN bus")
shim.cancelWatchBus()
shim.cancelWatchBus = nil
} 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(
env *C.JNIEnv,
cls C.jclass,
jsessionId C.jstring,
jmask C.jint) {
jenv := (*jni.Env)(unsafe.Pointer(env))
sessionIdRef := jni.NewGlobalRef(jenv, jni.Object(jsessionId))
sessionId := jni.GoString(jenv, jni.String(sessionIdRef))
defer jni.DeleteGlobalRef(jenv, sessionIdRef)
log.Printf("Registering app layer bus watcher with sessionid: %s", sessionId)
log.Printf("Start watching IPN bus")
ctx, cancel := context.WithCancel(context.Background())
shim.busWatchers[sessionId] = cancel
shim.cancelWatchBus = cancel
opts := ipn.NotifyWatchOpt(jmask)
shim.backend.WatchNotifications(ctx, opts, func() {
@ -198,9 +184,9 @@ func Java_com_tailscale_ipn_ui_notifier_Notifier_startIPNBusWatcher(
return true
}
jni.Do(shim.jvm, func(env *jni.Env) error {
jjson := jni.JavaString(env, string(js))
onNotify := jni.GetMethodID(env, shim.notifierClass, "onNotify", "(Ljava/lang/String;Ljava/lang/String;)V")
jni.CallVoidMethod(env, jni.Object(cls), onNotify, jni.Value(jjson), jni.Value(jsessionId))
jjson := jni.NewByteArray(jenv, js)
onNotify := jni.GetStaticMethodID(jenv, shim.notifierClass, "onNotify", "([B)V")
jni.CallStaticVoidMethod(jenv, shim.notifierClass, onNotify, jni.Value(jjson))
return nil
})
return true

Loading…
Cancel
Save