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,85 +1,104 @@
<?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_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.CHANGE_NETWORK_STATE" />
<!-- Disable input emulation on ChromeOS --> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-feature android:name="android.hardware.type.pc" android:required="false"/> <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.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Signal support for Android TV --> <!-- Disable input emulation on ChromeOS -->
<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.hardware.type.pc"
android:required="false" />
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" <!-- Signal support for Android TV -->
android:banner="@drawable/tv_banner" <uses-feature
android:name=".App" android:allowBackup="false"> android:name="android.software.leanback"
<activity android:name="MainActivity" android:required="false" />
android:label="@string/app_name" <uses-feature
android:theme="@style/Theme.GioApp" android:name="android.hardware.touchscreen"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden" android:required="false" />
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask" <application
android:exported="true"> android:label="Tailscale"
<intent-filter> android:icon="@mipmap/ic_launcher"
<action android:name="android.intent.action.MAIN" /> android:roundIcon="@mipmap/ic_launcher_round"
<category android:name="android.intent.category.LAUNCHER" /> android:banner="@drawable/tv_banner"
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> android:name=".App"
</intent-filter> android:allowBackup="false">
<intent-filter> <activity
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" /> android:name="MainActivity"
</intent-filter> android:label="@string/app_name"
<intent-filter> android:theme="@style/Theme.GioApp"
<action android:name="android.intent.action.SEND" /> android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
<category android:name="android.intent.category.DEFAULT"/> android:windowSoftInputMode="adjustResize"
<data android:mimeType="application/*" /> android:launchMode="singleTask"
<data android:mimeType="audio/*" /> android:exported="true">
<data android:mimeType="image/*" /> <intent-filter>
<data android:mimeType="message/*" /> <action android:name="android.intent.action.MAIN" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" /> <category android:name="android.intent.category.LAUNCHER" />
<data android:mimeType="video/*" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" /> <action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
<category android:name="android.intent.category.DEFAULT" /> </intent-filter>
<data android:mimeType="application/*" /> <intent-filter>
<data android:mimeType="audio/*" /> <action android:name="android.intent.action.SEND" />
<data android:mimeType="image/*" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
</activity>
<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"
android:permission="android.permission.BIND_VPN_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.net.VpnService"/>
</intent-filter>
</service>
<service
android:name=".QuickToggleService"
android:icon="@drawable/ic_tile"
android:label="@string/tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE"/>
</intent-filter>
</service>
</application>
</manifest>
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
<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/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
</activity>
<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"
android:permission="android.permission.BIND_VPN_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<service
android:name=".QuickToggleService"
android:icon="@drawable/ic_tile"
android:label="@string/tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
</application>
</manifest>

@ -3,447 +3,443 @@
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 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 java.io.IOException; import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting;
import java.io.File; import com.tailscale.ipn.mdm.BooleanSetting;
import java.io.FileOutputStream; import com.tailscale.ipn.mdm.MDMSettings;
import com.tailscale.ipn.mdm.ShowHideSetting;
import com.tailscale.ipn.mdm.StringSetting;
import java.lang.StringBuilder; import org.gioui.Gio;
import java.io.File;
import java.io.IOException;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.InterfaceAddress; import java.net.InterfaceAddress;
import java.net.NetworkInterface; import java.net.NetworkInterface;
import java.security.GeneralSecurityException; import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import androidx.core.app.NotificationCompat; public class App extends Application {
import androidx.core.app.NotificationManagerCompat; private static final String PEER_TAG = "peer";
import androidx.core.content.ContextCompat; static final String STATUS_CHANNEL_ID = "tailscale-status";
static final int STATUS_NOTIFICATION_ID = 1;
import androidx.security.crypto.EncryptedSharedPreferences; static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
import androidx.security.crypto.MasterKey; static final int NOTIFY_NOTIFICATION_ID = 2;
import androidx.browser.customtabs.CustomTabsIntent; private static final String FILE_CHANNEL_ID = "tailscale-files";
private static final int FILE_NOTIFICATION_ID = 3;
import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting; private static final Handler mainHandler = new Handler(Looper.getMainLooper());
import com.tailscale.ipn.mdm.BooleanSetting;
import com.tailscale.ipn.mdm.MDMSettings;
import com.tailscale.ipn.mdm.ShowHideSetting;
import com.tailscale.ipn.mdm.StringSetting;
import org.gioui.Gio; private ConnectivityManager connectivityManager;
public DnsConfig dns = new DnsConfig();
public class App extends Application { public DnsConfig getDnsConfigObj() {
private static final String PEER_TAG = "peer"; return this.dns;
}
static final String STATUS_CHANNEL_ID = "tailscale-status";
static final int STATUS_NOTIFICATION_ID = 1;
static App _application;
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
static final int NOTIFY_NOTIFICATION_ID = 2; public static App getApplication() {
return _application;
private static final String FILE_CHANNEL_ID = "tailscale-files"; }
private static final int FILE_NOTIFICATION_ID = 3;
@Override
private static final Handler mainHandler = new Handler(Looper.getMainLooper()); public void onCreate() {
super.onCreate();
private ConnectivityManager connectivityManager; // Load and initialize the Go library.
public DnsConfig dns = new DnsConfig(); Gio.init(this);
public DnsConfig getDnsConfigObj() { return this.dns; }
this.connectivityManager = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
setAndRegisterNetworkCallbacks();
static App _application;
public static App getApplication() { createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
return _application; createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
} createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);
@Override public void onCreate() { _application = this;
super.onCreate(); }
// Load and initialize the Go library.
Gio.init(this); // 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.connectivityManager = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE); private void setAndRegisterNetworkCallbacks() {
setAndRegisterNetworkCallbacks(); connectivityManager.requestNetwork(dns.getDNSConfigNetworkRequest(), new ConnectivityManager.NetworkCallback() {
@Override
createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT); public void onAvailable(Network network) {
createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW); super.onAvailable(network);
createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT); StringBuilder sb = new StringBuilder("");
LinkProperties linkProperties = connectivityManager.getLinkProperties(network);
_application = this; List<InetAddress> dnsList = linkProperties.getDnsServers();
} for (InetAddress ip : dnsList) {
sb.append(ip.getHostAddress()).append(" ");
// 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. String searchDomains = linkProperties.getDomains();
private void setAndRegisterNetworkCallbacks() { if (searchDomains != null) {
connectivityManager.requestNetwork(dns.getDNSConfigNetworkRequest(), new ConnectivityManager.NetworkCallback(){ sb.append("\n");
@Override sb.append(searchDomains);
public void onAvailable(Network network){ }
super.onAvailable(network);
StringBuilder sb = new StringBuilder(""); dns.updateDNSFromNetwork(sb.toString());
LinkProperties linkProperties = connectivityManager.getLinkProperties(network); onDnsConfigChanged();
List<InetAddress> dnsList = linkProperties.getDnsServers();
for (InetAddress ip : dnsList) {
sb.append(ip.getHostAddress()).append(" ");
}
String searchDomains = linkProperties.getDomains();
if (searchDomains != null) {
sb.append("\n");
sb.append(searchDomains);
}
dns.updateDNSFromNetwork(sb.toString());
onDnsConfigChanged();
}
@Override
public void onLost(Network network) {
super.onLost(network);
onDnsConfigChanged();
}
});
}
public void startVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_REQUEST_VPN);
startService(intent);
}
public void stopVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_STOP_VPN);
startService(intent);
}
// encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store.
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
}
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext.
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
return getEncryptedPrefs().getString(prefKey, null);
}
public SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
MasterKey key = new MasterKey.Builder(this)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
return EncryptedSharedPreferences.create(
this,
"secret_shared_prefs",
key,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
);
}
public boolean autoConnect = false;
public boolean vpnReady = false;
void setTileReady(boolean ready) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setReady(this, ready);
android.util.Log.d("App", "Set Tile Ready: " + ready + " " + autoConnect);
vpnReady = ready;
if (ready && autoConnect) {
startVPN();
}
}
void setTileStatus(boolean status) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setStatus(this, status);
}
String getHostname() {
String userConfiguredDeviceName = getUserConfiguredDeviceName();
if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;
return getModelName();
}
String getModelName() {
String manu = Build.MANUFACTURER;
String model = Build.MODEL;
// Strip manufacturer from model.
int idx = model.toLowerCase().indexOf(manu.toLowerCase());
if (idx != -1) {
model = model.substring(idx + manu.length());
model = model.trim();
}
return manu + " " + model;
}
String getOSVersion() {
return Build.VERSION.RELEASE;
}
// get user defined nickname from Settings
// returns null if not available
private String getUserConfiguredDeviceName() {
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
return null;
}
private static boolean isEmpty(String str) {
return str == null || str.length() == 0;
}
// attachPeer adds a Peer fragment for tracking the Activity
// lifecycle.
void attachPeer(Activity act) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
ft.add(new Peer(), PEER_TAG);
ft.commit();
act.getFragmentManager().executePendingTransactions();
}
});
}
boolean isChromeOS() {
return getPackageManager().hasSystemFeature("android.hardware.type.pc");
}
void prepareVPN(Activity act, int reqCode) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
Intent intent = VpnService.prepare(act);
if (intent == null) {
onVPNPrepared();
} else {
startActivityForResult(act, intent, reqCode);
}
}
});
}
static void startActivityForResult(Activity act, Intent intent, int request) {
Fragment f = act.getFragmentManager().findFragmentByTag(PEER_TAG);
f.startActivityForResult(intent, request);
}
void showURL(Activity act, String url) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
int headerColor = 0xff496495;
builder.setToolbarColor(headerColor);
CustomTabsIntent intent = builder.build();
intent.launchUrl(act, Uri.parse(url));
}
});
}
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
byte[] getPackageCertificate() throws Exception {
PackageInfo info;
info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
for (Signature signature : info.signatures) {
return signature.toByteArray();
}
return null;
}
void requestWriteStoragePermission(Activity act) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// We can write files without permission.
return;
}
if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
return;
}
act.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, IPNActivity.WRITE_STORAGE_RESULT);
}
String insertMedia(String name, String mimeType) throws IOException {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
if (!"".equals(mimeType)) {
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
}
Uri root = MediaStore.Files.getContentUri("external");
return resolver.insert(root, contentValues).toString();
} else {
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
dir.mkdirs();
File f = new File(dir, name);
return Uri.fromFile(f).toString();
}
}
int openUri(String uri, String mode) throws IOException {
ContentResolver resolver = getContentResolver();
return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
}
void deleteUri(String uri) {
ContentResolver resolver = getContentResolver();
resolver.delete(Uri.parse(uri), null, null);
}
public void notifyFile(String uri, String msg) {
Intent viewIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
} else {
// uri is a file:// which is not allowed to be shared outside the app.
viewIntent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
}
PendingIntent pending = PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("File received")
.setContentText(msg)
.setContentIntent(pending)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(FILE_NOTIFICATION_ID, builder.build());
}
public void createNotificationChannel(String id, String name, int importance) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(id, name, importance);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.createNotificationChannel(channel);
}
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
// of JNI transfer over to the Go environment.
//
// Example:
// rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
// dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
// rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
// r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
// rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
// lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
// v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
//
// Where the fields are:
// name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
String getInterfacesAsString() {
List<NetworkInterface> interfaces;
try {
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
} catch (Exception e) {
return "";
} }
StringBuilder sb = new StringBuilder(""); @Override
for (NetworkInterface nif : interfaces) { public void onLost(Network network) {
try { super.onLost(network);
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants onDnsConfigChanged();
// one, so we say the interface has broadcast if it has multicast. }
sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(), });
nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(), }
nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
for (InterfaceAddress ia : nif.getInterfaceAddresses()) { public void startVPN() {
// InterfaceAddress == hostname + "/" + IP Intent intent = new Intent(this, IPNService.class);
String[] parts = ia.toString().split("/", 0); intent.setAction(IPNService.ACTION_REQUEST_VPN);
if (parts.length > 1) { startService(intent);
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength())); }
}
} public void stopVPN() {
} catch (Exception e) { Intent intent = new Intent(this, IPNService.class);
// TODO(dgentry) should log the exception not silently suppress it. intent.setAction(IPNService.ACTION_STOP_VPN);
continue; startService(intent);
}
// encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store.
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
}
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext.
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
return getEncryptedPrefs().getString(prefKey, null);
}
public SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
MasterKey key = new MasterKey.Builder(this)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
return EncryptedSharedPreferences.create(
this,
"secret_shared_prefs",
key,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
);
}
public boolean autoConnect = false;
public boolean vpnReady = false;
void setTileReady(boolean ready) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setReady(this, ready);
android.util.Log.d("App", "Set Tile Ready: " + ready + " " + autoConnect);
vpnReady = ready;
if (ready && autoConnect) {
startVPN();
}
}
void setTileStatus(boolean status) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setStatus(this, status);
}
String getHostname() {
String userConfiguredDeviceName = getUserConfiguredDeviceName();
if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;
return getModelName();
}
String getModelName() {
String manu = Build.MANUFACTURER;
String model = Build.MODEL;
// Strip manufacturer from model.
int idx = model.toLowerCase().indexOf(manu.toLowerCase());
if (idx != -1) {
model = model.substring(idx + manu.length());
model = model.trim();
}
return manu + " " + model;
}
String getOSVersion() {
return Build.VERSION.RELEASE;
}
// get user defined nickname from Settings
// returns null if not available
private String getUserConfiguredDeviceName() {
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
return null;
}
private static boolean isEmpty(String str) {
return str == null || str.length() == 0;
}
// attachPeer adds a Peer fragment for tracking the Activity
// lifecycle.
void attachPeer(Activity act) {
act.runOnUiThread(new Runnable() {
@Override
public void run() {
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
ft.add(new Peer(), PEER_TAG);
ft.commit();
act.getFragmentManager().executePendingTransactions();
}
});
}
boolean isChromeOS() {
return getPackageManager().hasSystemFeature("android.hardware.type.pc");
}
void prepareVPN(Activity act, int reqCode) {
act.runOnUiThread(new Runnable() {
@Override
public void run() {
Intent intent = VpnService.prepare(act);
if (intent == null) {
onVPNPrepared();
} else {
startActivityForResult(act, intent, reqCode);
} }
sb.append("\n");
} }
});
}
static void startActivityForResult(Activity act, Intent intent, int request) {
Fragment f = act.getFragmentManager().findFragmentByTag(PEER_TAG);
f.startActivityForResult(intent, request);
}
void showURL(Activity act, String url) {
act.runOnUiThread(new Runnable() {
@Override
public void run() {
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
int headerColor = 0xff496495;
builder.setToolbarColor(headerColor);
CustomTabsIntent intent = builder.build();
intent.launchUrl(act, Uri.parse(url));
}
});
}
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
byte[] getPackageCertificate() throws Exception {
PackageInfo info;
info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
for (Signature signature : info.signatures) {
return signature.toByteArray();
}
return null;
}
void requestWriteStoragePermission(Activity act) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// We can write files without permission.
return;
}
if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
return;
}
act.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, IPNActivity.WRITE_STORAGE_RESULT);
}
String insertMedia(String name, String mimeType) throws IOException {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
if (!"".equals(mimeType)) {
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
}
Uri root = MediaStore.Files.getContentUri("external");
return resolver.insert(root, contentValues).toString();
} else {
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
dir.mkdirs();
File f = new File(dir, name);
return Uri.fromFile(f).toString();
}
}
int openUri(String uri, String mode) throws IOException {
ContentResolver resolver = getContentResolver();
return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
}
void deleteUri(String uri) {
ContentResolver resolver = getContentResolver();
resolver.delete(Uri.parse(uri), null, null);
}
public void notifyFile(String uri, String msg) {
Intent viewIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
} else {
// uri is a file:// which is not allowed to be shared outside the app.
viewIntent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
}
PendingIntent pending = PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("File received")
.setContentText(msg)
.setContentIntent(pending)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(FILE_NOTIFICATION_ID, builder.build());
}
public void createNotificationChannel(String id, String name, int importance) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(id, name, importance);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.createNotificationChannel(channel);
}
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
// of JNI transfer over to the Go environment.
//
// Example:
// rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
// dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
// rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
// r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
// rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
// lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
// v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
//
// Where the fields are:
// name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
String getInterfacesAsString() {
List<NetworkInterface> interfaces;
try {
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
} catch (Exception e) {
return "";
}
return sb.toString(); StringBuilder sb = new StringBuilder("");
for (NetworkInterface nif : interfaces) {
try {
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants
// one, so we say the interface has broadcast if it has multicast.
sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
// InterfaceAddress == hostname + "/" + IP
String[] parts = ia.toString().split("/", 0);
if (parts.length > 1) {
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
}
}
} catch (Exception e) {
// TODO(dgentry) should log the exception not silently suppress it.
continue;
}
sb.append("\n");
} }
boolean isTV() { return sb.toString();
UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE); }
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
} boolean isTV() {
UiModeManager mm = (UiModeManager) getSystemService(UI_MODE_SERVICE);
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
}
/* /*
The following methods are called by the syspolicy handler from Go via JNI. The following methods are called by the syspolicy handler from Go via JNI.
*/ */
boolean getSyspolicyBooleanValue(String key) { boolean getSyspolicyBooleanValue(String key) {
RestrictionsManager manager = (RestrictionsManager) this.getSystemService(Context.RESTRICTIONS_SERVICE); RestrictionsManager manager = (RestrictionsManager) this.getSystemService(Context.RESTRICTIONS_SERVICE);
MDMSettings mdmSettings = new MDMSettings(manager); MDMSettings mdmSettings = new MDMSettings(manager);
BooleanSetting setting = BooleanSetting.valueOf(key); BooleanSetting setting = BooleanSetting.valueOf(key);
return mdmSettings.get(setting); return mdmSettings.get(setting);
} }
String getSyspolicyStringValue(String key) { String getSyspolicyStringValue(String key) {
RestrictionsManager manager = (RestrictionsManager) this.getSystemService(Context.RESTRICTIONS_SERVICE); RestrictionsManager manager = (RestrictionsManager) this.getSystemService(Context.RESTRICTIONS_SERVICE);
MDMSettings mdmSettings = new MDMSettings(manager); MDMSettings mdmSettings = new MDMSettings(manager);
// Before looking for a StringSetting matching the given key, Go could also be // Before looking for a StringSetting matching the given key, Go could also be
// asking us for either a AlwaysNeverUserDecidesSetting or a ShowHideSetting. // asking us for either a AlwaysNeverUserDecidesSetting or a ShowHideSetting.
@ -461,10 +457,10 @@ 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,21 +55,26 @@ 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(
onNavigateToPeerDetails = { onNavigateToSettings = { navController.navigate("settings") },
navController.navigate("peerDetails/${it.StableID}") onNavigateToPeerDetails = {
}, navController.navigate("peerDetails/${it.StableID}")
onNavigateToExitNodes = { navController.navigate("exitNodes") }) },
onNavigateToExitNodes = { navController.navigate("exitNodes") },
)
val settingsNav = val settingsNav =
SettingsNav(onNavigateToBugReport = { navController.navigate("bugReport") }, SettingsNav(
onNavigateToAbout = { navController.navigate("about") }, onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToAbout = { navController.navigate("about") },
onNavigateToManagedBy = { navController.navigate("managedBy") }) onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
)
val exitNodePickerNav = ExitNodePickerNav(onNavigateHome = { val exitNodePickerNav = ExitNodePickerNav(onNavigateHome = {
navController.popBackStack( navController.popBackStack(
route = "main", inclusive = false route = "main", inclusive = false
) )
}, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }) }, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") })
@ -79,18 +89,18 @@ class MainActivity : ComponentActivity() {
ExitNodePicker(exitNodePickerNav) ExitNodePicker(exitNodePickerNav)
} }
composable( composable(
"mullvad/{countryCode}", arguments = listOf(navArgument("countryCode") { "mullvad/{countryCode}", arguments = listOf(navArgument("countryCode") {
type = NavType.StringType type = NavType.StringType
}) })
) { ) {
MullvadExitNodePicker( MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav it.arguments!!.getString("countryCode")!!, exitNodePickerNav
) )
} }
} }
composable( composable(
"peerDetails/{nodeId}", "peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType }) arguments = listOf(navArgument("nodeId") { type = NavType.StringType })
) { ) {
PeerDetails(it.arguments?.getString("nodeId") ?: "") PeerDetails(it.arguments?.getString("nodeId") ?: "")
} }
@ -106,6 +116,9 @@ class MainActivity : ComponentActivity() {
composable("managedBy") { composable("managedBy") {
ManagedByView() ManagedByView()
} }
composable("userSwitcher") {
UserSwitcherView()
}
} }
} }
} }
@ -136,7 +149,7 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val restrictionsManager = val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager)) IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager))
} }
@ -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
}
}

@ -66,7 +66,7 @@ class Client(private val scope: CoroutineScope) {
} }
fun editPrefs( fun editPrefs(
prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit
) { ) {
val body = Json.encodeToString(prefs).toByteArray() val body = Json.encodeToString(prefs).toByteArray()
return patch(Endpoint.PREFS, body, responseHandler = responseHandler) return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
@ -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)
} }
@ -89,77 +101,77 @@ class Client(private val scope: CoroutineScope) {
} }
private inline fun <reified T> get( private inline fun <reified T> get(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) { ) {
Request( Request(
scope = scope, scope = scope,
method = "GET", method = "GET",
path = path, path = path,
body = body, body = body,
responseType = typeOf<T>(), responseType = typeOf<T>(),
responseHandler = responseHandler responseHandler = responseHandler
).execute() ).execute()
} }
private inline fun <reified T> put( private inline fun <reified T> put(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) { ) {
Request( Request(
scope = scope, scope = scope,
method = "PUT", method = "PUT",
path = path, path = path,
body = body, body = body,
responseType = typeOf<T>(), responseType = typeOf<T>(),
responseHandler = responseHandler responseHandler = responseHandler
).execute() ).execute()
} }
private inline fun <reified T> post( private inline fun <reified T> post(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) { ) {
Request( Request(
scope = scope, scope = scope,
method = "POST", method = "POST",
path = path, path = path,
body = body, body = body,
responseType = typeOf<T>(), responseType = typeOf<T>(),
responseHandler = responseHandler responseHandler = responseHandler
).execute() ).execute()
} }
private inline fun <reified T> patch( private inline fun <reified T> patch(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) { ) {
Request( Request(
scope = scope, scope = scope,
method = "PATCH", method = "PATCH",
path = path, path = path,
body = body, body = body,
responseType = typeOf<T>(), responseType = typeOf<T>(),
responseHandler = responseHandler responseHandler = responseHandler
).execute() ).execute()
} }
private inline fun <reified T> delete( private inline fun <reified T> delete(
path: String, noinline responseHandler: (Result<T>) -> Unit path: String, noinline responseHandler: (Result<T>) -> Unit
) { ) {
Request( Request(
scope = scope, scope = scope,
method = "DELETE", method = "DELETE",
path = path, path = path,
responseType = typeOf<T>(), responseType = typeOf<T>(),
responseHandler = responseHandler responseHandler = responseHandler
).execute() ).execute()
} }
} }
class Request<T>( class Request<T>(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val method: String, private val method: String,
path: String, path: String,
private val body: ByteArray? = null, private val body: ByteArray? = null,
private val responseType: KType, private val responseType: KType,
private val responseHandler: (Result<T>) -> Unit private val responseHandler: (Result<T>) -> Unit
) { ) {
private val fullPath = "/localapi/v0/$path" private val fullPath = "/localapi/v0/$path"
@ -206,15 +218,15 @@ class Request<T>(
typeOf<String>() -> Result.success(respData.decodeToString() as T) typeOf<String>() -> Result.success(respData.decodeToString() as T)
else -> try { else -> try {
Result.success( Result.success(
jsonDecoder.decodeFromStream( jsonDecoder.decodeFromStream(
Json.serializersModule.serializer(responseType), respData.inputStream() Json.serializersModule.serializer(responseType), respData.inputStream()
) as T ) as T
) )
} catch (t: Throwable) { } catch (t: Throwable) {
// If we couldn't parse the response body, assume it's an error response // If we couldn't parse the response body, assume it's an error response
try { try {
val error = val error =
jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream()) jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream())
throw Exception(error.error) throw Exception(error.error)
} catch (t: Throwable) { } catch (t: Throwable) {
Result.failure(t) Result.failure(t)

@ -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")
@ -54,7 +59,7 @@ object Notifier {
// Wait for the notifier to be ready // Wait for the notifier to be ready
isReady.await() isReady.await()
val mask = val mask =
NotifyWatchOpt.Netmap.value or NotifyWatchOpt.Prefs.value or NotifyWatchOpt.InitialState.value NotifyWatchOpt.Netmap.value or NotifyWatchOpt.Prefs.value or NotifyWatchOpt.InitialState.value
startIPNBusWatcher(mask) startIPNBusWatcher(mask)
Log.d(TAG, "Stopped") Log.d(TAG, "Stopped")
} }
@ -91,7 +96,7 @@ object Notifier {
// what we want to see on the Notify bus // what we want to see on the Notify bus
private enum class NotifyWatchOpt(val value: Int) { private enum class NotifyWatchOpt(val value: Int) {
EngineUpdates(1), InitialState(2), Prefs(4), Netmap(8), NoPrivateKey(16), InitialTailFSShares( EngineUpdates(1), InitialState(2), Prefs(4), Netmap(8), NoPrivateKey(16), InitialTailFSShares(
32 32
) )
} }
} }

@ -13,9 +13,12 @@ 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)
val ts_color_dark_tintedBackground = Color(0xFF2E2D2D) val ts_color_dark_tintedBackground = Color(0xFF2E2D2D)
val ts_color_dark_blue = Color(0xFF4B70CC) val ts_color_dark_blue = Color(0xFF4B70CC)
var ts_color_dark_green = Color(0xFF33C27F) var ts_color_dark_green = Color(0xFF33C27F)

@ -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 {
@ -27,4 +34,34 @@ fun settingsRowModifier(): Modifier {
@Composable @Composable
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)
) { ) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size((size * .8f).dp)
)
profile?.UserProfile?.ProfilePicURL?.let { url -> profile?.UserProfile?.ProfilePicURL?.let { url ->
val painter = rememberImagePainter(data = url) AsyncImage(model = url, contentDescription = null)
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)
)
} }
} }
} }

@ -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
@ -17,20 +17,20 @@ import com.tailscale.ipn.ui.theme.ts_color_light_blue
@Composable @Composable
fun PrimaryActionButton( fun PrimaryActionButton(
onClick: () -> Unit, onClick: () -> Unit,
content: @Composable RowScope.() -> Unit content: @Composable RowScope.() -> Unit
) { ) {
Button( Button(
onClick = onClick, onClick = onClick,
colors = ButtonColors( colors = ButtonColors(
containerColor = ts_color_light_blue, containerColor = ts_color_light_blue,
contentColor = Color.White, contentColor = Color.White,
disabledContainerColor = MaterialTheme.colorScheme.secondary, disabledContainerColor = MaterialTheme.colorScheme.secondary,
disabledContentColor = MaterialTheme.colorScheme.onSecondary disabledContentColor = MaterialTheme.colorScheme.onSecondary
), ),
contentPadding = PaddingValues(vertical = 12.dp), contentPadding = PaddingValues(vertical = 12.dp),
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
content = content content = content
) )
} }

@ -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
@ -61,58 +63,66 @@ import kotlinx.coroutines.flow.StateFlow
// Navigation actions for the MainView // Navigation actions for the MainView
data class MainViewNavigation( data class MainViewNavigation(
val onNavigateToSettings: () -> Unit, val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit val onNavigateToExitNodes: () -> Unit
) )
@Composable @Composable
fun MainView(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( StateDisplay(viewModel.stateRes, viewModel.userName)
modifier = Modifier
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,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, state = viewModel.ipnState,
onSearch = { model.searchPeers(it) }) peers = viewModel.peers,
selfPeer = selfPeerId.value,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
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 {} }
)
} }
} }
} }
@ -131,24 +141,23 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
} }
} }
Box(modifier = Modifier Box(modifier = Modifier
.clickable { navAction() } .clickable { navAction() }
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.background(MaterialTheme.colorScheme.secondaryContainer) .background(MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) { .fillMaxWidth()) {
Column(modifier = Modifier.padding(6.dp)) { Column(modifier = Modifier.padding(6.dp)) {
Text( Text(
text = stringResource(id = R.string.exit_node), text = stringResource(id = R.string.exit_node),
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,22 +170,30 @@ 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)) {
Text(text = tailnet, style = MaterialTheme.typography.titleMedium) when (tailnet.isEmpty()) {
Text( false -> {
text = stateStr, Text(text = tailnet, style = MaterialTheme.typography.titleMedium)
style = MaterialTheme.typography.bodyMedium, Text(text = stateStr, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
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,
) )
} }
} }
@ -185,16 +202,15 @@ fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
fun StartingView() { fun StartingView() {
// (jonathan) TODO: On iOS this is the game-of-life Tailscale animation. // (jonathan) TODO: On iOS this is the game-of-life Tailscale animation.
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(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
) )
} }
} }
@ -203,66 +219,67 @@ fun StartingView() {
fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAction: () -> Unit) { fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAction: () -> Unit) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier modifier = Modifier
.background(MaterialTheme.colorScheme.secondaryContainer) .background(MaterialTheme.colorScheme.secondaryContainer)
.padding(8.dp) .padding(8.dp)
.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,
) { ) {
if (user != null && !user.isEmpty()) { if (user != null && !user.isEmpty()) {
Icon( Icon(
painter = painterResource(id = R.drawable.power), painter = painterResource(id = R.drawable.power),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(48.dp), modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.secondary tint = MaterialTheme.colorScheme.secondary
) )
Text( Text(
text = stringResource(id = R.string.not_connected), text = stringResource(id = R.string.not_connected),
fontSize = MaterialTheme.typography.titleMedium.fontSize, fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontFamily = MaterialTheme.typography.titleMedium.fontFamily fontFamily = MaterialTheme.typography.titleMedium.fontFamily
) )
val tailnetName = user.NetworkProfile?.DomainName ?: "" val tailnetName = user.NetworkProfile?.DomainName ?: ""
Text( Text(
stringResource(id = R.string.connect_to_tailnet, tailnetName), stringResource(id = R.string.connect_to_tailnet, tailnetName),
fontSize = MaterialTheme.typography.titleMedium.fontSize, fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(modifier = Modifier.size(1.dp)) Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = connectAction) { PrimaryActionButton(onClick = connectAction) {
Text( Text(
text = stringResource(id = R.string.connect), text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize fontSize = MaterialTheme.typography.titleMedium.fontSize
) )
} }
} else { } else {
TailscaleLogoView(Modifier.size(50.dp)) TailscaleLogoView(Modifier.size(50.dp))
Spacer(modifier = Modifier.size(1.dp)) Spacer(modifier = Modifier.size(1.dp))
Text( Text(
text = stringResource(id = R.string.welcome_to_tailscale), text = stringResource(id = R.string.welcome_to_tailscale),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Text( Text(
stringResource(R.string.login_to_join_your_tailnet), stringResource(R.string.login_to_join_your_tailnet),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.size(1.dp)) Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = loginAction) { PrimaryActionButton(onClick = loginAction) {
Text( Text(
text = stringResource(id = R.string.log_in), text = stringResource(id = R.string.log_in),
fontSize = MaterialTheme.typography.titleMedium.fontSize fontSize = MaterialTheme.typography.titleMedium.fontSize
) )
} }
} }
@ -270,88 +287,103 @@ 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(
searchTerm: StateFlow<String>, searchTerm: StateFlow<String>,
peers: StateFlow<List<PeerSet>>, peers: StateFlow<List<PeerSet>>,
state: StateFlow<Ipn.State>, state: StateFlow<Ipn.State>,
selfPeer: StableNodeID, selfPeer: StableNodeID,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit onSearch: (String) -> Unit
) { ) {
val peerList = peers.collectAsState(initial = emptyList<PeerSet>()) val peerList = peers.collectAsState(initial = emptyList<PeerSet>())
var searching = false
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)
SearchBar( SearchBar(
query = searchTermStr, query = searchTermStr,
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) },
tonalElevation = 2.dp, trailingIcon = { if (searchTermStr.isNotEmpty()) ClearButton({ onSearch("") }) else CloseButton() },
shadowElevation = 2.dp, tonalElevation = 2.dp,
colors = SearchBarDefaults.colors(), shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth() colors = SearchBarDefaults.colors(),
modifier = Modifier.fillMaxWidth()
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier
.fillMaxSize() modifier =
.background(MaterialTheme.colorScheme.secondaryContainer), Modifier
.fillMaxSize()
.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 {
onNavigateToPeerDetails(peer) ListItem(
}, headlineContent = { modifier = Modifier.clickable {
Row(verticalAlignment = Alignment.CenterVertically) { onNavigateToPeerDetails(peer)
// By definition, SelfPeer is online since we will not show the peer list unless you're connected. },
val isSelfAndRunning = headlineContent = {
(peer.StableID == selfPeer && stateVal.value == Ipn.State.Running) Row(verticalAlignment = Alignment.CenterVertically) {
val color: Color = if ((peer.Online == true) || isSelfAndRunning) { // By definition, SelfPeer is online since we will not show the peer list unless you're connected.
ts_color_light_green val isSelfAndRunning = (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
} else { val color: Color = if ((peer.Online == true) || isSelfAndRunning) {
Color.Gray ts_color_light_green
} else {
Color.Gray
}
Box(modifier = Modifier
.size(8.dp)
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
}
},
supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first()
?: "",
style = MaterialTheme.typography.bodyMedium
)
},
trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
} }
Box( )
modifier = Modifier
.size(8.dp)
.background(
color = color, shape = RoundedCornerShape(percent = 50)
)
) {}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = peer.ComputedName,
style = MaterialTheme.typography.titleMedium
)
}
}, supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
style = MaterialTheme.typography.bodyMedium
)
}, 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.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
@ -52,42 +48,27 @@ import com.tailscale.ipn.ui.viewModel.SettingsViewModelFactory
@Composable @Composable
fun Settings( fun Settings(
settingsNav: SettingsNav, settingsNav: SettingsNav,
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,12 +87,13 @@ fun Settings(
} }
} }
@Composable @Composable
fun UserView( fun UserView(
profile: IpnLocal.LoginProfile?, profile: IpnLocal.LoginProfile?,
isAdmin: Boolean, isAdmin: Boolean,
adminText: AnnotatedString, adminText: AnnotatedString,
onClick: () -> Unit onClick: () -> Unit
) { ) {
Column { Column {
Row(modifier = settingsRowModifier().padding(8.dp)) { Row(modifier = settingsRowModifier().padding(8.dp)) {
@ -140,8 +104,8 @@ fun UserView(
Column(verticalArrangement = Arrangement.Center) { Column(verticalArrangement = Arrangement.Center) {
Text( Text(
text = profile?.UserProfile?.DisplayName ?: "", text = profile?.UserProfile?.DisplayName ?: "",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium) Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium)
} }
@ -150,11 +114,11 @@ fun UserView(
if (isAdmin) { if (isAdmin) {
Column(modifier = Modifier.padding(horizontal = 12.dp)) { Column(modifier = Modifier.padding(horizontal = 12.dp)) {
ClickableText( ClickableText(
text = adminText, text = adminText,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
onClick = { onClick = {
onClick() onClick()
}) })
} }
} }
@ -162,38 +126,61 @@ fun UserView(
} }
@Composable @Composable
fun SettingsNavRow(setting: Setting) { fun SettingTitle(title: String) {
val txtVal = setting.value?.collectAsState()?.value ?: "" Text(
val enabled = setting.enabled.collectAsState().value text = title,
style = MaterialTheme.typography.titleMedium,
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }) { modifier = Modifier.padding(8.dp)
Text(setting.title.getString()) )
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
} }
@Composable @Composable
fun SettingsSwitchRow(setting: Setting) { fun SettingRow(setting: Setting) {
val swVal = setting.isOn?.collectAsState()?.value ?: false
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( Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) {
modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, when (setting.type) {
verticalAlignment = Alignment.CenterVertically SettingType.NAV_WITH_TEXT -> {
) { Text(setting.title.getString(),
Text(setting.title.getString()) style = MaterialTheme.typography.bodyMedium,
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary)
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled) Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
}
SettingType.TEXT -> {
Text(setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary)
}
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 @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() {
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 -> Client(viewModelScope).startLoginInteractive { result ->
result.onSuccess { result
Log.d(TAG, "Login started: $it") .onSuccess { Log.d(TAG, "Login started: $it") }
}.onFailure { .onFailure { Log.e(TAG, "Error starting login: ${it.message}") }
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,10 +164,12 @@ 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 =
callback(Result.failure(Exception("no prefs"))) Notifier.prefs.value
return@toggleCorpDNS ?: run {
} callback(Result.failure(Exception("no prefs")))
return@toggleCorpDNS
}
val prefsOut = Ipn.MaskedPrefs() val prefsOut = Ipn.MaskedPrefs()
prefsOut.CorpDNS = !prefs.CorpDNS prefsOut.CorpDNS = !prefs.CorpDNS
@ -111,10 +177,12 @@ 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 =
callback(Result.failure(Exception("no prefs"))) Notifier.prefs.value
return@toggleShieldsUp ?: run {
} callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp
}
val prefsOut = Ipn.MaskedPrefs() val prefsOut = Ipn.MaskedPrefs()
prefsOut.ShieldsUp = !prefs.ShieldsUp prefsOut.ShieldsUp = !prefs.ShieldsUp
@ -122,10 +190,12 @@ 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 =
callback(Result.failure(Exception("no prefs"))) Notifier.prefs.value
return@toggleRouteAll ?: run {
} callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll
}
val prefsOut = Ipn.MaskedPrefs() val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = !prefs.RouteAll prefsOut.RouteAll = !prefs.RouteAll

@ -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,39 +43,41 @@ 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 type: SettingType, val title: ComposableStringFormatter,
val enabled: StateFlow<Boolean> = MutableStateFlow(false), val type: SettingType,
val value: StateFlow<String?>? = null, val destructive: Boolean = false,
val isOn: StateFlow<Boolean?>? = null, val enabled: StateFlow<Boolean> = MutableStateFlow(true),
val onClick: () -> Unit = {}, val value: StateFlow<String?>? = null,
val onToggle: (Boolean) -> Unit = {} val isOn: StateFlow<Boolean?>? = null,
val onClick: () -> Unit = {},
val onToggle: (Boolean) -> Unit = {}
) { ) {
constructor( constructor(
titleRes: Int, titleRes: Int,
type: SettingType, type: SettingType,
enabled: StateFlow<Boolean> = MutableStateFlow(false), enabled: StateFlow<Boolean> = MutableStateFlow(false),
value: StateFlow<String?>? = null, value: StateFlow<String?>? = null,
isOn: StateFlow<Boolean?>? = null, isOn: StateFlow<Boolean?>? = null,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onToggle: (Boolean) -> Unit = {} onToggle: (Boolean) -> Unit = {}
) : this( ) : this(
title = ComposableStringFormatter(titleRes), title = ComposableStringFormatter(titleRes),
type = type, type = type,
enabled = enabled, enabled = enabled,
value = value, value = value,
isOn = isOn, isOn = isOn,
onClick = onClick, onClick = onClick,
onToggle = onToggle onToggle = onToggle
) )
} }
data class SettingsNav( data class SettingsNav(
val onNavigateToBugReport: () -> Unit, val onNavigateToBugReport: () -> Unit,
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,20 +85,18 @@ 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,
isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS), isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS),
onToggle = { onToggle = {
toggleCorpDNS { toggleCorpDNS {
// (jonathan) TODO: Error handling // (jonathan) TODO: Error handling
} }
}) })
val settings: StateFlow<List<SettingBundle>> = MutableStateFlow(emptyList()) val settings: StateFlow<List<SettingBundle>> = MutableStateFlow(emptyList())
@ -110,49 +110,55 @@ 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(
settings = listOf( settings = listOf(
useDNSSetting, useDNSSetting,
) )
), ),
// General settings, always enabled // General settings, always enabled
SettingBundle(settings = footerSettings(mdmSettings)) SettingBundle(settings = footerSettings(mdmSettings))
) )
) )
} }
} }
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(
Setting( Setting(
titleRes = R.string.about, titleRes = R.string.about,
SettingType.NAV, SettingType.NAV,
onClick = { navigation.onNavigateToAbout() }, onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true) enabled = MutableStateFlow(true)
), Setting( ), Setting(
titleRes = R.string.bug_report, titleRes = R.string.bug_report,
SettingType.NAV, SettingType.NAV,
onClick = { navigation.onNavigateToBugReport() }, onClick = { navigation.onNavigateToBugReport() },
enabled = MutableStateFlow(true) enabled = MutableStateFlow(true)
), mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let { ), mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Setting( Setting(
ComposableStringFormatter(R.string.managed_by_orgName, it), ComposableStringFormatter(R.string.managed_by_orgName, it),
SettingType.NAV, SettingType.NAV,
onClick = { navigation.onNavigateToManagedBy() }, onClick = { navigation.onNavigateToManagedBy() },
enabled = MutableStateFlow(true) enabled = MutableStateFlow(true)
) )
}, if (BuildConfig.DEBUG) { }, if (BuildConfig.DEBUG) {
Setting( Setting(
titleRes = R.string.mdm_settings, titleRes = R.string.mdm_settings,
SettingType.NAV, SettingType.NAV,
onClick = { navigation.onNavigateToMDMSettings() }, onClick = { navigation.onNavigateToMDMSettings() },
enabled = MutableStateFlow(true) enabled = MutableStateFlow(true)
) )
} else { } else {
null null
} }
) )
} }

@ -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