android: implement fast user switching (#209)

Updates tailscale/corp#18202
Updates ENG-2875
Fixes ENG-2863

Adds everything we need to do fast user switching and support multiple accounts.

Some work here to make the settings rows and a few other composables common and reusable.

Correct the focus and clear behavior on the search bar and corrected the connected in state of SelfNode.

Quick fix for requesting VPN permissions on newer Android phones.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/219/head
Jonathan Nobels 3 months ago committed by GitHub
parent e568741081
commit e4b0e1f8cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -93,7 +93,7 @@ dependencies {
implementation "androidx.navigation:navigation-compose:$nav_version"
// Supporting libraries.
implementation("io.coil-kt:coil-compose:1.3.1")
implementation("io.coil-kt:coil-compose:2.6.0")
// Tailscale dependencies.
implementation ':ipn@aar'
@ -105,5 +105,3 @@ dependencies {
// Non-free dependencies.
playImplementation 'com.google.android.gms:play-services-auth:20.7.0'
}

@ -1,23 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Disable input emulation on ChromeOS -->
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>
<uses-feature
android:name="android.hardware.type.pc"
android:required="false" />
<!-- Signal support for Android TV -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
<application
android:label="Tailscale"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:banner="@drawable/tv_banner"
android:name=".App" android:allowBackup="false">
<activity android:name="MainActivity"
android:name=".App"
android:allowBackup="false">
<activity
android:name="MainActivity"
android:label="@string/app_name"
android:theme="@style/Theme.GioApp"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
@ -26,6 +41,7 @@
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
@ -35,6 +51,7 @@
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
@ -46,6 +63,7 @@
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
@ -55,15 +73,17 @@
<data android:mimeType="video/*" />
</intent-filter>
</activity>
<receiver android:name="IPNReceiver"
android:exported="true"
>
<receiver
android:name="IPNReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
</intent-filter>
</receiver>
<service android:name=".IPNService"
<service
android:name=".IPNService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:exported="false">
<intent-filter>
@ -82,4 +102,3 @@
</service>
</application>
</manifest>

@ -3,72 +3,46 @@
package com.tailscale.ipn;
import android.app.Application;
import android.Manifest;
import android.app.Activity;
import android.app.Application;
import android.app.DownloadManager;
import android.app.Fragment;
import android.app.FragmentTransaction;
import android.app.NotificationChannel;
import android.app.PendingIntent;
import android.app.UiModeManager;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.RestrictionsManager;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.Signature;
import android.content.res.Configuration;
import android.provider.MediaStore;
import android.provider.Settings;
import android.net.ConnectivityManager;
import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkInfo;
import android.net.NetworkRequest;
import android.net.Uri;
import android.net.VpnService;
import android.view.View;
import android.os.Build;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.provider.MediaStore;
import android.provider.Settings;
import android.util.Log;
import android.Manifest;
import android.webkit.MimeTypeMap;
import java.io.IOException;
import java.io.File;
import java.io.FileOutputStream;
import java.lang.StringBuilder;
import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;
import androidx.browser.customtabs.CustomTabsIntent;
import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting;
import com.tailscale.ipn.mdm.BooleanSetting;
import com.tailscale.ipn.mdm.MDMSettings;
@ -77,6 +51,16 @@ import com.tailscale.ipn.mdm.StringSetting;
import org.gioui.Gio;
import java.io.File;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.security.GeneralSecurityException;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
public class App extends Application {
private static final String PEER_TAG = "peer";
@ -93,15 +77,20 @@ public class App extends Application {
private ConnectivityManager connectivityManager;
public DnsConfig dns = new DnsConfig();
public DnsConfig getDnsConfigObj() { return this.dns; }
public DnsConfig getDnsConfigObj() {
return this.dns;
}
static App _application;
public static App getApplication() {
return _application;
}
@Override public void onCreate() {
@Override
public void onCreate() {
super.onCreate();
// Load and initialize the Go library.
Gio.init(this);
@ -147,6 +136,7 @@ public class App extends Application {
});
}
public void startVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_REQUEST_VPN);
@ -247,7 +237,8 @@ public class App extends Application {
// lifecycle.
void attachPeer(Activity act) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
@Override
public void run() {
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
ft.add(new Peer(), PEER_TAG);
ft.commit();
@ -262,7 +253,8 @@ public class App extends Application {
void prepareVPN(Activity act, int reqCode) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
@Override
public void run() {
Intent intent = VpnService.prepare(act);
if (intent == null) {
onVPNPrepared();
@ -280,7 +272,8 @@ public class App extends Application {
void showURL(Activity act, String url) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
@Override
public void run() {
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
int headerColor = 0xff496495;
builder.setToolbarColor(headerColor);
@ -371,8 +364,11 @@ public class App extends Application {
}
static native void onVPNPrepared();
private static native void onDnsConfigChanged();
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
static native void onWriteStorageGranted();
// Returns details of the interfaces in the system, encoded as a single string for ease

@ -6,9 +6,12 @@ package com.tailscale.ipn;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import androidx.work.WorkManager;
import androidx.work.OneTimeWorkRequest;
import java.util.Objects;
public class IPNReceiver extends BroadcastReceiver {
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";
@ -19,9 +22,9 @@ public class IPNReceiver extends BroadcastReceiver {
WorkManager workManager = WorkManager.getInstance(context);
// On the relevant action, start the relevant worker, which can stay active for longer than this receiver can.
if (intent.getAction() == INTENT_CONNECT_VPN) {
if (Objects.equals(intent.getAction(), INTENT_CONNECT_VPN)) {
workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build());
} else if (intent.getAction() == INTENT_DISCONNECT_VPN) {
} else if (Objects.equals(intent.getAction(), INTENT_DISCONNECT_VPN)) {
workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build());
}
}

@ -3,13 +3,17 @@
package com.tailscale.ipn
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.RestrictionsManager
import android.net.Uri
import android.net.VpnService
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContract
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@ -31,6 +35,7 @@ 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.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav
@ -50,17 +55,22 @@ class MainActivity : ComponentActivity() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "main") {
val mainViewNav =
MainViewNavigation(onNavigateToSettings = { navController.navigate("settings") },
MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") },
onNavigateToPeerDetails = {
navController.navigate("peerDetails/${it.StableID}")
},
onNavigateToExitNodes = { navController.navigate("exitNodes") })
onNavigateToExitNodes = { navController.navigate("exitNodes") },
)
val settingsNav =
SettingsNav(onNavigateToBugReport = { navController.navigate("bugReport") },
SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") })
onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
)
val exitNodePickerNav = ExitNodePickerNav(onNavigateHome = {
navController.popBackStack(
@ -106,6 +116,9 @@ class MainActivity : ComponentActivity() {
composable("managedBy") {
ManagedByView()
}
composable("userSwitcher") {
UserSwitcherView()
}
}
}
}
@ -145,11 +158,43 @@ class MainActivity : ComponentActivity() {
val scope = CoroutineScope(Dispatchers.IO)
notifierScope = scope
Notifier.start(lifecycleScope)
// (jonathan) TODO: Requesting VPN permissions onStart is a bit aggressive. This should
// be done when the user initiall starts the VPN
requestVpnPermission()
}
override fun onStop() {
Notifier.stop()
super.onStop()
val restrictionsManager = this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager))
}
private fun requestVpnPermission() {
val vpnIntent = VpnService.prepare(this)
if (vpnIntent != null) {
val contract = VpnPermissionContract()
registerForActivityResult(contract) { granted ->
Notifier.vpnPermissionGranted.set(granted)
if (granted) {
Log.i("VPN", "VPN permission granted")
} else {
Log.i("VPN", "VPN permission not granted")
}
}
}
}
}
class VpnPermissionContract : ActivityResultContract<Unit, Boolean>() {
override fun createIntent(context: Context, input: Unit): Intent {
return VpnService.prepare(context) ?: Intent()
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == Activity.RESULT_OK
}
}

@ -80,6 +80,18 @@ class Client(private val scope: CoroutineScope) {
return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler)
}
fun addProfile(responseHandler: (Result<String>) -> Unit = {}) {
return put(Endpoint.PROFILES, responseHandler = responseHandler)
}
fun deleteProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result<String>) -> Unit = {}) {
return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun switchProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result<String>) -> Unit = {}) {
return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun startLoginInteractive(responseHandler: (Result<String>) -> Unit) {
return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler)
}

@ -40,6 +40,11 @@ object Notifier {
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null)
// Indicates whether or not we have granted permission to use the VPN. This must be
// explicitly set by the main activity. null indicates that we have not yet
// checked.
val vpnPermissionGranted: StateFlow<Boolean?> = MutableStateFlow(null)
// Called by the backend when the localAPI is ready to accept requests.
@JvmStatic
@Suppress("unused")

@ -13,6 +13,9 @@ val ts_color_light_tintedBackground = Color(0xFFF7F5F4)
val ts_color_light_blue = Color(0xFF4B70CC)
val ts_color_light_green = Color(0xFF1EA672)
var ts_color_dark_desctrutive_text = Color(0xFFFF0000)
var ts_color_light_desctrutive_text = Color(0xFFBB0000)
val ts_color_dark_primary = Color(0xFFFAF9F8)
val ts_color_dark_secondary = Color(0xFFAFACAB)
val ts_color_dark_background = Color(0xFF232222)

@ -7,14 +7,21 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
@Composable
fun settingsRowModifier(): Modifier {
@ -28,3 +35,33 @@ fun settingsRowModifier(): Modifier {
fun defaultPaddingModifier(): Modifier {
return Modifier.padding(8.dp)
}
@Composable
fun Header(@StringRes title: Int) {
Text(
text = stringResource(id = title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium
)
}
@Composable
fun ChevronRight() {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
@Composable
fun CheckedIndicator() {
Icon(Icons.Default.CheckCircle, null)
}
@Composable
fun LoadingIndicator(size: Int = 32) {
CircularProgressIndicator(
modifier = Modifier.width(size.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.secondary,
)
}

@ -3,7 +3,6 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
@ -18,7 +17,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import coil.compose.AsyncImage
import com.tailscale.ipn.ui.model.IpnLocal
@ -32,20 +31,15 @@ fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50) {
.clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiaryContainer)
) {
profile?.UserProfile?.ProfilePicURL?.let { url ->
val painter = rememberImagePainter(data = url)
Image(
painter = painter,
contentDescription = null,
modifier = Modifier.size(size.dp)
)
} ?: run {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size((size * .8f).dp)
)
profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage(model = url, contentDescription = null)
}
}
}

@ -37,6 +37,7 @@ 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.Header
import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
@ -48,18 +49,9 @@ 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()) {
Header(title = R.string.bug_report_title)
Spacer(modifier = Modifier.height(8.dp))
@ -136,4 +128,3 @@ fun contactText(): AnnotatedString {
}
return annotatedString
}

@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope

@ -0,0 +1,60 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.annotation.StringRes
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
enum class ErrorDialogType {
LOGOUT_FAILED, SWITCH_USER_FAILED, ADD_PROFILE_FAILED;
val message: Int
get() {
return when (this) {
LOGOUT_FAILED -> R.string.logout_failed
SWITCH_USER_FAILED -> R.string.switch_user_failed
ADD_PROFILE_FAILED -> R.string.add_profile_failed
}
}
val title: Int = R.string.error
val buttonText: Int = R.string.ok
}
@Composable
fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
ErrorDialog(title = type.title,
message = type.message,
buttonText = type.buttonText,
onDismiss = action)
}
@Composable
fun ErrorDialog(
@StringRes title: Int,
@StringRes message: Int,
@StringRes buttonText: Int = R.string.ok,
onDismiss: () -> Unit = {}
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(text = stringResource(id = title))
},
text = {
Text(text = stringResource(id = message))
},
confirmButton = {
PrimaryActionButton(onClick = onDismiss) {
Text(text = stringResource(id = buttonText))
}
}
)
}

@ -21,6 +21,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
@ -40,6 +42,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -53,7 +56,6 @@ import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.ts_color_light_green
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.PrimaryActionButton
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.StateFlow
@ -68,50 +70,58 @@ data class MainViewNavigation(
@Composable
fun MainView(navigation: MainViewNavigation, model: MainViewModel = viewModel()) {
fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
Column(
modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Center
) {
val state = model.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = model.loggedInUser.collectAsState(initial = null)
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null)
Row(
modifier = Modifier
Row(modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
val isOn = model.vpnToggleState.collectAsState(initial = false)
Switch(onCheckedChange = { model.toggleVpn() }, checked = isOn.value)
verticalAlignment = Alignment.CenterVertically) {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) {
Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
Spacer(Modifier.size(3.dp))
StateDisplay(model.stateRes, model.userName)
Box(
modifier = Modifier
}
StateDisplay(viewModel.stateRes, viewModel.userName)
Box(modifier = Modifier
.weight(1f)
.clickable { navigation.onNavigateToSettings() },
contentAlignment = Alignment.CenterEnd
) {
Avatar(profile = user.value, size = 36)
.clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) {
when (user.value) {
null -> SettingsButton(user.value) { navigation.onNavigateToSettings() }
else -> Avatar(profile = user.value, size = 36)
}
}
}
when (state.value) {
Ipn.State.Running -> {
ExitNodeStatus(navigation.onNavigateToExitNodes, model)
PeerList(searchTerm = model.searchTerm,
state = model.ipnState,
peers = model.peers,
selfPeer = model.selfPeerId,
val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "")
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
PeerList(
searchTerm = viewModel.searchTerm,
state = viewModel.ipnState,
peers = viewModel.peers,
selfPeer = selfPeerId.value,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { model.searchPeers(it) })
onSearch = { viewModel.searchPeers(it) })
}
Ipn.State.Starting -> StartingView()
else -> ConnectView(user.value, { model.toggleVpn() }, { model.login() }
else ->
ConnectView(
user.value,
{ viewModel.toggleVpn() },
{ viewModel.login {} }
)
}
}
@ -142,10 +152,9 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
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,
@ -161,19 +170,27 @@ fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
val stateStr = stringResource(id = stateVal.value)
Column(modifier = Modifier.padding(7.dp)) {
when (tailnet.isEmpty()) {
false -> {
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)
}
true -> {
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary)
}
}
}
}
@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,
@ -191,8 +208,7 @@ fun StartingView() {
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
)
@ -209,7 +225,8 @@ 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,
) {
@ -270,6 +287,22 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
}
}
@Composable
fun ClearButton(onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Clear, null)
}
}
@Composable
fun CloseButton() {
val focusManager = LocalFocusManager.current
IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Close, null)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeerList(
@ -281,7 +314,6 @@ fun PeerList(
onSearch: (String) -> Unit
) {
val peerList = peers.collectAsState(initial = emptyList<PeerSet>())
var searching = false
val searchTermStr by searchTerm.collectAsState(initial = "")
val stateVal = state.collectAsState(initial = Ipn.State.NoState)
@ -290,9 +322,10 @@ fun PeerList(
onQueryChange = onSearch,
onSearch = onSearch,
active = true,
onActiveChange = { searching = it },
onActiveChange = {},
shape = RoundedCornerShape(10.dp),
leadingIcon = { Icon(Icons.Outlined.Search, null) },
trailingIcon = { if (searchTermStr.isNotEmpty()) ClearButton({ onSearch("") }) else CloseButton() },
tonalElevation = 2.dp,
shadowElevation = 2.dp,
colors = SearchBarDefaults.colors(),
@ -300,55 +333,54 @@ fun PeerList(
) {
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,14 +11,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
@ -33,14 +28,15 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
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
import com.tailscale.ipn.ui.util.PrimaryActionButton
import com.tailscale.ipn.ui.theme.ts_color_dark_desctrutive_text
import com.tailscale.ipn.ui.util.ChevronRight
import com.tailscale.ipn.ui.util.Header
import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.Setting
@ -56,38 +52,23 @@ fun Settings(
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModelFactory(settingsNav))
) {
val handler = LocalUriHandler.current
val user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxHeight()) {
Column(modifier = defaultPaddingModifier().fillMaxHeight()) {
Text(
text = stringResource(id = R.string.settings_title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium
)
Header(title = R.string.settings_title)
Spacer(modifier = Modifier.height(8.dp))
// The login/logout button here is probably in the wrong location, but we need something
// somewhere for the time being. FUS should probably be implemented for V0 given that
// it's relatively simple to do so with localAPI. On iOS, the UI for user switching is
// all in the FUS screen.
viewModel.user?.let { user ->
UserView(profile = user, viewModel.isAdmin, adminText(), onClick = {
handler.openUri(Links.ADMIN_URL)
})
Spacer(modifier = Modifier.height(8.dp))
PrimaryActionButton(onClick = { viewModel.logout() }) {
Text(text = stringResource(id = R.string.log_out))
}
} ?: run {
Button(onClick = { viewModel.login() }) {
Text(text = stringResource(id = R.string.log_in))
}
UserView(profile = user,
actionState = UserActionState.NAV,
onClick = viewModel.navigation.onNavigateToUserSwitcher)
if (isAdmin) {
Spacer(modifier = Modifier.height(4.dp))
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
Spacer(modifier = Modifier.height(8.dp))
@ -96,27 +77,9 @@ fun Settings(
settings.forEach { settingBundle ->
Column(modifier = settingsRowModifier()) {
settingBundle.title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(8.dp)
)
}
settingBundle.settings.forEach { setting ->
when (setting.type) {
SettingType.NAV -> {
SettingsNavRow(setting)
}
SettingType.SWITCH -> {
SettingsSwitchRow(setting)
}
SettingType.NAV_WITH_TEXT -> {
SettingsNavRow(setting)
}
}
SettingTitle(it)
}
settingBundle.settings.forEach { SettingRow(it) }
}
Spacer(modifier = Modifier.height(8.dp))
}
@ -124,6 +87,7 @@ fun Settings(
}
}
@Composable
fun UserView(
profile: IpnLocal.LoginProfile?,
@ -162,38 +126,61 @@ fun UserView(
}
@Composable
fun SettingsNavRow(setting: Setting) {
val txtVal = setting.value?.collectAsState()?.value ?: ""
fun SettingTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(8.dp)
)
}
@Composable
fun SettingRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value
val swVal = setting.isOn?.collectAsState()?.value ?: false
val txtVal = setting.value?.collectAsState()?.value ?: ""
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }) {
Text(setting.title.getString())
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) {
when (setting.type) {
SettingType.NAV_WITH_TEXT -> {
Text(setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
}
@Composable
fun SettingsSwitchRow(setting: Setting) {
val swVal = setting.isOn?.collectAsState()?.value ?: false
val enabled = setting.enabled.collectAsState().value
SettingType.TEXT -> {
Text(setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary)
}
Row(
modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() },
verticalAlignment = Alignment.CenterVertically
) {
SettingType.SWITCH -> {
Text(setting.title.getString())
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
}
}
SettingType.NAV -> {
Text(setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
ChevronRight()
}
}
}
}
@Composable
fun adminText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val adminStr = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.settings_admin_prefix))
}
@ -204,5 +191,13 @@ fun adminText(): AnnotatedString {
}
pop()
}
return annotatedString
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
ClickableText(
text = adminStr,
style = MaterialTheme.typography.bodySmall,
onClick = {
onNavigateToAdminConsole()
})
}
}

@ -0,0 +1,87 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.Header
import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserSwitcherView(viewModel: UserSwitcherViewModel = viewModel()) {
val users = viewModel.loginProfiles.collectAsState().value
val currentUser = viewModel.loggedInUser.collectAsState().value
Surface(modifier = Modifier.fillMaxHeight(), color = MaterialTheme.colorScheme.background) {
Column(modifier = defaultPaddingModifier().fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
val showDialog = viewModel.showDialog.collectAsState().value
// Show the error overlay if need be
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
Header(title = R.string.accounts)
Column(modifier = settingsRowModifier()) {
// When switch is invoked, this stores the ID of the user we're trying to switch to
// so we can decorate it with a spinner. The actual logged in user will not change until
// we get our first netmap update back with the new userId for SelfNode.
// (jonathan) TODO: This user switch is not immediate. We may need to represent the
// "switching users" state globally (if ipnState is insufficient)
val nextUserId = remember { mutableStateOf<String?>(null) }
users?.forEach { user ->
if (user.ID == currentUser?.ID) {
UserView(profile = user, actionState = UserActionState.CURRENT)
} else {
val state =
if (user.ID == nextUserId.value) UserActionState.SWITCHING
else UserActionState.NONE
UserView(
profile = user,
actionState = state,
onClick = {
nextUserId.value = user.ID
viewModel.switchProfile(user) {
if (it.isFailure) {
viewModel.showDialog.set(ErrorDialogType.LOGOUT_FAILED)
nextUserId.value = null
}
}
})
}
}
SettingRow(viewModel.addProfileSetting)
}
Spacer(modifier = Modifier.height(8.dp))
Column(modifier = settingsRowModifier()) {
SettingRow(viewModel.loginSetting)
SettingRow(viewModel.logoutSetting)
}
}
}
}

@ -0,0 +1,67 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.util.CheckedIndicator
import com.tailscale.ipn.ui.util.ChevronRight
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier
// Used to decorate UserViews.
// NONE indicates no decoration
// CURRENT indicates the user is the current user and will be "checked"
// SWITCHING indicates the user is being switched to and will be "loading"
// NAV will show a chevron
enum class UserActionState { CURRENT, SWITCHING, NAV, NONE }
@Composable
fun UserView(
profile: IpnLocal.LoginProfile?,
onClick: () -> Unit = {},
actionState: UserActionState = UserActionState.NONE
) {
Column {
Row(modifier = settingsRowModifier().clickable { onClick() }) {
profile?.let {
Box(modifier = defaultPaddingModifier()) {
Avatar(profile = profile, size = 36)
}
Column(modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center) {
Text(
text = profile.UserProfile.DisplayName
?: "", style = MaterialTheme.typography.titleMedium
)
Text(text = profile.Name ?: "", style = MaterialTheme.typography.bodyMedium)
}
} ?: run {
Box(modifier = Modifier.weight(1f)) {
Text(text = stringResource(id = R.string.accounts), style = MaterialTheme.typography.titleMedium)
}
}
when (actionState) {
UserActionState.CURRENT -> CheckedIndicator()
UserActionState.SWITCHING -> LoadingIndicator(size = 32)
UserActionState.NAV -> ChevronRight()
UserActionState.NONE -> Unit
}
}
}
}

@ -3,13 +3,17 @@
package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.model.IpnLocal
import com.tailscale.ipn.ui.model.UserID
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
@ -26,9 +30,13 @@ open class IpnViewModel : ViewModel() {
}
protected val TAG = this::class.simpleName
val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)
val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null)
// The userId associated with the current node. ie: The logged in user.
var selfNodeUserId: UserID? = null
init {
viewModelScope.launch {
Notifier.state.collect {
@ -39,6 +47,20 @@ open class IpnViewModel : ViewModel() {
}
}
}
// This will observe the userId of the current node and reload our user profiles if
// we discover it has changed (e.g. due to a login or user switch)
viewModelScope.launch {
Notifier.netmap.collect {
it?.SelfNode?.User.let {
if (it != selfNodeUserId) {
selfNodeUserId = it
viewModelScope.launch { loadUserProfiles() }
}
}
}
}
viewModelScope.launch { loadUserProfiles() }
Log.d(TAG, "Created")
}
@ -51,29 +73,71 @@ open class IpnViewModel : ViewModel() {
}
Client(viewModelScope).currentProfile { result ->
result.onSuccess(loggedInUser::set).onFailure {
Log.e(TAG, "Error loading current profile: ${it.message}")
result.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
}
}
fun toggleVpn() {
when (Notifier.state.value) {
Ipn.State.Running -> stopVPN()
else -> startVPN()
}
}
fun login() {
Client(viewModelScope).startLoginInteractive { result ->
result.onSuccess {
Log.d(TAG, "Login started: $it")
}.onFailure {
Log.e(TAG, "Error starting login: ${it.message}")
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)
}
fun login(completionHandler: (Result<String>) -> Unit = {}) {
Client(viewModelScope).startLoginInteractive { result ->
result
.onSuccess { Log.d(TAG, "Login started: $it") }
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result)
}
}
fun logout() {
fun logout(completionHandler: (Result<String>) -> Unit = {}) {
Client(viewModelScope).logout { result ->
result.onSuccess {
Log.d(TAG, "Logout started: $it")
}.onFailure {
Log.e(TAG, "Error starting logout: ${it.message}")
result
.onSuccess { Log.d(TAG, "Logout started: $it") }
.onFailure { Log.e(TAG, "Error starting logout: ${it.message}") }
completionHandler(result)
}
}
fun switchProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).switchProfile(profile) {
startVPN()
completionHandler(it)
}
}
fun addProfile(completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).addProfile {
if (it.isSuccess) {
login {}
}
startVPN()
completionHandler(it)
}
}
fun deleteProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).deleteProfile(profile) {
viewModelScope.launch { loadUserProfiles() }
completionHandler(it)
}
}
@ -100,7 +164,9 @@ open class IpnViewModel : ViewModel() {
}
fun toggleCorpDNS(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = Notifier.prefs.value ?: run {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleCorpDNS
}
@ -111,7 +177,9 @@ open class IpnViewModel : ViewModel() {
}
fun toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = Notifier.prefs.value ?: run {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp
}
@ -122,7 +190,9 @@ open class IpnViewModel : ViewModel() {
}
fun toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = Notifier.prefs.value ?: run {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll
}

@ -4,12 +4,10 @@
package com.tailscale.ipn.ui.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.model.StableNodeID
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
@ -39,10 +37,16 @@ class MainViewModel : IpnViewModel() {
val searchTerm: StateFlow<String> = MutableStateFlow("")
// The peerID of the local node
val selfPeerId = Notifier.netmap.value?.SelfNode?.StableID ?: ""
val selfPeerId: StateFlow<StableNodeID> = MutableStateFlow("")
private val peerCategorizer = PeerCategorizer(viewModelScope)
val userName: String
get() {
return loggedInUser.value?.Name ?: ""
}
init {
viewModelScope.launch {
Notifier.state.collect { state ->
@ -54,6 +58,7 @@ class MainViewModel : IpnViewModel() {
viewModelScope.launch {
Notifier.netmap.collect { netmap ->
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
selfPeerId.set(netmap?.SelfNode?.StableID ?: "")
}
}
}
@ -64,32 +69,6 @@ class MainViewModel : IpnViewModel() {
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm))
}
}
val userName: String
get() {
return loggedInUser.value?.Name ?: ""
}
fun toggleVpn() {
when (Notifier.state.value) {
State.Running -> stopVPN()
else -> startVPN()
}
}
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 {

@ -19,7 +19,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
enum class SettingType { NAV, SWITCH, NAV_WITH_TEXT }
enum class SettingType { NAV, SWITCH, NAV_WITH_TEXT, TEXT }
class ComposableStringFormatter(@StringRes val stringRes: Int, vararg val params: Any) {
@ -43,9 +43,11 @@ data class SettingBundle(val title: String? = null, val settings: List<Setting>)
// 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<Boolean> = MutableStateFlow(false),
val destructive: Boolean = false,
val enabled: StateFlow<Boolean> = MutableStateFlow(true),
val value: StateFlow<String?>? = null,
val isOn: StateFlow<Boolean?>? = null,
val onClick: () -> Unit = {},
@ -75,7 +77,7 @@ data class SettingsNav(
val onNavigateToAbout: () -> Unit,
val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit,
)
val onNavigateToUserSwitcher: () -> Unit)
class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@ -83,11 +85,9 @@ class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelP
}
}
class SettingsViewModel(private val navigation: SettingsNav) : IpnViewModel() {
val user = loggedInUser.value
class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() {
// Display name for the logged in user
val isAdmin = Notifier.netmap.value?.SelfNode?.isAdmin ?: false
var isAdmin: StateFlow<Boolean> = MutableStateFlow(false)
val useDNSSetting = Setting(R.string.use_ts_dns,
SettingType.SWITCH,
@ -110,7 +110,7 @@ class SettingsViewModel(private val navigation: SettingsNav) : IpnViewModel() {
}
viewModelScope.launch {
IpnViewModel.mdmSettings.collect { mdmSettings ->
mdmSettings.collect { mdmSettings ->
settings.set(
listOf(
SettingBundle(
@ -124,6 +124,12 @@ class SettingsViewModel(private val navigation: SettingsNav) : IpnViewModel() {
)
}
}
viewModelScope.launch {
Notifier.netmap.collect { netmap ->
isAdmin.set(netmap?.SelfNode?.isAdmin ?: false)
}
}
}
private fun footerSettings(mdmSettings: MDMSettings): List<Setting> = listOfNotNull(

@ -0,0 +1,47 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.ErrorDialogType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class UserSwitcherViewModel() : IpnViewModel() {
// Set to a non-null value to show the appropriate error dialog
val showDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
val loginSetting =
Setting(
title = ComposableStringFormatter(R.string.reauthenticate),
type = SettingType.NAV,
onClick = { login {} })
val logoutSetting =
Setting(
title = ComposableStringFormatter(R.string.log_out),
destructive = true,
type = SettingType.TEXT,
onClick = {
logout {
if (it.isFailure) {
showDialog.set(ErrorDialogType.LOGOUT_FAILED)
}
}
})
val addProfileSetting =
Setting(
title = ComposableStringFormatter(R.string.add_account),
type = SettingType.NAV,
onClick = {
addProfile {
if (it.isFailure) {
showDialog.set(ErrorDialogType.ADD_PROFILE_FAILED)
}
}
})
}

@ -13,6 +13,7 @@
<string name="template">%s</string>
<string name="more">More</string>
<string name="offline">offline</string>
<string name="ok">OK</string>
<!-- Strings for the about screen -->
<string name="app_name">Tailscale</string>
@ -73,6 +74,14 @@
<string name="in_x_days">in %d days</string>
<string name="in_x_months">in %d months</string>
<string name="in_x_years">in %.1f years</string>
<string name="user_switcher">Accounts</string>
<string name="logout_failed">Unable to logout at this time. Please try again.</string>
<string name="error">Error</string>
<string name="accounts">Accounts</string>
<string name="add_account">Add Another Account…</string>
<string name="reauthenticate">Reauthenticate</string>
<string name="switch_user_failed">Unable to switch users. Please try again.</string>
<string name="add_profile_failed">Unable to add a new profile. Please try again.</string>
<!-- Strings for ExitNode picker -->
<string name="choose_exit_node">Choose Exit Node</string>

Loading…
Cancel
Save