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

@ -1,23 +1,38 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <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.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Disable input emulation on ChromeOS --> <!-- 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 --> <!-- Signal support for Android TV -->
<uses-feature android:name="android.software.leanback" android:required="false" /> <uses-feature
<uses-feature android:name="android.hardware.touchscreen" android:required="false" /> 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:banner="@drawable/tv_banner"
android:name=".App" android:allowBackup="false"> android:name=".App"
<activity android:name="MainActivity" android:allowBackup="false">
<activity
android:name="MainActivity"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.GioApp" android:theme="@style/Theme.GioApp"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
@ -26,6 +41,7 @@
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
@ -34,7 +50,8 @@
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" /> <data android:mimeType="application/*" />
<data android:mimeType="audio/*" /> <data android:mimeType="audio/*" />
<data android:mimeType="image/*" /> <data android:mimeType="image/*" />
@ -46,6 +63,7 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" /> <action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" /> <data android:mimeType="application/*" />
<data android:mimeType="audio/*" /> <data android:mimeType="audio/*" />
<data android:mimeType="image/*" /> <data android:mimeType="image/*" />
@ -55,19 +73,21 @@
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
</intent-filter> </intent-filter>
</activity> </activity>
<receiver android:name="IPNReceiver" <receiver
android:exported="true" android:name="IPNReceiver"
> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="com.tailscale.ipn.CONNECT_VPN" /> <action android:name="com.tailscale.ipn.CONNECT_VPN" />
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" /> <action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<service android:name=".IPNService"
<service
android:name=".IPNService"
android:permission="android.permission.BIND_VPN_SERVICE" android:permission="android.permission.BIND_VPN_SERVICE"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.net.VpnService"/> <action android:name="android.net.VpnService" />
</intent-filter> </intent-filter>
</service> </service>
<service <service
@ -77,9 +97,8 @@
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE"/> <action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter> </intent-filter>
</service> </service>
</application> </application>
</manifest> </manifest>

@ -3,72 +3,46 @@
package com.tailscale.ipn; package com.tailscale.ipn;
import android.app.Application; import android.Manifest;
import android.app.Activity; import android.app.Activity;
import android.app.Application;
import android.app.DownloadManager; import android.app.DownloadManager;
import android.app.Fragment; import android.app.Fragment;
import android.app.FragmentTransaction; import android.app.FragmentTransaction;
import android.app.NotificationChannel; import android.app.NotificationChannel;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.app.UiModeManager; import android.app.UiModeManager;
import android.content.BroadcastReceiver;
import android.content.ContentResolver; import android.content.ContentResolver;
import android.content.ContentValues; import android.content.ContentValues;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter;
import android.content.RestrictionsManager; import android.content.RestrictionsManager;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.PackageInfo; import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.Signature; import android.content.pm.Signature;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.provider.MediaStore;
import android.provider.Settings;
import android.net.ConnectivityManager; import android.net.ConnectivityManager;
import android.net.LinkProperties; import android.net.LinkProperties;
import android.net.Network; import android.net.Network;
import android.net.NetworkInfo;
import android.net.NetworkRequest;
import android.net.Uri; import android.net.Uri;
import android.net.VpnService; import android.net.VpnService;
import android.view.View;
import android.os.Build; import android.os.Build;
import android.os.Environment; import android.os.Environment;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.provider.MediaStore;
import android.provider.Settings;
import android.util.Log;
import android.Manifest; import androidx.activity.result.contract.ActivityResultContracts;
import android.webkit.MimeTypeMap; import androidx.browser.customtabs.CustomTabsIntent;
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.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat; import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.security.crypto.EncryptedSharedPreferences; import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey; import androidx.security.crypto.MasterKey;
import androidx.browser.customtabs.CustomTabsIntent;
import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting; import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting;
import com.tailscale.ipn.mdm.BooleanSetting; import com.tailscale.ipn.mdm.BooleanSetting;
import com.tailscale.ipn.mdm.MDMSettings; import com.tailscale.ipn.mdm.MDMSettings;
@ -77,6 +51,16 @@ import com.tailscale.ipn.mdm.StringSetting;
import org.gioui.Gio; 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 { public class App extends Application {
private static final String PEER_TAG = "peer"; private static final String PEER_TAG = "peer";
@ -93,15 +77,20 @@ public class App extends Application {
private ConnectivityManager connectivityManager; private ConnectivityManager connectivityManager;
public DnsConfig dns = new DnsConfig(); public DnsConfig dns = new DnsConfig();
public DnsConfig getDnsConfigObj() { return this.dns; }
public DnsConfig getDnsConfigObj() {
return this.dns;
}
static App _application; static App _application;
public static App getApplication() { public static App getApplication() {
return _application; return _application;
} }
@Override public void onCreate() { @Override
public void onCreate() {
super.onCreate(); super.onCreate();
// Load and initialize the Go library. // Load and initialize the Go library.
Gio.init(this); Gio.init(this);
@ -119,9 +108,9 @@ public class App extends Application {
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is possible that // requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is possible that
// this might return an unusuable network, eg a captive portal. // this might return an unusuable network, eg a captive portal.
private void setAndRegisterNetworkCallbacks() { private void setAndRegisterNetworkCallbacks() {
connectivityManager.requestNetwork(dns.getDNSConfigNetworkRequest(), new ConnectivityManager.NetworkCallback(){ connectivityManager.requestNetwork(dns.getDNSConfigNetworkRequest(), new ConnectivityManager.NetworkCallback() {
@Override @Override
public void onAvailable(Network network){ public void onAvailable(Network network) {
super.onAvailable(network); super.onAvailable(network);
StringBuilder sb = new StringBuilder(""); StringBuilder sb = new StringBuilder("");
LinkProperties linkProperties = connectivityManager.getLinkProperties(network); LinkProperties linkProperties = connectivityManager.getLinkProperties(network);
@ -147,6 +136,7 @@ public class App extends Application {
}); });
} }
public void startVPN() { public void startVPN() {
Intent intent = new Intent(this, IPNService.class); Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_REQUEST_VPN); intent.setAction(IPNService.ACTION_REQUEST_VPN);
@ -247,7 +237,8 @@ public class App extends Application {
// lifecycle. // lifecycle.
void attachPeer(Activity act) { void attachPeer(Activity act) {
act.runOnUiThread(new Runnable() { act.runOnUiThread(new Runnable() {
@Override public void run() { @Override
public void run() {
FragmentTransaction ft = act.getFragmentManager().beginTransaction(); FragmentTransaction ft = act.getFragmentManager().beginTransaction();
ft.add(new Peer(), PEER_TAG); ft.add(new Peer(), PEER_TAG);
ft.commit(); ft.commit();
@ -262,7 +253,8 @@ public class App extends Application {
void prepareVPN(Activity act, int reqCode) { void prepareVPN(Activity act, int reqCode) {
act.runOnUiThread(new Runnable() { act.runOnUiThread(new Runnable() {
@Override public void run() { @Override
public void run() {
Intent intent = VpnService.prepare(act); Intent intent = VpnService.prepare(act);
if (intent == null) { if (intent == null) {
onVPNPrepared(); onVPNPrepared();
@ -280,7 +272,8 @@ public class App extends Application {
void showURL(Activity act, String url) { void showURL(Activity act, String url) {
act.runOnUiThread(new Runnable() { act.runOnUiThread(new Runnable() {
@Override public void run() { @Override
public void run() {
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
int headerColor = 0xff496495; int headerColor = 0xff496495;
builder.setToolbarColor(headerColor); builder.setToolbarColor(headerColor);
@ -371,8 +364,11 @@ public class App extends Application {
} }
static native void onVPNPrepared(); static native void onVPNPrepared();
private static native void onDnsConfigChanged(); private static native void onDnsConfigChanged();
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes); static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
static native void onWriteStorageGranted(); static native void onWriteStorageGranted();
// Returns details of the interfaces in the system, encoded as a single string for ease // Returns details of the interfaces in the system, encoded as a single string for ease
@ -426,7 +422,7 @@ public class App extends Application {
} }
boolean isTV() { boolean isTV() {
UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE); UiModeManager mm = (UiModeManager) getSystemService(UI_MODE_SERVICE);
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION; return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
} }
@ -461,7 +457,7 @@ public class App extends Application {
String value = mdmSettings.get(stringSetting); String value = mdmSettings.get(stringSetting);
return Objects.requireNonNullElse(value, ""); return Objects.requireNonNullElse(value, "");
} catch (IllegalArgumentException estr) { } catch (IllegalArgumentException estr) {
android.util.Log.d("MDM", key+" is not defined on Android. Returning empty."); android.util.Log.d("MDM", key + " is not defined on Android. Returning empty.");
return ""; return "";
} }
} }

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

@ -3,13 +3,17 @@
package com.tailscale.ipn package com.tailscale.ipn
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.RestrictionsManager import android.content.RestrictionsManager
import android.net.Uri import android.net.Uri
import android.net.VpnService
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContract
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@ -31,6 +35,7 @@ import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.MullvadExitNodePicker import com.tailscale.ipn.ui.view.MullvadExitNodePicker
import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.Settings import com.tailscale.ipn.ui.view.Settings
import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.IpnViewModel import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
@ -50,17 +55,22 @@ class MainActivity : ComponentActivity() {
val navController = rememberNavController() val navController = rememberNavController()
NavHost(navController = navController, startDestination = "main") { NavHost(navController = navController, startDestination = "main") {
val mainViewNav = val mainViewNav =
MainViewNavigation(onNavigateToSettings = { navController.navigate("settings") }, MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") },
onNavigateToPeerDetails = { onNavigateToPeerDetails = {
navController.navigate("peerDetails/${it.StableID}") navController.navigate("peerDetails/${it.StableID}")
}, },
onNavigateToExitNodes = { navController.navigate("exitNodes") }) onNavigateToExitNodes = { navController.navigate("exitNodes") },
)
val settingsNav = val settingsNav =
SettingsNav(onNavigateToBugReport = { navController.navigate("bugReport") }, SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") }, onNavigateToAbout = { navController.navigate("about") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") }) onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
)
val exitNodePickerNav = ExitNodePickerNav(onNavigateHome = { val exitNodePickerNav = ExitNodePickerNav(onNavigateHome = {
navController.popBackStack( navController.popBackStack(
@ -106,6 +116,9 @@ class MainActivity : ComponentActivity() {
composable("managedBy") { composable("managedBy") {
ManagedByView() ManagedByView()
} }
composable("userSwitcher") {
UserSwitcherView()
}
} }
} }
} }
@ -145,11 +158,43 @@ class MainActivity : ComponentActivity() {
val scope = CoroutineScope(Dispatchers.IO) val scope = CoroutineScope(Dispatchers.IO)
notifierScope = scope notifierScope = scope
Notifier.start(lifecycleScope) 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() { override fun onStop() {
Notifier.stop() Notifier.stop()
super.onStop() 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) 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) { fun startLoginInteractive(responseHandler: (Result<String>) -> Unit) {
return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler) return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler)
} }

@ -40,6 +40,11 @@ object Notifier {
val loginFinished: StateFlow<String?> = MutableStateFlow(null) val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: 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. // Called by the backend when the localAPI is ready to accept requests.
@JvmStatic @JvmStatic
@Suppress("unused") @Suppress("unused")

@ -13,6 +13,9 @@ val ts_color_light_tintedBackground = Color(0xFFF7F5F4)
val ts_color_light_blue = Color(0xFF4B70CC) val ts_color_light_blue = Color(0xFF4B70CC)
val ts_color_light_green = Color(0xFF1EA672) 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_primary = Color(0xFFFAF9F8)
val ts_color_dark_secondary = Color(0xFFAFACAB) val ts_color_dark_secondary = Color(0xFFAFACAB)
val ts_color_dark_background = Color(0xFF232222) val ts_color_dark_background = Color(0xFF232222)

@ -7,14 +7,21 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
@Composable @Composable
fun settingsRowModifier(): Modifier { fun settingsRowModifier(): Modifier {
@ -28,3 +35,33 @@ fun settingsRowModifier(): Modifier {
fun defaultPaddingModifier(): Modifier { fun defaultPaddingModifier(): Modifier {
return Modifier.padding(8.dp) 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 package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size 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.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter import coil.compose.AsyncImage
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
@ -32,20 +31,15 @@ fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50) {
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiaryContainer) .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( Icon(
imageVector = Icons.Default.Person, imageVector = Icons.Default.Person,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer, tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size((size * .8f).dp) 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 androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.util.Header
import com.tailscale.ipn.ui.util.defaultPaddingModifier import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.BugReportViewModel import com.tailscale.ipn.ui.viewModel.BugReportViewModel
@ -48,18 +49,9 @@ fun BugReportView(model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.surface) { Surface(color = MaterialTheme.colorScheme.surface) {
Column( Column(modifier = defaultPaddingModifier().fillMaxWidth().fillMaxHeight()) {
modifier = defaultPaddingModifier()
.fillMaxWidth() Header(title = R.string.bug_report_title)
.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)) Spacer(modifier = Modifier.height(8.dp))
@ -136,4 +128,3 @@ fun contactText(): AnnotatedString {
} }
return annotatedString return annotatedString
} }

@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // 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.PaddingValues
import androidx.compose.foundation.layout.RowScope 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.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.outlined.ArrowDropDown 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.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -40,6 +42,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.model.Tailcfg
import com.tailscale.ipn.ui.theme.ts_color_light_green import com.tailscale.ipn.ui.theme.ts_color_light_green
import com.tailscale.ipn.ui.util.PeerSet 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.util.flag
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -68,50 +70,58 @@ data class MainViewNavigation(
@Composable @Composable
fun MainView(navigation: MainViewNavigation, model: MainViewModel = viewModel()) { fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.secondaryContainer) { Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
Column( Column(
modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Center
) { ) {
val state = model.ipnState.collectAsState(initial = Ipn.State.NoState) val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = model.loggedInUser.collectAsState(initial = null) val user = viewModel.loggedInUser.collectAsState(initial = null)
Row( Row(modifier = Modifier
modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically) {
) { val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
val isOn = model.vpnToggleState.collectAsState(initial = false) if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) {
Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
Switch(onCheckedChange = { model.toggleVpn() }, checked = isOn.value)
Spacer(Modifier.size(3.dp)) Spacer(Modifier.size(3.dp))
StateDisplay(model.stateRes, model.userName) }
Box(
modifier = Modifier StateDisplay(viewModel.stateRes, viewModel.userName)
Box(modifier = Modifier
.weight(1f) .weight(1f)
.clickable { navigation.onNavigateToSettings() }, .clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) {
contentAlignment = Alignment.CenterEnd when (user.value) {
) { null -> SettingsButton(user.value) { navigation.onNavigateToSettings() }
Avatar(profile = user.value, size = 36) else -> Avatar(profile = user.value, size = 36)
}
} }
} }
when (state.value) { when (state.value) {
Ipn.State.Running -> { Ipn.State.Running -> {
ExitNodeStatus(navigation.onNavigateToExitNodes, model)
PeerList(searchTerm = model.searchTerm, val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "")
state = model.ipnState, ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
peers = model.peers, PeerList(
selfPeer = model.selfPeerId, searchTerm = viewModel.searchTerm,
state = viewModel.ipnState,
peers = viewModel.peers,
selfPeer = selfPeerId.value,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { model.searchPeers(it) }) onSearch = { viewModel.searchPeers(it) })
} }
Ipn.State.Starting -> StartingView() 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 style = MaterialTheme.typography.titleMedium
) )
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = exitNode ?: stringResource(id = R.string.none), Text(text = exitNode
style = MaterialTheme.typography.bodyMedium ?: stringResource(id = R.string.none), style = MaterialTheme.typography.bodyMedium)
)
Icon( Icon(
Icons.Outlined.ArrowDropDown, Icons.Outlined.ArrowDropDown,
null, null,
@ -161,19 +170,27 @@ fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
val stateStr = stringResource(id = stateVal.value) val stateStr = stringResource(id = stateVal.value)
Column(modifier = Modifier.padding(7.dp)) { Column(modifier = Modifier.padding(7.dp)) {
when (tailnet.isEmpty()) {
false -> {
Text(text = tailnet, style = MaterialTheme.typography.titleMedium) Text(text = tailnet, style = MaterialTheme.typography.titleMedium)
Text( Text(text = stateStr, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
text = stateStr, }
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary true -> {
) Text(text = stateStr, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary)
}
}
} }
} }
@Composable @Composable
fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) { fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
// (jonathan) TODO: On iOS this is the users avatar or a letter avatar. // (jonathan) TODO: On iOS this is the users avatar or a letter avatar.
IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
IconButton(
modifier = Modifier.size(24.dp),
onClick = { action() }
) {
Icon( Icon(
Icons.Outlined.Settings, Icons.Outlined.Settings,
null, null,
@ -191,8 +208,7 @@ fun StartingView() {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(text = stringResource(id = R.string.starting),
text = stringResource(id = R.string.starting),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@ -209,7 +225,8 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
.fillMaxWidth(0.7f) .fillMaxWidth(0.7f)
.fillMaxHeight(), .fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy( verticalArrangement = Arrangement.spacedBy(
8.dp, alignment = Alignment.CenterVertically 8.dp,
alignment = Alignment.CenterVertically
), ),
horizontalAlignment = Alignment.CenterHorizontally, 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PeerList( fun PeerList(
@ -281,7 +314,6 @@ fun PeerList(
onSearch: (String) -> Unit onSearch: (String) -> Unit
) { ) {
val peerList = peers.collectAsState(initial = emptyList<PeerSet>()) val peerList = peers.collectAsState(initial = emptyList<PeerSet>())
var searching = false
val searchTermStr by searchTerm.collectAsState(initial = "") val searchTermStr by searchTerm.collectAsState(initial = "")
val stateVal = state.collectAsState(initial = Ipn.State.NoState) val stateVal = state.collectAsState(initial = Ipn.State.NoState)
@ -290,9 +322,10 @@ fun PeerList(
onQueryChange = onSearch, onQueryChange = onSearch,
onSearch = onSearch, onSearch = onSearch,
active = true, active = true,
onActiveChange = { searching = it }, onActiveChange = {},
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
leadingIcon = { Icon(Icons.Outlined.Search, null) }, leadingIcon = { Icon(Icons.Outlined.Search, null) },
trailingIcon = { if (searchTermStr.isNotEmpty()) ClearButton({ onSearch("") }) else CloseButton() },
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 2.dp, shadowElevation = 2.dp,
colors = SearchBarDefaults.colors(), colors = SearchBarDefaults.colors(),
@ -300,55 +333,54 @@ fun PeerList(
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier
modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer), .background(MaterialTheme.colorScheme.secondaryContainer),
) { ) {
peerList.value.forEach { peerSet -> peerList.value.forEach { peerSet ->
item { item {
ListItem(headlineContent = { ListItem(headlineContent = {
Text(
text = peerSet.user?.DisplayName Text(text = peerSet.user?.DisplayName
?: stringResource(id = R.string.unknown_user), ?: stringResource(id = R.string.unknown_user), style = MaterialTheme.typography.titleLarge)
style = MaterialTheme.typography.titleLarge
)
}) })
} }
peerSet.peers.forEach { peer -> peerSet.peers.forEach { peer ->
item { item {
ListItem(modifier = Modifier.clickable {
ListItem(
modifier = Modifier.clickable {
onNavigateToPeerDetails(peer) onNavigateToPeerDetails(peer)
}, headlineContent = { },
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
// By definition, SelfPeer is online since we will not show the peer list unless you're connected. // By definition, SelfPeer is online since we will not show the peer list unless you're connected.
val isSelfAndRunning = val isSelfAndRunning = (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
(peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
val color: Color = if ((peer.Online == true) || isSelfAndRunning) { val color: Color = if ((peer.Online == true) || isSelfAndRunning) {
ts_color_light_green ts_color_light_green
} else { } else {
Color.Gray Color.Gray
} }
Box( Box(modifier = Modifier
modifier = Modifier
.size(8.dp) .size(8.dp)
.background( .background(color = color, shape = RoundedCornerShape(percent = 50))) {}
color = color, shape = RoundedCornerShape(percent = 50)
)
) {}
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text( Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
text = peer.ComputedName,
style = MaterialTheme.typography.titleMedium
)
} }
}, supportingContent = { },
supportingContent = {
Text( Text(
text = peer.Addresses?.first()?.split("/")?.first() ?: "", text = peer.Addresses?.first()?.split("/")?.first()
?: "",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
}, trailingContent = { },
trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}) }
)
} }
} }
} }

@ -11,14 +11,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText 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.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch 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.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
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.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.Setting import com.tailscale.ipn.ui.viewModel.Setting
@ -56,38 +52,23 @@ fun Settings(
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModelFactory(settingsNav)) viewModel: SettingsViewModel = viewModel(factory = SettingsViewModelFactory(settingsNav))
) { ) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
val user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxHeight()) { Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxHeight()) {
Column(modifier = defaultPaddingModifier().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)) Spacer(modifier = Modifier.height(8.dp))
// The login/logout button here is probably in the wrong location, but we need something UserView(profile = user,
// somewhere for the time being. FUS should probably be implemented for V0 given that actionState = UserActionState.NAV,
// it's relatively simple to do so with localAPI. On iOS, the UI for user switching is onClick = viewModel.navigation.onNavigateToUserSwitcher)
// all in the FUS screen. if (isAdmin) {
Spacer(modifier = Modifier.height(4.dp))
viewModel.user?.let { user -> AdminTextView { handler.openUri(Links.ADMIN_URL) }
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))
}
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -96,27 +77,9 @@ fun Settings(
settings.forEach { settingBundle -> settings.forEach { settingBundle ->
Column(modifier = settingsRowModifier()) { Column(modifier = settingsRowModifier()) {
settingBundle.title?.let { settingBundle.title?.let {
Text( SettingTitle(it)
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)
}
}
} }
settingBundle.settings.forEach { SettingRow(it) }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
@ -124,6 +87,7 @@ fun Settings(
} }
} }
@Composable @Composable
fun UserView( fun UserView(
profile: IpnLocal.LoginProfile?, profile: IpnLocal.LoginProfile?,
@ -162,38 +126,61 @@ fun UserView(
} }
@Composable @Composable
fun SettingsNavRow(setting: Setting) { fun SettingTitle(title: String) {
val txtVal = setting.value?.collectAsState()?.value ?: "" Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(8.dp)
)
}
@Composable
fun SettingRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value 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() }) { Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) {
Text(setting.title.getString()) 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) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium) Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
} }
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
} }
}
@Composable SettingType.TEXT -> {
fun SettingsSwitchRow(setting: Setting) { Text(setting.title.getString(),
val swVal = setting.isOn?.collectAsState()?.value ?: false style = MaterialTheme.typography.bodyMedium,
val enabled = setting.enabled.collectAsState().value color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary)
}
Row( SettingType.SWITCH -> {
modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() },
verticalAlignment = Alignment.CenterVertically
) {
Text(setting.title.getString()) Text(setting.title.getString())
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled) Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
} }
} }
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 @Composable
fun adminText(): AnnotatedString { fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val annotatedString = buildAnnotatedString { val adminStr = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.settings_admin_prefix)) append(stringResource(id = R.string.settings_admin_prefix))
} }
@ -204,5 +191,13 @@ fun adminText(): AnnotatedString {
} }
pop() 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 package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal 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.notifier.Notifier
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -26,9 +30,13 @@ open class IpnViewModel : ViewModel() {
} }
protected val TAG = this::class.simpleName protected val TAG = this::class.simpleName
val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null) val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)
val loginProfiles: StateFlow<List<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 { init {
viewModelScope.launch { viewModelScope.launch {
Notifier.state.collect { 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() } viewModelScope.launch { loadUserProfiles() }
Log.d(TAG, "Created") Log.d(TAG, "Created")
} }
@ -51,29 +73,71 @@ open class IpnViewModel : ViewModel() {
} }
Client(viewModelScope).currentProfile { result -> Client(viewModelScope).currentProfile { result ->
result.onSuccess(loggedInUser::set).onFailure { result.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
Log.e(TAG, "Error loading current profile: ${it.message}") .onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
} }
} }
fun toggleVpn() {
when (Notifier.state.value) {
Ipn.State.Running -> stopVPN()
else -> startVPN()
}
} }
fun login() { private fun startVPN() {
Client(viewModelScope).startLoginInteractive { result -> val context = App.getApplication().applicationContext
result.onSuccess { val intent = Intent(context, IPNReceiver::class.java)
Log.d(TAG, "Login started: $it") intent.action = IPNReceiver.INTENT_CONNECT_VPN
}.onFailure { context.sendBroadcast(intent)
Log.e(TAG, "Error starting login: ${it.message}")
} }
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 -> Client(viewModelScope).logout { result ->
result.onSuccess { result
Log.d(TAG, "Logout started: $it") .onSuccess { Log.d(TAG, "Logout started: $it") }
}.onFailure { .onFailure { Log.e(TAG, "Error starting logout: ${it.message}") }
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) { 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"))) callback(Result.failure(Exception("no prefs")))
return@toggleCorpDNS return@toggleCorpDNS
} }
@ -111,7 +177,9 @@ open class IpnViewModel : ViewModel() {
} }
fun toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) { 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"))) callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp return@toggleShieldsUp
} }
@ -122,7 +190,9 @@ open class IpnViewModel : ViewModel() {
} }
fun toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) { 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"))) callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll return@toggleRouteAll
} }

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

@ -19,7 +19,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch 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) { 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 // isOn and onToggle, while navigation settings should supply an onClick and an optional
// value // value
data class Setting( data class Setting(
val title: ComposableStringFormatter, val title: ComposableStringFormatter,
val type: SettingType, 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 value: StateFlow<String?>? = null,
val isOn: StateFlow<Boolean?>? = null, val isOn: StateFlow<Boolean?>? = null,
val onClick: () -> Unit = {}, val onClick: () -> Unit = {},
@ -75,7 +77,7 @@ data class SettingsNav(
val onNavigateToAbout: () -> Unit, val onNavigateToAbout: () -> Unit,
val onNavigateToMDMSettings: () -> Unit, val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit, val onNavigateToManagedBy: () -> Unit,
) val onNavigateToUserSwitcher: () -> Unit)
class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelProvider.Factory { class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { 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() { class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() {
val user = loggedInUser.value
// Display name for the logged in user // 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, val useDNSSetting = Setting(R.string.use_ts_dns,
SettingType.SWITCH, SettingType.SWITCH,
@ -110,7 +110,7 @@ class SettingsViewModel(private val navigation: SettingsNav) : IpnViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
IpnViewModel.mdmSettings.collect { mdmSettings -> mdmSettings.collect { mdmSettings ->
settings.set( settings.set(
listOf( listOf(
SettingBundle( 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( 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="template">%s</string>
<string name="more">More</string> <string name="more">More</string>
<string name="offline">offline</string> <string name="offline">offline</string>
<string name="ok">OK</string>
<!-- Strings for the about screen --> <!-- Strings for the about screen -->
<string name="app_name">Tailscale</string> <string name="app_name">Tailscale</string>
@ -73,6 +74,14 @@
<string name="in_x_days">in %d days</string> <string name="in_x_days">in %d days</string>
<string name="in_x_months">in %d months</string> <string name="in_x_months">in %d months</string>
<string name="in_x_years">in %.1f years</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 --> <!-- Strings for ExitNode picker -->
<string name="choose_exit_node">Choose Exit Node</string> <string name="choose_exit_node">Choose Exit Node</string>

Loading…
Cancel
Save