android: use ktfmt formatting and use scaffold consistently across all views (#217)

* android: use scaffold consistently across all views

Updates tailscale/corp#18202

Updates all the main view to remove the surface containers and replaces them with a Scaffold.  All view now use a common Header element (a TopAppBar with common styling).

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

* android: run ktfmt over all kt, java and xml source files

Updates tailscale/corp#18202

Standardize code formatting using ktfmt default settings.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

* android: update readme for new code formatting guidelines

Updates tailscale/corp#18202

Mandate the use of ktfmt in the default configuration.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

---------

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

@ -47,6 +47,11 @@ If you installed Android Studio the tools may not be in your path. To get the
correct tool path, run `make androidpath` and export the provided path in your correct tool path, run `make androidpath` and export the provided path in your
shell. shell.
#### Code Formatting
The ktmft plugin on the default setting should be used to autoformat all Java, Kotlin
and XML files in Android Studio. Enable "Format on Save".
### Docker ### Docker
If you wish to avoid installing software on your host system, a Docker based development strategy is available, you can build and start a shell with: If you wish to avoid installing software on your host system, a Docker based development strategy is available, you can build and start a shell with:

@ -25,20 +25,20 @@
android:required="false" /> android:required="false" />
<application <application
android:label="Tailscale"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:banner="@drawable/tv_banner"
android:name=".App" android:name=".App"
android:allowBackup="false"> android:allowBackup="false"
android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher"
android:label="Tailscale"
android:roundIcon="@mipmap/ic_launcher_round">
<activity <activity
android:name="MainActivity" android:name="MainActivity"
android:label="@string/app_name"
android:theme="@style/Theme.GioApp"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize" android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTask" android:launchMode="singleTask"
android:exported="true"> android:theme="@style/Theme.GioApp"
android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@ -84,18 +84,18 @@
<service <service
android:name=".IPNService" android:name=".IPNService"
android:permission="android.permission.BIND_VPN_SERVICE" android:exported="false"
android:exported="false"> android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter> <intent-filter>
<action android:name="android.net.VpnService" /> <action android:name="android.net.VpnService" />
</intent-filter> </intent-filter>
</service> </service>
<service <service
android:name=".QuickToggleService" android:name=".QuickToggleService"
android:exported="true"
android:icon="@drawable/ic_tile" android:icon="@drawable/ic_tile"
android:label="@string/tile_name" android:label="@string/tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" /> <action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter> </intent-filter>

@ -33,9 +33,7 @@ import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.provider.Settings; import android.provider.Settings;
import android.util.Log;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.browser.customtabs.CustomTabsIntent; import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat; import androidx.core.app.NotificationManagerCompat;
@ -62,31 +60,44 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
public class App extends Application { public class App extends Application {
private static final String PEER_TAG = "peer";
static final String STATUS_CHANNEL_ID = "tailscale-status"; static final String STATUS_CHANNEL_ID = "tailscale-status";
static final int STATUS_NOTIFICATION_ID = 1; static final int STATUS_NOTIFICATION_ID = 1;
static final String NOTIFY_CHANNEL_ID = "tailscale-notify"; static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
static final int NOTIFY_NOTIFICATION_ID = 2; static final int NOTIFY_NOTIFICATION_ID = 2;
private static final String PEER_TAG = "peer";
private static final String FILE_CHANNEL_ID = "tailscale-files"; private static final String FILE_CHANNEL_ID = "tailscale-files";
private static final int FILE_NOTIFICATION_ID = 3; private static final int FILE_NOTIFICATION_ID = 3;
private static final Handler mainHandler = new Handler(Looper.getMainLooper()); private static final Handler mainHandler = new Handler(Looper.getMainLooper());
static App _application;
private ConnectivityManager connectivityManager;
public DnsConfig dns = new DnsConfig(); public DnsConfig dns = new DnsConfig();
public boolean autoConnect = false;
public boolean vpnReady = false;
private ConnectivityManager connectivityManager;
public DnsConfig getDnsConfigObj() { public static App getApplication() {
return this.dns; return _application;
} }
private static boolean isEmpty(String str) {
return str == null || str.length() == 0;
}
static App _application; static void startActivityForResult(Activity act, Intent intent, int request) {
Fragment f = act.getFragmentManager().findFragmentByTag(PEER_TAG);
f.startActivityForResult(intent, request);
}
public static App getApplication() { static native void onVPNPrepared();
return _application;
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();
public DnsConfig getDnsConfigObj() {
return this.dns;
} }
@Override @Override
@ -112,7 +123,7 @@ public class App extends Application {
@Override @Override
public void onAvailable(Network network) { public void onAvailable(Network network) {
super.onAvailable(network); super.onAvailable(network);
StringBuilder sb = new StringBuilder(""); StringBuilder sb = new StringBuilder();
LinkProperties linkProperties = connectivityManager.getLinkProperties(network); LinkProperties linkProperties = connectivityManager.getLinkProperties(network);
List<InetAddress> dnsList = linkProperties.getDnsServers(); List<InetAddress> dnsList = linkProperties.getDnsServers();
for (InetAddress ip : dnsList) { for (InetAddress ip : dnsList) {
@ -136,7 +147,6 @@ public class App extends Application {
}); });
} }
public void startVPN() { public void startVPN() {
Intent intent = new Intent(this, IPNService.class); Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_REQUEST_VPN); intent.setAction(IPNService.ACTION_REQUEST_VPN);
@ -175,9 +185,6 @@ public class App extends Application {
); );
} }
public boolean autoConnect = false;
public boolean vpnReady = false;
void setTileReady(boolean ready) { void setTileReady(boolean ready) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return; return;
@ -229,10 +236,6 @@ public class App extends Application {
return null; return null;
} }
private static boolean isEmpty(String str) {
return str == null || str.length() == 0;
}
// attachPeer adds a Peer fragment for tracking the Activity // attachPeer adds a Peer fragment for tracking the Activity
// lifecycle. // lifecycle.
void attachPeer(Activity act) { void attachPeer(Activity act) {
@ -265,11 +268,6 @@ public class App extends Application {
}); });
} }
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) { void showURL(Activity act, String url) {
act.runOnUiThread(new Runnable() { act.runOnUiThread(new Runnable() {
@Override @Override
@ -363,14 +361,6 @@ public class App extends Application {
nm.createNotificationChannel(channel); 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 // Returns details of the interfaces in the system, encoded as a single string for ease
// of JNI transfer over to the Go environment. // of JNI transfer over to the Go environment.
// //
@ -395,7 +385,7 @@ public class App extends Application {
return ""; return "";
} }
StringBuilder sb = new StringBuilder(""); StringBuilder sb = new StringBuilder();
for (NetworkInterface nif : interfaces) { for (NetworkInterface nif : interfaces) {
try { try {
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants // Android doesn't have a supportsBroadcast() but the Go net.Interface wants

@ -6,16 +6,6 @@ package com.tailscale.ipn;
import android.net.NetworkCapabilities; import android.net.NetworkCapabilities;
import android.net.NetworkRequest; import android.net.NetworkRequest;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
// Tailscale DNS Config retrieval // Tailscale DNS Config retrieval
// //
// Tailscale's DNS support can either override the local DNS servers with a set of servers // Tailscale's DNS support can either override the local DNS servers with a set of servers
@ -29,35 +19,35 @@ import java.util.concurrent.locks.ReentrantLock;
// from Wi-Fi to LTE, we want the DNS servers received from LTE. // from Wi-Fi to LTE, we want the DNS servers received from LTE.
public class DnsConfig { public class DnsConfig {
private String dnsConfigs; private String dnsConfigs;
// getDnsConfigAsString returns the current DNS configuration as a multiline string: // getDnsConfigAsString returns the current DNS configuration as a multiline string:
// line[0] DNS server addresses separated by spaces // line[0] DNS server addresses separated by spaces
// line[1] search domains separated by spaces // line[1] search domains separated by spaces
// //
// For example: // For example:
// 8.8.8.8 8.8.4.4 // 8.8.8.8 8.8.4.4
// example.com // example.com
// //
// an empty string means the current DNS configuration could not be retrieved. // an empty string means the current DNS configuration could not be retrieved.
String getDnsConfigAsString() { String getDnsConfigAsString() {
return getDnsConfigs().trim(); return getDnsConfigs().trim();
} }
private String getDnsConfigs(){ private String getDnsConfigs() {
synchronized(this) { synchronized (this) {
return this.dnsConfigs; return this.dnsConfigs;
} }
} }
void updateDNSFromNetwork(String dnsConfigs){ void updateDNSFromNetwork(String dnsConfigs) {
synchronized(this) { synchronized (this) {
this.dnsConfigs = dnsConfigs; this.dnsConfigs = dnsConfigs;
} }
} }
NetworkRequest getDNSConfigNetworkRequest(){ NetworkRequest getDNSConfigNetworkRequest() {
// Request networks that are able to reach the Internet. // Request networks that are able to reach the Internet.
return new NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build(); return new NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build();
} }
} }

@ -4,129 +4,135 @@
package com.tailscale.ipn; package com.tailscale.ipn;
import android.app.Activity; import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.res.Configuration;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.provider.OpenableColumns; import android.provider.OpenableColumns;
import android.net.Uri;
import android.content.pm.PackageManager;
import java.util.List;
import java.util.ArrayList;
import org.gioui.GioView; import org.gioui.GioView;
import java.util.List;
public final class IPNActivity extends Activity { public final class IPNActivity extends Activity {
final static int WRITE_STORAGE_RESULT = 1000; final static int WRITE_STORAGE_RESULT = 1000;
private GioView view; private GioView view;
@Override public void onCreate(Bundle state) { @Override
super.onCreate(state); public void onCreate(Bundle state) {
view = new GioView(this); super.onCreate(state);
setContentView(view); view = new GioView(this);
handleIntent(); setContentView(view);
} handleIntent();
}
@Override public void onNewIntent(Intent i) {
setIntent(i); @Override
handleIntent(); public void onNewIntent(Intent i) {
} setIntent(i);
handleIntent();
private void handleIntent() { }
Intent it = getIntent();
String act = it.getAction(); private void handleIntent() {
String[] texts; Intent it = getIntent();
Uri[] uris; String act = it.getAction();
if (Intent.ACTION_SEND.equals(act)) { String[] texts;
uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)}; Uri[] uris;
texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)}; if (Intent.ACTION_SEND.equals(act)) {
} else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) { uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM); texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
uris = extraUris.toArray(new Uri[0]); } else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
texts = new String[uris.length]; List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
} else { uris = extraUris.toArray(new Uri[0]);
return; texts = new String[uris.length];
} } else {
String mime = it.getType(); return;
int nitems = uris.length; }
String[] items = new String[nitems]; String mime = it.getType();
String[] mimes = new String[nitems]; int nitems = uris.length;
int[] types = new int[nitems]; String[] items = new String[nitems];
String[] names = new String[nitems]; String[] mimes = new String[nitems];
long[] sizes = new long[nitems]; int[] types = new int[nitems];
int nfiles = 0; String[] names = new String[nitems];
for (int i = 0; i < uris.length; i++) { long[] sizes = new long[nitems];
String text = texts[i]; int nfiles = 0;
Uri uri = uris[i]; for (int i = 0; i < uris.length; i++) {
if (text != null) { String text = texts[i];
types[nfiles] = 1; // FileTypeText Uri uri = uris[i];
names[nfiles] = "file.txt"; if (text != null) {
mimes[nfiles] = mime; types[nfiles] = 1; // FileTypeText
items[nfiles] = text; names[nfiles] = "file.txt";
// Determined by len(text) in Go to eliminate UTF-8 encoding differences. mimes[nfiles] = mime;
sizes[nfiles] = 0; items[nfiles] = text;
nfiles++; // Determined by len(text) in Go to eliminate UTF-8 encoding differences.
} else if (uri != null) { sizes[nfiles] = 0;
Cursor c = getContentResolver().query(uri, null, null, null, null); nfiles++;
if (c == null) { } else if (uri != null) {
// Ignore files we have no permission to access. Cursor c = getContentResolver().query(uri, null, null, null, null);
continue; if (c == null) {
} // Ignore files we have no permission to access.
int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME); continue;
int sizeCol = c.getColumnIndex(OpenableColumns.SIZE); }
c.moveToFirst(); int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
String name = c.getString(nameCol); int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
long size = c.getLong(sizeCol); c.moveToFirst();
types[nfiles] = 2; // FileTypeURI String name = c.getString(nameCol);
mimes[nfiles] = mime; long size = c.getLong(sizeCol);
items[nfiles] = uri.toString(); types[nfiles] = 2; // FileTypeURI
names[nfiles] = name; mimes[nfiles] = mime;
sizes[nfiles] = size; items[nfiles] = uri.toString();
nfiles++; names[nfiles] = name;
} sizes[nfiles] = size;
} nfiles++;
App.onShareIntent(nfiles, types, mimes, items, names, sizes); }
} }
App.onShareIntent(nfiles, types, mimes, items, names, sizes);
@Override public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) { }
switch (reqCode) {
case WRITE_STORAGE_RESULT: @Override
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) { public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
App.onWriteStorageGranted(); if (reqCode == WRITE_STORAGE_RESULT) {
} if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
} App.onWriteStorageGranted();
} }
}
@Override public void onDestroy() { }
view.destroy();
super.onDestroy(); @Override
} public void onDestroy() {
view.destroy();
@Override public void onStart() { super.onDestroy();
super.onStart(); }
view.start();
} @Override
public void onStart() {
@Override public void onStop() { super.onStart();
view.stop(); view.start();
super.onStop(); }
}
@Override
@Override public void onConfigurationChanged(Configuration c) { public void onStop() {
super.onConfigurationChanged(c); view.stop();
view.configurationChanged(); super.onStop();
} }
@Override public void onLowMemory() { @Override
super.onLowMemory(); public void onConfigurationChanged(Configuration c) {
view.onLowMemory(); super.onConfigurationChanged(c);
} view.configurationChanged();
}
@Override public void onBackPressed() {
if (!view.backPressed()) @Override
super.onBackPressed(); public void onLowMemory() {
} super.onLowMemory();
GioView.onLowMemory();
}
@Override
public void onBackPressed() {
if (!view.backPressed())
super.onBackPressed();
}
} }

@ -7,8 +7,8 @@ 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.OneTimeWorkRequest; import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import java.util.Objects; import java.util.Objects;

@ -3,136 +3,134 @@
package com.tailscale.ipn; package com.tailscale.ipn;
import android.util.Log;
import android.os.Build;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.VpnService; import android.net.VpnService;
import android.os.Build;
import android.system.OsConstants; import android.system.OsConstants;
import androidx.work.WorkManager;
import androidx.work.OneTimeWorkRequest;
import org.gioui.GioActivity;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat; import androidx.core.app.NotificationManagerCompat;
public class IPNService extends VpnService { public class IPNService extends VpnService {
public static final String ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN"; public static final String ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN";
public static final String ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"; public static final String ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN";
@Override public int onStartCommand(Intent intent, int flags, int startId) { @Override
if (intent != null && ACTION_STOP_VPN.equals(intent.getAction())) { public int onStartCommand(Intent intent, int flags, int startId) {
((App)getApplicationContext()).autoConnect = false; if (intent != null && ACTION_STOP_VPN.equals(intent.getAction())) {
close(); ((App) getApplicationContext()).autoConnect = false;
return START_NOT_STICKY; close();
} return START_NOT_STICKY;
if (intent != null && "android.net.VpnService".equals(intent.getAction())) { }
// Start VPN and connect to it due to Always-on VPN if (intent != null && "android.net.VpnService".equals(intent.getAction())) {
Intent i = new Intent(IPNReceiver.INTENT_CONNECT_VPN); // Start VPN and connect to it due to Always-on VPN
i.setPackage(getPackageName()); Intent i = new Intent(IPNReceiver.INTENT_CONNECT_VPN);
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class); i.setPackage(getPackageName());
sendBroadcast(i); i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
requestVPN(); sendBroadcast(i);
connect(); requestVPN();
return START_STICKY; connect();
} return START_STICKY;
requestVPN(); }
App app = ((App)getApplicationContext()); requestVPN();
if (app.vpnReady && app.autoConnect) { App app = ((App) getApplicationContext());
connect(); if (app.vpnReady && app.autoConnect) {
} connect();
return START_STICKY; }
} return START_STICKY;
}
private void close() {
stopForeground(true); private void close() {
disconnect(); stopForeground(true);
} disconnect();
}
@Override public void onDestroy() {
close(); @Override
super.onDestroy(); public void onDestroy() {
} close();
super.onDestroy();
@Override public void onRevoke() { }
close();
super.onRevoke(); @Override
} public void onRevoke() {
close();
private PendingIntent configIntent() { super.onRevoke();
return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class), }
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
} private PendingIntent configIntent() {
return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class),
private void disallowApp(VpnService.Builder b, String name) { PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
try { }
b.addDisallowedApplication(name);
} catch (PackageManager.NameNotFoundException e) { private void disallowApp(VpnService.Builder b, String name) {
return; try {
} b.addDisallowedApplication(name);
} } catch (PackageManager.NameNotFoundException e) {
}
protected VpnService.Builder newBuilder() { }
VpnService.Builder b = new VpnService.Builder()
.setConfigureIntent(configIntent()) protected VpnService.Builder newBuilder() {
.allowFamily(OsConstants.AF_INET) VpnService.Builder b = new VpnService.Builder()
.allowFamily(OsConstants.AF_INET6); .setConfigureIntent(configIntent())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) .allowFamily(OsConstants.AF_INET)
b.setMetered(false); // Inherit the metered status from the underlying networks. .allowFamily(OsConstants.AF_INET6);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
b.setUnderlyingNetworks(null); // Use all available networks. b.setMetered(false); // Inherit the metered status from the underlying networks.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322 b.setUnderlyingNetworks(null); // Use all available networks.
this.disallowApp(b, "com.google.android.apps.messaging");
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
// Stadia https://github.com/tailscale/tailscale/issues/3460 this.disallowApp(b, "com.google.android.apps.messaging");
this.disallowApp(b, "com.google.stadia.android");
// Stadia https://github.com/tailscale/tailscale/issues/3460
// Android Auto https://github.com/tailscale/tailscale/issues/3828 this.disallowApp(b, "com.google.stadia.android");
this.disallowApp(b, "com.google.android.projection.gearhead");
// Android Auto https://github.com/tailscale/tailscale/issues/3828
// GoPro https://github.com/tailscale/tailscale/issues/2554 this.disallowApp(b, "com.google.android.projection.gearhead");
this.disallowApp(b, "com.gopro.smarty");
// GoPro https://github.com/tailscale/tailscale/issues/2554
// Sonos https://github.com/tailscale/tailscale/issues/2548 this.disallowApp(b, "com.gopro.smarty");
this.disallowApp(b, "com.sonos.acr");
this.disallowApp(b, "com.sonos.acr2"); // Sonos https://github.com/tailscale/tailscale/issues/2548
this.disallowApp(b, "com.sonos.acr");
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636 this.disallowApp(b, "com.sonos.acr2");
this.disallowApp(b, "com.google.android.apps.chromecast.app");
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
return b; this.disallowApp(b, "com.google.android.apps.chromecast.app");
}
return b;
public void notify(String title, String message) { }
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) public void notify(String title, String message) {
.setContentTitle(title) NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
.setContentText(message) .setSmallIcon(R.drawable.ic_notification)
.setContentIntent(configIntent()) .setContentTitle(title)
.setAutoCancel(true) .setContentText(message)
.setOnlyAlertOnce(true) .setContentIntent(configIntent())
.setPriority(NotificationCompat.PRIORITY_DEFAULT); .setAutoCancel(true)
.setOnlyAlertOnce(true)
NotificationManagerCompat nm = NotificationManagerCompat.from(this); .setPriority(NotificationCompat.PRIORITY_DEFAULT);
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build());
} NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build());
public void updateStatusNotification(String title, String message) { }
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) public void updateStatusNotification(String title, String message) {
.setContentTitle(title) NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
.setContentText(message) .setSmallIcon(R.drawable.ic_notification)
.setContentIntent(configIntent()) .setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_LOW); .setContentText(message)
.setContentIntent(configIntent())
startForeground(App.STATUS_NOTIFICATION_ID, builder.build()); .setPriority(NotificationCompat.PRIORITY_LOW);
}
startForeground(App.STATUS_NOTIFICATION_ID, builder.build());
private native void requestVPN(); }
private native void disconnect(); private native void requestVPN();
private native void connect();
private native void disconnect();
private native void connect();
} }

@ -43,158 +43,131 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var notifierScope: CoroutineScope? = null private var notifierScope: CoroutineScope? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
AppTheme { AppTheme {
val navController = rememberNavController() val navController = rememberNavController()
NavHost(navController = navController, startDestination = "main") { NavHost(navController = navController, startDestination = "main") {
val mainViewNav = val mainViewNav =
MainViewNavigation( MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") }, onNavigateToSettings = { navController.navigate("settings") },
onNavigateToPeerDetails = { onNavigateToPeerDetails = {
navController.navigate("peerDetails/${it.StableID}") navController.navigate("peerDetails/${it.StableID}")
}, },
onNavigateToExitNodes = { navController.navigate("exitNodes") }, onNavigateToExitNodes = { navController.navigate("exitNodes") },
) )
val settingsNav = val settingsNav =
SettingsNav( SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") }, onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") }, onNavigateToAbout = { navController.navigate("about") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") }, onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") }, onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
) )
val exitNodePickerNav = ExitNodePickerNav(onNavigateHome = { val exitNodePickerNav =
navController.popBackStack( ExitNodePickerNav(
route = "main", inclusive = false onNavigateHome = {
) navController.popBackStack(route = "main", inclusive = false)
}, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }) },
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") })
composable("main") {
MainView(navigation = mainViewNav) composable("main") { MainView(navigation = mainViewNav) }
} composable("settings") { Settings(settingsNav) }
composable("settings") { navigation(startDestination = "list", route = "exitNodes") {
Settings(settingsNav) composable("list") { ExitNodePicker(exitNodePickerNav) }
} composable(
navigation(startDestination = "list", route = "exitNodes") { "mullvad/{countryCode}",
composable("list") { arguments = listOf(navArgument("countryCode") { type = NavType.StringType })) {
ExitNodePicker(exitNodePickerNav) MullvadExitNodePicker(
} it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
composable(
"mullvad/{countryCode}", arguments = listOf(navArgument("countryCode") {
type = NavType.StringType
})
) {
MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav
)
}
}
composable(
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })
) {
PeerDetails(it.arguments?.getString("nodeId") ?: "")
}
composable("bugReport") {
BugReportView()
}
composable("about") {
AboutView()
}
composable("mdmSettings") {
MDMSettingsDebugView()
}
composable("managedBy") {
ManagedByView()
}
composable("userSwitcher") {
UserSwitcherView()
}
}
}
}
}
init {
// Watch the model's browseToURL and launch the browser when it changes
// This will trigger the login flow
lifecycleScope.launch {
Notifier.browseToURL.collect { url ->
url?.let {
Dispatchers.Main.run {
login(it)
}
} }
} }
composable(
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
PeerDetails(it.arguments?.getString("nodeId") ?: "")
}
composable("bugReport") { BugReportView() }
composable("about") { AboutView() }
composable("mdmSettings") { MDMSettingsDebugView() }
composable("managedBy") { ManagedByView() }
composable("userSwitcher") { UserSwitcherView() }
} }
}
} }
}
private fun login(url: String) { init {
// (jonathan) TODO: This is functional, but the navigation doesn't quite work // Watch the model's browseToURL and launch the browser when it changes
// as expected. There's probably a better built in way to do this. This will // This will trigger the login flow
// unblock in dev for the time being though. lifecycleScope.launch {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) Notifier.browseToURL.collect { url -> url?.let { Dispatchers.Main.run { login(it) } } }
startActivity(browserIntent)
}
override fun onResume() {
super.onResume()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager))
}
override fun onStart() {
super.onStart()
val scope = CoroutineScope(Dispatchers.IO)
notifierScope = scope
Notifier.start(lifecycleScope)
// (jonathan) TODO: Requesting VPN permissions onStart is a bit aggressive. This should
// be done when the user initiall starts the VPN
requestVpnPermission()
}
override fun onStop() {
Notifier.stop()
super.onStop()
val restrictionsManager = this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager))
} }
}
private fun requestVpnPermission() { private fun login(url: String) {
val vpnIntent = VpnService.prepare(this) // (jonathan) TODO: This is functional, but the navigation doesn't quite work
if (vpnIntent != null) { // as expected. There's probably a better built in way to do this. This will
val contract = VpnPermissionContract() // unblock in dev for the time being though.
registerForActivityResult(contract) { granted -> val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
Notifier.vpnPermissionGranted.set(granted) startActivity(browserIntent)
if (granted) { }
Log.i("VPN", "VPN permission granted")
} else { override fun onResume() {
Log.i("VPN", "VPN permission not granted") super.onResume()
} val restrictionsManager =
} this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager))
}
override fun onStart() {
super.onStart()
val scope = CoroutineScope(Dispatchers.IO)
notifierScope = scope
Notifier.start(lifecycleScope)
// (jonathan) TODO: Requesting VPN permissions onStart is a bit aggressive. This should
// be done when the user initiall starts the VPN
requestVpnPermission()
}
override fun onStop() {
Notifier.stop()
super.onStop()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager))
}
private fun requestVpnPermission() {
val vpnIntent = VpnService.prepare(this)
if (vpnIntent != null) {
val contract = VpnPermissionContract()
registerForActivityResult(contract) { granted ->
Notifier.vpnPermissionGranted.set(granted)
if (granted) {
Log.i("VPN", "VPN permission granted")
} else {
Log.i("VPN", "VPN permission not granted")
} }
}
} }
}
} }
class VpnPermissionContract : ActivityResultContract<Unit, Boolean>() { class VpnPermissionContract : ActivityResultContract<Unit, Boolean>() {
override fun createIntent(context: Context, input: Unit): Intent { override fun createIntent(context: Context, input: Unit): Intent {
return VpnService.prepare(context) ?: Intent() return VpnService.prepare(context) ?: Intent()
} }
override fun parseResult(resultCode: Int, intent: Intent?): Boolean { override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == Activity.RESULT_OK return resultCode == Activity.RESULT_OK
} }
} }

@ -8,9 +8,10 @@ import android.app.Fragment;
import android.content.Intent; import android.content.Intent;
public class Peer extends Fragment { public class Peer extends Fragment {
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) { private static native void onActivityResult0(Activity act, int reqCode, int resCode);
onActivityResult0(getActivity(), requestCode, resultCode);
}
private static native void onActivityResult0(Activity act, int reqCode, int resCode); @Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
onActivityResult0(getActivity(), requestCode, resultCode);
}
} }

@ -9,79 +9,82 @@ import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService; import android.service.quicksettings.TileService;
public class QuickToggleService extends TileService { public class QuickToggleService extends TileService {
// lock protects the static fields below it. // lock protects the static fields below it.
private static Object lock = new Object(); private static final Object lock = new Object();
// Active tracks whether the VPN is active. // Active tracks whether the VPN is active.
private static boolean active; private static boolean active;
// Ready tracks whether the tailscale backend is // Ready tracks whether the tailscale backend is
// ready to switch on/off. // ready to switch on/off.
private static boolean ready; private static boolean ready;
// currentTile tracks getQsTile while service is listening. // currentTile tracks getQsTile while service is listening.
private static Tile currentTile; private static Tile currentTile;
@Override public void onStartListening() { private static void updateTile() {
synchronized (lock) { Tile t;
currentTile = getQsTile(); boolean act;
} synchronized (lock) {
updateTile(); t = currentTile;
} act = active && ready;
}
if (t == null) {
return;
}
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
t.updateTile();
}
@Override public void onStopListening() { static void setReady(Context ctx, boolean rdy) {
synchronized (lock) { synchronized (lock) {
currentTile = null; ready = rdy;
} }
} updateTile();
}
@Override public void onClick() { static void setStatus(Context ctx, boolean act) {
boolean r; synchronized (lock) {
synchronized (lock) { active = act;
r = ready; }
} updateTile();
if (r) { }
onTileClick();
} else {
// Start main activity.
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
startActivityAndCollapse(i);
}
}
private static void updateTile() { @Override
Tile t; public void onStartListening() {
boolean act; synchronized (lock) {
synchronized (lock) { currentTile = getQsTile();
t = currentTile; }
act = active && ready; updateTile();
} }
if (t == null) {
return;
}
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
t.updateTile();
}
static void setReady(Context ctx, boolean rdy) { @Override
synchronized (lock) { public void onStopListening() {
ready = rdy; synchronized (lock) {
} currentTile = null;
updateTile(); }
} }
static void setStatus(Context ctx, boolean act) { @Override
synchronized (lock) { public void onClick() {
active = act; boolean r;
} synchronized (lock) {
updateTile(); r = ready;
} }
if (r) {
onTileClick();
} else {
// Start main activity.
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
startActivityAndCollapse(i);
}
}
private void onTileClick() { private void onTileClick() {
boolean act; boolean act;
synchronized (lock) { synchronized (lock) {
act = active && ready; act = active && ready;
} }
Intent i = new Intent(act ? IPNReceiver.INTENT_DISCONNECT_VPN : IPNReceiver.INTENT_CONNECT_VPN); Intent i = new Intent(act ? IPNReceiver.INTENT_DISCONNECT_VPN : IPNReceiver.INTENT_CONNECT_VPN);
i.setPackage(getPackageName()); i.setPackage(getPackageName());
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class); i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
sendBroadcast(i); sendBroadcast(i);
} }
} }

@ -4,13 +4,13 @@
package com.tailscale.ipn; package com.tailscale.ipn;
import android.app.Notification; import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager; import android.app.NotificationManager;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.VpnService; import android.net.VpnService;
import android.os.Build; import android.os.Build;
import androidx.work.Worker; import androidx.work.Worker;
import androidx.work.WorkerParameters; import androidx.work.WorkerParameters;
@ -22,8 +22,9 @@ public final class StartVPNWorker extends Worker {
super(appContext, workerParams); super(appContext, workerParams);
} }
@Override public Result doWork() { @Override
App app = ((App)getApplicationContext()); public Result doWork() {
App app = ((App) getApplicationContext());
// We will start the VPN from the background // We will start the VPN from the background
app.autoConnect = true; app.autoConnect = true;

@ -3,8 +3,9 @@
package com.tailscale.ipn; package com.tailscale.ipn;
import androidx.work.Worker;
import android.content.Context; import android.content.Context;
import androidx.work.Worker;
import androidx.work.WorkerParameters; import androidx.work.WorkerParameters;
public final class StopVPNWorker extends Worker { public final class StopVPNWorker extends Worker {
@ -15,7 +16,8 @@ public final class StopVPNWorker extends Worker {
super(appContext, workerParams); super(appContext, workerParams);
} }
@Override public Result doWork() { @Override
public Result doWork() {
disconnect(); disconnect();
return Result.success(); return Result.success();
} }

@ -7,63 +7,63 @@ import android.content.RestrictionsManager
import com.tailscale.ipn.App import com.tailscale.ipn.App
class MDMSettings(private val restrictionsManager: RestrictionsManager? = null) { class MDMSettings(private val restrictionsManager: RestrictionsManager? = null) {
fun get(setting: BooleanSetting): Boolean { fun get(setting: BooleanSetting): Boolean {
restrictionsManager?.let { restrictionsManager?.let {
if (it.applicationRestrictions.containsKey(setting.key)) { if (it.applicationRestrictions.containsKey(setting.key)) {
return it.applicationRestrictions.getBoolean(setting.key) return it.applicationRestrictions.getBoolean(setting.key)
} }
}
return App.getApplication().encryptedPrefs.getBoolean(setting.key, false)
} }
return App.getApplication().encryptedPrefs.getBoolean(setting.key, false)
}
fun get(setting: StringSetting): String? { fun get(setting: StringSetting): String? {
return restrictionsManager?.applicationRestrictions?.getString(setting.key) return restrictionsManager?.applicationRestrictions?.getString(setting.key)
?: App.getApplication().encryptedPrefs.getString(setting.key, null) ?: App.getApplication().encryptedPrefs.getString(setting.key, null)
} }
fun get(setting: AlwaysNeverUserDecidesSetting): AlwaysNeverUserDecidesValue {
val storedString: String =
restrictionsManager?.applicationRestrictions?.getString(setting.key)
?: App.getApplication().encryptedPrefs.getString(setting.key, null)
?: "user-decides"
return when (storedString) {
"always" -> {
AlwaysNeverUserDecidesValue.Always
}
"never" -> { fun get(setting: AlwaysNeverUserDecidesSetting): AlwaysNeverUserDecidesValue {
AlwaysNeverUserDecidesValue.Never val storedString: String =
} restrictionsManager?.applicationRestrictions?.getString(setting.key)
?: App.getApplication().encryptedPrefs.getString(setting.key, null)
else -> { ?: "user-decides"
AlwaysNeverUserDecidesValue.UserDecides return when (storedString) {
} "always" -> {
} AlwaysNeverUserDecidesValue.Always
}
"never" -> {
AlwaysNeverUserDecidesValue.Never
}
else -> {
AlwaysNeverUserDecidesValue.UserDecides
}
} }
}
fun get(setting: ShowHideSetting): ShowHideValue { fun get(setting: ShowHideSetting): ShowHideValue {
val storedString: String = val storedString: String =
restrictionsManager?.applicationRestrictions?.getString(setting.key) restrictionsManager?.applicationRestrictions?.getString(setting.key)
?: App.getApplication().encryptedPrefs.getString(setting.key, null) ?: App.getApplication().encryptedPrefs.getString(setting.key, null)
?: "show" ?: "show"
return when (storedString) { return when (storedString) {
"hide" -> { "hide" -> {
ShowHideValue.Hide ShowHideValue.Hide
} }
else -> {
else -> { ShowHideValue.Show
ShowHideValue.Show }
}
}
} }
}
fun get(setting: StringArraySetting): Array<String>? { fun get(setting: StringArraySetting): Array<String>? {
restrictionsManager?.let { restrictionsManager?.let {
if (it.applicationRestrictions.containsKey(setting.key)) { if (it.applicationRestrictions.containsKey(setting.key)) {
return it.applicationRestrictions.getStringArray(setting.key) return it.applicationRestrictions.getStringArray(setting.key)
} }
}
return App.getApplication().encryptedPrefs.getStringSet(setting.key, HashSet<String>())
?.toTypedArray()?.sortedArray()
} }
return App.getApplication()
.encryptedPrefs
.getStringSet(setting.key, HashSet<String>())
?.toTypedArray()
?.sortedArray()
}
} }

@ -4,56 +4,57 @@
package com.tailscale.ipn.mdm package com.tailscale.ipn.mdm
enum class BooleanSetting(val key: String, val localizedTitle: String) { enum class BooleanSetting(val key: String, val localizedTitle: String) {
ForceEnabled("ForceEnabled", "Force Enabled Connection Toggle") ForceEnabled("ForceEnabled", "Force Enabled Connection Toggle")
} }
enum class StringSetting(val key: String, val localizedTitle: String) { enum class StringSetting(val key: String, val localizedTitle: String) {
ExitNodeID("ExitNodeID", "Forced Exit Node: Stable ID"), ExitNodeID("ExitNodeID", "Forced Exit Node: Stable ID"),
KeyExpirationNotice("KeyExpirationNotice", "Key Expiration Notice Period"), KeyExpirationNotice("KeyExpirationNotice", "Key Expiration Notice Period"),
LoginURL("LoginURL", "Custom control server URL"), LoginURL("LoginURL", "Custom control server URL"),
ManagedByCaption("ManagedByCaption", "Managed By - Caption"), ManagedByCaption("ManagedByCaption", "Managed By - Caption"),
ManagedByOrganizationName("ManagedByOrganizationName", "Managed By - Organization Name"), ManagedByOrganizationName("ManagedByOrganizationName", "Managed By - Organization Name"),
ManagedByURL("ManagedByURL", "Managed By - Support URL"), ManagedByURL("ManagedByURL", "Managed By - Support URL"),
Tailnet("Tailnet", "Recommended/Required Tailnet Name"), Tailnet("Tailnet", "Recommended/Required Tailnet Name"),
} }
enum class StringArraySetting(val key: String, val localizedTitle: String) { enum class StringArraySetting(val key: String, val localizedTitle: String) {
HiddenNetworkDevices("HiddenNetworkDevices", "Hidden Network Device Categories") HiddenNetworkDevices("HiddenNetworkDevices", "Hidden Network Device Categories")
} }
// A setting representing a String value which is set to either `always`, `never` or `user-decides`. // A setting representing a String value which is set to either `always`, `never` or `user-decides`.
enum class AlwaysNeverUserDecidesSetting(val key: String, val localizedTitle: String) { enum class AlwaysNeverUserDecidesSetting(val key: String, val localizedTitle: String) {
AllowIncomingConnections("AllowIncomingConnections", "Allow Incoming Connections"), AllowIncomingConnections("AllowIncomingConnections", "Allow Incoming Connections"),
DetectThirdPartyAppConflicts("DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps"), DetectThirdPartyAppConflicts(
ExitNodeAllowLANAccess("ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node"), "DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps"),
PostureChecking("PostureChecking", "Enable Posture Checking"), ExitNodeAllowLANAccess("ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node"),
UseTailscaleDNSSettings("UseTailscaleDNSSettings", "Use Tailscale DNS Settings"), PostureChecking("PostureChecking", "Enable Posture Checking"),
UseTailscaleSubnets("UseTailscaleSubnets", "Use Tailscale Subnets") UseTailscaleDNSSettings("UseTailscaleDNSSettings", "Use Tailscale DNS Settings"),
UseTailscaleSubnets("UseTailscaleSubnets", "Use Tailscale Subnets")
} }
enum class AlwaysNeverUserDecidesValue(val value: String) { enum class AlwaysNeverUserDecidesValue(val value: String) {
Always("always"), Always("always"),
Never("never"), Never("never"),
UserDecides("user-decides") UserDecides("user-decides")
} }
// A setting representing a String value which is set to either `show` or `hide`. // A setting representing a String value which is set to either `show` or `hide`.
enum class ShowHideSetting(val key: String, val localizedTitle: String) { enum class ShowHideSetting(val key: String, val localizedTitle: String) {
ExitNodesPicker("ExitNodesPicker", "Exit Nodes Picker"), ExitNodesPicker("ExitNodesPicker", "Exit Nodes Picker"),
ManageTailnetLock("ManageTailnetLock", "“Manage Tailnet lock” menu item"), ManageTailnetLock("ManageTailnetLock", "“Manage Tailnet lock” menu item"),
ResetToDefaults("ResetToDefaults", "“Reset to Defaults” menu item"), ResetToDefaults("ResetToDefaults", "“Reset to Defaults” menu item"),
RunExitNode("RunExitNode", "Run as Exit Node"), RunExitNode("RunExitNode", "Run as Exit Node"),
TestMenu("TestMenu", "Show Debug Menu"), TestMenu("TestMenu", "Show Debug Menu"),
UpdateMenu("UpdateMenu", "“Update Available” menu item"), UpdateMenu("UpdateMenu", "“Update Available” menu item"),
} }
enum class ShowHideValue(val value: String) { enum class ShowHideValue(val value: String) {
Show("show"), Show("show"),
Hide("hide") Hide("hide")
} }
enum class NetworkDevices(val value: String) { enum class NetworkDevices(val value: String) {
currentUser("current-user"), currentUser("current-user"),
otherUsers("other-users"), otherUsers("other-users"),
taggedDevices("tagged-devices"), taggedDevices("tagged-devices"),
} }

@ -4,23 +4,24 @@
package com.tailscale.ipn.ui package com.tailscale.ipn.ui
object Links { object Links {
const val DEFAULT_CONTROL_URL = "https://controlplane.tailscale.com" const val DEFAULT_CONTROL_URL = "https://controlplane.tailscale.com"
const val SERVER_URL = "https://login.tailscale.com" const val SERVER_URL = "https://login.tailscale.com"
const val ADMIN_URL = SERVER_URL + "/admin" const val ADMIN_URL = SERVER_URL + "/admin"
const val SIGNIN_URL = "https://tailscale.com/login" const val SIGNIN_URL = "https://tailscale.com/login"
const val PRIVACY_POLICY_URL = "https://tailscale.com/privacy-policy/" const val PRIVACY_POLICY_URL = "https://tailscale.com/privacy-policy/"
const val TERMS_URL = "https://tailscale.com/terms" const val TERMS_URL = "https://tailscale.com/terms"
const val DOCS_URL = "https://tailscale.com/kb/" const val DOCS_URL = "https://tailscale.com/kb/"
const val START_GUIDE_URL = "https://tailscale.com/kb/1017/install/" const val START_GUIDE_URL = "https://tailscale.com/kb/1017/install/"
const val LICENSES_URL = "https://tailscale.com/licenses/android" const val LICENSES_URL = "https://tailscale.com/licenses/android"
const val DELETE_ACCOUNT_URL = "https://login.tailscale.com/login?next_url=%2Fadmin%2Fsettings%2Fgeneral" const val DELETE_ACCOUNT_URL =
const val TAILNET_LOCK_KB_URL = "https://tailscale.com/kb/1226/tailnet-lock/" "https://login.tailscale.com/login?next_url=%2Fadmin%2Fsettings%2Fgeneral"
const val KEY_EXPIRY_KB_URL = "https://tailscale.com/kb/1028/key-expiry/" const val TAILNET_LOCK_KB_URL = "https://tailscale.com/kb/1226/tailnet-lock/"
const val INSTALL_TAILSCALE_KB_URL = "https://tailscale.com/kb/installation/" const val KEY_EXPIRY_KB_URL = "https://tailscale.com/kb/1028/key-expiry/"
const val INSTALL_UNSTABLE_KB_URL = "https://tailscale.com/kb/1083/install-unstable" const val INSTALL_TAILSCALE_KB_URL = "https://tailscale.com/kb/installation/"
const val MAGICDNS_KB_URL = "https://tailscale.com/kb/1081/magicdns" const val INSTALL_UNSTABLE_KB_URL = "https://tailscale.com/kb/1083/install-unstable"
const val TROUBLESHOOTING_KB_URL = "https://tailscale.com/kb/1023/troubleshooting" const val MAGICDNS_KB_URL = "https://tailscale.com/kb/1081/magicdns"
const val SUPPORT_URL = "https://tailscale.com/contact/support#support-form" const val TROUBLESHOOTING_KB_URL = "https://tailscale.com/kb/1023/troubleshooting"
const val TAILDROP_KB_URL = "https://tailscale.com/kb/1106/taildrop" const val SUPPORT_URL = "https://tailscale.com/contact/support#support-form"
const val TAILFS_KB_URL = "https://tailscale.com/kb/1106/taildrop" const val TAILDROP_KB_URL = "https://tailscale.com/kb/1106/taildrop"
const val TAILFS_KB_URL = "https://tailscale.com/kb/1106/taildrop"
} }

@ -22,30 +22,32 @@ import kotlin.reflect.KType
import kotlin.reflect.typeOf import kotlin.reflect.typeOf
private object Endpoint { private object Endpoint {
const val DEBUG = "debug" const val DEBUG = "debug"
const val DEBUG_LOG = "debug-log" const val DEBUG_LOG = "debug-log"
const val BUG_REPORT = "bugreport" const val BUG_REPORT = "bugreport"
const val PREFS = "prefs" const val PREFS = "prefs"
const val FILE_TARGETS = "file-targets" const val FILE_TARGETS = "file-targets"
const val UPLOAD_METRICS = "upload-client-metrics" const val UPLOAD_METRICS = "upload-client-metrics"
const val START = "start" const val START = "start"
const val LOGIN_INTERACTIVE = "login-interactive" const val LOGIN_INTERACTIVE = "login-interactive"
const val RESET_AUTH = "reset-auth" const val RESET_AUTH = "reset-auth"
const val LOGOUT = "logout" const val LOGOUT = "logout"
const val PROFILES = "profiles/" const val PROFILES = "profiles/"
const val PROFILES_CURRENT = "profiles/current" const val PROFILES_CURRENT = "profiles/current"
const val STATUS = "status" const val STATUS = "status"
const val TKA_STATUS = "tka/status" const val TKA_STATUS = "tka/status"
const val TKA_SIGN = "tka/sign" const val TKA_SIGN = "tka/sign"
const val TKA_VERIFY_DEEP_LINK = "tka/verify-deeplink" const val TKA_VERIFY_DEEP_LINK = "tka/verify-deeplink"
const val PING = "ping" const val PING = "ping"
const val FILES = "files" const val FILES = "files"
const val FILE_PUT = "file-put" const val FILE_PUT = "file-put"
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address" const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
} }
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
/** /**
@ -53,190 +55,202 @@ typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
* corresponding method on this Client. * corresponding method on this Client.
*/ */
class Client(private val scope: CoroutineScope) { class Client(private val scope: CoroutineScope) {
fun status(responseHandler: StatusResponseHandler) { fun status(responseHandler: StatusResponseHandler) {
get(Endpoint.STATUS, responseHandler = responseHandler) get(Endpoint.STATUS, responseHandler = responseHandler)
} }
fun bugReportId(responseHandler: BugReportIdHandler) { fun bugReportId(responseHandler: BugReportIdHandler) {
post(Endpoint.BUG_REPORT, responseHandler = responseHandler) post(Endpoint.BUG_REPORT, responseHandler = responseHandler)
} }
fun prefs(responseHandler: PrefsHandler) { fun prefs(responseHandler: PrefsHandler) {
get(Endpoint.PREFS, responseHandler = responseHandler) get(Endpoint.PREFS, responseHandler = responseHandler)
} }
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()
) { return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
val body = Json.encodeToString(prefs).toByteArray() }
return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
}
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) { fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
get(Endpoint.PROFILES, responseHandler = responseHandler) get(Endpoint.PROFILES, responseHandler = responseHandler)
} }
fun currentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit) { fun currentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit) {
return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler) return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler)
} }
fun addProfile(responseHandler: (Result<String>) -> Unit = {}) { fun addProfile(responseHandler: (Result<String>) -> Unit = {}) {
return put(Endpoint.PROFILES, responseHandler = responseHandler) return put(Endpoint.PROFILES, responseHandler = responseHandler)
} }
fun deleteProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result<String>) -> Unit = {}) { fun deleteProfile(
return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler) profile: IpnLocal.LoginProfile,
} responseHandler: (Result<String>) -> Unit = {}
) {
return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun switchProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result<String>) -> Unit = {}) { fun switchProfile(
return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler) 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)
} }
fun logout(responseHandler: (Result<String>) -> Unit) { fun logout(responseHandler: (Result<String>) -> Unit) {
return post(Endpoint.LOGOUT, responseHandler = responseHandler) return post(Endpoint.LOGOUT, responseHandler = responseHandler)
} }
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,
Request( noinline responseHandler: (Result<T>) -> Unit
scope = scope, ) {
method = "GET", Request(
path = path, scope = scope,
body = body, method = "GET",
responseType = typeOf<T>(), path = path,
responseHandler = responseHandler body = body,
).execute() responseType = typeOf<T>(),
} responseHandler = responseHandler)
.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,
Request( noinline responseHandler: (Result<T>) -> Unit
scope = scope, ) {
method = "PUT", Request(
path = path, scope = scope,
body = body, method = "PUT",
responseType = typeOf<T>(), path = path,
responseHandler = responseHandler body = body,
).execute() responseType = typeOf<T>(),
} responseHandler = responseHandler)
.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,
Request( noinline responseHandler: (Result<T>) -> Unit
scope = scope, ) {
method = "POST", Request(
path = path, scope = scope,
body = body, method = "POST",
responseType = typeOf<T>(), path = path,
responseHandler = responseHandler body = body,
).execute() responseType = typeOf<T>(),
} responseHandler = responseHandler)
.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,
Request( noinline responseHandler: (Result<T>) -> Unit
scope = scope, ) {
method = "PATCH", Request(
path = path, scope = scope,
body = body, method = "PATCH",
responseType = typeOf<T>(), path = path,
responseHandler = responseHandler body = body,
).execute() responseType = typeOf<T>(),
} responseHandler = responseHandler)
.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( ) {
scope = scope, Request(
method = "DELETE", scope = scope,
path = path, method = "DELETE",
responseType = typeOf<T>(), path = path,
responseHandler = responseHandler responseType = typeOf<T>(),
).execute() responseHandler = responseHandler)
} .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"
companion object { companion object {
private const val TAG = "LocalAPIRequest" private const val TAG = "LocalAPIRequest"
private val jsonDecoder = Json { ignoreUnknownKeys = true } private val jsonDecoder = Json { ignoreUnknownKeys = true }
private val isReady = CompletableDeferred<Boolean>() private val isReady = CompletableDeferred<Boolean>()
// 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")
fun onReady() { fun onReady() {
isReady.complete(true) isReady.complete(true)
Log.d(TAG, "Ready") Log.d(TAG, "Ready")
}
} }
}
// Perform a request to the local API in the go backend. This is // Perform a request to the local API in the go backend. This is
// the primary JNI method for servicing a localAPI call. This // the primary JNI method for servicing a localAPI call. This
// is GUARANTEED to call back into onResponse. // is GUARANTEED to call back into onResponse.
// @see cmd/localapiclient/localapishim.go // @see cmd/localapiclient/localapishim.go
// //
// method: The HTTP method to use. // method: The HTTP method to use.
// request: The path to the localAPI endpoint. // request: The path to the localAPI endpoint.
// body: The body of the request. // body: The body of the request.
private external fun doRequest(method: String, request: String, body: ByteArray?) private external fun doRequest(method: String, request: String, body: ByteArray?)
fun execute() { fun execute() {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
isReady.await() isReady.await()
Log.d(TAG, "Executing request:${method}:${fullPath}") Log.d(TAG, "Executing request:${method}:${fullPath}")
doRequest(method, fullPath, body) doRequest(method, fullPath, body)
}
} }
}
// This is called from the JNI layer to publish responses. // This is called from the JNI layer to publish responses.
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
@Suppress("unused", "UNCHECKED_CAST") @Suppress("unused", "UNCHECKED_CAST")
fun onResponse(respData: ByteArray) { fun onResponse(respData: ByteArray) {
Log.d(TAG, "Response for request: $fullPath") Log.d(TAG, "Response for request: $fullPath")
val response: Result<T> = when (responseType) { val response: Result<T> =
typeOf<String>() -> Result.success(respData.decodeToString() as T) when (responseType) {
else -> try { typeOf<String>() -> Result.success(respData.decodeToString() as T)
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)
} }
} }
} }
// The response handler will invoked internally by the request parser // The response handler will invoked internally by the request parser
scope.launch { scope.launch { responseHandler(response) }
responseHandler(response) }
}
}
} }

@ -6,25 +6,25 @@ package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
class Dns { class Dns {
@Serializable @Serializable data class HostEntry(val addr: Addr?, val hosts: List<String>?)
data class HostEntry(val addr: Addr?, val hosts: List<String>?)
@Serializable @Serializable
data class OSConfig( data class OSConfig(
val hosts: List<HostEntry>? = null, val hosts: List<HostEntry>? = null,
val nameservers: List<Addr>? = null, val nameservers: List<Addr>? = null,
val searchDomains: List<String>? = null, val searchDomains: List<String>? = null,
val matchDomains: List<String>? = null, val matchDomains: List<String>? = null,
) { ) {
val isEmpty: Boolean val isEmpty: Boolean
get() = (hosts.isNullOrEmpty()) && get() =
(nameservers.isNullOrEmpty()) && (hosts.isNullOrEmpty()) &&
(searchDomains.isNullOrEmpty()) && (nameservers.isNullOrEmpty()) &&
(matchDomains.isNullOrEmpty()) (searchDomains.isNullOrEmpty()) &&
} (matchDomains.isNullOrEmpty())
}
} }
class DnsType { class DnsType {
@Serializable @Serializable
data class Resolver(var Addr: String? = null, var BootstrapResolution: List<Addr>? = null) data class Resolver(var Addr: String? = null, var BootstrapResolution: List<Addr>? = null)
} }

@ -7,156 +7,164 @@ import kotlinx.serialization.Serializable
class Ipn { class Ipn {
// Represents the overall state of the Tailscale engine. // Represents the overall state of the Tailscale engine.
enum class State(val value: Int) { enum class State(val value: Int) {
NoState(0), NoState(0),
InUseOtherUser(1), InUseOtherUser(1),
NeedsLogin(2), NeedsLogin(2),
NeedsMachineAuth(3), NeedsMachineAuth(3),
Stopped(4), Stopped(4),
Starting(5), Starting(5),
Running(6); Running(6);
companion object {
fun fromInt(value: Int): State {
return State.values().firstOrNull { it.value == value } ?: NoState
}
}
}
// A nofitication message recieved on the Notify bus. Fields will be populated based companion object {
// on which NotifyWatchOpts were set when the Notifier was created. fun fromInt(value: Int): State {
@Serializable return State.values().firstOrNull { it.value == value } ?: NoState
data class Notify( }
val Version: String? = null,
val ErrMessage: String? = null,
val LoginFinished: Empty.Message? = null,
val FilesWaiting: Empty.Message? = null,
val State: Int? = null,
var Prefs: Prefs? = null,
var NetMap: Netmap.NetworkMap? = null,
var Engine: EngineStatus? = null,
var BrowseToURL: String? = null,
var BackendLogId: String? = null,
var LocalTCPPort: Int? = null,
var IncomingFiles: List<PartialFile>? = null,
var ClientVersion: Tailcfg.ClientVersion? = null,
var TailFSShares: Map<String, String>? = null,
)
@Serializable
data class Prefs(
var ControlURL: String = "",
var RouteAll: Boolean = false,
var AllowsSingleHosts: Boolean = false,
var CorpDNS: Boolean = false,
var WantRunning: Boolean = false,
var LoggedOut: Boolean = false,
var ShieldsUp: Boolean = false,
var AdvertiseRoutes: List<String>? = null,
var AdvertiseTags: List<String>? = null,
var ExitNodeID: StableNodeID? = null,
var ExitNodeAllowLANAccess: Boolean = false,
var Config: Persist.Persist? = null,
var ForceDaemon: Boolean = false,
var HostName: String = "",
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
)
@Serializable
data class MaskedPrefs(
var RouteAllSet: Boolean? = null,
var CorpDNSSet: Boolean? = null,
var ExitNodeIDSet: Boolean? = null,
var ExitNodeAllowLANAccessSet: Boolean? = null,
var WantRunningSet: Boolean? = null,
var ShieldsUpSet: Boolean? = null,
var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null,
) {
var RouteAll: Boolean? = null
set(value) {
field = value
RouteAllSet = true
}
var CorpDNS: Boolean? = null
set(value) {
field = value
CorpDNSSet = true
}
var ExitNodeID: StableNodeID? = null
set(value) {
field = value
ExitNodeIDSet = true
}
var ExitNodeAllowLANAccess: Boolean? = null
set(value) {
field = value
ExitNodeAllowLANAccessSet = true
}
var WantRunning: Boolean? = null
set(value) {
field = value
WantRunningSet = true
}
var ShieldsUp: Boolean? = null
set(value) {
field = value
ShieldsUpSet = true
}
var AdvertiseRoutes: Boolean? = null
set(value) {
field = value
AdvertiseRoutesSet = true
}
var ForceDaemon: Boolean? = null
set(value) {
field = value
ForceDaemonSet = true
}
var Hostname: Boolean? = null
set(value) {
field = value
HostnameSet = true
}
} }
}
// A nofitication message recieved on the Notify bus. Fields will be populated based
// on which NotifyWatchOpts were set when the Notifier was created.
@Serializable
data class Notify(
val Version: String? = null,
val ErrMessage: String? = null,
val LoginFinished: Empty.Message? = null,
val FilesWaiting: Empty.Message? = null,
val State: Int? = null,
var Prefs: Prefs? = null,
var NetMap: Netmap.NetworkMap? = null,
var Engine: EngineStatus? = null,
var BrowseToURL: String? = null,
var BackendLogId: String? = null,
var LocalTCPPort: Int? = null,
var IncomingFiles: List<PartialFile>? = null,
var ClientVersion: Tailcfg.ClientVersion? = null,
var TailFSShares: Map<String, String>? = null,
)
@Serializable
data class Prefs(
var ControlURL: String = "",
var RouteAll: Boolean = false,
var AllowsSingleHosts: Boolean = false,
var CorpDNS: Boolean = false,
var WantRunning: Boolean = false,
var LoggedOut: Boolean = false,
var ShieldsUp: Boolean = false,
var AdvertiseRoutes: List<String>? = null,
var AdvertiseTags: List<String>? = null,
var ExitNodeID: StableNodeID? = null,
var ExitNodeAllowLANAccess: Boolean = false,
var Config: Persist.Persist? = null,
var ForceDaemon: Boolean = false,
var HostName: String = "",
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
)
@Serializable
data class MaskedPrefs(
var RouteAllSet: Boolean? = null,
var CorpDNSSet: Boolean? = null,
var ExitNodeIDSet: Boolean? = null,
var ExitNodeAllowLANAccessSet: Boolean? = null,
var WantRunningSet: Boolean? = null,
var ShieldsUpSet: Boolean? = null,
var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null,
) {
var RouteAll: Boolean? = null
set(value) {
field = value
RouteAllSet = true
}
var CorpDNS: Boolean? = null
set(value) {
field = value
CorpDNSSet = true
}
var ExitNodeID: StableNodeID? = null
set(value) {
field = value
ExitNodeIDSet = true
}
var ExitNodeAllowLANAccess: Boolean? = null
set(value) {
field = value
ExitNodeAllowLANAccessSet = true
}
var WantRunning: Boolean? = null
set(value) {
field = value
WantRunningSet = true
}
var ShieldsUp: Boolean? = null
set(value) {
field = value
ShieldsUpSet = true
}
var AdvertiseRoutes: Boolean? = null
set(value) {
field = value
AdvertiseRoutesSet = true
}
var ForceDaemon: Boolean? = null
set(value) {
field = value
ForceDaemonSet = true
}
var Hostname: Boolean? = null
set(value) {
field = value
HostnameSet = true
}
}
@Serializable
data class AutoUpdatePrefs(
var Check: Boolean? = null,
var Apply: Boolean? = null,
)
@Serializable
data class EngineStatus(
val RBytes: Long,
val WBytes: Long,
val NumLive: Int,
val LivePeers: Map<String, IpnState.PeerStatusLite>,
)
@Serializable @Serializable
data class AutoUpdatePrefs( data class PartialFile(
var Check: Boolean? = null, val Name: String,
var Apply: Boolean? = null, val Started: String,
) val DeclaredSize: Long,
val Received: Long,
@Serializable val PartialPath: String? = null,
data class EngineStatus( var FinalPath: String? = null,
val RBytes: Long, val Done: Boolean? = null,
val WBytes: Long, )
val NumLive: Int,
val LivePeers: Map<String, IpnState.PeerStatusLite>,
)
@Serializable
data class PartialFile(
val Name: String,
val Started: String,
val DeclaredSize: Long,
val Received: Long,
val PartialPath: String? = null,
var FinalPath: String? = null,
val Done: Boolean? = null,
)
} }
class Persist { class Persist {
@Serializable @Serializable
data class Persist( data class Persist(
var PrivateMachineKey: String = var PrivateMachineKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000", "privkey:0000000000000000000000000000000000000000000000000000000000000000",
var PrivateNodeKey: String = var PrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000", "privkey:0000000000000000000000000000000000000000000000000000000000000000",
var OldPrivateNodeKey: String = var OldPrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000", "privkey:0000000000000000000000000000000000000000000000000000000000000000",
var Provider: String = "", var Provider: String = "",
) )
} }

@ -6,116 +6,116 @@ package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
class IpnState { class IpnState {
@Serializable @Serializable
data class PeerStatusLite( data class PeerStatusLite(
val RxBytes: Long, val RxBytes: Long,
val TxBytes: Long, val TxBytes: Long,
val LastHandshake: String, val LastHandshake: String,
val NodeKey: String, val NodeKey: String,
) )
@Serializable @Serializable
data class PeerStatus( data class PeerStatus(
val ID: StableNodeID, val ID: StableNodeID,
val HostName: String, val HostName: String,
val DNSName: String, val DNSName: String,
val TailscaleIPs: List<Addr>? = null, val TailscaleIPs: List<Addr>? = null,
val Tags: List<String>? = null, val Tags: List<String>? = null,
val PrimaryRoutes: List<String>? = null, val PrimaryRoutes: List<String>? = null,
val Addrs: List<String>? = null, val Addrs: List<String>? = null,
val Online: Boolean, val Online: Boolean,
val ExitNode: Boolean, val ExitNode: Boolean,
val ExitNodeOption: Boolean, val ExitNodeOption: Boolean,
val Active: Boolean, val Active: Boolean,
val PeerAPIURL: List<String>? = null, val PeerAPIURL: List<String>? = null,
val Capabilities: List<String>? = null, val Capabilities: List<String>? = null,
val SSH_HostKeys: List<String>? = null, val SSH_HostKeys: List<String>? = null,
val ShareeNode: Boolean? = null, val ShareeNode: Boolean? = null,
val Expired: Boolean? = null, val Expired: Boolean? = null,
val Location: Tailcfg.Location? = null, val Location: Tailcfg.Location? = null,
) { ) {
fun computedName(status: Status): String { fun computedName(status: Status): String {
val name = DNSName val name = DNSName
val suffix = status.CurrentTailnet?.MagicDNSSuffix val suffix = status.CurrentTailnet?.MagicDNSSuffix
suffix ?: return name suffix ?: return name
if (!(name.endsWith("." + suffix + "."))) { if (!(name.endsWith("." + suffix + "."))) {
return name return name
} }
return name.dropLast(suffix.count() + 2) return name.dropLast(suffix.count() + 2)
}
} }
}
@Serializable @Serializable
data class ExitNodeStatus( data class ExitNodeStatus(
val ID: StableNodeID, val ID: StableNodeID,
val Online: Boolean, val Online: Boolean,
val TailscaleIPs: List<Prefix>? = null, val TailscaleIPs: List<Prefix>? = null,
) )
@Serializable @Serializable
data class TailnetStatus( data class TailnetStatus(
val Name: String, val Name: String,
val MagicDNSSuffix: String, val MagicDNSSuffix: String,
val MagicDNSEnabled: Boolean, val MagicDNSEnabled: Boolean,
) )
@Serializable @Serializable
data class Status( data class Status(
val Version: String, val Version: String,
val TUN: Boolean, val TUN: Boolean,
val BackendState: String, val BackendState: String,
val AuthURL: String, val AuthURL: String,
val TailscaleIPs: List<Addr>? = null, val TailscaleIPs: List<Addr>? = null,
val Self: PeerStatus? = null, val Self: PeerStatus? = null,
val ExitNodeStatus: ExitNodeStatus? = null, val ExitNodeStatus: ExitNodeStatus? = null,
val Health: List<String>? = null, val Health: List<String>? = null,
val CurrentTailnet: TailnetStatus? = null, val CurrentTailnet: TailnetStatus? = null,
val CertDomains: List<String>? = null, val CertDomains: List<String>? = null,
val Peer: Map<String, PeerStatus>? = null, val Peer: Map<String, PeerStatus>? = null,
val User: Map<String, Tailcfg.UserProfile>? = null, val User: Map<String, Tailcfg.UserProfile>? = null,
val ClientVersion: Tailcfg.ClientVersion? = null, val ClientVersion: Tailcfg.ClientVersion? = null,
) )
@Serializable @Serializable
data class NetworkLockStatus( data class NetworkLockStatus(
var Enabled: Boolean, var Enabled: Boolean,
var PublicKey: String, var PublicKey: String,
var NodeKey: String, var NodeKey: String,
var NodeKeySigned: Boolean, var NodeKeySigned: Boolean,
var FilteredPeers: List<TKAFilteredPeer>? = null, var FilteredPeers: List<TKAFilteredPeer>? = null,
var StateID: ULong? = null, var StateID: ULong? = null,
) )
@Serializable @Serializable
data class TKAFilteredPeer( data class TKAFilteredPeer(
var Name: String, var Name: String,
var TailscaleIPs: List<Addr>, var TailscaleIPs: List<Addr>,
var NodeKey: String, var NodeKey: String,
) )
@Serializable @Serializable
data class PingResult( data class PingResult(
var IP: Addr, var IP: Addr,
var Err: String, var Err: String,
var LatencySeconds: Double, var LatencySeconds: Double,
) )
} }
class IpnLocal { class IpnLocal {
@Serializable @Serializable
data class LoginProfile( data class LoginProfile(
var ID: String, var ID: String,
val Name: String, val Name: String,
val Key: String, val Key: String,
val UserProfile: Tailcfg.UserProfile, val UserProfile: Tailcfg.UserProfile,
val NetworkProfile: Tailcfg.NetworkProfile? = null, val NetworkProfile: Tailcfg.NetworkProfile? = null,
val LocalUserID: String, val LocalUserID: String,
) { ) {
fun isEmpty(): Boolean { fun isEmpty(): Boolean {
return ID.isEmpty() return ID.isEmpty()
}
} }
}
} }

@ -6,50 +6,50 @@ package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
class Netmap { class Netmap {
@Serializable @Serializable
data class NetworkMap( data class NetworkMap(
var SelfNode: Tailcfg.Node, var SelfNode: Tailcfg.Node,
var NodeKey: KeyNodePublic, var NodeKey: KeyNodePublic,
var Peers: List<Tailcfg.Node>? = null, var Peers: List<Tailcfg.Node>? = null,
var Expiry: Time, var Expiry: Time,
var Domain: String, var Domain: String,
var UserProfiles: Map<String, Tailcfg.UserProfile>, var UserProfiles: Map<String, Tailcfg.UserProfile>,
var TKAEnabled: Boolean, var TKAEnabled: Boolean,
var DNS: Tailcfg.DNSConfig? = null var DNS: Tailcfg.DNSConfig? = null
) { ) {
// Keys are tailcfg.UserIDs thet get stringified // Keys are tailcfg.UserIDs thet get stringified
// Helpers // Helpers
fun currentUserProfile(): Tailcfg.UserProfile? { fun currentUserProfile(): Tailcfg.UserProfile? {
return userProfile(User()) return userProfile(User())
}
fun User(): UserID {
return SelfNode.User
}
fun userProfile(id: Long): Tailcfg.UserProfile? {
return UserProfiles[id.toString()]
}
fun getPeer(id: StableNodeID): Tailcfg.Node? {
if(id == SelfNode.StableID) {
return SelfNode
}
return Peers?.find { it.StableID == id }
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is NetworkMap) return false
return SelfNode == other.SelfNode &&
NodeKey == other.NodeKey &&
Peers == other.Peers &&
Expiry == other.Expiry &&
User() == other.User() &&
Domain == other.Domain &&
UserProfiles == other.UserProfiles &&
TKAEnabled == other.TKAEnabled
}
} }
fun User(): UserID {
return SelfNode.User
}
fun userProfile(id: Long): Tailcfg.UserProfile? {
return UserProfiles[id.toString()]
}
fun getPeer(id: StableNodeID): Tailcfg.Node? {
if (id == SelfNode.StableID) {
return SelfNode
}
return Peers?.find { it.StableID == id }
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is NetworkMap) return false
return SelfNode == other.SelfNode &&
NodeKey == other.NodeKey &&
Peers == other.Peers &&
Expiry == other.Expiry &&
User() == other.User() &&
Domain == other.Domain &&
UserProfiles == other.UserProfiles &&
TKAEnabled == other.TKAEnabled
}
}
} }

@ -6,102 +6,102 @@ package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
class Tailcfg { class Tailcfg {
@Serializable @Serializable
data class ClientVersion( data class ClientVersion(
var RunningLatest: Boolean? = null, var RunningLatest: Boolean? = null,
var LatestVersion: String? = null, var LatestVersion: String? = null,
var UrgentSecurityUpdate: Boolean? = null, var UrgentSecurityUpdate: Boolean? = null,
var Notify: Boolean? = null, var Notify: Boolean? = null,
var NotifyURL: String? = null, var NotifyURL: String? = null,
var NotifyText: String? = null var NotifyText: String? = null
) )
@Serializable @Serializable
data class UserProfile( data class UserProfile(
val ID: Long, val ID: Long,
val DisplayName: String, val DisplayName: String,
val LoginName: String, val LoginName: String,
val ProfilePicURL: String? = null, val ProfilePicURL: String? = null,
) { ) {
fun isTaggedDevice(): Boolean { fun isTaggedDevice(): Boolean {
return LoginName == "tagged-devices" return LoginName == "tagged-devices"
}
} }
}
@Serializable @Serializable
data class Hostinfo( data class Hostinfo(
var IPNVersion: String? = null, var IPNVersion: String? = null,
var FrontendLogID: String? = null, var FrontendLogID: String? = null,
var BackendLogID: String? = null, var BackendLogID: String? = null,
var OS: String? = null, var OS: String? = null,
var OSVersion: String? = null, var OSVersion: String? = null,
var Env: String? = null, var Env: String? = null,
var Distro: String? = null, var Distro: String? = null,
var DistroVersion: String? = null, var DistroVersion: String? = null,
var DistroCodeName: String? = null, var DistroCodeName: String? = null,
var Desktop: Boolean? = null, var Desktop: Boolean? = null,
var Package: String? = null, var Package: String? = null,
var DeviceModel: String? = null, var DeviceModel: String? = null,
var ShareeNode: Boolean? = null, var ShareeNode: Boolean? = null,
var Hostname: String? = null, var Hostname: String? = null,
var ShieldsUp: Boolean? = null, var ShieldsUp: Boolean? = null,
var NoLogsNoSupport: Boolean? = null, var NoLogsNoSupport: Boolean? = null,
var Machine: String? = null, var Machine: String? = null,
var RoutableIPs: List<Prefix>? = null, var RoutableIPs: List<Prefix>? = null,
var Services: List<Service>? = null, var Services: List<Service>? = null,
var Location: Location? = null, var Location: Location? = null,
) )
@Serializable @Serializable
data class Node( data class Node(
var ID: NodeID, var ID: NodeID,
var StableID: StableNodeID, var StableID: StableNodeID,
var Name: String, var Name: String,
var User: UserID, var User: UserID,
var Sharer: UserID? = null, var Sharer: UserID? = null,
var Key: KeyNodePublic, var Key: KeyNodePublic,
var KeyExpiry: String, var KeyExpiry: String,
var Machine: MachineKey, var Machine: MachineKey,
var Addresses: List<Prefix>? = null, var Addresses: List<Prefix>? = null,
var AllowedIPs: List<Prefix>? = null, var AllowedIPs: List<Prefix>? = null,
var Endpoints: List<String>? = null, var Endpoints: List<String>? = null,
var Hostinfo: Hostinfo, var Hostinfo: Hostinfo,
var Created: Time, var Created: Time,
var LastSeen: Time? = null, var LastSeen: Time? = null,
var Online: Boolean? = null, var Online: Boolean? = null,
var Capabilities: List<String>? = null, var Capabilities: List<String>? = null,
var ComputedName: String, var ComputedName: String,
var ComputedNameWithHost: String var ComputedNameWithHost: String
) { ) {
val isAdmin: Boolean val isAdmin: Boolean
get() = (Capabilities ?: emptyList()).contains("https://tailscale.com/cap/is-admin") get() = (Capabilities ?: emptyList()).contains("https://tailscale.com/cap/is-admin")
// isExitNode reproduces the Go logic in local.go peerStatusFromNode // isExitNode reproduces the Go logic in local.go peerStatusFromNode
val isExitNode: Boolean = val isExitNode: Boolean =
AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false
} }
@Serializable @Serializable
data class Service(var Proto: String, var Port: Int, var Description: String? = null) data class Service(var Proto: String, var Port: Int, var Description: String? = null)
@Serializable @Serializable
data class NetworkProfile(var MagicDNSName: String? = null, var DomainName: String? = null) data class NetworkProfile(var MagicDNSName: String? = null, var DomainName: String? = null)
@Serializable @Serializable
data class Location( data class Location(
var Country: String? = null, var Country: String? = null,
var CountryCode: String? = null, var CountryCode: String? = null,
var City: String? = null, var City: String? = null,
var CityCode: String? = null, var CityCode: String? = null,
var Priority: Int? = null var Priority: Int? = null
) )
@Serializable @Serializable
data class DNSConfig( data class DNSConfig(
var Resolvers: List<DnsType.Resolver>? = null, var Resolvers: List<DnsType.Resolver>? = null,
var Routes: Map<String, List<DnsType.Resolver>?>? = null, var Routes: Map<String, List<DnsType.Resolver>?>? = null,
var FallbackResolvers: List<DnsType.Resolver>? = null, var FallbackResolvers: List<DnsType.Resolver>? = null,
var Domains: List<String>? = null, var Domains: List<String>? = null,
var Nameservers: List<Addr>? = null var Nameservers: List<Addr>? = null
) )
} }

@ -6,23 +6,29 @@ package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
typealias Addr = String typealias Addr = String
typealias Prefix = String typealias Prefix = String
typealias NodeID = Long typealias NodeID = Long
typealias KeyNodePublic = String typealias KeyNodePublic = String
typealias MachineKey = String typealias MachineKey = String
typealias UserID = Long typealias UserID = Long
typealias Time = String typealias Time = String
typealias StableNodeID = String typealias StableNodeID = String
typealias BugReportID = String typealias BugReportID = String
// Represents and empty message with a single 'property' field. // Represents and empty message with a single 'property' field.
class Empty { class Empty {
@Serializable @Serializable data class Message(val property: String)
data class Message(val property: String)
} }
// Parsable errors returned by localApiService // Parsable errors returned by localApiService
class Errors { class Errors {
@Serializable @Serializable data class GenericError(val error: String)
data class GenericError(val error: String)
} }

@ -27,76 +27,81 @@ import kotlinx.serialization.json.decodeFromStream
// and return you the session Id. When you are done with your watcher, you must call // and return you the session Id. When you are done with your watcher, you must call
// unwatchIPNBus with the sessionId. // unwatchIPNBus with the sessionId.
object Notifier { object Notifier {
private val TAG = Notifier::class.simpleName private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true } private val decoder = Json { ignoreUnknownKeys = true }
private val isReady = CompletableDeferred<Boolean>() private val isReady = CompletableDeferred<Boolean>()
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState) val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null) val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null) val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null) val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
val tailFSShares: StateFlow<Map<String, String>?> = MutableStateFlow(null) val tailFSShares: StateFlow<Map<String, String>?> = MutableStateFlow(null)
val browseToURL: StateFlow<String?> = MutableStateFlow(null) val browseToURL: StateFlow<String?> = MutableStateFlow(null)
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 // 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 // explicitly set by the main activity. null indicates that we have not yet
// checked. // checked.
val vpnPermissionGranted: StateFlow<Boolean?> = MutableStateFlow(null) 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")
fun onReady() { fun onReady() {
isReady.complete(true) isReady.complete(true)
Log.d(TAG, "Ready") Log.d(TAG, "Ready")
} }
fun start(scope: CoroutineScope) { fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting") Log.d(TAG, "Starting")
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
// 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
startIPNBusWatcher(mask) NotifyWatchOpt.Prefs.value or
Log.d(TAG, "Stopped") NotifyWatchOpt.InitialState.value
} startIPNBusWatcher(mask)
Log.d(TAG, "Stopped")
} }
}
fun stop() { fun stop() {
Log.d(TAG, "Stopping") Log.d(TAG, "Stopping")
stopIPNBusWatcher() stopIPNBusWatcher()
} }
// Callback from jni when a new notification is received // Callback from jni when a new notification is received
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
@JvmStatic @JvmStatic
@Suppress("unused") @Suppress("unused")
fun onNotify(notification: ByteArray) { fun onNotify(notification: ByteArray) {
val notify = decoder.decodeFromStream<Notify>(notification.inputStream()) val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
notify.State?.let { state.set(Ipn.State.fromInt(it)) } notify.State?.let { state.set(Ipn.State.fromInt(it)) }
notify.NetMap?.let(netmap::set) notify.NetMap?.let(netmap::set)
notify.Prefs?.let(prefs::set) notify.Prefs?.let(prefs::set)
notify.Engine?.let(engineStatus::set) notify.Engine?.let(engineStatus::set)
notify.TailFSShares?.let(tailFSShares::set) notify.TailFSShares?.let(tailFSShares::set)
notify.BrowseToURL?.let(browseToURL::set) notify.BrowseToURL?.let(browseToURL::set)
notify.LoginFinished?.let { loginFinished.set(it.property) } notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set) notify.Version?.let(version::set)
} }
// Starts watching the IPN Bus. This is blocking. // Starts watching the IPN Bus. This is blocking.
private external fun startIPNBusWatcher(mask: Int) private external fun startIPNBusWatcher(mask: Int)
// Stop watching the IPN Bus. This is non-blocking. // Stop watching the IPN Bus. This is non-blocking.
private external fun stopIPNBusWatcher() private external fun stopIPNBusWatcher()
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which // NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// 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),
32 InitialState(2),
) Prefs(4),
} Netmap(8),
NoPrivateKey(16),
InitialTailFSShares(32)
}
} }

@ -1,7 +1,6 @@
// 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.theme package com.tailscale.ipn.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color

@ -1,7 +1,6 @@
// 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.theme package com.tailscale.ipn.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@ -10,38 +9,34 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
private val LightColors =
lightColorScheme(
primary = ts_color_light_primary,
onPrimary = ts_color_light_background,
secondary = ts_color_light_secondary,
onSecondary = ts_color_light_background,
secondaryContainer = ts_color_light_tintedBackground,
surface = ts_color_light_background,
)
private val LightColors = lightColorScheme( private val DarkColors =
primary = ts_color_light_primary, darkColorScheme(
onPrimary = ts_color_light_background, primary = ts_color_dark_primary,
secondary = ts_color_light_secondary, onPrimary = ts_color_dark_background,
onSecondary = ts_color_light_background, secondary = ts_color_dark_secondary,
secondaryContainer = ts_color_light_tintedBackground, onSecondary = ts_color_dark_background,
surface = ts_color_light_background, secondaryContainer = ts_color_dark_tintedBackground,
) surface = ts_color_dark_background,
)
private val DarkColors = darkColorScheme(
primary = ts_color_dark_primary,
onPrimary = ts_color_dark_background,
secondary = ts_color_dark_secondary,
onSecondary = ts_color_dark_background,
secondaryContainer = ts_color_dark_tintedBackground,
surface = ts_color_dark_background,
)
@Composable @Composable
fun AppTheme( fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
useDarkTheme: Boolean = isSystemInDarkTheme(), val colors =
content: @Composable() () -> Unit if (!useDarkTheme) {
) {
val colors = if (!useDarkTheme) {
LightColors LightColors
} else { } else {
DarkColors DarkColors
} }
MaterialTheme( MaterialTheme(colorScheme = colors, content = content)
colorScheme = colors,
content = content
)
} }

@ -8,14 +8,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R import com.tailscale.ipn.R
// Convenience wrapper for passing formatted strings to Composables // Convenience wrapper for passing formatted strings to Composables
class ComposableStringFormatter(@StringRes val stringRes: Int = R.string.template, private vararg val params: Any) { class ComposableStringFormatter(
@StringRes val stringRes: Int = R.string.template,
private vararg val params: Any
) {
// Convenience constructor for passing a non-formatted string directly // Convenience constructor for passing a non-formatted string directly
constructor(string: String) : this(stringRes = R.string.template, string) constructor(string: String) : this(stringRes = R.string.template, string)
// Returns the fully formatted string // Returns the fully formatted string
@Composable @Composable fun getString(): String = stringResource(id = stringRes, *params)
fun getString(): String = stringResource(id = stringRes, *params)
} }

@ -1,42 +1,46 @@
// 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.util
class DisplayAddress(val ip: String) { class DisplayAddress(val ip: String) {
enum class addrType { enum class addrType {
V4, V6, MagicDNS V4,
} V6,
MagicDNS
val type: addrType = when { }
val type: addrType =
when {
ip.isIPV6() -> addrType.V6 ip.isIPV6() -> addrType.V6
ip.isIPV4() -> addrType.V4 ip.isIPV4() -> addrType.V4
else -> addrType.MagicDNS else -> addrType.MagicDNS
} }
val typeString: String = when (type) { val typeString: String =
when (type) {
addrType.V4 -> "IPv4" addrType.V4 -> "IPv4"
addrType.V6 -> "IPv6" addrType.V6 -> "IPv6"
addrType.MagicDNS -> "MagicDNS" addrType.MagicDNS -> "MagicDNS"
} }
val address: String = when (type) { val address: String =
when (type) {
addrType.MagicDNS -> ip addrType.MagicDNS -> ip
else -> ip.split("/").first() else -> ip.split("/").first()
} }
} }
fun String.isIPV6(): Boolean { fun String.isIPV6(): Boolean {
return this.contains(":") return this.contains(":")
} }
fun String.isIPV4(): Boolean { fun String.isIPV4(): Boolean {
val parts = this.split("/").first().split(".") val parts = this.split("/").first().split(".")
if (parts.size != 4) return false if (parts.size != 4) return false
for (part in parts) { for (part in parts) {
val value = part.toIntOrNull() ?: return false val value = part.toIntOrNull() ?: return false
if (value !in 0..255) return false if (value !in 0..255) return false
} }
return true return true
} }

@ -4,31 +4,30 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
/** /**
* Code adapted from https://github.com/piashcse/Compose-museum/blob/master/app/src/main/java/com/piashcse/compose_museum/screens/CountryList.kt#L75 * Code adapted from
* https://github.com/piashcse/Compose-museum/blob/master/app/src/main/java/com/piashcse/compose_museum/screens/CountryList.kt#L75
*/ */
//Copyright 2023 piashcse (Mehedi Hassan Piash) // Copyright 2023 piashcse (Mehedi Hassan Piash)
// //
//Licensed under the Apache License, Version 2.0 (the "License"); // Licensed under the Apache License, Version 2.0 (the "License");
//you may not use this file except in compliance with the License. // you may not use this file except in compliance with the License.
//You may obtain a copy of the License at // You may obtain a copy of the License at
// //
//http://www.apache.org/licenses/LICENSE-2.0 // http://www.apache.org/licenses/LICENSE-2.0
// //
//Unless required by applicable law or agreed to in writing, software // Unless required by applicable law or agreed to in writing, software
//distributed under the License is distributed on an "AS IS" BASIS, // distributed under the License is distributed on an "AS IS" BASIS,
//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
//limitations under the License. // limitations under the License.
/** /** Flag turns an ISO3166 country code into a flag emoji. */
* Flag turns an ISO3166 country code into a flag emoji.
*/
fun String.flag(): String { fun String.flag(): String {
val caps = this.uppercase() val caps = this.uppercase()
val flagOffset = 0x1F1E6 val flagOffset = 0x1F1E6
val asciiOffset = 0x41 val asciiOffset = 0x41
val firstChar = Character.codePointAt(caps, 0) - asciiOffset + flagOffset val firstChar = Character.codePointAt(caps, 0) - asciiOffset + flagOffset
val secondChar = Character.codePointAt(caps, 1) - asciiOffset + flagOffset val secondChar = Character.codePointAt(caps, 1) - asciiOffset + flagOffset
return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar)) return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar))
} }

@ -16,39 +16,33 @@ import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
object LoadingIndicator { object LoadingIndicator {
private val loading = MutableStateFlow(false) private val loading = MutableStateFlow(false)
fun start() { fun start() {
loading.value = true loading.value = true
} }
fun stop() { fun stop() {
loading.value = false loading.value = false
} }
@Composable @Composable
fun Wrap(content: @Composable () -> Unit) { fun Wrap(content: @Composable () -> Unit) {
Box( Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
content()
val isLoading = loading.collectAsState().value
if (isLoading) {
Box(Modifier.matchParentSize().background(Color.Gray.copy(alpha = 0.5f)))
Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center, horizontalAlignment = Alignment.CenterHorizontally) {
) { CircularProgressIndicator()
content()
val isLoading = loading.collectAsState().value
if (isLoading) {
Box(
Modifier
.matchParentSize()
.background(Color.Gray.copy(alpha = 0.5f))
)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
}
} }
} }
} }
}
} }

@ -1,7 +1,6 @@
// 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.util
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
@ -11,97 +10,101 @@ import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>) data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>)
typealias GroupedPeers = MutableMap<UserID, MutableList<Tailcfg.Node>> typealias GroupedPeers = MutableMap<UserID, MutableList<Tailcfg.Node>>
class PeerCategorizer(scope: CoroutineScope) { class PeerCategorizer(scope: CoroutineScope) {
var peerSets: List<PeerSet> = emptyList() var peerSets: List<PeerSet> = emptyList()
var lastSearchResult: List<PeerSet> = emptyList() var lastSearchResult: List<PeerSet> = emptyList()
var searchTerm: String = "" var searchTerm: String = ""
// Keep the peer sets current while the model is active // Keep the peer sets current while the model is active
init { init {
scope.launch { scope.launch {
Notifier.netmap.collect { netmap -> Notifier.netmap.collect { netmap ->
netmap?.let { netmap?.let {
peerSets = regenerateGroupedPeers(netmap) peerSets = regenerateGroupedPeers(netmap)
lastSearchResult = peerSets lastSearchResult = peerSets
} ?: run {
peerSets = emptyList()
lastSearchResult = emptyList()
}
}
} }
?: run {
peerSets = emptyList()
lastSearchResult = emptyList()
}
}
}
}
private fun regenerateGroupedPeers(netmap: Netmap.NetworkMap): List<PeerSet> {
val peers: List<Tailcfg.Node> = netmap.Peers ?: return emptyList()
val selfNode = netmap.SelfNode
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
for (peer in (peers + selfNode)) {
// (jonathan) TODO: MDM -> There are a number of MDM settings to hide devices from the user
// (jonathan) TODO: MDM -> currentUser, otherUsers, taggedDevices
val userId = peer.User
if (!grouped.containsKey(userId)) {
grouped[userId] = mutableListOf()
}
grouped[userId]?.add(peer)
} }
private fun regenerateGroupedPeers(netmap: Netmap.NetworkMap): List<PeerSet> { val me = netmap.currentUserProfile()
val peers: List<Tailcfg.Node> = netmap.Peers ?: return emptyList()
val selfNode = netmap.SelfNode
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
for (peer in (peers + selfNode)) {
// (jonathan) TODO: MDM -> There are a number of MDM settings to hide devices from the user
// (jonathan) TODO: MDM -> currentUser, otherUsers, taggedDevices
val userId = peer.User
if (!grouped.containsKey(userId)) { val peerSets =
grouped[userId] = mutableListOf() grouped
.map { (userId, peers) ->
val profile = netmap.userProfile(userId)
PeerSet(profile, peers.sortedBy { it.ComputedName })
} }
grouped[userId]?.add(peer) .sortedBy {
} if (it.user?.ID == me?.ID) {
val me = netmap.currentUserProfile()
val peerSets = grouped.map { (userId, peers) ->
val profile = netmap.userProfile(userId)
PeerSet(profile, peers.sortedBy { it.ComputedName })
}.sortedBy {
if (it.user?.ID == me?.ID) {
"" ""
} else { } else {
it.user?.DisplayName ?: "Unknown User" it.user?.DisplayName ?: "Unknown User"
}
} }
}
return peerSets return peerSets
} }
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> { fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
if (searchTerm.isEmpty()) { if (searchTerm.isEmpty()) {
return peerSets return peerSets
} }
if (searchTerm == this.searchTerm) { if (searchTerm == this.searchTerm) {
return lastSearchResult return lastSearchResult
} }
// We can optimize out typing... If the search term starts with the last search term, we can just search the last result // We can optimize out typing... If the search term starts with the last search term, we can
val setsToSearch = // just search the last result
if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets val setsToSearch = if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets
this.searchTerm = searchTerm this.searchTerm = searchTerm
val matchingSets = setsToSearch.map { peerSet -> val matchingSets =
val user = peerSet.user setsToSearch
val peers = peerSet.peers .map { peerSet ->
val user = peerSet.user
val peers = peerSet.peers
val userMatches = user?.DisplayName?.contains(searchTerm, ignoreCase = true) ?: false val userMatches = user?.DisplayName?.contains(searchTerm, ignoreCase = true) ?: false
if (userMatches) { if (userMatches) {
return@map peerSet return@map peerSet
} }
val matchingPeers = val matchingPeers =
peers.filter { it.ComputedName.contains(searchTerm, ignoreCase = true) } peers.filter { it.ComputedName.contains(searchTerm, ignoreCase = true) }
if (matchingPeers.isNotEmpty()) { if (matchingPeers.isNotEmpty()) {
PeerSet(user, matchingPeers) PeerSet(user, matchingPeers)
} else { } else {
null null
}
} }
}.filterNotNull() .filterNotNull()
return matchingSets
}
return matchingSets
}
} }

@ -6,9 +6,7 @@ package com.tailscale.ipn.ui.util
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** /** Provides a way to expose a MutableStateFlow as an immutable StateFlow. */
* Provides a way to expose a MutableStateFlow as an immutable StateFlow.
*/
fun <T> StateFlow<T>.set(v: T) { fun <T> StateFlow<T>.set(v: T) {
(this as MutableStateFlow<T>).value = v (this as MutableStateFlow<T>).value = v
} }

@ -3,65 +3,24 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable @Composable
fun settingsRowModifier(): Modifier { fun settingsRowModifier(): Modifier {
return Modifier return Modifier.clip(shape = RoundedCornerShape(8.dp))
.clip(shape = RoundedCornerShape(8.dp)) .background(color = MaterialTheme.colorScheme.secondaryContainer)
.background(color = MaterialTheme.colorScheme.secondaryContainer) .fillMaxWidth()
.fillMaxWidth()
} }
@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,
)
} }

@ -8,44 +8,48 @@ import java.time.Instant
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Date import java.util.Date
class TimeUtil { class TimeUtil {
fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter { fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter {
val time = goTime ?: return ComposableStringFormatter(R.string.empty) val time = goTime ?: return ComposableStringFormatter(R.string.empty)
val expTime = epochMillisFromGoTime(time) val expTime = epochMillisFromGoTime(time)
val now = Instant.now().toEpochMilli() val now = Instant.now().toEpochMilli()
val diff = (expTime - now) / 1000
if (diff < 0) {
return ComposableStringFormatter(R.string.expired)
}
// Rather than use plurals here, we'll just use the singular form for everything and
// double the minimum. "in 70 minutes" instead of "in 1 hour". 121 minutes becomes
// 2 hours, as does 179 minutes... Close enough for what this is used for.
return when (diff) {
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
in 61..7200 -> ComposableStringFormatter(R.string.in_x_minutes, diff / 60) // 1 minute to 1 hour
in 7201..172800 -> ComposableStringFormatter(R.string.in_x_hours, diff / 3600) // 2 hours to 24 hours
in 172801..5184000 -> ComposableStringFormatter(R.string.in_x_days, diff / 86400) // 2 Days to 60 days
in 5184001..124416000 -> ComposableStringFormatter(R.string.in_x_months, diff / 2592000) // ~2 months to 2 years
else -> ComposableStringFormatter(R.string.in_x_years, diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
}
}
fun epochMillisFromGoTime(goTime: String): Long { val diff = (expTime - now) / 1000
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
val i = Instant.from(ta) if (diff < 0) {
return i.toEpochMilli() return ComposableStringFormatter(R.string.expired)
} }
fun dateFromGoString(goTime: String): Date { // Rather than use plurals here, we'll just use the singular form for everything and
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime) // double the minimum. "in 70 minutes" instead of "in 1 hour". 121 minutes becomes
val i = Instant.from(ta) // 2 hours, as does 179 minutes... Close enough for what this is used for.
return Date.from(i) return when (diff) {
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
in 61..7200 ->
ComposableStringFormatter(R.string.in_x_minutes, diff / 60) // 1 minute to 1 hour
in 7201..172800 ->
ComposableStringFormatter(R.string.in_x_hours, diff / 3600) // 2 hours to 24 hours
in 172801..5184000 ->
ComposableStringFormatter(R.string.in_x_days, diff / 86400) // 2 Days to 60 days
in 5184001..124416000 ->
ComposableStringFormatter(R.string.in_x_months, diff / 2592000) // ~2 months to 2 years
else ->
ComposableStringFormatter(
R.string.in_x_years, diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
} }
}
fun epochMillisFromGoTime(goTime: String): Long {
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
val i = Instant.from(ta)
return i.toEpochMilli()
}
fun dateFromGoString(goTime: String): Date {
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
val i = Instant.from(ta)
return Date.from(i)
}
} }

@ -14,17 +14,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment 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.LocalUriHandler
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
@ -36,84 +33,51 @@ import com.tailscale.ipn.ui.Links
@Composable @Composable
fun AboutView() { fun AboutView() {
Surface(color = MaterialTheme.colorScheme.surface) { Scaffold { _ ->
Column( Column(
verticalArrangement = Arrangement.spacedBy( verticalArrangement =
space = 20.dp, alignment = Alignment.CenterVertically Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
), horizontalAlignment = Alignment.CenterHorizontally,
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth().fillMaxHeight().safeContentPadding()) {
modifier = Modifier Image(
.fillMaxWidth() modifier =
.fillMaxHeight() Modifier.width(100.dp)
.safeContentPadding() .height(100.dp)
) { .clip(RoundedCornerShape(50))
Image( .background(Color.Black)
modifier = Modifier .padding(15.dp),
.width(100.dp) painter = painterResource(id = R.drawable.ic_tile),
.height(100.dp) contentDescription = stringResource(R.string.app_icon_content_description))
.clip(RoundedCornerShape(50)) Column(
.background(Color.Black) verticalArrangement =
.padding(15.dp), Arrangement.spacedBy(space = 2.dp, alignment = Alignment.CenterVertically),
painter = painterResource(id = R.drawable.ic_tile), horizontalAlignment = Alignment.CenterHorizontally) {
contentDescription = stringResource(R.string.app_icon_content_description)
)
Column(
verticalArrangement = Arrangement.spacedBy(
space = 2.dp, alignment = Alignment.CenterVertically
), horizontalAlignment = Alignment.CenterHorizontally
) {
Text( Text(
stringResource(R.string.about_view_title), stringResource(R.string.about_view_title),
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = MaterialTheme.typography.titleLarge.fontSize, fontSize = MaterialTheme.typography.titleLarge.fontSize,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary)
)
Text( Text(
text = BuildConfig.VERSION_NAME, text = BuildConfig.VERSION_NAME,
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight, fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontSize = MaterialTheme.typography.bodyMedium.fontSize, fontSize = MaterialTheme.typography.bodyMedium.fontSize,
color = MaterialTheme.colorScheme.secondary color = MaterialTheme.colorScheme.secondary)
) }
} Column(
Column( verticalArrangement =
verticalArrangement = Arrangement.spacedBy( Arrangement.spacedBy(space = 4.dp, alignment = Alignment.CenterVertically),
space = 4.dp, alignment = Alignment.CenterVertically horizontalAlignment = Alignment.CenterHorizontally) {
), horizontalAlignment = Alignment.CenterHorizontally OpenURLButton(stringResource(R.string.acknowledgements), Links.LICENSES_URL)
) { OpenURLButton(stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL)
OpenURLButton( OpenURLButton(stringResource(R.string.terms_of_service), Links.TERMS_URL)
stringResource(R.string.acknowledgements), Links.LICENSES_URL }
)
OpenURLButton(
stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL
)
OpenURLButton(
stringResource(R.string.terms_of_service), Links.TERMS_URL
)
}
Text( Text(
stringResource(R.string.about_view_footnotes), stringResource(R.string.about_view_footnotes),
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = MaterialTheme.typography.labelMedium.fontSize, fontSize = MaterialTheme.typography.labelMedium.fontSize,
color = MaterialTheme.colorScheme.tertiary, color = MaterialTheme.colorScheme.tertiary,
textAlign = TextAlign.Center textAlign = TextAlign.Center)
)
} }
} }
}
@Composable
fun OpenURLButton(title: String, url: String) {
val handler = LocalUriHandler.current
Button(
onClick = { handler.openUri(url) },
content = {
Text(title)
},
colors = ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.secondary,
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
)
} }

@ -20,26 +20,23 @@ import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
@Composable @Composable
fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50) { fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50) {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier modifier =
.size(size.dp) Modifier.size(size.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiaryContainer) .background(MaterialTheme.colorScheme.tertiaryContainer)) {
) {
Icon( Icon(
imageVector = Icons.Default.Person, imageVector = Icons.Default.Person,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer, tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size((size * .8f).dp) modifier = Modifier.size((size * .8f).dp))
)
profile?.UserProfile?.ProfilePicURL?.let { url -> profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage(model = url, contentDescription = null) AsyncImage(model = url, contentDescription = null)
} }
} }
} }

@ -11,13 +11,14 @@ 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -37,94 +38,81 @@ 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
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@Composable @Composable
fun BugReportView(model: BugReportViewModel = viewModel()) { fun BugReportView(model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = defaultPaddingModifier().fillMaxWidth().fillMaxHeight()) {
Header(title = R.string.bug_report_title)
Spacer(modifier = Modifier.height(8.dp)) Scaffold(topBar = { Header(R.string.bug_report_title) }) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).padding(8.dp).fillMaxWidth().fillMaxHeight()) {
ClickableText(
text = contactText(),
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium,
onClick = { handler.openUri(Links.SUPPORT_URL) })
ClickableText(text = contactText(), Spacer(modifier = Modifier.height(8.dp))
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium,
onClick = {
handler.openUri(Links.SUPPORT_URL)
})
Spacer(modifier = Modifier.height(8.dp)) ReportIdRow(bugReportIdFlow = model.bugReportID)
ReportIdRow(bugReportIdFlow = model.bugReportID) Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp)) Text(
text = stringResource(id = R.string.bug_report_id_desc),
Text( modifier = Modifier.fillMaxWidth(),
text = stringResource(id = R.string.bug_report_id_desc), textAlign = TextAlign.Left,
modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Left, style = MaterialTheme.typography.bodySmall)
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall
)
}
} }
}
} }
@Composable @Composable
fun ReportIdRow(bugReportIdFlow: StateFlow<String>) { fun ReportIdRow(bugReportIdFlow: StateFlow<String>) {
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
val bugReportId = bugReportIdFlow.collectAsState() val bugReportId = bugReportIdFlow.collectAsState()
Row( Row(
modifier = settingsRowModifier() modifier =
.fillMaxWidth() settingsRowModifier()
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(bugReportId.value)) }), .fillMaxWidth()
verticalAlignment = Alignment.CenterVertically .clickable(
) { onClick = { localClipboardManager.setText(AnnotatedString(bugReportId.value)) }),
verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(10f)) { Box(Modifier.weight(10f)) {
Text( Text(
text = bugReportId.value, text = bugReportId.value,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = defaultPaddingModifier() modifier = defaultPaddingModifier())
)
} }
Box(Modifier.weight(1f)) { Box(Modifier.weight(1f)) {
Icon( Icon(Icons.Outlined.Share, null, modifier = Modifier.width(24.dp).height(24.dp))
Icons.Outlined.Share, null, modifier = Modifier
.width(24.dp)
.height(24.dp)
)
} }
} }
} }
@Composable @Composable
fun contactText(): AnnotatedString { fun contactText(): AnnotatedString {
val annotatedString = buildAnnotatedString { val annotatedString = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.bug_report_instructions_prefix)) append(stringResource(id = R.string.bug_report_instructions_prefix))
} }
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL) pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
withStyle(style = SpanStyle(color = Color.Blue)) { withStyle(style = SpanStyle(color = Color.Blue)) {
append(stringResource(id = R.string.bug_report_instructions_linktext)) append(stringResource(id = R.string.bug_report_instructions_linktext))
} }
pop() pop()
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.bug_report_instructions_suffix)) append(stringResource(id = R.string.bug_report_instructions_suffix))
}
} }
return annotatedString }
return annotatedString
} }

@ -8,29 +8,40 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.ts_color_light_blue import com.tailscale.ipn.ui.theme.ts_color_light_blue
@Composable @Composable
fun PrimaryActionButton( fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
onClick: () -> Unit, Button(
content: @Composable RowScope.() -> Unit onClick = onClick,
) { colors =
Button( ButtonColors(
onClick = onClick, containerColor = ts_color_light_blue,
colors = ButtonColors( contentColor = Color.White,
containerColor = ts_color_light_blue, disabledContainerColor = MaterialTheme.colorScheme.secondary,
contentColor = Color.White, disabledContentColor = MaterialTheme.colorScheme.onSecondary),
disabledContainerColor = MaterialTheme.colorScheme.secondary, contentPadding = PaddingValues(vertical = 12.dp),
disabledContentColor = MaterialTheme.colorScheme.onSecondary modifier = Modifier.fillMaxWidth(),
), content = content)
contentPadding = PaddingValues(vertical = 12.dp), }
modifier = Modifier
.fillMaxWidth(), @Composable
content = content fun OpenURLButton(title: String, url: String) {
) val handler = LocalUriHandler.current
Button(
onClick = { handler.openUri(url) },
content = { Text(title) },
colors =
ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.secondary,
containerColor = MaterialTheme.colorScheme.secondaryContainer))
} }

@ -11,50 +11,42 @@ import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R import com.tailscale.ipn.R
enum class ErrorDialogType { enum class ErrorDialogType {
LOGOUT_FAILED, SWITCH_USER_FAILED, ADD_PROFILE_FAILED; LOGOUT_FAILED,
SWITCH_USER_FAILED,
val message: Int ADD_PROFILE_FAILED;
get() {
return when (this) { val message: Int
LOGOUT_FAILED -> R.string.logout_failed get() {
SWITCH_USER_FAILED -> R.string.switch_user_failed return when (this) {
ADD_PROFILE_FAILED -> R.string.add_profile_failed 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 val title: Int = R.string.error
val buttonText: Int = R.string.ok
} }
@Composable @Composable
fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) { fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
ErrorDialog(title = type.title, ErrorDialog(
message = type.message, title = type.title, message = type.message, buttonText = type.buttonText, onDismiss = action)
buttonText = type.buttonText,
onDismiss = action)
} }
@Composable @Composable
fun ErrorDialog( fun ErrorDialog(
@StringRes title: Int, @StringRes title: Int,
@StringRes message: Int, @StringRes message: Int,
@StringRes buttonText: Int = R.string.ok, @StringRes buttonText: Int = R.string.ok,
onDismiss: () -> Unit = {} onDismiss: () -> Unit = {}
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { title = { Text(text = stringResource(id = title)) },
Text(text = stringResource(id = title)) text = { Text(text = stringResource(id = message)) },
}, confirmButton = {
text = { PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) }
Text(text = stringResource(id = message)) })
},
confirmButton = {
PrimaryActionButton(onClick = onDismiss) {
Text(text = stringResource(id = buttonText))
}
}
)
} }

@ -1,7 +1,6 @@
// 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.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -21,7 +20,6 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -38,119 +36,104 @@ import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ExitNodePicker( fun ExitNodePicker(
nav: ExitNodePickerNav, nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav)) model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) { ) {
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(topBar = { Scaffold(topBar = { Header(R.string.choose_exit_node) }) { innerPadding ->
TopAppBar(title = { Text(stringResource(R.string.choose_exit_node)) }) val tailnetExitNodes = model.tailnetExitNodes.collectAsState()
}) { innerPadding -> val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val tailnetExitNodes = model.tailnetExitNodes.collectAsState() val anyActive = model.anyActive.collectAsState()
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val anyActive = model.anyActive.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "none") { item(key = "none") {
ExitNodeItem( ExitNodeItem(
model, ExitNodePickerViewModel.ExitNode( model,
label = stringResource(R.string.none), ExitNodePickerViewModel.ExitNode(
online = true, label = stringResource(R.string.none),
selected = !anyActive.value, online = true,
) selected = !anyActive.value,
) ))
} }
item {
ListHeading(stringResource(R.string.tailnet_exit_nodes))
}
items(tailnetExitNodes.value, key = { it.id!! }) { node -> item { ListHeading(stringResource(R.string.tailnet_exit_nodes)) }
ExitNodeItem(model, node, indent = 16.dp)
}
item { items(tailnetExitNodes.value, key = { it.id!! }) { node ->
ListHeading(stringResource(R.string.mullvad_exit_nodes)) ExitNodeItem(model, node, indent = 16.dp)
} }
val sortedCountries = mullvadExitNodes.value.entries.toList().sortedBy { item { ListHeading(stringResource(R.string.mullvad_exit_nodes)) }
it.value.first().country.lowercase()
}
items(sortedCountries) { (countryCode, nodes) ->
val first = nodes.first()
// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash val sortedCountries =
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be cast to androidx.compose.runtime.RecomposeScopeImpl mullvadExitNodes.value.entries.toList().sortedBy {
// Wrapping it in a Box eliminates this. It appears to be some kind of it.value.first().country.lowercase()
// interaction between the LazyList and the modifier.
Box {
ListItem(modifier = Modifier
.padding(start = 16.dp)
.clickable {
if (nodes.size > 1) {
nav.onNavigateToMullvadCountry(
countryCode
)
} else {
model.setExitNode(first)
}
}, headlineContent = {
Text("${countryCode.flag()} ${first.country}")
}, trailingContent = {
val text = if (nodes.size == 1) first.city else "${nodes.size}"
val icon =
if (nodes.size > 1) Icons.AutoMirrored.Outlined.KeyboardArrowRight
else if (first.selected) Icons.Outlined.Check
else null
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text)
Spacer(modifier = Modifier.width(8.dp))
icon?.let {
Icon(
it, contentDescription = stringResource(R.string.more)
)
}
}
})
}
}
} }
items(sortedCountries) { (countryCode, nodes) ->
val first = nodes.first()
// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be cast
// to androidx.compose.runtime.RecomposeScopeImpl
// Wrapping it in a Box eliminates this. It appears to be some kind of
// interaction between the LazyList and the modifier.
Box {
ListItem(
modifier =
Modifier.padding(start = 16.dp).clickable {
if (nodes.size > 1) {
nav.onNavigateToMullvadCountry(countryCode)
} else {
model.setExitNode(first)
}
},
headlineContent = { Text("${countryCode.flag()} ${first.country}") },
trailingContent = {
val text = if (nodes.size == 1) first.city else "${nodes.size}"
val icon =
if (nodes.size > 1) Icons.AutoMirrored.Outlined.KeyboardArrowRight
else if (first.selected) Icons.Outlined.Check else null
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text)
Spacer(modifier = Modifier.width(8.dp))
icon?.let { Icon(it, contentDescription = stringResource(R.string.more)) }
}
})
}
} }
}
} }
}
} }
@Composable @Composable
fun ListHeading(label: String, indent: Dp = 0.dp) { fun ListHeading(label: String, indent: Dp = 0.dp) {
ListItem(modifier = Modifier.padding(start = indent), headlineContent = { ListItem(
Text(text = label, style = MaterialTheme.typography.titleMedium) modifier = Modifier.padding(start = indent),
}) headlineContent = { Text(text = label, style = MaterialTheme.typography.titleMedium) })
} }
@Composable @Composable
fun ExitNodeItem( fun ExitNodeItem(
viewModel: ExitNodePickerViewModel, node: ExitNodePickerViewModel.ExitNode, indent: Dp = 0.dp viewModel: ExitNodePickerViewModel,
node: ExitNodePickerViewModel.ExitNode,
indent: Dp = 0.dp
) { ) {
Box { Box {
ListItem(modifier = Modifier ListItem(
.padding(start = indent) modifier = Modifier.padding(start = indent).clickable { viewModel.setExitNode(node) },
.clickable { viewModel.setExitNode(node) }, headlineContent = { Text(node.city.ifEmpty { node.label }) },
headlineContent = { trailingContent = {
Text(node.city.ifEmpty { node.label }) Row {
}, if (node.selected) {
trailingContent = { Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.more))
Row { } else if (!node.online) {
if (node.selected) { Spacer(modifier = Modifier.width(8.dp))
Icon( Text(stringResource(R.string.offline), fontStyle = FontStyle.Italic)
Icons.Outlined.Check, contentDescription = stringResource(R.string.more) }
) }
} else if (!node.online) { })
Spacer(modifier = Modifier.width(8.dp)) }
Text(stringResource(R.string.offline), fontStyle = FontStyle.Italic)
}
}
})
}
} }

@ -14,12 +14,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@ -29,82 +26,65 @@ import com.tailscale.ipn.mdm.BooleanSetting
import com.tailscale.ipn.mdm.ShowHideSetting import com.tailscale.ipn.mdm.ShowHideSetting
import com.tailscale.ipn.mdm.StringArraySetting import com.tailscale.ipn.mdm.StringArraySetting
import com.tailscale.ipn.mdm.StringSetting import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.util.defaultPaddingModifier import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.viewModel.IpnViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MDMSettingsDebugView(model: IpnViewModel = viewModel()) { fun MDMSettingsDebugView(model: IpnViewModel = viewModel()) {
Scaffold( Scaffold(topBar = { Header(R.string.current_mdm_settings) }) { innerPadding ->
topBar = { val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value
TopAppBar(colors = TopAppBarDefaults.topAppBarColors( LazyColumn(modifier = Modifier.padding(innerPadding)) {
containerColor = MaterialTheme.colorScheme.surfaceContainer, items(enumValues<BooleanSetting>()) { booleanSetting ->
titleContentColor = MaterialTheme.colorScheme.primary, MDMSettingView(
), title = { title = booleanSetting.localizedTitle,
Text(stringResource(R.string.current_mdm_settings)) caption = booleanSetting.key,
}) valueDescription = mdmSettings.get(booleanSetting).toString())
}, }
) { innerPadding ->
val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value
LazyColumn(modifier = Modifier.padding(innerPadding)) {
items(enumValues<BooleanSetting>()) { booleanSetting ->
MDMSettingView(
title = booleanSetting.localizedTitle,
caption = booleanSetting.key,
valueDescription = mdmSettings.get(booleanSetting).toString()
)
}
items(enumValues<StringSetting>()) { stringSetting -> items(enumValues<StringSetting>()) { stringSetting ->
MDMSettingView( MDMSettingView(
title = stringSetting.localizedTitle, title = stringSetting.localizedTitle,
caption = stringSetting.key, caption = stringSetting.key,
valueDescription = mdmSettings.get(stringSetting).toString() valueDescription = mdmSettings.get(stringSetting).toString())
) }
}
items(enumValues<ShowHideSetting>()) { showHideSetting -> items(enumValues<ShowHideSetting>()) { showHideSetting ->
MDMSettingView( MDMSettingView(
title = showHideSetting.localizedTitle, title = showHideSetting.localizedTitle,
caption = showHideSetting.key, caption = showHideSetting.key,
valueDescription = mdmSettings.get(showHideSetting).toString() valueDescription = mdmSettings.get(showHideSetting).toString())
) }
}
items(enumValues<AlwaysNeverUserDecidesSetting>()) { anuSetting -> items(enumValues<AlwaysNeverUserDecidesSetting>()) { anuSetting ->
MDMSettingView( MDMSettingView(
title = anuSetting.localizedTitle, title = anuSetting.localizedTitle,
caption = anuSetting.key, caption = anuSetting.key,
valueDescription = mdmSettings.get(anuSetting).toString() valueDescription = mdmSettings.get(anuSetting).toString())
) }
}
items(enumValues<StringArraySetting>()) { stringArraySetting -> items(enumValues<StringArraySetting>()) { stringArraySetting ->
MDMSettingView( MDMSettingView(
title = stringArraySetting.localizedTitle, title = stringArraySetting.localizedTitle,
caption = stringArraySetting.key, caption = stringArraySetting.key,
valueDescription = mdmSettings.get(stringArraySetting).toString() valueDescription = mdmSettings.get(stringArraySetting).toString())
) }
}
}
} }
}
} }
@Composable @Composable
fun MDMSettingView(title: String, caption: String, valueDescription: String) { fun MDMSettingView(title: String, caption: String, valueDescription: String) {
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = defaultPaddingModifier().fillMaxWidth() modifier = defaultPaddingModifier().fillMaxWidth()) {
) {
Column { Column {
Text(title, maxLines = 3) Text(title, maxLines = 3)
Text( Text(
caption, caption,
fontSize = MaterialTheme.typography.labelSmall.fontSize, fontSize = MaterialTheme.typography.labelSmall.fontSize,
color = MaterialTheme.colorScheme.tertiary, color = MaterialTheme.colorScheme.tertiary,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace)
)
} }
Text( Text(
@ -112,7 +92,6 @@ fun MDMSettingView(title: String, caption: String, valueDescription: String) {
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
maxLines = 1, maxLines = 1,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold)
) }
}
} }

@ -1,7 +1,6 @@
// 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.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -30,9 +29,9 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -60,330 +59,307 @@ 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
// 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, viewModel: MainViewModel = viewModel()) { fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.secondaryContainer) { Scaffold { _ ->
Column( Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center) {
modifier = Modifier.fillMaxWidth(), val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
verticalArrangement = Arrangement.Center val user = viewModel.loggedInUser.collectAsState(initial = null)
) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState) Row(
val user = viewModel.loggedInUser.collectAsState(initial = null) modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically) {
Row(modifier = Modifier val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
.fillMaxWidth() if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) {
.padding(horizontal = 8.dp), Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.size(3.dp))
val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) {
Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
Spacer(Modifier.size(3.dp))
}
StateDisplay(viewModel.stateRes, viewModel.userName)
Box(modifier = Modifier
.weight(1f)
.clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) {
when (user.value) {
null -> SettingsButton(user.value) { navigation.onNavigateToSettings() }
else -> Avatar(profile = user.value, size = 36)
}
}
} }
StateDisplay(viewModel.stateRes, viewModel.userName)
when (state.value) { Box(
Ipn.State.Running -> { modifier = Modifier.weight(1f).clickable { navigation.onNavigateToSettings() },
contentAlignment = Alignment.CenterEnd) {
val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "") when (user.value) {
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) null -> SettingsButton(user.value) { navigation.onNavigateToSettings() }
PeerList( else -> Avatar(profile = user.value, size = 36)
searchTerm = viewModel.searchTerm, }
state = viewModel.ipnState,
peers = viewModel.peers,
selfPeer = selfPeerId.value,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
} }
}
Ipn.State.Starting -> StartingView()
else -> when (state.value) {
ConnectView( Ipn.State.Running -> {
user.value,
{ viewModel.toggleVpn() }, val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "")
{ viewModel.login {} } ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
) PeerList(
} searchTerm = viewModel.searchTerm,
state = viewModel.ipnState,
peers = viewModel.peers,
selfPeer = selfPeerId.value,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
} }
Ipn.State.Starting -> StartingView()
else -> ConnectView(user.value, { viewModel.toggleVpn() }, { viewModel.login {} })
}
} }
}
} }
@Composable @Composable
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val prefs = viewModel.prefs.collectAsState() val prefs = viewModel.prefs.collectAsState()
val netmap = viewModel.netmap.collectAsState() val netmap = viewModel.netmap.collectAsState()
val exitNodeId = prefs.value?.ExitNodeID val exitNodeId = prefs.value?.ExitNodeID
val exitNode = exitNodeId?.let { id -> val exitNode =
netmap.value?.Peers?.find { it.StableID == id }?.let { peer -> exitNodeId?.let { id ->
peer.Hostinfo.Location?.let { location -> netmap.value
?.Peers
?.find { it.StableID == id }
?.let { peer ->
peer.Hostinfo.Location?.let { location ->
"${location.Country?.flag()} ${location.Country} - ${location.City}" "${location.Country?.flag()} ${location.Country} - ${location.City}"
} ?: peer.Name } ?: peer.Name
} }
} }
Box(modifier = Modifier Box(
.clickable { navAction() } modifier =
.padding(horizontal = 8.dp) Modifier.clickable { navAction() }
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) .padding(horizontal = 8.dp)
.background(MaterialTheme.colorScheme.secondaryContainer) .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.fillMaxWidth()) { .background(MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) {
Column(modifier = Modifier.padding(6.dp)) { Column(modifier = Modifier.padding(6.dp)) {
Text(
text = stringResource(id = R.string.exit_node),
style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = stringResource(id = R.string.exit_node), text = exitNode ?: stringResource(id = R.string.none),
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.bodyMedium)
Icon(
Icons.Outlined.ArrowDropDown,
null,
) )
Row(verticalAlignment = Alignment.CenterVertically) { }
Text(text = exitNode
?: stringResource(id = R.string.none), style = MaterialTheme.typography.bodyMedium)
Icon(
Icons.Outlined.ArrowDropDown,
null,
)
}
} }
} }
} }
@Composable @Composable
fun StateDisplay(state: StateFlow<Int>, tailnet: String) { fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
val stateVal = state.collectAsState(initial = R.string.placeholder) val stateVal = state.collectAsState(initial = R.string.placeholder)
val stateStr = stringResource(id = stateVal.value) val stateStr = stringResource(id = stateVal.value)
Column(modifier = Modifier.padding(7.dp)) { Column(modifier = Modifier.padding(7.dp)) {
when (tailnet.isEmpty()) { when (tailnet.isEmpty()) {
false -> { false -> {
Text(text = tailnet, style = MaterialTheme.typography.titleMedium) Text(text = tailnet, style = MaterialTheme.typography.titleMedium)
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary) Text(
} text = stateStr,
style = MaterialTheme.typography.bodyMedium,
true -> { color = MaterialTheme.colorScheme.secondary)
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) }
} 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( IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
modifier = Modifier.size(24.dp), Icon(
onClick = { action() } Icons.Outlined.Settings,
) { null,
Icon( )
Icons.Outlined.Settings, }
null,
)
}
} }
@Composable @Composable
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().background(MaterialTheme.colorScheme.background),
.fillMaxSize() verticalArrangement = Arrangement.Center,
.background(MaterialTheme.colorScheme.background), horizontalAlignment = Alignment.CenterHorizontally) {
verticalArrangement = Arrangement.Center, Text(
horizontalAlignment = Alignment.CenterHorizontally text = stringResource(id = R.string.starting),
) { style = MaterialTheme.typography.titleMedium,
Text(text = stringResource(id = R.string.starting), color = MaterialTheme.colorScheme.primary)
style = MaterialTheme.typography.titleMedium, }
color = MaterialTheme.colorScheme.primary
)
}
} }
@Composable @Composable
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 =
.background(MaterialTheme.colorScheme.secondaryContainer) Modifier.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, horizontalAlignment = Alignment.CenterHorizontally,
alignment = Alignment.CenterVertically ) {
), if (user != null && !user.isEmpty()) {
horizontalAlignment = Alignment.CenterHorizontally, Icon(
) { painter = painterResource(id = R.drawable.power),
if (user != null && !user.isEmpty()) { contentDescription = null,
Icon( modifier = Modifier.size(48.dp),
painter = painterResource(id = R.drawable.power), tint = MaterialTheme.colorScheme.secondary)
contentDescription = null, Text(
modifier = Modifier.size(48.dp), text = stringResource(id = R.string.not_connected),
tint = MaterialTheme.colorScheme.secondary fontSize = MaterialTheme.typography.titleMedium.fontSize,
) fontWeight = FontWeight.SemiBold,
Text( color = MaterialTheme.colorScheme.primary,
text = stringResource(id = R.string.not_connected), textAlign = TextAlign.Center,
fontSize = MaterialTheme.typography.titleMedium.fontSize, fontFamily = MaterialTheme.typography.titleMedium.fontFamily)
fontWeight = FontWeight.SemiBold, val tailnetName = user.NetworkProfile?.DomainName ?: ""
color = MaterialTheme.colorScheme.primary, Text(
textAlign = TextAlign.Center, stringResource(id = R.string.connect_to_tailnet, tailnetName),
fontFamily = MaterialTheme.typography.titleMedium.fontFamily fontSize = MaterialTheme.typography.titleMedium.fontSize,
) fontWeight = FontWeight.Normal,
val tailnetName = user.NetworkProfile?.DomainName ?: "" color = MaterialTheme.colorScheme.secondary,
Text( textAlign = TextAlign.Center,
stringResource(id = R.string.connect_to_tailnet, tailnetName), )
fontSize = MaterialTheme.typography.titleMedium.fontSize, Spacer(modifier = Modifier.size(1.dp))
fontWeight = FontWeight.Normal, PrimaryActionButton(onClick = connectAction) {
color = MaterialTheme.colorScheme.secondary, Text(
textAlign = TextAlign.Center, text = stringResource(id = R.string.connect),
) fontSize = MaterialTheme.typography.titleMedium.fontSize)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = connectAction) {
Text(
text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize
)
}
} else {
TailscaleLogoView(Modifier.size(50.dp))
Spacer(modifier = Modifier.size(1.dp))
Text(
text = stringResource(id = R.string.welcome_to_tailscale),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
Text(
stringResource(R.string.login_to_join_your_tailnet),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = loginAction) {
Text(
text = stringResource(id = R.string.log_in),
fontSize = MaterialTheme.typography.titleMedium.fontSize
)
}
}
} }
} else {
TailscaleLogoView(Modifier.size(50.dp))
Spacer(modifier = Modifier.size(1.dp))
Text(
text = stringResource(id = R.string.welcome_to_tailscale),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center)
Text(
stringResource(R.string.login_to_join_your_tailnet),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = loginAction) {
Text(
text = stringResource(id = R.string.log_in),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
}
} }
}
} }
@Composable @Composable
fun ClearButton(onClick: () -> Unit) { fun ClearButton(onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) { IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Clear, null) Icon(Icons.Outlined.Clear, null)
} }
} }
@Composable @Composable
fun CloseButton() { fun CloseButton() {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) { IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Close, null) 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>())
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 = {}, onActiveChange = {},
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
leadingIcon = { Icon(Icons.Outlined.Search, null) }, leadingIcon = { Icon(Icons.Outlined.Search, null) },
trailingIcon = { if (searchTermStr.isNotEmpty()) ClearButton({ onSearch("") }) else CloseButton() }, trailingIcon = {
tonalElevation = 2.dp, if (searchTermStr.isNotEmpty()) ClearButton({ onSearch("") }) else CloseButton()
shadowElevation = 2.dp, },
colors = SearchBarDefaults.colors(), tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth() shadowElevation = 2.dp,
) { colors = SearchBarDefaults.colors(),
modifier = Modifier.fillMaxWidth()) {
LazyColumn( LazyColumn(
modifier =
modifier = Modifier.fillMaxSize().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(
?: stringResource(id = R.string.unknown_user), style = MaterialTheme.typography.titleLarge) text =
}) peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
} style = MaterialTheme.typography.titleLarge)
peerSet.peers.forEach { peer -> })
item { }
peerSet.peers.forEach { peer ->
ListItem( item {
modifier = Modifier.clickable { ListItem(
onNavigateToPeerDetails(peer) modifier = Modifier.clickable { onNavigateToPeerDetails(peer) },
}, headlineContent = {
headlineContent = { Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically) { // By definition, SelfPeer is online since we will not show the peer list
// By definition, SelfPeer is online since we will not show the peer list unless you're connected. // unless you're connected.
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running) val isSelfAndRunning =
val color: Color = if ((peer.Online == true) || isSelfAndRunning) { (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
ts_color_light_green val color: Color =
} else { if ((peer.Online == true) || isSelfAndRunning) {
Color.Gray ts_color_light_green
} } else {
Box(modifier = Modifier Color.Gray
.size(8.dp) }
.background(color = color, shape = RoundedCornerShape(percent = 50))) {} Box(
Spacer(modifier = Modifier.size(8.dp)) modifier =
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium) Modifier.size(8.dp)
} .background(
}, color = color, shape = RoundedCornerShape(percent = 50))) {}
supportingContent = { Spacer(modifier = Modifier.size(8.dp))
Text( Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
text = peer.Addresses?.first()?.split("/")?.first() }
?: "", },
style = MaterialTheme.typography.bodyMedium supportingContent = {
) Text(
}, text = peer.Addresses?.first()?.split("/")?.first() ?: "",
trailingContent = { style = MaterialTheme.typography.bodyMedium)
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) },
} trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) })
) }
}
}
} }
}
} }
} }
} }

@ -23,30 +23,24 @@ import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Composable @Composable
fun ManagedByView(model: IpnViewModel = viewModel()) { fun ManagedByView(model: IpnViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.surface) { Surface(color = MaterialTheme.colorScheme.surface) {
Column( Column(
verticalArrangement = Arrangement.spacedBy( verticalArrangement =
space = 20.dp, alignment = Alignment.CenterVertically Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
), horizontalAlignment = Alignment.Start,
horizontalAlignment = Alignment.Start, modifier = Modifier.fillMaxWidth().safeContentPadding()) {
modifier = Modifier val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value
.fillMaxWidth() mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
.safeContentPadding() Text(stringResource(R.string.managed_by_explainer_orgName, it))
) { } ?: run { Text(stringResource(R.string.managed_by_explainer)) }
val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value mdmSettings.get(StringSetting.ManagedByCaption)?.let {
mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let { if (it.isNotEmpty()) {
Text(stringResource(R.string.managed_by_explainer_orgName, it)) Text(it)
} ?: run {
Text(stringResource(R.string.managed_by_explainer))
}
mdmSettings.get(StringSetting.ManagedByCaption)?.let {
if (it.isNotEmpty()) {
Text(it)
}
}
mdmSettings.get(StringSetting.ManagedByURL)?.let {
OpenURLButton(stringResource(R.string.open_support), it)
} }
}
mdmSettings.get(StringSetting.ManagedByURL)?.let {
OpenURLButton(stringResource(R.string.open_support), it)
}
} }
} }
} }

@ -1,7 +1,6 @@
// 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.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -23,7 +22,6 @@ import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MullvadExitNodePicker( fun MullvadExitNodePicker(
@ -31,36 +29,33 @@ fun MullvadExitNodePicker(
nav: ExitNodePickerNav, nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav)) model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) { ) {
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState() val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val bestAvailableByCountry = model.mullvadBestAvailableByCountry.collectAsState() val bestAvailableByCountry = model.mullvadBestAvailableByCountry.collectAsState()
mullvadExitNodes.value[countryCode]?.toList()?.let { nodes -> mullvadExitNodes.value[countryCode]?.toList()?.let { nodes ->
val any = nodes.first() val any = nodes.first()
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(topBar = { Scaffold(topBar = { TopAppBar(title = { Text("${countryCode.flag()} ${any.country}") }) }) {
TopAppBar(title = { Text("${countryCode.flag()} ${any.country}") }) innerPadding ->
}) { innerPadding -> LazyColumn(modifier = Modifier.padding(innerPadding)) {
LazyColumn(modifier = Modifier.padding(innerPadding)) { if (nodes.size > 1) {
if (nodes.size > 1) { val bestAvailableNode = bestAvailableByCountry.value[countryCode]!!
val bestAvailableNode = bestAvailableByCountry.value[countryCode]!! item {
item { ExitNodeItem(
ExitNodeItem( model,
model, ExitNodePickerViewModel.ExitNode( ExitNodePickerViewModel.ExitNode(
id = bestAvailableNode.id, id = bestAvailableNode.id,
label = stringResource(R.string.best_available), label = stringResource(R.string.best_available),
online = bestAvailableNode.online, online = bestAvailableNode.online,
selected = false, selected = false,
) ))
)
}
}
items(nodes) { node ->
ExitNodeItem(model, node)
}
}
} }
}
items(nodes) { node -> ExitNodeItem(model, node) }
} }
}
} }
}
} }

@ -1,7 +1,6 @@
// 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.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -19,7 +18,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -34,105 +33,80 @@ import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
@Composable @Composable
fun PeerDetails( fun PeerDetails(
nodeId: String, model: PeerDetailsViewModel = viewModel( nodeId: String,
factory = PeerDetailsViewModelFactory(nodeId) model: PeerDetailsViewModel = viewModel(factory = PeerDetailsViewModelFactory(nodeId))
)
) { ) {
Surface(color = MaterialTheme.colorScheme.surface) { Scaffold(
topBar = {
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(8.dp),
.padding(horizontal = 8.dp)
.fillMaxHeight()
) { ) {
Column( Text(
modifier = Modifier text = model.nodeName,
.fillMaxWidth() style = MaterialTheme.typography.titleLarge,
.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.primary)
horizontalAlignment = Alignment.CenterHorizontally Row(verticalAlignment = Alignment.CenterVertically) {
) { Box(
Text( modifier =
text = model.nodeName, Modifier.size(8.dp)
style = MaterialTheme.typography.titleMedium, .background(
color = MaterialTheme.colorScheme.primary color = model.connectedColor,
) shape = RoundedCornerShape(percent = 50))) {}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.background(
color = model.connectedColor,
shape = RoundedCornerShape(percent = 50)
)
) {}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(id = model.connectedStrRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text( Text(
text = stringResource(id = R.string.addresses_section), text = stringResource(id = model.connectedStrRes),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary)
) }
}
Column(modifier = settingsRowModifier()) { }) { innerPadding ->
model.addresses.forEach { Column(modifier = Modifier.padding(innerPadding).fillMaxHeight()) {
AddressRow(address = it.address, type = it.typeString) Text(
} text = stringResource(id = R.string.addresses_section),
} style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.size(16.dp))
Column(modifier = settingsRowModifier()) {
Column(modifier = settingsRowModifier()) { model.addresses.forEach { AddressRow(address = it.address, type = it.typeString) }
model.info.forEach { }
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
} Spacer(modifier = Modifier.size(16.dp))
Column(modifier = settingsRowModifier()) {
model.info.forEach {
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
} }
}
} }
} }
} }
@Composable @Composable
fun AddressRow(address: String, type: String) { fun AddressRow(address: String, type: String) {
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
Row( Row(
modifier = Modifier modifier =
.padding(horizontal = 8.dp, vertical = 4.dp) Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) }) .clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })) {
) {
Column { Column {
Text(text = address, style = MaterialTheme.typography.titleMedium) Text(text = address, style = MaterialTheme.typography.titleMedium)
Text(text = type, style = MaterialTheme.typography.bodyMedium) Text(text = type, style = MaterialTheme.typography.bodyMedium)
} }
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Icon(Icons.Outlined.Share, null) Icon(Icons.Outlined.Share, null)
} }
} }
} }
@Composable @Composable
fun ValueRow(title: String, value: String) { fun ValueRow(title: String, value: String) {
Row( Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).fillMaxWidth()) {
modifier = Modifier Text(text = title, style = MaterialTheme.typography.titleMedium)
.padding(horizontal = 8.dp, vertical = 4.dp) Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
.fillMaxWidth() Text(text = value, style = MaterialTheme.typography.bodyMedium)
) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = value, style = MaterialTheme.typography.bodyMedium)
}
} }
}
} }

@ -1,7 +1,6 @@
// 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.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -15,7 +14,7 @@ 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.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -35,8 +34,6 @@ 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.theme.ts_color_dark_desctrutive_text 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
@ -45,159 +42,143 @@ import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.SettingsViewModelFactory 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 user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value val isAdmin = viewModel.isAdmin.collectAsState().value
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxHeight()) { Scaffold(topBar = { Header(title = R.string.settings_title) }) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).fillMaxHeight()) {
Column(modifier = defaultPaddingModifier().fillMaxHeight()) { UserView(
profile = user,
actionState = UserActionState.NAV,
Header(title = R.string.settings_title) onClick = viewModel.navigation.onNavigateToUserSwitcher)
Spacer(modifier = Modifier.height(8.dp)) if (isAdmin) {
Spacer(modifier = Modifier.height(4.dp))
UserView(profile = user, AdminTextView { handler.openUri(Links.ADMIN_URL) }
actionState = UserActionState.NAV, }
onClick = viewModel.navigation.onNavigateToUserSwitcher)
if (isAdmin) { Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(4.dp))
AdminTextView { handler.openUri(Links.ADMIN_URL) } val settings = viewModel.settings.collectAsState().value
} settings.forEach { settingBundle ->
Column(modifier = settingsRowModifier()) {
Spacer(modifier = Modifier.height(8.dp)) settingBundle.title?.let { SettingTitle(it) }
settingBundle.settings.forEach { SettingRow(it) }
val settings = viewModel.settings.collectAsState().value
settings.forEach { settingBundle ->
Column(modifier = settingsRowModifier()) {
settingBundle.title?.let {
SettingTitle(it)
}
settingBundle.settings.forEach { SettingRow(it) }
}
Spacer(modifier = Modifier.height(8.dp))
}
} }
Spacer(modifier = Modifier.height(8.dp))
}
} }
}
} }
@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)) {
Box(modifier = defaultPaddingModifier()) { Avatar(profile = profile, size = 36) }
Box(modifier = defaultPaddingModifier()) {
Avatar(profile = profile, size = 36) Column(verticalArrangement = Arrangement.Center) {
} Text(
text = profile?.UserProfile?.DisplayName ?: "",
Column(verticalArrangement = Arrangement.Center) { style = MaterialTheme.typography.titleMedium)
Text( Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium)
text = profile?.UserProfile?.DisplayName ?: "", }
style = MaterialTheme.typography.titleMedium }
)
Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium)
}
}
if (isAdmin) {
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
ClickableText(
text = adminText,
style = MaterialTheme.typography.bodySmall,
onClick = {
onClick()
})
}
}
if (isAdmin) {
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
ClickableText(
text = adminText, style = MaterialTheme.typography.bodySmall, onClick = { onClick() })
}
} }
}
} }
@Composable @Composable
fun SettingTitle(title: String) { fun SettingTitle(title: String) {
Text( Text(
text = title, text = title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp))
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(8.dp)
)
} }
@Composable @Composable
fun SettingRow(setting: Setting) { fun SettingRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value val enabled = setting.enabled.collectAsState().value
val swVal = setting.isOn?.collectAsState()?.value ?: false val swVal = setting.isOn?.collectAsState()?.value ?: false
val txtVal = setting.value?.collectAsState()?.value ?: "" val txtVal = setting.value?.collectAsState()?.value ?: ""
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) { Row(
modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() },
verticalAlignment = Alignment.CenterVertically) {
when (setting.type) { when (setting.type) {
SettingType.NAV_WITH_TEXT -> { SettingType.NAV_WITH_TEXT -> {
Text(setting.title.getString(), Text(
style = MaterialTheme.typography.bodyMedium, setting.title.getString(),
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary) style = MaterialTheme.typography.bodyMedium,
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { color =
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium) 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)
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 -> { SettingType.TEXT -> {
Text(setting.title.getString()) Text(
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { setting.title.getString(),
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled) 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 -> { SettingType.NAV -> {
Text(setting.title.getString(), Text(
style = MaterialTheme.typography.bodyMedium, setting.title.getString(),
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary) style = MaterialTheme.typography.bodyMedium,
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { color =
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium) if (setting.destructive) ts_color_dark_desctrutive_text
} else MaterialTheme.colorScheme.primary)
ChevronRight() Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
} }
ChevronRight()
}
} }
} }
} }
@Composable @Composable
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) { fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val adminStr = 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))
}
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
withStyle(style = SpanStyle(color = Color.Blue)) {
append(stringResource(id = R.string.settings_admin_link))
}
pop()
} }
Column(modifier = Modifier.padding(horizontal = 12.dp)) { pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
ClickableText( withStyle(style = SpanStyle(color = Color.Blue)) {
text = adminStr, append(stringResource(id = R.string.settings_admin_link))
style = MaterialTheme.typography.bodySmall,
onClick = {
onNavigateToAdminConsole()
})
} }
pop()
}
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
ClickableText(
text = adminStr,
style = MaterialTheme.typography.bodySmall,
onClick = { onNavigateToAdminConsole() })
}
} }

@ -0,0 +1,53 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.width
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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
// Header view for all secondary screens
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Header(@StringRes title: Int) {
TopAppBar(
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = { Text(stringResource(title)) })
}
@Composable
fun ChevronRight() {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
@Composable
fun CheckedIndicator() {
Icon(Icons.Default.CheckCircle, null)
}
@Composable
fun SimpleActivityIndicator(size: Int = 32) {
CircularProgressIndicator(
modifier = Modifier.width(size.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.secondary,
)
}

@ -21,43 +21,43 @@ import androidx.compose.ui.graphics.Color
@Composable @Composable
fun TailscaleLogoView(modifier: Modifier) { fun TailscaleLogoView(modifier: Modifier) {
val primaryColor: Color = MaterialTheme.colorScheme.primary val primaryColor: Color = MaterialTheme.colorScheme.primary
val secondaryColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) val secondaryColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
BoxWithConstraints(modifier) { BoxWithConstraints(modifier) {
Column(verticalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) { Column(verticalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) { Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = secondaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = secondaryColor) })
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = secondaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = secondaryColor) })
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = secondaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = secondaryColor) })
} }
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) { Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = primaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = primaryColor) })
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = primaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = primaryColor) })
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = primaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = primaryColor) })
} }
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) { Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = secondaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = secondaryColor) })
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = primaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = primaryColor) })
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = secondaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = secondaryColor) })
} }
}
} }
}
} }

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

@ -11,57 +11,60 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal 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.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier import com.tailscale.ipn.ui.util.settingsRowModifier
// Used to decorate UserViews. // Used to decorate UserViews.
// NONE indicates no decoration // NONE indicates no decoration
// CURRENT indicates the user is the current user and will be "checked" // CURRENT indicates the user is the current user and will be "checked"
// SWITCHING indicates the user is being switched to and will be "loading" // SWITCHING indicates the user is being switched to and will be "loading"
// NAV will show a chevron // NAV will show a chevron
enum class UserActionState { CURRENT, SWITCHING, NAV, NONE } enum class UserActionState {
CURRENT,
SWITCHING,
NAV,
NONE
}
@Composable @Composable
fun UserView( fun UserView(
profile: IpnLocal.LoginProfile?, profile: IpnLocal.LoginProfile?,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
actionState: UserActionState = UserActionState.NONE actionState: UserActionState = UserActionState.NONE
) { ) {
Column { Column {
Row(modifier = settingsRowModifier().clickable { onClick() }) { Row(
modifier = settingsRowModifier().clickable { onClick() },
verticalAlignment = Alignment.CenterVertically) {
profile?.let {
Box(modifier = defaultPaddingModifier()) { Avatar(profile = profile, size = 36) }
profile?.let { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) {
Box(modifier = defaultPaddingModifier()) { Text(
Avatar(profile = profile, size = 36) text = profile.UserProfile.DisplayName,
} style = MaterialTheme.typography.titleMedium)
Text(text = profile.Name, style = MaterialTheme.typography.bodyMedium)
Column(modifier = Modifier.weight(1f), }
verticalArrangement = Arrangement.Center) { }
Text( ?: run {
text = profile.UserProfile.DisplayName
?: "", style = MaterialTheme.typography.titleMedium
)
Text(text = profile.Name ?: "", style = MaterialTheme.typography.bodyMedium)
}
} ?: run {
Box(modifier = Modifier.weight(1f)) { Box(modifier = Modifier.weight(1f)) {
Text(text = stringResource(id = R.string.accounts), style = MaterialTheme.typography.titleMedium) Text(
text = stringResource(id = R.string.accounts),
style = MaterialTheme.typography.titleMedium)
} }
} }
when (actionState) {
UserActionState.CURRENT -> CheckedIndicator() when (actionState) {
UserActionState.SWITCHING -> LoadingIndicator(size = 32) UserActionState.CURRENT -> CheckedIndicator()
UserActionState.NAV -> ChevronRight() UserActionState.SWITCHING -> SimpleActivityIndicator(size = 26)
UserActionState.NONE -> Unit UserActionState.NAV -> ChevronRight()
} UserActionState.NONE -> Unit
}
} }
} }
} }

@ -10,14 +10,12 @@ import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class BugReportViewModel : ViewModel() { class BugReportViewModel : ViewModel() {
val bugReportID: StateFlow<String> = MutableStateFlow("") val bugReportID: StateFlow<String> = MutableStateFlow("")
init { init {
Client(viewModelScope).bugReportId { result -> Client(viewModelScope).bugReportId { result ->
result.onSuccess { bugReportID.set(it) } result.onSuccess { bugReportID.set(it) }.onFailure { bugReportID.set("(Error fetching ID)") }
.onFailure { bugReportID.set("(Error fetching ID)") }
}
} }
}
} }

@ -1,7 +1,6 @@
// 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.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -13,12 +12,12 @@ 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.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import java.util.TreeMap
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.TreeMap
data class ExitNodePickerNav( data class ExitNodePickerNav(
val onNavigateHome: () -> Unit, val onNavigateHome: () -> Unit,
@ -27,104 +26,111 @@ data class ExitNodePickerNav(
class ExitNodePickerViewModelFactory(private val nav: ExitNodePickerNav) : class ExitNodePickerViewModelFactory(private val nav: ExitNodePickerNav) :
ViewModelProvider.Factory { ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ExitNodePickerViewModel(nav) as T return ExitNodePickerViewModel(nav) as T
} }
} }
class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel() { class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel() {
data class ExitNode( data class ExitNode(
val id: StableNodeID? = null, val id: StableNodeID? = null,
val label: String, val label: String,
val online: Boolean, val online: Boolean,
val selected: Boolean, val selected: Boolean,
val mullvad: Boolean = false, val mullvad: Boolean = false,
val priority: Int = 0, val priority: Int = 0,
val countryCode: String = "", val countryCode: String = "",
val country: String = "", val country: String = "",
val city: String = "" val city: String = ""
) )
val tailnetExitNodes: StateFlow<List<ExitNode>> = MutableStateFlow(emptyList()) val tailnetExitNodes: StateFlow<List<ExitNode>> = MutableStateFlow(emptyList())
val mullvadExitNodesByCountryCode: StateFlow<Map<String, List<ExitNode>>> = MutableStateFlow( val mullvadExitNodesByCountryCode: StateFlow<Map<String, List<ExitNode>>> =
TreeMap() MutableStateFlow(TreeMap())
) val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap())
val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow( val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
TreeMap()
)
val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
init { init {
viewModelScope.launch { viewModelScope.launch {
Notifier.netmap.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) } Notifier.netmap
.stateIn(viewModelScope).collect { (netmap, prefs) -> .combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
val exitNodeId = prefs?.ExitNodeID .stateIn(viewModelScope)
netmap?.Peers?.let { peers -> .collect { (netmap, prefs) ->
val allNodes = peers.filter { it.isExitNode }.map { val exitNodeId = prefs?.ExitNodeID
ExitNode( netmap?.Peers?.let { peers ->
id = it.StableID, val allNodes =
label = it.Name, peers
online = it.Online ?: false, .filter { it.isExitNode }
selected = it.StableID == exitNodeId, .map {
mullvad = it.Name.endsWith(".mullvad.ts.net."), ExitNode(
priority = it.Hostinfo?.Location?.Priority ?: 0, id = it.StableID,
countryCode = it.Hostinfo?.Location?.CountryCode ?: "", label = it.Name,
country = it.Hostinfo?.Location?.Country ?: "", online = it.Online ?: false,
city = it.Hostinfo?.Location?.City ?: "", selected = it.StableID == exitNodeId,
) mullvad = it.Name.endsWith(".mullvad.ts.net."),
} priority = it.Hostinfo.Location?.Priority ?: 0,
countryCode = it.Hostinfo.Location?.CountryCode ?: "",
country = it.Hostinfo.Location?.Country ?: "",
city = it.Hostinfo.Location?.City ?: "",
)
}
val tailnetNodes = allNodes.filter { !it.mullvad } val tailnetNodes = allNodes.filter { !it.mullvad }
tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> a.label.compareTo(b.label) })
a.label.compareTo(
b.label
)
})
val mullvadExitNodes = allNodes.filter { val mullvadExitNodes =
// Pick all mullvad nodes that are online or the currently selected allNodes
it.mullvad && (it.selected || it.online) .filter {
}.groupBy { // Pick all mullvad nodes that are online or the currently selected
// Group by countryCode it.mullvad && (it.selected || it.online)
it.countryCode }
}.mapValues { (_, nodes) -> .groupBy {
// Group by city // Group by countryCode
nodes.groupBy { it.countryCode
it.city }
}.mapValues { (_, nodes) -> .mapValues { (_, nodes) ->
// Pick one node per city, either the selected one or the best // Group by city
// available nodes
nodes.sortedWith { a, b -> .groupBy { it.city }
.mapValues { (_, nodes) ->
// Pick one node per city, either the selected one or the best
// available
nodes
.sortedWith { a, b ->
if (a.selected && !b.selected) { if (a.selected && !b.selected) {
-1 -1
} else if (b.selected && !a.selected) { } else if (b.selected && !a.selected) {
1 1
} else { } else {
b.priority.compareTo(a.priority) b.priority.compareTo(a.priority)
} }
}.first() }
}.values.sortedBy { it.city.lowercase() } .first()
} }
mullvadExitNodesByCountryCode.set(mullvadExitNodes) .values
.sortedBy { it.city.lowercase() }
}
mullvadExitNodesByCountryCode.set(mullvadExitNodes)
val bestAvailableByCountry = mullvadExitNodes.mapValues { (_, nodes) -> val bestAvailableByCountry =
nodes.minByOrNull { -1 * it.priority }!! mullvadExitNodes.mapValues { (_, nodes) ->
} nodes.minByOrNull { -1 * it.priority }!!
mullvadBestAvailableByCountry.set(bestAvailableByCountry) }
mullvadBestAvailableByCountry.set(bestAvailableByCountry)
anyActive.set(allNodes.any { it.selected }) anyActive.set(allNodes.any { it.selected })
} }
} }
}
} }
}
fun setExitNode(node: ExitNode) { fun setExitNode(node: ExitNode) {
LoadingIndicator.start() LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs() val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = node.id prefsOut.ExitNodeID = node.id
Client(viewModelScope).editPrefs(prefsOut) { Client(viewModelScope).editPrefs(prefsOut) {
nav.onNavigateHome() nav.onNavigateHome()
LoadingIndicator.stop() LoadingIndicator.stop()
}
} }
}
} }

@ -25,180 +25,181 @@ import kotlinx.coroutines.launch
* notifications, managing login/logout, updating preferences, etc. * notifications, managing login/logout, updating preferences, etc.
*/ */
open class IpnViewModel : ViewModel() { open class IpnViewModel : ViewModel() {
companion object { companion object {
val mdmSettings: StateFlow<MDMSettings> = MutableStateFlow(MDMSettings()) val mdmSettings: StateFlow<MDMSettings> = MutableStateFlow(MDMSettings())
} }
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. // The userId associated with the current node. ie: The logged in user.
var selfNodeUserId: UserID? = null var selfNodeUserId: UserID? = null
init { init {
viewModelScope.launch { viewModelScope.launch {
Notifier.state.collect { Notifier.state.collect {
// Refresh the user profiles if we're transitioning out of the // Refresh the user profiles if we're transitioning out of the
// NeedsLogin state. // NeedsLogin state.
if (it == Ipn.State.NeedsLogin) { if (it == Ipn.State.NeedsLogin) {
viewModelScope.launch { loadUserProfiles() } viewModelScope.launch { loadUserProfiles() }
}
}
} }
}
// This will observe the userId of the current node and reload our user profiles if
// we discover it has changed (e.g. due to a login or user switch)
viewModelScope.launch {
Notifier.netmap.collect {
it?.SelfNode?.User.let {
if (it != selfNodeUserId) {
selfNodeUserId = it
viewModelScope.launch { loadUserProfiles() }
}
}
}
}
viewModelScope.launch { loadUserProfiles() }
Log.d(TAG, "Created")
} }
private fun loadUserProfiles() { // This will observe the userId of the current node and reload our user profiles if
Client(viewModelScope).profiles { result -> // we discover it has changed (e.g. due to a login or user switch)
result.onSuccess(loginProfiles::set).onFailure { viewModelScope.launch {
Log.e(TAG, "Error loading profiles: ${it.message}") Notifier.netmap.collect {
} it?.SelfNode?.User.let {
} if (it != selfNodeUserId) {
selfNodeUserId = it
Client(viewModelScope).currentProfile { result -> viewModelScope.launch { loadUserProfiles() }
result.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) } }
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
} }
}
} }
fun toggleVpn() { viewModelScope.launch { loadUserProfiles() }
when (Notifier.state.value) { Log.d(TAG, "Created")
Ipn.State.Running -> stopVPN() }
else -> startVPN()
}
}
private fun startVPN() { private fun loadUserProfiles() {
val context = App.getApplication().applicationContext Client(viewModelScope).profiles { result ->
val intent = Intent(context, IPNReceiver::class.java) result.onSuccess(loginProfiles::set).onFailure {
intent.action = IPNReceiver.INTENT_CONNECT_VPN Log.e(TAG, "Error loading profiles: ${it.message}")
context.sendBroadcast(intent) }
} }
fun stopVPN() { Client(viewModelScope).currentProfile { result ->
val context = App.getApplication().applicationContext result
val intent = Intent(context, IPNReceiver::class.java) .onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN .onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
context.sendBroadcast(intent)
} }
}
fun login(completionHandler: (Result<String>) -> Unit = {}) { fun toggleVpn() {
Client(viewModelScope).startLoginInteractive { result -> when (Notifier.state.value) {
result Ipn.State.Running -> stopVPN()
.onSuccess { Log.d(TAG, "Login started: $it") } else -> startVPN()
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result)
}
} }
}
fun logout(completionHandler: (Result<String>) -> Unit = {}) {
Client(viewModelScope).logout { result -> private fun startVPN() {
result val context = App.getApplication().applicationContext
.onSuccess { Log.d(TAG, "Logout started: $it") } val intent = Intent(context, IPNReceiver::class.java)
.onFailure { Log.e(TAG, "Error starting logout: ${it.message}") } intent.action = IPNReceiver.INTENT_CONNECT_VPN
completionHandler(result) context.sendBroadcast(intent)
} }
fun stopVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
}
fun login(completionHandler: (Result<String>) -> Unit = {}) {
Client(viewModelScope).startLoginInteractive { result ->
result
.onSuccess { Log.d(TAG, "Login started: $it") }
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result)
} }
}
fun switchProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).switchProfile(profile) { fun logout(completionHandler: (Result<String>) -> Unit = {}) {
startVPN() Client(viewModelScope).logout { result ->
completionHandler(it) result
} .onSuccess { Log.d(TAG, "Logout started: $it") }
.onFailure { Log.e(TAG, "Error starting logout: ${it.message}") }
completionHandler(result)
} }
}
fun addProfile(completionHandler: (Result<String>) -> Unit) { fun switchProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).addProfile { Client(viewModelScope).switchProfile(profile) {
if (it.isSuccess) { startVPN()
login {} completionHandler(it)
}
startVPN()
completionHandler(it)
}
} }
}
fun deleteProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).deleteProfile(profile) { fun addProfile(completionHandler: (Result<String>) -> Unit) {
viewModelScope.launch { loadUserProfiles() } Client(viewModelScope).addProfile {
completionHandler(it) if (it.isSuccess) {
} login {}
}
startVPN()
completionHandler(it)
} }
}
// The below handle all types of preference modifications typically invoked by the UI. fun deleteProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
// Callers generally shouldn't care about the returned prefs value - the source of Client(viewModelScope).deleteProfile(profile) {
// truth is the IPNModel, who's prefs flow will change in value to reflect the true viewModelScope.launch { loadUserProfiles() }
// value of the pref setting in the back end (and will match the value returned here). completionHandler(it)
// Generally, you will want to inspect the returned value in the callback for errors
// to indicate why a particular setting did not change in the interface.
//
// Usage:
// - User/Interface changed to new value. Render the new value.
// - Submit the new value to the PrefsEditor
// - Observe the prefs on the IpnModel and update the UI when/if the value changes.
// For a typical flow, the changed value should reflect the value already shown.
// - Inform the user of any error which may have occurred
//
// The "toggle' functions here will attempt to set the pref value to the inverse of
// what is currently known in the IpnModel.prefs. If IpnModel.prefs is not available,
// the callback will be called with a NO_PREFS error
fun setWantRunning(wantRunning: Boolean, callback: (Result<Ipn.Prefs>) -> Unit) {
Ipn.MaskedPrefs().WantRunning = wantRunning
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback)
} }
}
// The below handle all types of preference modifications typically invoked by the UI.
// Callers generally shouldn't care about the returned prefs value - the source of
// truth is the IPNModel, who's prefs flow will change in value to reflect the true
// value of the pref setting in the back end (and will match the value returned here).
// Generally, you will want to inspect the returned value in the callback for errors
// to indicate why a particular setting did not change in the interface.
//
// Usage:
// - User/Interface changed to new value. Render the new value.
// - Submit the new value to the PrefsEditor
// - Observe the prefs on the IpnModel and update the UI when/if the value changes.
// For a typical flow, the changed value should reflect the value already shown.
// - Inform the user of any error which may have occurred
//
// The "toggle' functions here will attempt to set the pref value to the inverse of
// what is currently known in the IpnModel.prefs. If IpnModel.prefs is not available,
// the callback will be called with a NO_PREFS error
fun setWantRunning(wantRunning: Boolean, callback: (Result<Ipn.Prefs>) -> Unit) {
Ipn.MaskedPrefs().WantRunning = wantRunning
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback)
}
fun toggleCorpDNS(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleCorpDNS
}
fun toggleCorpDNS(callback: (Result<Ipn.Prefs>) -> Unit) { val prefsOut = Ipn.MaskedPrefs()
val prefs = prefsOut.CorpDNS = !prefs.CorpDNS
Notifier.prefs.value Client(viewModelScope).editPrefs(prefsOut, callback)
?: run { }
callback(Result.failure(Exception("no prefs")))
return@toggleCorpDNS fun toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
} val prefs =
Notifier.prefs.value
val prefsOut = Ipn.MaskedPrefs() ?: run {
prefsOut.CorpDNS = !prefs.CorpDNS callback(Result.failure(Exception("no prefs")))
Client(viewModelScope).editPrefs(prefsOut, callback) return@toggleShieldsUp
} }
fun toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) { val prefsOut = Ipn.MaskedPrefs()
val prefs = prefsOut.ShieldsUp = !prefs.ShieldsUp
Notifier.prefs.value Client(viewModelScope).editPrefs(prefsOut, callback)
?: run { }
callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp fun toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) {
} val prefs =
Notifier.prefs.value
val prefsOut = Ipn.MaskedPrefs() ?: run {
prefsOut.ShieldsUp = !prefs.ShieldsUp callback(Result.failure(Exception("no prefs")))
Client(viewModelScope).editPrefs(prefsOut, callback) return@toggleRouteAll
} }
fun toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) { val prefsOut = Ipn.MaskedPrefs()
val prefs = prefsOut.RouteAll = !prefs.RouteAll
Notifier.prefs.value Client(viewModelScope).editPrefs(prefsOut, callback)
?: run { }
callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = !prefs.RouteAll
Client(viewModelScope).editPrefs(prefsOut, callback)
}
} }

@ -1,7 +1,6 @@
// 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.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -18,68 +17,65 @@ import kotlinx.coroutines.launch
class MainViewModel : IpnViewModel() { class MainViewModel : IpnViewModel() {
// The user readable state of the system // The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(State.NoState.userStringRes()) val stateRes: StateFlow<Int> = MutableStateFlow(State.NoState.userStringRes())
// The expected state of the VPN toggle
val vpnToggleState: StateFlow<Boolean> = MutableStateFlow(false)
// The list of peers
val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>())
// The current state of the IPN for determining view visibility // The expected state of the VPN toggle
val ipnState = Notifier.state val vpnToggleState: StateFlow<Boolean> = MutableStateFlow(false)
val prefs = Notifier.prefs // The list of peers
val netmap = Notifier.netmap val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>())
// The active search term for filtering peers // The current state of the IPN for determining view visibility
val searchTerm: StateFlow<String> = MutableStateFlow("") val ipnState = Notifier.state
// The peerID of the local node val prefs = Notifier.prefs
val selfPeerId: StateFlow<StableNodeID> = MutableStateFlow("") val netmap = Notifier.netmap
private val peerCategorizer = PeerCategorizer(viewModelScope) // The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("")
val userName: String // The peerID of the local node
get() { val selfPeerId: StateFlow<StableNodeID> = MutableStateFlow("")
return loggedInUser.value?.Name ?: ""
}
private val peerCategorizer = PeerCategorizer(viewModelScope)
init { val userName: String
viewModelScope.launch { get() {
Notifier.state.collect { state -> return loggedInUser.value?.Name ?: ""
stateRes.set(state.userStringRes()) }
vpnToggleState.set((state == State.Running || state == State.Starting))
}
}
viewModelScope.launch { init {
Notifier.netmap.collect { netmap -> viewModelScope.launch {
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value)) Notifier.state.collect { state ->
selfPeerId.set(netmap?.SelfNode?.StableID ?: "") stateRes.set(state.userStringRes())
} vpnToggleState.set((state == State.Running || state == State.Starting))
} }
} }
fun searchPeers(searchTerm: String) { viewModelScope.launch {
this.searchTerm.set(searchTerm) Notifier.netmap.collect { netmap ->
viewModelScope.launch { peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm)) selfPeerId.set(netmap?.SelfNode?.StableID ?: "")
} }
} }
}
fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm)
viewModelScope.launch { peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm)) }
}
} }
private fun State?.userStringRes(): Int { private fun State?.userStringRes(): Int {
return when (this) { return when (this) {
State.NoState -> R.string.waiting State.NoState -> R.string.waiting
State.InUseOtherUser -> R.string.placeholder State.InUseOtherUser -> R.string.placeholder
State.NeedsLogin -> R.string.please_login State.NeedsLogin -> R.string.please_login
State.NeedsMachineAuth -> R.string.placeholder State.NeedsMachineAuth -> R.string.placeholder
State.Stopped -> R.string.stopped State.Stopped -> R.string.stopped
State.Starting -> R.string.starting State.Starting -> R.string.starting
State.Running -> R.string.connected State.Running -> R.string.connected
else -> R.string.placeholder else -> R.string.placeholder
} }
} }

@ -16,45 +16,36 @@ import com.tailscale.ipn.ui.util.TimeUtil
data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter) data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter)
class PeerDetailsViewModelFactory(private val nodeId: StableNodeID) : ViewModelProvider.Factory { class PeerDetailsViewModelFactory(private val nodeId: StableNodeID) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PeerDetailsViewModel(nodeId) as T return PeerDetailsViewModel(nodeId) as T
} }
} }
class PeerDetailsViewModel(val nodeId: StableNodeID) : IpnViewModel() { class PeerDetailsViewModel(val nodeId: StableNodeID) : IpnViewModel() {
var addresses: List<DisplayAddress> = emptyList() var addresses: List<DisplayAddress> = emptyList()
var info: List<PeerSettingInfo> = emptyList() var info: List<PeerSettingInfo> = emptyList()
val nodeName: String
val connectedStrRes: Int
val connectedColor: Color
init { val nodeName: String
val peer = Notifier.netmap.value?.getPeer(nodeId) val connectedStrRes: Int
peer?.Addresses?.let { val connectedColor: Color
addresses = it.map { addr ->
DisplayAddress(addr)
}
}
peer?.Name?.let { init {
addresses = listOf(DisplayAddress(it)) + addresses val peer = Notifier.netmap.value?.getPeer(nodeId)
} peer?.Addresses?.let { addresses = it.map { addr -> DisplayAddress(addr) } }
peer?.Name?.let { addresses = listOf(DisplayAddress(it)) + addresses }
peer?.let { p -> peer?.let { p ->
info = listOf( info =
PeerSettingInfo(R.string.os, ComposableStringFormatter(p.Hostinfo.OS ?: "")), listOf(
PeerSettingInfo(R.string.key_expiry, TimeUtil().keyExpiryFromGoTime(p.KeyExpiry)) PeerSettingInfo(R.string.os, ComposableStringFormatter(p.Hostinfo.OS ?: "")),
) PeerSettingInfo(R.string.key_expiry, TimeUtil().keyExpiryFromGoTime(p.KeyExpiry)))
}
nodeName = peer?.ComputedName ?: ""
connectedStrRes = if (peer?.Online == true) R.string.connected else R.string.not_connected
connectedColor = if (peer?.Online == true) ts_color_light_green else Color.Gray
} }
nodeName = peer?.ComputedName ?: ""
connectedStrRes = if (peer?.Online == true) R.string.connected else R.string.not_connected
connectedColor = if (peer?.Online == true) ts_color_light_green else Color.Gray
}
} }

@ -19,12 +19,15 @@ 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, 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) {
@Composable @Composable fun getString(): String = stringResource(id = stringRes, *params)
fun getString(): String = stringResource(id = stringRes, *params)
} }
// Represents a bundle of settings values that should be grouped together under a title // Represents a bundle of settings values that should be grouped together under a title
@ -43,122 +46,118 @@ data class SettingBundle(val title: String? = null, val settings: List<Setting>)
// isOn and onToggle, while navigation settings should supply an onClick and an optional // isOn and onToggle, while navigation settings should supply an onClick and an optional
// value // value
data class Setting( data class Setting(
val title: ComposableStringFormatter,
val title: ComposableStringFormatter, val type: SettingType,
val type: SettingType, val destructive: Boolean = false,
val destructive: Boolean = false, val enabled: StateFlow<Boolean> = MutableStateFlow(true),
val enabled: StateFlow<Boolean> = MutableStateFlow(true), val value: StateFlow<String?>? = null,
val value: StateFlow<String?>? = null, val isOn: StateFlow<Boolean?>? = null,
val isOn: StateFlow<Boolean?>? = null, val onClick: () -> Unit = {},
val onClick: () -> Unit = {}, val onToggle: (Boolean) -> 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) 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 {
return SettingsViewModel(navigation) as T return SettingsViewModel(navigation) as T
} }
} }
class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() { class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() {
// Display name for the logged in user // Display name for the logged in user
var isAdmin: StateFlow<Boolean> = MutableStateFlow(false) var isAdmin: StateFlow<Boolean> = MutableStateFlow(false)
val useDNSSetting = Setting(R.string.use_ts_dns, val useDNSSetting =
SettingType.SWITCH, Setting(
isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS), R.string.use_ts_dns,
onToggle = { SettingType.SWITCH,
toggleCorpDNS { isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS),
// (jonathan) TODO: Error handling onToggle = {
} toggleCorpDNS {
}) // (jonathan) TODO: Error handling
}
})
val settings: StateFlow<List<SettingBundle>> = MutableStateFlow(emptyList()) val settings: StateFlow<List<SettingBundle>> = MutableStateFlow(emptyList())
init { init {
viewModelScope.launch { viewModelScope.launch {
// Monitor our prefs for changes and update the displayed values accordingly // Monitor our prefs for changes and update the displayed values accordingly
Notifier.prefs.collect { prefs -> Notifier.prefs.collect { prefs ->
useDNSSetting.isOn?.set(prefs?.CorpDNS) useDNSSetting.isOn?.set(prefs?.CorpDNS)
useDNSSetting.enabled.set(prefs != null) useDNSSetting.enabled.set(prefs != null)
} }
} }
viewModelScope.launch { viewModelScope.launch {
mdmSettings.collect { mdmSettings -> mdmSettings.collect { mdmSettings ->
settings.set( settings.set(
listOf(
SettingBundle(
settings =
listOf( listOf(
SettingBundle( useDNSSetting,
settings = listOf( )),
useDNSSetting, // General settings, always enabled
) SettingBundle(settings = footerSettings(mdmSettings))))
), }
// General settings, always enabled
SettingBundle(settings = footerSettings(mdmSettings))
)
)
}
}
viewModelScope.launch {
Notifier.netmap.collect { netmap ->
isAdmin.set(netmap?.SelfNode?.isAdmin ?: false)
}
}
} }
private fun footerSettings(mdmSettings: MDMSettings): List<Setting> = listOfNotNull( viewModelScope.launch {
Notifier.netmap.collect { netmap -> isAdmin.set(netmap?.SelfNode?.isAdmin ?: false) }
}
}
private fun footerSettings(mdmSettings: MDMSettings): List<Setting> =
listOfNotNull(
Setting(
titleRes = R.string.about,
SettingType.NAV,
onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true)),
Setting(
titleRes = R.string.bug_report,
SettingType.NAV,
onClick = { navigation.onNavigateToBugReport() },
enabled = MutableStateFlow(true)),
mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Setting( Setting(
titleRes = R.string.about,
SettingType.NAV,
onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true)
), Setting(
titleRes = R.string.bug_report,
SettingType.NAV,
onClick = { navigation.onNavigateToBugReport() },
enabled = MutableStateFlow(true)
), mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Setting(
ComposableStringFormatter(R.string.managed_by_orgName, it), 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 })
}
)
} }

@ -9,7 +9,7 @@ import com.tailscale.ipn.ui.view.ErrorDialogType
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class UserSwitcherViewModel() : IpnViewModel() { class UserSwitcherViewModel : IpnViewModel() {
// Set to a non-null value to show the appropriate error dialog // Set to a non-null value to show the appropriate error dialog
val showDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null) val showDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)

Loading…
Cancel
Save