From a1e67ff1e9509859d9c6bb49bd808b01f17484e1 Mon Sep 17 00:00:00 2001 From: Percy Wegmann Date: Mon, 18 Mar 2024 06:59:02 -0500 Subject: [PATCH] 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 --- .../src/main/java/com/tailscale/ipn/App.java | 1 - .../java/com/tailscale/ipn/MainActivity.kt | 73 ++++--- .../com/tailscale/ipn/ui/notifier/Notifier.kt | 162 ++++++---------- .../tailscale/ipn/ui/service/IpnManager.kt | 81 -------- .../com/tailscale/ipn/ui/service/IpnModel.kt | 84 -------- .../tailscale/ipn/ui/service/PrefsEditor.kt | 75 -------- .../tailscale/ipn/ui/util/LoadingIndicator.kt | 4 +- .../com/tailscale/ipn/ui/util/PeerHelper.kt | 14 +- .../tailscale/ipn/ui/view/BugReportView.kt | 65 ++++--- .../tailscale/ipn/ui/view/ExitNodePicker.kt | 24 +-- .../ipn/ui/view/MDMSettingsDebugView.kt | 79 ++++---- .../com/tailscale/ipn/ui/view/MainView.kt | 173 +++++++++-------- .../tailscale/ipn/ui/view/ManagedByView.kt | 7 +- .../ipn/ui/view/MullvadExitNodePicker.kt | 17 +- .../com/tailscale/ipn/ui/view/PeerDetails.kt | 66 ++++--- .../com/tailscale/ipn/ui/view/SettingsView.kt | 31 +-- .../ui/viewModel/ExitNodePickerViewModel.kt | 25 ++- .../ipn/ui/viewModel/IpnViewModel.kt | 134 +++++++++++++ .../ipn/ui/viewModel/MainViewModel.kt | 46 +++-- .../ipn/ui/viewModel/PeerDetailsViewModel.kt | 19 +- .../ipn/ui/viewModel/SettingsViewModel.kt | 179 +++++++++--------- cmd/localapiservice/localapishim.go | 38 ++-- 22 files changed, 667 insertions(+), 730 deletions(-) delete mode 100644 android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt delete mode 100644 android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt delete mode 100644 android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt diff --git a/android/src/main/java/com/tailscale/ipn/App.java b/android/src/main/java/com/tailscale/ipn/App.java index 6c03846..7afb8b9 100644 --- a/android/src/main/java/com/tailscale/ipn/App.java +++ b/android/src/main/java/com/tailscale/ipn/App.java @@ -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; diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 1f6d789..52a7c56 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -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() } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt index 3855624..b74cf64 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt @@ -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 - private val decoder = Json { ignoreUnknownKeys = true } - private val isReady = CompletableDeferred() - - // Called by the backend when the localAPI is ready to accept requests. - @JvmStatic - 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() - - // Callback from jni when a new notification is received - fun onNotify(notification: String, sessionId: String) { - val notify = decoder.decodeFromString(notification) - val watcher = watchers[sessionId] - watcher?.let { watcher.callback(notify) } ?: { - Log.e( - "Notifier", - "Received notification for unknown session: ${sessionId}" - ) - } +object Notifier { + private val TAG = Notifier::class.simpleName + private val decoder = Json { ignoreUnknownKeys = true } + private val isReady = CompletableDeferred() + + val state: StateFlow = MutableStateFlow(Ipn.State.NoState) + val netmap: StateFlow = MutableStateFlow(null) + val prefs: StateFlow = MutableStateFlow(null) + val engineStatus: StateFlow = MutableStateFlow(null) + val tailFSShares: StateFlow?> = MutableStateFlow(null) + val browseToURL: StateFlow = MutableStateFlow(null) + val loginFinished: StateFlow = MutableStateFlow(null) + val version: StateFlow = MutableStateFlow(null) + + // Called by the backend when the localAPI is ready to accept requests. + @JvmStatic + @Suppress("unused") + fun onReady() { + isReady.complete(true) + 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 { - 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(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") - } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt b/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt deleted file mode 100644 index 2599398..0000000 --- a/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt +++ /dev/null @@ -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) -> 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)) - } - } - } -} diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt b/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt deleted file mode 100644 index a714db9..0000000 --- a/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt +++ /dev/null @@ -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 = mutableListOf() - - val state: StateFlow = MutableStateFlow(Ipn.State.NoState) - val netmap: StateFlow = MutableStateFlow(null) - val prefs: StateFlow = MutableStateFlow(null) - val engineStatus: StateFlow = MutableStateFlow(null) - val tailFSShares: StateFlow?> = MutableStateFlow(null) - val browseToURL: StateFlow = MutableStateFlow(null) - val loginFinished: StateFlow = MutableStateFlow(null) - val version: StateFlow = MutableStateFlow(null) - val loggedInUser: StateFlow = MutableStateFlow(null) - val loginProfiles: StateFlow?> = 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() } - } -} diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt b/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt deleted file mode 100644 index 2964cf0..0000000 --- a/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt +++ /dev/null @@ -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) -> Unit) { - Ipn.MaskedPrefs().WantRunning = wantRunning - Client(scope).editPrefs(Ipn.MaskedPrefs(), callback) -} - -fun IpnModel.toggleCorpDNS(callback: (Result) -> 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) -> 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) -> Unit) { - val prefsOut = Ipn.MaskedPrefs() - prefsOut.ExitNodeID = id - Client(scope).editPrefs(prefsOut, callback) -} - -fun IpnModel.toggleRouteAll(callback: (Result) -> 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) -} - - - - - diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt b/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt index 1815276..97db6ba 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt @@ -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() diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt index 0d8d5d2..096b4db 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt @@ -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 typealias GroupedPeers = MutableMap> -class PeerCategorizer(val model: IpnModel, val scope: CoroutineScope) { +class PeerCategorizer(scope: CoroutineScope) { var peerSets: List = emptyList() var lastSearchResult: List = 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,19 +79,21 @@ 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 -> val user = peerSet.user val peers = peerSet.peers - + val userMatches = user?.DisplayName?.contains(searchTerm, ignoreCase = true) ?: false if (userMatches) { 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 { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt index 9a97ee2..487c00c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt @@ -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,37 +44,45 @@ 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), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.titleMedium) + 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 + ) Spacer(modifier = Modifier.height(8.dp)) ClickableText(text = contactText(), - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.bodyMedium, - onClick = { - handler.openUri(Links.SUPPORT_URL) - }) + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium, + onClick = { + handler.openUri(Links.SUPPORT_URL) + }) 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), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Left, - color = MaterialTheme.colorScheme.secondary, - style = MaterialTheme.typography.bodySmall) + Text( + text = stringResource(id = R.string.bug_report_id_desc), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Left, + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.bodySmall + ) } } } @@ -83,17 +92,27 @@ fun ReportIdRow(bugReportIdFlow: StateFlow) { 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) + ) } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt index d36e817..72915da 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -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}") diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt index 3c2b1cf..745426c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt @@ -17,73 +17,73 @@ 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( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - titleContentColor = MaterialTheme.colorScheme.primary, - ), - title = { - Text(stringResource(R.string.current_mdm_settings)) - } - ) - }, + topBar = { + TopAppBar(colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), title = { + Text(stringResource(R.string.current_mdm_settings)) + }) + }, ) { innerPadding -> + val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value LazyColumn(modifier = Modifier.padding(innerPadding)) { items(enumValues()) { booleanSetting -> MDMSettingView( - title = booleanSetting.localizedTitle, - caption = booleanSetting.key, - valueDescription = mdmSettings.get(booleanSetting).toString() + title = booleanSetting.localizedTitle, + caption = booleanSetting.key, + valueDescription = mdmSettings.get(booleanSetting).toString() ) } items(enumValues()) { stringSetting -> MDMSettingView( - title = stringSetting.localizedTitle, - caption = stringSetting.key, - valueDescription = mdmSettings.get(stringSetting).toString() + title = stringSetting.localizedTitle, + caption = stringSetting.key, + valueDescription = mdmSettings.get(stringSetting).toString() ) } items(enumValues()) { showHideSetting -> MDMSettingView( - title = showHideSetting.localizedTitle, - caption = showHideSetting.key, - valueDescription = mdmSettings.get(showHideSetting).toString() + title = showHideSetting.localizedTitle, + caption = showHideSetting.key, + valueDescription = mdmSettings.get(showHideSetting).toString() ) } items(enumValues()) { anuSetting -> MDMSettingView( - title = anuSetting.localizedTitle, - caption = anuSetting.key, - valueDescription = mdmSettings.get(anuSetting).toString() + title = anuSetting.localizedTitle, + caption = anuSetting.key, + valueDescription = mdmSettings.get(anuSetting).toString() ) } items(enumValues()) { stringArraySetting -> MDMSettingView( - title = stringArraySetting.localizedTitle, - caption = stringArraySetting.key, - valueDescription = mdmSettings.get(stringArraySetting).toString() + title = stringArraySetting.localizedTitle, + caption = stringArraySetting.key, + valueDescription = mdmSettings.get(stringArraySetting).toString() ) } } @@ -94,26 +94,25 @@ fun MDMSettingsDebugView(mdmSettings: MDMSettings) { @Composable fun MDMSettingView(title: String, caption: String, valueDescription: String) { Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = defaultPaddingModifier() - .fillMaxWidth() + horizontalArrangement = Arrangement.SpaceBetween, + modifier = defaultPaddingModifier().fillMaxWidth() ) { Column { Text(title, maxLines = 3) Text( - caption, - fontSize = MaterialTheme.typography.labelSmall.fontSize, - color = MaterialTheme.colorScheme.tertiary, - fontFamily = FontFamily.Monospace + caption, + fontSize = MaterialTheme.typography.labelSmall.fontSize, + color = MaterialTheme.colorScheme.tertiary, + fontFamily = FontFamily.Monospace ) } Text( - valueDescription, - color = MaterialTheme.colorScheme.secondary, - fontFamily = FontFamily.Monospace, - maxLines = 1, - fontWeight = FontWeight.SemiBold + valueDescription, + color = MaterialTheme.colorScheme.secondary, + fontFamily = FontFamily.Monospace, + maxLines = 1, + fontWeight = FontWeight.SemiBold ) } } \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index dfd818f..fc2c481 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -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 - .fillMaxWidth() - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically) { - val isOn = viewModel.vpnToggleState.collectAsState(initial = false) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + 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 - .weight(1f) - .clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) { + StateDisplay(model.stateRes, model.userName) + Box( + modifier = Modifier + .weight(1f) + .clickable { navigation.onNavigateToSettings() }, + contentAlignment = Alignment.CenterEnd + ) { Avatar(profile = user.value, size = 36) } } @@ -94,24 +100,19 @@ 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, 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, - peers: StateFlow>, - state: StateFlow, - selfPeer: StableNodeID, - onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, - onSearch: (String) -> Unit) { +fun PeerList( + searchTerm: StateFlow, + peers: StateFlow>, + state: StateFlow, + selfPeer: StableNodeID, + onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, + onSearch: (String) -> Unit +) { val peerList = peers.collectAsState(initial = emptyList()) var searching = false val searchTermStr by searchTerm.collectAsState(initial = "") @@ -291,51 +300,55 @@ fun PeerList(searchTerm: StateFlow, ) { 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 { - onNavigateToPeerDetails(peer) - }, - 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 color: Color = if ((peer.Online == true) || isSelfAndRunning) { - ts_color_light_green - } else { - Color.Gray - } - Box(modifier = Modifier - .size(8.dp) - .background(color = color, shape = RoundedCornerShape(percent = 50))) {} - Spacer(modifier = Modifier.size(8.dp)) - Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium) + ListItem(modifier = Modifier.clickable { + onNavigateToPeerDetails(peer) + }, 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 color: Color = if ((peer.Online == true) || isSelfAndRunning) { + ts_color_light_green + } else { + Color.Gray } - }, - supportingContent = { + Box( + modifier = Modifier + .size(8.dp) + .background( + color = color, shape = RoundedCornerShape(percent = 50) + ) + ) {} + Spacer(modifier = Modifier.size(8.dp)) Text( - text = peer.Addresses?.first()?.split("/")?.first() - ?: "", - style = MaterialTheme.typography.bodyMedium + text = peer.ComputedName, + style = MaterialTheme.typography.titleMedium ) - }, - trailingContent = { - Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) } - ) + }, supportingContent = { + Text( + text = peer.Addresses?.first()?.split("/")?.first() ?: "", + style = MaterialTheme.typography.bodyMedium + ) + }, trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) + }) } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt index 61de828..2f21354 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt @@ -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 { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt index 714c878..de291eb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt @@ -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) } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt index 503c39e..9b26556 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt @@ -28,47 +28,65 @@ 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, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary + 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), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary + Text( + text = stringResource(id = model.connectedStrRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary ) } } Spacer(modifier = Modifier.size(8.dp)) - Text(text = stringResource(id = R.string.addresses_section), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary + 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) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index 488a6ad..5a4298f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -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) diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt index 59e605b..b71b213 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt @@ -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 create(modelClass: Class): 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() } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt new file mode 100644 index 0000000..4290498 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -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 = MutableStateFlow(MDMSettings()) + } + + protected val TAG = this::class.simpleName + val loggedInUser: StateFlow = MutableStateFlow(null) + val loginProfiles: StateFlow?> = 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) -> Unit) { + Ipn.MaskedPrefs().WantRunning = wantRunning + Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback) + } + + fun toggleCorpDNS(callback: (Result) -> 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) -> 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) -> 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) + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 7a8d27a..392e351 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -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 = MutableStateFlow(State.NoState.userStringRes()) @@ -29,29 +30,29 @@ class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel() val peers: StateFlow> = MutableStateFlow(emptyList()) // 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 = MutableStateFlow("") // The peerID of the local node - val selfPeerId = model.netmap.value?.SelfNode?.StableID ?: "" - - val peerCategorizer = PeerCategorizer(model, viewModelScope) + val selfPeerId = Notifier.netmap.value?.SelfNode?.StableID ?: "" + + 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 { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt index e58167f..d31469d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt @@ -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 create(modelClass: Class): T { + return PeerDetailsViewModel(nodeId) as T + } +} + +class PeerDetailsViewModel(val nodeId: StableNodeID) : IpnViewModel() { var addresses: List = emptyList() var info: List = 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) @@ -36,12 +43,12 @@ class PeerDetailsViewModel(val model: IpnModel, val nodeId: StableNodeID) : View peer?.Name?.let { addresses = listOf(DisplayAddress(it)) + addresses } - + peer?.let { p -> info = listOf( - PeerSettingInfo(R.string.os, ComposableStringFormatter(p.Hostinfo.OS ?: "")), - PeerSettingInfo(R.string.key_expiry, TimeUtil().keyExpiryFromGoTime(p.KeyExpiry)) + PeerSettingInfo(R.string.os, ComposableStringFormatter(p.Hostinfo.OS ?: "")), + PeerSettingInfo(R.string.key_expiry, TimeUtil().keyExpiryFromGoTime(p.KeyExpiry)) ) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt index e76fdff..afb1ef8 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -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) // Represents a UI setting. @@ -43,107 +43,116 @@ data class SettingBundle(val title: String? = null, val settings: List) // isOn and onToggle, while navigation settings should supply an onClick and an optional // value data class Setting( - val title: ComposableStringFormatter, - val type: SettingType, - val enabled: StateFlow = MutableStateFlow(false), - val value: StateFlow? = null, - val isOn: StateFlow? = null, - val onClick: () -> Unit = {}, - val onToggle: (Boolean) -> Unit = {} + val title: ComposableStringFormatter, + val type: SettingType, + val enabled: StateFlow = MutableStateFlow(false), + val value: StateFlow? = null, + val isOn: StateFlow? = null, + val onClick: () -> Unit = {}, + val onToggle: (Boolean) -> Unit = {} ) { constructor( - titleRes: Int, - type: SettingType, - enabled: StateFlow = MutableStateFlow(false), - value: StateFlow? = null, - isOn: StateFlow? = null, - onClick: () -> Unit = {}, - onToggle: (Boolean) -> Unit = {} + titleRes: Int, + type: SettingType, + enabled: StateFlow = MutableStateFlow(false), + value: StateFlow? = null, + isOn: StateFlow? = null, + onClick: () -> Unit = {}, + onToggle: (Boolean) -> Unit = {} ) : this( - title = ComposableStringFormatter(titleRes), - type = type, - enabled = enabled, - value = value, - isOn = isOn, - onClick = onClick, - onToggle = onToggle + title = ComposableStringFormatter(titleRes), + type = type, + enabled = enabled, + value = value, + isOn = isOn, + onClick = onClick, + onToggle = onToggle ) } +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 - - val user = model.loggedInUser.value +class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return SettingsViewModel(navigation) as T + } +} + +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 useDNSSetting = Setting( - R.string.use_ts_dns, - SettingType.SWITCH, - isOn = MutableStateFlow(model.prefs.value?.CorpDNS), - onToggle = { - model.toggleCorpDNS { - // (jonathan) TODO: Error handling - } - }) + val isAdmin = Notifier.netmap.value?.SelfNode?.isAdmin ?: false + + val useDNSSetting = Setting(R.string.use_ts_dns, + SettingType.SWITCH, + isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS), + onToggle = { + toggleCorpDNS { + // (jonathan) TODO: Error handling + } + }) + + val settings: StateFlow> = 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) } } - } - private val footerSettings: List = listOfNotNull( - Setting( - titleRes = R.string.about, - SettingType.NAV, - onClick = { navigation.onNavigateToAbout() }, - enabled = MutableStateFlow(true) - ), - Setting( - titleRes = R.string.bug_report, - SettingType.NAV, - onClick = { navigation.onNavigateToBugReport() }, - enabled = MutableStateFlow(true) - ), - mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let { - Setting( - ComposableStringFormatter(R.string.managed_by_orgName, it), - SettingType.NAV, - onClick = { navigation.onNavigateToManagedBy() }, - enabled = MutableStateFlow(true) - ) - }, - if (BuildConfig.DEBUG) { - Setting( - titleRes = R.string.mdm_settings, - SettingType.NAV, - onClick = { navigation.onNavigateToMDMSettings() }, - enabled = MutableStateFlow(true) + viewModelScope.launch { + IpnViewModel.mdmSettings.collect { mdmSettings -> + settings.set( + listOf( + SettingBundle( + settings = listOf( + useDNSSetting, + ) + ), + // General settings, always enabled + SettingBundle(settings = footerSettings(mdmSettings)) + ) ) - } else { - null } - ) + } + } - val settings: List = listOf( - SettingBundle( - settings = listOf( - useDNSSetting, - ) - ), - // General settings, always enabled - SettingBundle(settings = footerSettings) + private fun footerSettings(mdmSettings: MDMSettings): List = listOfNotNull( + Setting( + titleRes = R.string.about, + SettingType.NAV, + onClick = { navigation.onNavigateToAbout() }, + enabled = MutableStateFlow(true) + ), Setting( + titleRes = R.string.bug_report, + SettingType.NAV, + onClick = { navigation.onNavigateToBugReport() }, + enabled = MutableStateFlow(true) + ), mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let { + Setting( + ComposableStringFormatter(R.string.managed_by_orgName, it), + SettingType.NAV, + onClick = { navigation.onNavigateToManagedBy() }, + enabled = MutableStateFlow(true) + ) + }, if (BuildConfig.DEBUG) { + Setting( + titleRes = R.string.mdm_settings, + SettingType.NAV, + onClick = { navigation.onNavigateToMDMSettings() }, + enabled = MutableStateFlow(true) + ) + } else { + null + } ) } diff --git a/cmd/localapiservice/localapishim.go b/cmd/localapiservice/localapishim.go index 95148f4..974b107 100644 --- a/cmd/localapiservice/localapishim.go +++ b/cmd/localapiservice/localapishim.go @@ -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