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

@ -33,9 +33,7 @@ import android.os.Handler;
import android.os.Looper;
import android.provider.MediaStore;
import android.provider.Settings;
import android.util.Log;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
@ -62,31 +60,44 @@ import java.util.List;
import java.util.Objects;
public class App extends Application {
private static final String PEER_TAG = "peer";
static final String STATUS_CHANNEL_ID = "tailscale-status";
static final int STATUS_NOTIFICATION_ID = 1;
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
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 int FILE_NOTIFICATION_ID = 3;
private static final Handler mainHandler = new Handler(Looper.getMainLooper());
private ConnectivityManager connectivityManager;
static App _application;
public DnsConfig dns = new DnsConfig();
public boolean autoConnect = false;
public boolean vpnReady = false;
private ConnectivityManager connectivityManager;
public DnsConfig getDnsConfigObj() {
return this.dns;
public static App getApplication() {
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() {
return _application;
static native void onVPNPrepared();
private static native void onDnsConfigChanged();
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
static native void onWriteStorageGranted();
public DnsConfig getDnsConfigObj() {
return this.dns;
}
@Override
@ -112,7 +123,7 @@ public class App extends Application {
@Override
public void onAvailable(Network network) {
super.onAvailable(network);
StringBuilder sb = new StringBuilder("");
StringBuilder sb = new StringBuilder();
LinkProperties linkProperties = connectivityManager.getLinkProperties(network);
List<InetAddress> dnsList = linkProperties.getDnsServers();
for (InetAddress ip : dnsList) {
@ -136,7 +147,6 @@ public class App extends Application {
});
}
public void startVPN() {
Intent intent = new Intent(this, IPNService.class);
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) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
@ -229,10 +236,6 @@ public class App extends Application {
return null;
}
private static boolean isEmpty(String str) {
return str == null || str.length() == 0;
}
// attachPeer adds a Peer fragment for tracking the Activity
// lifecycle.
void attachPeer(Activity act) {
@ -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) {
act.runOnUiThread(new Runnable() {
@Override
@ -363,14 +361,6 @@ public class App extends Application {
nm.createNotificationChannel(channel);
}
static native void onVPNPrepared();
private static native void onDnsConfigChanged();
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
static native void onWriteStorageGranted();
// Returns details of the interfaces in the system, encoded as a single string for ease
// of JNI transfer over to the Go environment.
//
@ -395,7 +385,7 @@ public class App extends Application {
return "";
}
StringBuilder sb = new StringBuilder("");
StringBuilder sb = new StringBuilder();
for (NetworkInterface nif : interfaces) {
try {
// 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.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'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.
public class DnsConfig {
private String dnsConfigs;
// getDnsConfigAsString returns the current DNS configuration as a multiline string:
// line[0] DNS server addresses separated by spaces
// line[1] search domains separated by spaces
//
// For example:
// 8.8.8.8 8.8.4.4
// example.com
//
// an empty string means the current DNS configuration could not be retrieved.
String getDnsConfigAsString() {
return getDnsConfigs().trim();
}
private String getDnsConfigs(){
synchronized(this) {
return this.dnsConfigs;
}
}
void updateDNSFromNetwork(String dnsConfigs){
synchronized(this) {
this.dnsConfigs = dnsConfigs;
}
}
NetworkRequest getDNSConfigNetworkRequest(){
// Request networks that are able to reach the Internet.
return new NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build();
}
private String dnsConfigs;
// getDnsConfigAsString returns the current DNS configuration as a multiline string:
// line[0] DNS server addresses separated by spaces
// line[1] search domains separated by spaces
//
// For example:
// 8.8.8.8 8.8.4.4
// example.com
//
// an empty string means the current DNS configuration could not be retrieved.
String getDnsConfigAsString() {
return getDnsConfigs().trim();
}
private String getDnsConfigs() {
synchronized (this) {
return this.dnsConfigs;
}
}
void updateDNSFromNetwork(String dnsConfigs) {
synchronized (this) {
this.dnsConfigs = dnsConfigs;
}
}
NetworkRequest getDNSConfigNetworkRequest() {
// Request networks that are able to reach the Internet.
return new NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build();
}
}

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

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

@ -43,158 +43,131 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private var notifierScope: CoroutineScope? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "main") {
val mainViewNav =
MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") },
onNavigateToPeerDetails = {
navController.navigate("peerDetails/${it.StableID}")
},
onNavigateToExitNodes = { navController.navigate("exitNodes") },
)
val settingsNav =
SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
)
val exitNodePickerNav = ExitNodePickerNav(onNavigateHome = {
navController.popBackStack(
route = "main", inclusive = false
)
}, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") })
composable("main") {
MainView(navigation = mainViewNav)
}
composable("settings") {
Settings(settingsNav)
}
navigation(startDestination = "list", route = "exitNodes") {
composable("list") {
ExitNodePicker(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)
}
private var notifierScope: CoroutineScope? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "main") {
val mainViewNav =
MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") },
onNavigateToPeerDetails = {
navController.navigate("peerDetails/${it.StableID}")
},
onNavigateToExitNodes = { navController.navigate("exitNodes") },
)
val settingsNav =
SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
)
val exitNodePickerNav =
ExitNodePickerNav(
onNavigateHome = {
navController.popBackStack(route = "main", inclusive = false)
},
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") })
composable("main") { MainView(navigation = mainViewNav) }
composable("settings") { Settings(settingsNav) }
navigation(startDestination = "list", route = "exitNodes") {
composable("list") { ExitNodePicker(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() }
}
}
}
}
private fun login(url: String) {
// (jonathan) TODO: This is functional, but the navigation doesn't quite work
// as expected. There's probably a better built in way to do this. This will
// unblock in dev for the time being though.
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
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))
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) } } }
}
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")
}
}
}
private fun login(url: String) {
// (jonathan) TODO: This is functional, but the navigation doesn't quite work
// as expected. There's probably a better built in way to do this. This will
// unblock in dev for the time being though.
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
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() {
val vpnIntent = VpnService.prepare(this)
if (vpnIntent != null) {
val contract = VpnPermissionContract()
registerForActivityResult(contract) { granted ->
Notifier.vpnPermissionGranted.set(granted)
if (granted) {
Log.i("VPN", "VPN permission granted")
} else {
Log.i("VPN", "VPN permission not granted")
}
}
}
}
}
class VpnPermissionContract : ActivityResultContract<Unit, Boolean>() {
override fun createIntent(context: Context, input: Unit): Intent {
return VpnService.prepare(context) ?: Intent()
}
override fun createIntent(context: Context, input: Unit): Intent {
return VpnService.prepare(context) ?: Intent()
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == Activity.RESULT_OK
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == Activity.RESULT_OK
}
}

@ -8,9 +8,10 @@ import android.app.Fragment;
import android.content.Intent;
public class Peer extends Fragment {
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
onActivityResult0(getActivity(), requestCode, resultCode);
}
private static native void onActivityResult0(Activity act, int reqCode, int resCode);
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;
public class QuickToggleService extends TileService {
// lock protects the static fields below it.
private static Object lock = new Object();
// Active tracks whether the VPN is active.
private static boolean active;
// Ready tracks whether the tailscale backend is
// ready to switch on/off.
private static boolean ready;
// currentTile tracks getQsTile while service is listening.
private static Tile currentTile;
// lock protects the static fields below it.
private static final Object lock = new Object();
// Active tracks whether the VPN is active.
private static boolean active;
// Ready tracks whether the tailscale backend is
// ready to switch on/off.
private static boolean ready;
// currentTile tracks getQsTile while service is listening.
private static Tile currentTile;
@Override public void onStartListening() {
synchronized (lock) {
currentTile = getQsTile();
}
updateTile();
}
private static void updateTile() {
Tile t;
boolean act;
synchronized (lock) {
t = currentTile;
act = active && ready;
}
if (t == null) {
return;
}
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
t.updateTile();
}
@Override public void onStopListening() {
synchronized (lock) {
currentTile = null;
}
}
static void setReady(Context ctx, boolean rdy) {
synchronized (lock) {
ready = rdy;
}
updateTile();
}
@Override public void onClick() {
boolean r;
synchronized (lock) {
r = ready;
}
if (r) {
onTileClick();
} else {
// Start main activity.
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
startActivityAndCollapse(i);
}
}
static void setStatus(Context ctx, boolean act) {
synchronized (lock) {
active = act;
}
updateTile();
}
private static void updateTile() {
Tile t;
boolean act;
synchronized (lock) {
t = currentTile;
act = active && ready;
}
if (t == null) {
return;
}
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
t.updateTile();
}
@Override
public void onStartListening() {
synchronized (lock) {
currentTile = getQsTile();
}
updateTile();
}
static void setReady(Context ctx, boolean rdy) {
synchronized (lock) {
ready = rdy;
}
updateTile();
}
@Override
public void onStopListening() {
synchronized (lock) {
currentTile = null;
}
}
static void setStatus(Context ctx, boolean act) {
synchronized (lock) {
active = act;
}
updateTile();
}
@Override
public void onClick() {
boolean r;
synchronized (lock) {
r = ready;
}
if (r) {
onTileClick();
} else {
// Start main activity.
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
startActivityAndCollapse(i);
}
}
private void onTileClick() {
boolean act;
synchronized (lock) {
act = active && ready;
}
Intent i = new Intent(act ? IPNReceiver.INTENT_DISCONNECT_VPN : IPNReceiver.INTENT_CONNECT_VPN);
i.setPackage(getPackageName());
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
sendBroadcast(i);
}
private void onTileClick() {
boolean act;
synchronized (lock) {
act = active && ready;
}
Intent i = new Intent(act ? IPNReceiver.INTENT_DISCONNECT_VPN : IPNReceiver.INTENT_CONNECT_VPN);
i.setPackage(getPackageName());
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
sendBroadcast(i);
}
}

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

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

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

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

@ -4,23 +4,24 @@
package com.tailscale.ipn.ui
object Links {
const val DEFAULT_CONTROL_URL = "https://controlplane.tailscale.com"
const val SERVER_URL = "https://login.tailscale.com"
const val ADMIN_URL = SERVER_URL + "/admin"
const val SIGNIN_URL = "https://tailscale.com/login"
const val PRIVACY_POLICY_URL = "https://tailscale.com/privacy-policy/"
const val TERMS_URL = "https://tailscale.com/terms"
const val DOCS_URL = "https://tailscale.com/kb/"
const val START_GUIDE_URL = "https://tailscale.com/kb/1017/install/"
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 TAILNET_LOCK_KB_URL = "https://tailscale.com/kb/1226/tailnet-lock/"
const val KEY_EXPIRY_KB_URL = "https://tailscale.com/kb/1028/key-expiry/"
const val INSTALL_TAILSCALE_KB_URL = "https://tailscale.com/kb/installation/"
const val INSTALL_UNSTABLE_KB_URL = "https://tailscale.com/kb/1083/install-unstable"
const val MAGICDNS_KB_URL = "https://tailscale.com/kb/1081/magicdns"
const val TROUBLESHOOTING_KB_URL = "https://tailscale.com/kb/1023/troubleshooting"
const val SUPPORT_URL = "https://tailscale.com/contact/support#support-form"
const val TAILDROP_KB_URL = "https://tailscale.com/kb/1106/taildrop"
const val TAILFS_KB_URL = "https://tailscale.com/kb/1106/taildrop"
const val DEFAULT_CONTROL_URL = "https://controlplane.tailscale.com"
const val SERVER_URL = "https://login.tailscale.com"
const val ADMIN_URL = SERVER_URL + "/admin"
const val SIGNIN_URL = "https://tailscale.com/login"
const val PRIVACY_POLICY_URL = "https://tailscale.com/privacy-policy/"
const val TERMS_URL = "https://tailscale.com/terms"
const val DOCS_URL = "https://tailscale.com/kb/"
const val START_GUIDE_URL = "https://tailscale.com/kb/1017/install/"
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 TAILNET_LOCK_KB_URL = "https://tailscale.com/kb/1226/tailnet-lock/"
const val KEY_EXPIRY_KB_URL = "https://tailscale.com/kb/1028/key-expiry/"
const val INSTALL_TAILSCALE_KB_URL = "https://tailscale.com/kb/installation/"
const val INSTALL_UNSTABLE_KB_URL = "https://tailscale.com/kb/1083/install-unstable"
const val MAGICDNS_KB_URL = "https://tailscale.com/kb/1081/magicdns"
const val TROUBLESHOOTING_KB_URL = "https://tailscale.com/kb/1023/troubleshooting"
const val SUPPORT_URL = "https://tailscale.com/contact/support#support-form"
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
private object Endpoint {
const val DEBUG = "debug"
const val DEBUG_LOG = "debug-log"
const val BUG_REPORT = "bugreport"
const val PREFS = "prefs"
const val FILE_TARGETS = "file-targets"
const val UPLOAD_METRICS = "upload-client-metrics"
const val START = "start"
const val LOGIN_INTERACTIVE = "login-interactive"
const val RESET_AUTH = "reset-auth"
const val LOGOUT = "logout"
const val PROFILES = "profiles/"
const val PROFILES_CURRENT = "profiles/current"
const val STATUS = "status"
const val TKA_STATUS = "tka/status"
const val TKA_SIGN = "tka/sign"
const val TKA_VERIFY_DEEP_LINK = "tka/verify-deeplink"
const val PING = "ping"
const val FILES = "files"
const val FILE_PUT = "file-put"
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
const val DEBUG = "debug"
const val DEBUG_LOG = "debug-log"
const val BUG_REPORT = "bugreport"
const val PREFS = "prefs"
const val FILE_TARGETS = "file-targets"
const val UPLOAD_METRICS = "upload-client-metrics"
const val START = "start"
const val LOGIN_INTERACTIVE = "login-interactive"
const val RESET_AUTH = "reset-auth"
const val LOGOUT = "logout"
const val PROFILES = "profiles/"
const val PROFILES_CURRENT = "profiles/current"
const val STATUS = "status"
const val TKA_STATUS = "tka/status"
const val TKA_SIGN = "tka/sign"
const val TKA_VERIFY_DEEP_LINK = "tka/verify-deeplink"
const val PING = "ping"
const val FILES = "files"
const val FILE_PUT = "file-put"
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
}
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
/**
@ -53,190 +55,202 @@ typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
* corresponding method on this Client.
*/
class Client(private val scope: CoroutineScope) {
fun status(responseHandler: StatusResponseHandler) {
get(Endpoint.STATUS, responseHandler = responseHandler)
}
fun status(responseHandler: StatusResponseHandler) {
get(Endpoint.STATUS, responseHandler = responseHandler)
}
fun bugReportId(responseHandler: BugReportIdHandler) {
post(Endpoint.BUG_REPORT, responseHandler = responseHandler)
}
fun bugReportId(responseHandler: BugReportIdHandler) {
post(Endpoint.BUG_REPORT, responseHandler = responseHandler)
}
fun prefs(responseHandler: PrefsHandler) {
get(Endpoint.PREFS, responseHandler = responseHandler)
}
fun prefs(responseHandler: PrefsHandler) {
get(Endpoint.PREFS, responseHandler = responseHandler)
}
fun editPrefs(
prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit
) {
val body = Json.encodeToString(prefs).toByteArray()
return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
}
fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
val body = Json.encodeToString(prefs).toByteArray()
return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
}
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
get(Endpoint.PROFILES, responseHandler = responseHandler)
}
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
get(Endpoint.PROFILES, responseHandler = responseHandler)
}
fun currentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit) {
return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler)
}
fun currentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit) {
return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler)
}
fun addProfile(responseHandler: (Result<String>) -> Unit = {}) {
return put(Endpoint.PROFILES, responseHandler = responseHandler)
}
fun addProfile(responseHandler: (Result<String>) -> Unit = {}) {
return put(Endpoint.PROFILES, responseHandler = responseHandler)
}
fun deleteProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result<String>) -> Unit = {}) {
return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun deleteProfile(
profile: IpnLocal.LoginProfile,
responseHandler: (Result<String>) -> Unit = {}
) {
return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun switchProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result<String>) -> Unit = {}) {
return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun switchProfile(
profile: IpnLocal.LoginProfile,
responseHandler: (Result<String>) -> Unit = {}
) {
return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun startLoginInteractive(responseHandler: (Result<String>) -> Unit) {
return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler)
}
fun startLoginInteractive(responseHandler: (Result<String>) -> Unit) {
return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler)
}
fun logout(responseHandler: (Result<String>) -> Unit) {
return post(Endpoint.LOGOUT, responseHandler = responseHandler)
}
fun logout(responseHandler: (Result<String>) -> Unit) {
return post(Endpoint.LOGOUT, responseHandler = responseHandler)
}
private inline fun <reified T> get(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "GET",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
private inline fun <reified T> get(
path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "GET",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> put(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "PUT",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
private inline fun <reified T> put(
path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "PUT",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> post(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "POST",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
private inline fun <reified T> post(
path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "POST",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> patch(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "PATCH",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
private inline fun <reified T> patch(
path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "PATCH",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> delete(
path: String, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "DELETE",
path = path,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
private inline fun <reified T> delete(
path: String,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "DELETE",
path = path,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
}
class Request<T>(
private val scope: CoroutineScope,
private val method: String,
path: String,
private val body: ByteArray? = null,
private val responseType: KType,
private val responseHandler: (Result<T>) -> Unit
private val scope: CoroutineScope,
private val method: String,
path: String,
private val body: ByteArray? = null,
private val responseType: KType,
private val responseHandler: (Result<T>) -> Unit
) {
private val fullPath = "/localapi/v0/$path"
private val fullPath = "/localapi/v0/$path"
companion object {
private const val TAG = "LocalAPIRequest"
companion object {
private const val TAG = "LocalAPIRequest"
private val jsonDecoder = Json { ignoreUnknownKeys = true }
private val isReady = CompletableDeferred<Boolean>()
private val jsonDecoder = Json { ignoreUnknownKeys = true }
private val isReady = CompletableDeferred<Boolean>()
// Called by the backend when the localAPI is ready to accept requests.
@JvmStatic
@Suppress("unused")
fun onReady() {
isReady.complete(true)
Log.d(TAG, "Ready")
}
// Called by the backend when the localAPI is ready to accept requests.
@JvmStatic
@Suppress("unused")
fun onReady() {
isReady.complete(true)
Log.d(TAG, "Ready")
}
}
// Perform a request to the local API in the go backend. This is
// the primary JNI method for servicing a localAPI call. This
// is GUARANTEED to call back into onResponse.
// @see cmd/localapiclient/localapishim.go
//
// method: The HTTP method to use.
// request: The path to the localAPI endpoint.
// body: The body of the request.
private external fun doRequest(method: String, request: String, body: ByteArray?)
fun execute() {
scope.launch(Dispatchers.IO) {
isReady.await()
Log.d(TAG, "Executing request:${method}:${fullPath}")
doRequest(method, fullPath, body)
}
// Perform a request to the local API in the go backend. This is
// the primary JNI method for servicing a localAPI call. This
// is GUARANTEED to call back into onResponse.
// @see cmd/localapiclient/localapishim.go
//
// method: The HTTP method to use.
// request: The path to the localAPI endpoint.
// body: The body of the request.
private external fun doRequest(method: String, request: String, body: ByteArray?)
fun execute() {
scope.launch(Dispatchers.IO) {
isReady.await()
Log.d(TAG, "Executing request:${method}:${fullPath}")
doRequest(method, fullPath, body)
}
}
// This is called from the JNI layer to publish responses.
@OptIn(ExperimentalSerializationApi::class)
@Suppress("unused", "UNCHECKED_CAST")
fun onResponse(respData: ByteArray) {
Log.d(TAG, "Response for request: $fullPath")
// This is called from the JNI layer to publish responses.
@OptIn(ExperimentalSerializationApi::class)
@Suppress("unused", "UNCHECKED_CAST")
fun onResponse(respData: ByteArray) {
Log.d(TAG, "Response for request: $fullPath")
val response: Result<T> = when (responseType) {
typeOf<String>() -> Result.success(respData.decodeToString() as T)
else -> try {
val response: Result<T> =
when (responseType) {
typeOf<String>() -> Result.success(respData.decodeToString() as T)
else ->
try {
Result.success(
jsonDecoder.decodeFromStream(
Json.serializersModule.serializer(responseType), respData.inputStream()
) as T
)
} catch (t: Throwable) {
jsonDecoder.decodeFromStream(
Json.serializersModule.serializer(responseType), respData.inputStream())
as T)
} catch (t: Throwable) {
// If we couldn't parse the response body, assume it's an error response
try {
val error =
jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream())
throw Exception(error.error)
val error =
jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream())
throw Exception(error.error)
} catch (t: Throwable) {
Result.failure(t)
Result.failure(t)
}
}
}
}
// The response handler will invoked internally by the request parser
scope.launch {
responseHandler(response)
}
}
// The response handler will invoked internally by the request parser
scope.launch { responseHandler(response) }
}
}

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

@ -7,156 +7,164 @@ import kotlinx.serialization.Serializable
class Ipn {
// Represents the overall state of the Tailscale engine.
enum class State(val value: Int) {
NoState(0),
InUseOtherUser(1),
NeedsLogin(2),
NeedsMachineAuth(3),
Stopped(4),
Starting(5),
Running(6);
companion object {
fun fromInt(value: Int): State {
return State.values().firstOrNull { it.value == value } ?: NoState
}
}
}
// Represents the overall state of the Tailscale engine.
enum class State(val value: Int) {
NoState(0),
InUseOtherUser(1),
NeedsLogin(2),
NeedsMachineAuth(3),
Stopped(4),
Starting(5),
Running(6);
// 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
}
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
// 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
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
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,
)
@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 {
@Serializable
data class Persist(
var PrivateMachineKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var PrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var OldPrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var Provider: String = "",
)
@Serializable
data class Persist(
var PrivateMachineKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var PrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var OldPrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var Provider: String = "",
)
}

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

@ -6,50 +6,50 @@ package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
class Netmap {
@Serializable
data class NetworkMap(
var SelfNode: Tailcfg.Node,
var NodeKey: KeyNodePublic,
var Peers: List<Tailcfg.Node>? = null,
var Expiry: Time,
var Domain: String,
var UserProfiles: Map<String, Tailcfg.UserProfile>,
var TKAEnabled: Boolean,
var DNS: Tailcfg.DNSConfig? = null
) {
// Keys are tailcfg.UserIDs thet get stringified
// Helpers
fun currentUserProfile(): Tailcfg.UserProfile? {
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
}
@Serializable
data class NetworkMap(
var SelfNode: Tailcfg.Node,
var NodeKey: KeyNodePublic,
var Peers: List<Tailcfg.Node>? = null,
var Expiry: Time,
var Domain: String,
var UserProfiles: Map<String, Tailcfg.UserProfile>,
var TKAEnabled: Boolean,
var DNS: Tailcfg.DNSConfig? = null
) {
// Keys are tailcfg.UserIDs thet get stringified
// Helpers
fun currentUserProfile(): Tailcfg.UserProfile? {
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
}
}
}

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

@ -6,23 +6,29 @@ package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
typealias Addr = String
typealias Prefix = String
typealias NodeID = Long
typealias KeyNodePublic = String
typealias MachineKey = String
typealias UserID = Long
typealias Time = String
typealias StableNodeID = String
typealias BugReportID = String
// Represents and empty message with a single 'property' field.
class Empty {
@Serializable
data class Message(val property: String)
@Serializable data class Message(val property: String)
}
// Parsable errors returned by localApiService
class Errors {
@Serializable
data class GenericError(val error: String)
@Serializable 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
// unwatchIPNBus with the sessionId.
object Notifier {
private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true }
private val isReady = CompletableDeferred<Boolean>()
private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true }
private val isReady = CompletableDeferred<Boolean>()
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
val tailFSShares: StateFlow<Map<String, String>?> = MutableStateFlow(null)
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null)
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
val tailFSShares: StateFlow<Map<String, String>?> = MutableStateFlow(null)
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null)
// Indicates whether or not we have granted permission to use the VPN. This must be
// explicitly set by the main activity. null indicates that we have not yet
// checked.
val vpnPermissionGranted: StateFlow<Boolean?> = MutableStateFlow(null)
// Indicates whether or not we have granted permission to use the VPN. This must be
// explicitly set by the main activity. null indicates that we have not yet
// checked.
val vpnPermissionGranted: StateFlow<Boolean?> = MutableStateFlow(null)
// Called by the backend when the localAPI is ready to accept requests.
@JvmStatic
@Suppress("unused")
fun onReady() {
isReady.complete(true)
Log.d(TAG, "Ready")
}
// Called by the backend when the localAPI is ready to accept requests.
@JvmStatic
@Suppress("unused")
fun onReady() {
isReady.complete(true)
Log.d(TAG, "Ready")
}
fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting")
scope.launch(Dispatchers.IO) {
// Wait for the notifier to be ready
isReady.await()
val mask =
NotifyWatchOpt.Netmap.value or NotifyWatchOpt.Prefs.value or NotifyWatchOpt.InitialState.value
startIPNBusWatcher(mask)
Log.d(TAG, "Stopped")
}
fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting")
scope.launch(Dispatchers.IO) {
// Wait for the notifier to be ready
isReady.await()
val mask =
NotifyWatchOpt.Netmap.value or
NotifyWatchOpt.Prefs.value or
NotifyWatchOpt.InitialState.value
startIPNBusWatcher(mask)
Log.d(TAG, "Stopped")
}
}
fun stop() {
Log.d(TAG, "Stopping")
stopIPNBusWatcher()
}
fun stop() {
Log.d(TAG, "Stopping")
stopIPNBusWatcher()
}
// Callback from jni when a new notification is received
@OptIn(ExperimentalSerializationApi::class)
@JvmStatic
@Suppress("unused")
fun onNotify(notification: ByteArray) {
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
notify.State?.let { state.set(Ipn.State.fromInt(it)) }
notify.NetMap?.let(netmap::set)
notify.Prefs?.let(prefs::set)
notify.Engine?.let(engineStatus::set)
notify.TailFSShares?.let(tailFSShares::set)
notify.BrowseToURL?.let(browseToURL::set)
notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set)
}
// Callback from jni when a new notification is received
@OptIn(ExperimentalSerializationApi::class)
@JvmStatic
@Suppress("unused")
fun onNotify(notification: ByteArray) {
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
notify.State?.let { state.set(Ipn.State.fromInt(it)) }
notify.NetMap?.let(netmap::set)
notify.Prefs?.let(prefs::set)
notify.Engine?.let(engineStatus::set)
notify.TailFSShares?.let(tailFSShares::set)
notify.BrowseToURL?.let(browseToURL::set)
notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set)
}
// Starts watching the IPN Bus. This is blocking.
private external fun startIPNBusWatcher(mask: Int)
// Starts watching the IPN Bus. This is blocking.
private external fun startIPNBusWatcher(mask: Int)
// Stop watching the IPN Bus. This is non-blocking.
private external fun stopIPNBusWatcher()
// Stop watching the IPN Bus. This is non-blocking.
private external fun stopIPNBusWatcher()
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// what we want to see on the Notify bus
private enum class NotifyWatchOpt(val value: Int) {
EngineUpdates(1), InitialState(2), Prefs(4), Netmap(8), NoPrivateKey(16), InitialTailFSShares(
32
)
}
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// what we want to see on the Notify bus
private enum class NotifyWatchOpt(val value: Int) {
EngineUpdates(1),
InitialState(2),
Prefs(4),
Netmap(8),
NoPrivateKey(16),
InitialTailFSShares(32)
}
}

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.theme
import androidx.compose.ui.graphics.Color

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
@ -10,38 +9,34 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
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(
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 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,
)
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
fun AppTheme(
useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable() () -> Unit
) {
val colors = if (!useDarkTheme) {
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
val colors =
if (!useDarkTheme) {
LightColors
} else {
} else {
DarkColors
}
}
MaterialTheme(
colorScheme = colors,
content = content
)
}
MaterialTheme(colorScheme = colors, content = content)
}

@ -8,14 +8,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
// 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
constructor(string: String) : this(stringRes = R.string.template, string)
// Convenience constructor for passing a non-formatted string directly
constructor(string: String) : this(stringRes = R.string.template, string)
// Returns the fully formatted string
@Composable
fun getString(): String = stringResource(id = stringRes, *params)
// Returns the fully formatted string
@Composable fun getString(): String = stringResource(id = stringRes, *params)
}

@ -1,42 +1,46 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
class DisplayAddress(val ip: String) {
enum class addrType {
V4, V6, MagicDNS
}
val type: addrType = when {
enum class addrType {
V4,
V6,
MagicDNS
}
val type: addrType =
when {
ip.isIPV6() -> addrType.V6
ip.isIPV4() -> addrType.V4
else -> addrType.MagicDNS
}
}
val typeString: String = when (type) {
val typeString: String =
when (type) {
addrType.V4 -> "IPv4"
addrType.V6 -> "IPv6"
addrType.MagicDNS -> "MagicDNS"
}
}
val address: String = when (type) {
val address: String =
when (type) {
addrType.MagicDNS -> ip
else -> ip.split("/").first()
}
}
}
fun String.isIPV6(): Boolean {
return this.contains(":")
return this.contains(":")
}
fun String.isIPV4(): Boolean {
val parts = this.split("/").first().split(".")
if (parts.size != 4) return false
for (part in parts) {
val value = part.toIntOrNull() ?: return false
if (value !in 0..255) return false
}
return true
}
val parts = this.split("/").first().split(".")
if (parts.size != 4) return false
for (part in parts) {
val value = part.toIntOrNull() ?: return false
if (value !in 0..255) return false
}
return true
}

@ -4,31 +4,30 @@
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");
//you may not use this file except in compliance with the License.
//You may obtain a copy of the License at
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// 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
//distributed under the License is distributed on an "AS IS" BASIS,
//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//See the License for the specific language governing permissions and
//limitations under the License.
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// 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 {
val caps = this.uppercase()
val flagOffset = 0x1F1E6
val asciiOffset = 0x41
val firstChar = Character.codePointAt(caps, 0) - asciiOffset + flagOffset
val secondChar = Character.codePointAt(caps, 1) - asciiOffset + flagOffset
return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar))
}
val caps = this.uppercase()
val flagOffset = 0x1F1E6
val asciiOffset = 0x41
val firstChar = Character.codePointAt(caps, 0) - asciiOffset + flagOffset
val secondChar = Character.codePointAt(caps, 1) - asciiOffset + flagOffset
return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar))
}

@ -16,39 +16,33 @@ import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.flow.MutableStateFlow
object LoadingIndicator {
private val loading = MutableStateFlow(false)
fun start() {
loading.value = true
}
fun stop() {
loading.value = false
}
@Composable
fun Wrap(content: @Composable () -> Unit) {
Box(
private val loading = MutableStateFlow(false)
fun start() {
loading.value = true
}
fun stop() {
loading.value = false
}
@Composable
fun Wrap(content: @Composable () -> Unit) {
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(),
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(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
}
horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
}
}
}
}
}
}
}

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
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.launch
data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>)
typealias GroupedPeers = MutableMap<UserID, MutableList<Tailcfg.Node>>
class PeerCategorizer(scope: CoroutineScope) {
var peerSets: List<PeerSet> = emptyList()
var lastSearchResult: List<PeerSet> = emptyList()
var searchTerm: String = ""
// Keep the peer sets current while the model is active
init {
scope.launch {
Notifier.netmap.collect { netmap ->
netmap?.let {
peerSets = regenerateGroupedPeers(netmap)
lastSearchResult = peerSets
} ?: run {
peerSets = emptyList()
lastSearchResult = emptyList()
}
}
var peerSets: List<PeerSet> = emptyList()
var lastSearchResult: List<PeerSet> = emptyList()
var searchTerm: String = ""
// Keep the peer sets current while the model is active
init {
scope.launch {
Notifier.netmap.collect { netmap ->
netmap?.let {
peerSets = regenerateGroupedPeers(netmap)
lastSearchResult = peerSets
}
?: 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 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
val me = netmap.currentUserProfile()
if (!grouped.containsKey(userId)) {
grouped[userId] = mutableListOf()
val peerSets =
grouped
.map { (userId, peers) ->
val profile = netmap.userProfile(userId)
PeerSet(profile, peers.sortedBy { it.ComputedName })
}
grouped[userId]?.add(peer)
}
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) {
.sortedBy {
if (it.user?.ID == me?.ID) {
""
} else {
} else {
it.user?.DisplayName ?: "Unknown User"
}
}
}
return peerSets
}
return peerSets
}
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
if (searchTerm.isEmpty()) {
return peerSets
}
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
if (searchTerm.isEmpty()) {
return peerSets
}
if (searchTerm == this.searchTerm) {
return lastSearchResult
}
if (searchTerm == this.searchTerm) {
return lastSearchResult
}
// We can optimize out typing... If the search term starts with the last search term, we can just search the last result
val setsToSearch =
if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets
this.searchTerm = searchTerm
// We can optimize out typing... If the search term starts with the last search term, we can
// just search the last result
val setsToSearch = if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets
this.searchTerm = searchTerm
val matchingSets = setsToSearch.map { peerSet ->
val user = peerSet.user
val peers = peerSet.peers
val matchingSets =
setsToSearch
.map { peerSet ->
val user = peerSet.user
val peers = peerSet.peers
val userMatches = user?.DisplayName?.contains(searchTerm, ignoreCase = true) ?: false
if (userMatches) {
val userMatches = user?.DisplayName?.contains(searchTerm, ignoreCase = true) ?: false
if (userMatches) {
return@map peerSet
}
}
val matchingPeers =
peers.filter { it.ComputedName.contains(searchTerm, ignoreCase = true) }
if (matchingPeers.isNotEmpty()) {
val matchingPeers =
peers.filter { it.ComputedName.contains(searchTerm, ignoreCase = true) }
if (matchingPeers.isNotEmpty()) {
PeerSet(user, matchingPeers)
} else {
} else {
null
}
}
}.filterNotNull()
return matchingSets
}
.filterNotNull()
}
return matchingSets
}
}

@ -6,9 +6,7 @@ package com.tailscale.ipn.ui.util
import kotlinx.coroutines.flow.MutableStateFlow
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) {
(this as MutableStateFlow<T>).value = v
}
(this as MutableStateFlow<T>).value = v
}

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

@ -8,44 +8,48 @@ import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.Date
class TimeUtil {
fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter {
val time = goTime ?: return ComposableStringFormatter(R.string.empty)
val expTime = epochMillisFromGoTime(time)
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 keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter {
val time = goTime ?: return ComposableStringFormatter(R.string.empty)
val expTime = epochMillisFromGoTime(time)
val now = Instant.now().toEpochMilli()
fun epochMillisFromGoTime(goTime: String): Long {
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
val i = Instant.from(ta)
return i.toEpochMilli()
val diff = (expTime - now) / 1000
if (diff < 0) {
return ComposableStringFormatter(R.string.expired)
}
fun dateFromGoString(goTime: String): Date {
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
val i = Instant.from(ta)
return Date.from(i)
// 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 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.width
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.Surface
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -36,84 +33,51 @@ import com.tailscale.ipn.ui.Links
@Composable
fun AboutView() {
Surface(color = MaterialTheme.colorScheme.surface) {
Column(
verticalArrangement = Arrangement.spacedBy(
space = 20.dp, alignment = Alignment.CenterVertically
),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.safeContentPadding()
) {
Image(
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black)
.padding(15.dp),
painter = painterResource(id = R.drawable.ic_tile),
contentDescription = stringResource(R.string.app_icon_content_description)
)
Column(
verticalArrangement = Arrangement.spacedBy(
space = 2.dp, alignment = Alignment.CenterVertically
), horizontalAlignment = Alignment.CenterHorizontally
) {
Scaffold { _ ->
Column(
verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().fillMaxHeight().safeContentPadding()) {
Image(
modifier =
Modifier.width(100.dp)
.height(100.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black)
.padding(15.dp),
painter = painterResource(id = R.drawable.ic_tile),
contentDescription = stringResource(R.string.app_icon_content_description))
Column(
verticalArrangement =
Arrangement.spacedBy(space = 2.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.about_view_title),
fontWeight = FontWeight.SemiBold,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
color = MaterialTheme.colorScheme.primary
)
stringResource(R.string.about_view_title),
fontWeight = FontWeight.SemiBold,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
color = MaterialTheme.colorScheme.primary)
Text(
text = BuildConfig.VERSION_NAME,
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
color = MaterialTheme.colorScheme.secondary
)
}
Column(
verticalArrangement = Arrangement.spacedBy(
space = 4.dp, alignment = Alignment.CenterVertically
), horizontalAlignment = Alignment.CenterHorizontally
) {
OpenURLButton(
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 = BuildConfig.VERSION_NAME,
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
color = MaterialTheme.colorScheme.secondary)
}
Column(
verticalArrangement =
Arrangement.spacedBy(space = 4.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally) {
OpenURLButton(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(
stringResource(R.string.about_view_footnotes),
fontWeight = FontWeight.Normal,
fontSize = MaterialTheme.typography.labelMedium.fontSize,
color = MaterialTheme.colorScheme.tertiary,
textAlign = TextAlign.Center
)
Text(
stringResource(R.string.about_view_footnotes),
fontWeight = FontWeight.Normal,
fontSize = MaterialTheme.typography.labelMedium.fontSize,
color = MaterialTheme.colorScheme.tertiary,
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 com.tailscale.ipn.ui.model.IpnLocal
@OptIn(ExperimentalCoilApi::class)
@Composable
fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(size.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiaryContainer)
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier.size(size.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiaryContainer)) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size((size * .8f).dp)
)
imageVector = Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size((size * .8f).dp))
profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage(model = url, contentDescription = null)
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@ -37,94 +38,81 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.util.Header
import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
import kotlinx.coroutines.flow.StateFlow
@Composable
fun BugReportView(model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = defaultPaddingModifier().fillMaxWidth().fillMaxHeight()) {
Header(title = R.string.bug_report_title)
val handler = LocalUriHandler.current
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(),
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium,
onClick = {
handler.openUri(Links.SUPPORT_URL)
})
Spacer(modifier = Modifier.height(8.dp))
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),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Left,
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall
)
}
Text(
text = stringResource(id = R.string.bug_report_id_desc),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Left,
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
fun ReportIdRow(bugReportIdFlow: StateFlow<String>) {
val localClipboardManager = LocalClipboardManager.current
val bugReportId = bugReportIdFlow.collectAsState()
Row(
modifier = settingsRowModifier()
.fillMaxWidth()
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(bugReportId.value)) }),
verticalAlignment = Alignment.CenterVertically
) {
val localClipboardManager = LocalClipboardManager.current
val bugReportId = bugReportIdFlow.collectAsState()
Row(
modifier =
settingsRowModifier()
.fillMaxWidth()
.clickable(
onClick = { localClipboardManager.setText(AnnotatedString(bugReportId.value)) }),
verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(10f)) {
Text(
text = bugReportId.value,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = defaultPaddingModifier()
)
Text(
text = bugReportId.value,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = defaultPaddingModifier())
}
Box(Modifier.weight(1f)) {
Icon(
Icons.Outlined.Share, null, modifier = Modifier
.width(24.dp)
.height(24.dp)
)
Icon(Icons.Outlined.Share, null, modifier = Modifier.width(24.dp).height(24.dp))
}
}
}
}
@Composable
fun contactText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.bug_report_instructions_prefix))
}
val annotatedString = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.bug_report_instructions_prefix))
}
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
withStyle(style = SpanStyle(color = Color.Blue)) {
append(stringResource(id = R.string.bug_report_instructions_linktext))
}
pop()
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
withStyle(style = SpanStyle(color = Color.Blue)) {
append(stringResource(id = R.string.bug_report_instructions_linktext))
}
pop()
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.bug_report_instructions_suffix))
}
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
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.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.ts_color_light_blue
@Composable
fun PrimaryActionButton(
onClick: () -> Unit,
content: @Composable RowScope.() -> Unit
) {
Button(
onClick = onClick,
colors = ButtonColors(
containerColor = ts_color_light_blue,
contentColor = Color.White,
disabledContainerColor = MaterialTheme.colorScheme.secondary,
disabledContentColor = MaterialTheme.colorScheme.onSecondary
),
contentPadding = PaddingValues(vertical = 12.dp),
modifier = Modifier
.fillMaxWidth(),
content = content
)
}
fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
Button(
onClick = onClick,
colors =
ButtonColors(
containerColor = ts_color_light_blue,
contentColor = Color.White,
disabledContainerColor = MaterialTheme.colorScheme.secondary,
disabledContentColor = MaterialTheme.colorScheme.onSecondary),
contentPadding = PaddingValues(vertical = 12.dp),
modifier = Modifier.fillMaxWidth(),
content = content)
}
@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))
}

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

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
@ -21,7 +20,6 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.ExitNodePickerViewModelFactory
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExitNodePicker(
nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
LoadingIndicator.Wrap {
Scaffold(topBar = {
TopAppBar(title = { Text(stringResource(R.string.choose_exit_node)) })
}) { innerPadding ->
val tailnetExitNodes = model.tailnetExitNodes.collectAsState()
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val anyActive = model.anyActive.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "none") {
ExitNodeItem(
model, ExitNodePickerViewModel.ExitNode(
label = stringResource(R.string.none),
online = true,
selected = !anyActive.value,
)
)
}
LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_exit_node) }) { innerPadding ->
val tailnetExitNodes = model.tailnetExitNodes.collectAsState()
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val anyActive = model.anyActive.collectAsState()
item {
ListHeading(stringResource(R.string.tailnet_exit_nodes))
}
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "none") {
ExitNodeItem(
model,
ExitNodePickerViewModel.ExitNode(
label = stringResource(R.string.none),
online = true,
selected = !anyActive.value,
))
}
items(tailnetExitNodes.value, key = { it.id!! }) { node ->
ExitNodeItem(model, node, indent = 16.dp)
}
item { ListHeading(stringResource(R.string.tailnet_exit_nodes)) }
item {
ListHeading(stringResource(R.string.mullvad_exit_nodes))
}
items(tailnetExitNodes.value, key = { it.id!! }) { node ->
ExitNodeItem(model, node, indent = 16.dp)
}
val sortedCountries = mullvadExitNodes.value.entries.toList().sortedBy {
it.value.first().country.lowercase()
}
items(sortedCountries) { (countryCode, nodes) ->
val first = nodes.first()
item { ListHeading(stringResource(R.string.mullvad_exit_nodes)) }
// 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)
)
}
}
})
}
}
val sortedCountries =
mullvadExitNodes.value.entries.toList().sortedBy {
it.value.first().country.lowercase()
}
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
fun ListHeading(label: String, indent: Dp = 0.dp) {
ListItem(modifier = Modifier.padding(start = indent), headlineContent = {
Text(text = label, style = MaterialTheme.typography.titleMedium)
})
ListItem(
modifier = Modifier.padding(start = indent),
headlineContent = { Text(text = label, style = MaterialTheme.typography.titleMedium) })
}
@Composable
fun ExitNodeItem(
viewModel: ExitNodePickerViewModel, node: ExitNodePickerViewModel.ExitNode, indent: Dp = 0.dp
viewModel: ExitNodePickerViewModel,
node: ExitNodePickerViewModel.ExitNode,
indent: Dp = 0.dp
) {
Box {
ListItem(modifier = Modifier
.padding(start = indent)
.clickable { viewModel.setExitNode(node) },
headlineContent = {
Text(node.city.ifEmpty { node.label })
},
trailingContent = {
Row {
if (node.selected) {
Icon(
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)
}
}
})
}
}
Box {
ListItem(
modifier = Modifier.padding(start = indent).clickable { viewModel.setExitNode(node) },
headlineContent = { Text(node.city.ifEmpty { node.label }) },
trailingContent = {
Row {
if (node.selected) {
Icon(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.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.viewmodel.compose.viewModel
@ -29,82 +26,65 @@ import com.tailscale.ipn.mdm.BooleanSetting
import com.tailscale.ipn.mdm.ShowHideSetting
import com.tailscale.ipn.mdm.StringArraySetting
import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.viewModel.IpnViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MDMSettingsDebugView(model: IpnViewModel = viewModel()) {
Scaffold(
topBar = {
TopAppBar(colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
), title = {
Text(stringResource(R.string.current_mdm_settings))
})
},
) { 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()
)
}
Scaffold(topBar = { Header(R.string.current_mdm_settings) }) { 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 ->
MDMSettingView(
title = stringSetting.localizedTitle,
caption = stringSetting.key,
valueDescription = mdmSettings.get(stringSetting).toString()
)
}
items(enumValues<StringSetting>()) { stringSetting ->
MDMSettingView(
title = stringSetting.localizedTitle,
caption = stringSetting.key,
valueDescription = mdmSettings.get(stringSetting).toString())
}
items(enumValues<ShowHideSetting>()) { showHideSetting ->
MDMSettingView(
title = showHideSetting.localizedTitle,
caption = showHideSetting.key,
valueDescription = mdmSettings.get(showHideSetting).toString()
)
}
items(enumValues<ShowHideSetting>()) { showHideSetting ->
MDMSettingView(
title = showHideSetting.localizedTitle,
caption = showHideSetting.key,
valueDescription = mdmSettings.get(showHideSetting).toString())
}
items(enumValues<AlwaysNeverUserDecidesSetting>()) { anuSetting ->
MDMSettingView(
title = anuSetting.localizedTitle,
caption = anuSetting.key,
valueDescription = mdmSettings.get(anuSetting).toString()
)
}
items(enumValues<AlwaysNeverUserDecidesSetting>()) { anuSetting ->
MDMSettingView(
title = anuSetting.localizedTitle,
caption = anuSetting.key,
valueDescription = mdmSettings.get(anuSetting).toString())
}
items(enumValues<StringArraySetting>()) { stringArraySetting ->
MDMSettingView(
title = stringArraySetting.localizedTitle,
caption = stringArraySetting.key,
valueDescription = mdmSettings.get(stringArraySetting).toString()
)
}
}
items(enumValues<StringArraySetting>()) { stringArraySetting ->
MDMSettingView(
title = stringArraySetting.localizedTitle,
caption = stringArraySetting.key,
valueDescription = mdmSettings.get(stringArraySetting).toString())
}
}
}
}
@Composable
fun MDMSettingView(title: String, caption: String, valueDescription: String) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = defaultPaddingModifier().fillMaxWidth()
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = defaultPaddingModifier().fillMaxWidth()) {
Column {
Text(title, maxLines = 3)
Text(
caption,
fontSize = MaterialTheme.typography.labelSmall.fontSize,
color = MaterialTheme.colorScheme.tertiary,
fontFamily = FontFamily.Monospace
)
Text(title, maxLines = 3)
Text(
caption,
fontSize = MaterialTheme.typography.labelSmall.fontSize,
color = MaterialTheme.colorScheme.tertiary,
fontFamily = FontFamily.Monospace)
}
Text(
@ -112,7 +92,6 @@ fun MDMSettingView(title: String, caption: String, valueDescription: String) {
color = MaterialTheme.colorScheme.secondary,
fontFamily = FontFamily.Monospace,
maxLines = 1,
fontWeight = FontWeight.SemiBold
)
}
}
fontWeight = FontWeight.SemiBold)
}
}

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

@ -23,30 +23,24 @@ import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Composable
fun ManagedByView(model: IpnViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.surface) {
Column(
verticalArrangement = Arrangement.spacedBy(
space = 20.dp, alignment = Alignment.CenterVertically
),
horizontalAlignment = Alignment.Start,
modifier = Modifier
.fillMaxWidth()
.safeContentPadding()
) {
val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value
mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Text(stringResource(R.string.managed_by_explainer_orgName, 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)
Surface(color = MaterialTheme.colorScheme.surface) {
Column(
verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.Start,
modifier = Modifier.fillMaxWidth().safeContentPadding()) {
val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value
mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Text(stringResource(R.string.managed_by_explainer_orgName, 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)
}
}
}
}
}
}

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

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
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.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.PeerDetailsViewModelFactory
@Composable
fun PeerDetails(
nodeId: String, model: PeerDetailsViewModel = viewModel(
factory = PeerDetailsViewModelFactory(nodeId)
)
nodeId: String,
model: PeerDetailsViewModel = viewModel(factory = PeerDetailsViewModelFactory(nodeId))
) {
Surface(color = MaterialTheme.colorScheme.surface) {
Scaffold(
topBar = {
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxHeight()
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = model.nodeName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
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
)
}
}
Text(
text = model.nodeName,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary)
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 = R.string.addresses_section),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Column(modifier = settingsRowModifier()) {
model.addresses.forEach {
AddressRow(address = it.address, type = it.typeString)
}
}
Spacer(modifier = Modifier.size(16.dp))
Column(modifier = settingsRowModifier()) {
model.info.forEach {
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
}
text = stringResource(id = model.connectedStrRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary)
}
}
}) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).fillMaxHeight()) {
Text(
text = stringResource(id = R.string.addresses_section),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary)
Column(modifier = settingsRowModifier()) {
model.addresses.forEach { AddressRow(address = it.address, type = it.typeString) }
}
Spacer(modifier = Modifier.size(16.dp))
Column(modifier = settingsRowModifier()) {
model.info.forEach {
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
}
}
}
}
}
}
@Composable
fun AddressRow(address: String, type: String) {
val localClipboardManager = LocalClipboardManager.current
val localClipboardManager = LocalClipboardManager.current
Row(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })
) {
Row(
modifier =
Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })) {
Column {
Text(text = address, style = MaterialTheme.typography.titleMedium)
Text(text = type, style = MaterialTheme.typography.bodyMedium)
Text(text = address, style = MaterialTheme.typography.titleMedium)
Text(text = type, style = MaterialTheme.typography.bodyMedium)
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Icon(Icons.Outlined.Share, null)
Icon(Icons.Outlined.Share, null)
}
}
}
}
@Composable
fun ValueRow(title: String, value: String) {
Row(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth()
) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = value, style = MaterialTheme.typography.bodyMedium)
}
Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).fillMaxWidth()) {
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
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
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.text.ClickableText
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
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.model.IpnLocal
import com.tailscale.ipn.ui.theme.ts_color_dark_desctrutive_text
import com.tailscale.ipn.ui.util.ChevronRight
import com.tailscale.ipn.ui.util.Header
import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.Setting
@ -45,159 +42,143 @@ import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.SettingsViewModelFactory
@Composable
fun Settings(
settingsNav: SettingsNav,
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModelFactory(settingsNav))
settingsNav: SettingsNav,
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModelFactory(settingsNav))
) {
val handler = LocalUriHandler.current
val user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxHeight()) {
Column(modifier = defaultPaddingModifier().fillMaxHeight()) {
Header(title = R.string.settings_title)
Spacer(modifier = Modifier.height(8.dp))
UserView(profile = user,
actionState = UserActionState.NAV,
onClick = viewModel.navigation.onNavigateToUserSwitcher)
if (isAdmin) {
Spacer(modifier = Modifier.height(4.dp))
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
Spacer(modifier = Modifier.height(8.dp))
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))
}
val handler = LocalUriHandler.current
val user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value
Scaffold(topBar = { Header(title = R.string.settings_title) }) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).fillMaxHeight()) {
UserView(
profile = user,
actionState = UserActionState.NAV,
onClick = viewModel.navigation.onNavigateToUserSwitcher)
if (isAdmin) {
Spacer(modifier = Modifier.height(4.dp))
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
Spacer(modifier = Modifier.height(8.dp))
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))
}
}
}
}
@Composable
fun UserView(
profile: IpnLocal.LoginProfile?,
isAdmin: Boolean,
adminText: AnnotatedString,
onClick: () -> Unit
profile: IpnLocal.LoginProfile?,
isAdmin: Boolean,
adminText: AnnotatedString,
onClick: () -> Unit
) {
Column {
Row(modifier = settingsRowModifier().padding(8.dp)) {
Box(modifier = defaultPaddingModifier()) {
Avatar(profile = profile, size = 36)
}
Column(verticalArrangement = Arrangement.Center) {
Text(
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()
})
}
}
Column {
Row(modifier = settingsRowModifier().padding(8.dp)) {
Box(modifier = defaultPaddingModifier()) { Avatar(profile = profile, size = 36) }
Column(verticalArrangement = Arrangement.Center) {
Text(
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() })
}
}
}
}
@Composable
fun SettingTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(8.dp)
)
Text(
text = title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp))
}
@Composable
fun SettingRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value
val swVal = setting.isOn?.collectAsState()?.value ?: false
val txtVal = setting.value?.collectAsState()?.value ?: ""
val enabled = setting.enabled.collectAsState().value
val swVal = setting.isOn?.collectAsState()?.value ?: false
val txtVal = setting.value?.collectAsState()?.value ?: ""
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) {
Row(
modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() },
verticalAlignment = Alignment.CenterVertically) {
when (setting.type) {
SettingType.NAV_WITH_TEXT -> {
Text(setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
}
SettingType.TEXT -> {
Text(setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary)
SettingType.NAV_WITH_TEXT -> {
Text(
setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color =
if (setting.destructive) ts_color_dark_desctrutive_text
else MaterialTheme.colorScheme.primary)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
SettingType.SWITCH -> {
Text(setting.title.getString())
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
}
}
SettingType.TEXT -> {
Text(
setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color =
if (setting.destructive) ts_color_dark_desctrutive_text
else MaterialTheme.colorScheme.primary)
}
SettingType.SWITCH -> {
Text(setting.title.getString())
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
}
SettingType.NAV -> {
Text(setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
ChevronRight()
}
SettingType.NAV -> {
Text(
setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color =
if (setting.destructive) ts_color_dark_desctrutive_text
else MaterialTheme.colorScheme.primary)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
ChevronRight()
}
}
}
}
}
@Composable
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val adminStr = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
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()
val adminStr = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.settings_admin_prefix))
}
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
ClickableText(
text = adminStr,
style = MaterialTheme.typography.bodySmall,
onClick = {
onNavigateToAdminConsole()
})
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)) {
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
fun TailscaleLogoView(modifier: Modifier) {
val primaryColor: Color = MaterialTheme.colorScheme.primary
val secondaryColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
BoxWithConstraints(modifier) {
Column(verticalArrangement = 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 = {
drawCircle(color = secondaryColor)
})
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = {
drawCircle(color = secondaryColor)
})
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = {
drawCircle(color = secondaryColor)
})
}
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = {
drawCircle(color = primaryColor)
})
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = {
drawCircle(color = primaryColor)
})
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = {
drawCircle(color = primaryColor)
})
}
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = {
drawCircle(color = secondaryColor)
})
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = {
drawCircle(color = primaryColor)
})
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = {
drawCircle(color = secondaryColor)
})
}
}
val primaryColor: Color = MaterialTheme.colorScheme.primary
val secondaryColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
BoxWithConstraints(modifier) {
Column(verticalArrangement = 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 = { drawCircle(color = secondaryColor) })
Canvas(
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
onDraw = { drawCircle(color = secondaryColor) })
Canvas(
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
onDraw = { drawCircle(color = secondaryColor) })
}
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Canvas(
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
onDraw = { drawCircle(color = primaryColor) })
Canvas(
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
onDraw = { drawCircle(color = primaryColor) })
Canvas(
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
onDraw = { drawCircle(color = primaryColor) })
}
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Canvas(
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
onDraw = { drawCircle(color = secondaryColor) })
Canvas(
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
onDraw = { drawCircle(color = primaryColor) })
Canvas(
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.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
@ -20,8 +19,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.Header
import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel
@ -30,58 +27,58 @@ import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel
@Composable
fun UserSwitcherView(viewModel: UserSwitcherViewModel = viewModel()) {
val users = viewModel.loginProfiles.collectAsState().value
val currentUser = viewModel.loggedInUser.collectAsState().value
val users = viewModel.loginProfiles.collectAsState().value
val currentUser = viewModel.loggedInUser.collectAsState().value
Surface(modifier = Modifier.fillMaxHeight(), color = MaterialTheme.colorScheme.background) {
Column(modifier = defaultPaddingModifier().fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
val showDialog = viewModel.showDialog.collectAsState().value
Scaffold(topBar = { Header(R.string.accounts) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
val showDialog = viewModel.showDialog.collectAsState().value
// Show the error overlay if need be
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
// Show the error overlay if need be
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
Header(title = R.string.accounts)
Column(modifier = settingsRowModifier()) {
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
// so we can decorate it with a spinner. The actual logged in user will not change until
// we get our first netmap update back with the new userId for SelfNode.
// (jonathan) TODO: This user switch is not immediate. We may need to represent the
// "switching users" state globally (if ipnState is insufficient)
val nextUserId = remember { mutableStateOf<String?>(null) }
users?.forEach { user ->
if (user.ID == currentUser?.ID) {
UserView(profile = user, actionState = UserActionState.CURRENT)
} else {
val state =
if (user.ID == nextUserId.value) UserActionState.SWITCHING
else UserActionState.NONE
UserView(
profile = user,
actionState = state,
onClick = {
nextUserId.value = user.ID
viewModel.switchProfile(user) {
if (it.isFailure) {
viewModel.showDialog.set(ErrorDialogType.LOGOUT_FAILED)
nextUserId.value = null
}
}
})
}
}
SettingRow(viewModel.addProfileSetting)
users?.forEach { user ->
if (user.ID == currentUser?.ID) {
UserView(profile = user, actionState = UserActionState.CURRENT)
} else {
val state =
if (user.ID == nextUserId.value) UserActionState.SWITCHING
else UserActionState.NONE
UserView(
profile = user,
actionState = state,
onClick = {
nextUserId.value = user.ID
viewModel.switchProfile(user) {
if (it.isFailure) {
viewModel.showDialog.set(ErrorDialogType.LOGOUT_FAILED)
nextUserId.value = null
}
}
})
}
}
SettingRow(viewModel.addProfileSetting)
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
Column(modifier = settingsRowModifier()) {
SettingRow(viewModel.loginSetting)
SettingRow(viewModel.logoutSetting)
}
Column(modifier = settingsRowModifier()) {
SettingRow(viewModel.loginSetting)
SettingRow(viewModel.logoutSetting)
}
}
}
}
}

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

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

@ -25,180 +25,181 @@ import kotlinx.coroutines.launch
* notifications, managing login/logout, updating preferences, etc.
*/
open class IpnViewModel : ViewModel() {
companion object {
val mdmSettings: StateFlow<MDMSettings> = MutableStateFlow(MDMSettings())
}
companion object {
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 loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null)
val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)
val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null)
// The userId associated with the current node. ie: The logged in user.
var selfNodeUserId: UserID? = null
// The userId associated with the current node. ie: The logged in user.
var selfNodeUserId: UserID? = null
init {
viewModelScope.launch {
Notifier.state.collect {
// Refresh the user profiles if we're transitioning out of the
// NeedsLogin state.
if (it == Ipn.State.NeedsLogin) {
viewModelScope.launch { loadUserProfiles() }
}
}
init {
viewModelScope.launch {
Notifier.state.collect {
// Refresh the user profiles if we're transitioning out of the
// NeedsLogin state.
if (it == Ipn.State.NeedsLogin) {
viewModelScope.launch { loadUserProfiles() }
}
// 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() {
Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure {
Log.e(TAG, "Error loading profiles: ${it.message}")
}
}
Client(viewModelScope).currentProfile { result ->
result.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
// 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() }
}
}
}
}
fun toggleVpn() {
when (Notifier.state.value) {
Ipn.State.Running -> stopVPN()
else -> startVPN()
}
}
viewModelScope.launch { loadUserProfiles() }
Log.d(TAG, "Created")
}
private fun startVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN
context.sendBroadcast(intent)
private fun loadUserProfiles() {
Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure {
Log.e(TAG, "Error loading profiles: ${it.message}")
}
}
fun stopVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
Client(viewModelScope).currentProfile { result ->
result
.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
}
}
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 toggleVpn() {
when (Notifier.state.value) {
Ipn.State.Running -> stopVPN()
else -> startVPN()
}
fun logout(completionHandler: (Result<String>) -> Unit = {}) {
Client(viewModelScope).logout { result ->
result
.onSuccess { Log.d(TAG, "Logout started: $it") }
.onFailure { Log.e(TAG, "Error starting logout: ${it.message}") }
completionHandler(result)
}
}
private fun startVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN
context.sendBroadcast(intent)
}
fun stopVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
}
fun login(completionHandler: (Result<String>) -> Unit = {}) {
Client(viewModelScope).startLoginInteractive { result ->
result
.onSuccess { Log.d(TAG, "Login started: $it") }
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result)
}
fun switchProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).switchProfile(profile) {
startVPN()
completionHandler(it)
}
}
fun logout(completionHandler: (Result<String>) -> Unit = {}) {
Client(viewModelScope).logout { result ->
result
.onSuccess { Log.d(TAG, "Logout started: $it") }
.onFailure { Log.e(TAG, "Error starting logout: ${it.message}") }
completionHandler(result)
}
}
fun addProfile(completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).addProfile {
if (it.isSuccess) {
login {}
}
startVPN()
completionHandler(it)
}
fun switchProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).switchProfile(profile) {
startVPN()
completionHandler(it)
}
fun deleteProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).deleteProfile(profile) {
viewModelScope.launch { loadUserProfiles() }
completionHandler(it)
}
}
fun addProfile(completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).addProfile {
if (it.isSuccess) {
login {}
}
startVPN()
completionHandler(it)
}
}
// 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 deleteProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).deleteProfile(profile) {
viewModelScope.launch { loadUserProfiles() }
completionHandler(it)
}
}
// 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 prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleCorpDNS
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.CorpDNS = !prefs.CorpDNS
Client(viewModelScope).editPrefs(prefsOut, callback)
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.CorpDNS = !prefs.CorpDNS
Client(viewModelScope).editPrefs(prefsOut, callback)
}
fun toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp
}
fun toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ShieldsUp = !prefs.ShieldsUp
Client(viewModelScope).editPrefs(prefsOut, callback)
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ShieldsUp = !prefs.ShieldsUp
Client(viewModelScope).editPrefs(prefsOut, callback)
}
fun toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll
}
fun toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = !prefs.RouteAll
Client(viewModelScope).editPrefs(prefsOut, callback)
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = !prefs.RouteAll
Client(viewModelScope).editPrefs(prefsOut, callback)
}
}

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

@ -16,45 +16,36 @@ import com.tailscale.ipn.ui.util.TimeUtil
data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter)
class PeerDetailsViewModelFactory(private val nodeId: StableNodeID) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PeerDetailsViewModel(nodeId) as T
}
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PeerDetailsViewModel(nodeId) as T
}
}
class PeerDetailsViewModel(val nodeId: StableNodeID) : IpnViewModel() {
var addresses: List<DisplayAddress> = emptyList()
var info: List<PeerSettingInfo> = emptyList()
val nodeName: String
val connectedStrRes: Int
val connectedColor: Color
var addresses: List<DisplayAddress> = emptyList()
var info: List<PeerSettingInfo> = emptyList()
init {
val peer = Notifier.netmap.value?.getPeer(nodeId)
peer?.Addresses?.let {
addresses = it.map { addr ->
DisplayAddress(addr)
}
}
val nodeName: String
val connectedStrRes: Int
val connectedColor: Color
peer?.Name?.let {
addresses = listOf(DisplayAddress(it)) + addresses
}
init {
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 ->
info = listOf(
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
peer?.let { p ->
info =
listOf(
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
}
}

@ -19,12 +19,15 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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) {
@Composable
fun getString(): String = stringResource(id = stringRes, *params)
@Composable fun getString(): String = stringResource(id = stringRes, *params)
}
// 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
// value
data class Setting(
val title: ComposableStringFormatter,
val type: SettingType,
val destructive: Boolean = false,
val enabled: StateFlow<Boolean> = MutableStateFlow(true),
val value: StateFlow<String?>? = null,
val isOn: StateFlow<Boolean?>? = null,
val onClick: () -> Unit = {},
val onToggle: (Boolean) -> Unit = {}
val title: ComposableStringFormatter,
val type: SettingType,
val destructive: Boolean = false,
val enabled: StateFlow<Boolean> = MutableStateFlow(true),
val value: StateFlow<String?>? = null,
val isOn: StateFlow<Boolean?>? = null,
val onClick: () -> Unit = {},
val onToggle: (Boolean) -> Unit = {}
) {
constructor(
titleRes: Int,
type: SettingType,
enabled: StateFlow<Boolean> = MutableStateFlow(false),
value: StateFlow<String?>? = null,
isOn: StateFlow<Boolean?>? = null,
onClick: () -> Unit = {},
onToggle: (Boolean) -> Unit = {}
) : this(
title = ComposableStringFormatter(titleRes),
type = type,
enabled = enabled,
value = value,
isOn = isOn,
onClick = onClick,
onToggle = onToggle
)
constructor(
titleRes: Int,
type: SettingType,
enabled: StateFlow<Boolean> = MutableStateFlow(false),
value: StateFlow<String?>? = null,
isOn: StateFlow<Boolean?>? = null,
onClick: () -> Unit = {},
onToggle: (Boolean) -> Unit = {}
) : this(
title = ComposableStringFormatter(titleRes),
type = type,
enabled = enabled,
value = value,
isOn = isOn,
onClick = onClick,
onToggle = onToggle)
}
data class SettingsNav(
val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit,
val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit,
val onNavigateToUserSwitcher: () -> Unit)
val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit,
val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit,
val onNavigateToUserSwitcher: () -> Unit
)
class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return SettingsViewModel(navigation) as T
}
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return SettingsViewModel(navigation) as T
}
}
class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() {
// Display name for the logged in user
var isAdmin: StateFlow<Boolean> = MutableStateFlow(false)
val useDNSSetting = Setting(R.string.use_ts_dns,
SettingType.SWITCH,
isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS),
onToggle = {
toggleCorpDNS {
// (jonathan) TODO: Error handling
}
})
// Display name for the logged in user
var isAdmin: StateFlow<Boolean> = MutableStateFlow(false)
val useDNSSetting =
Setting(
R.string.use_ts_dns,
SettingType.SWITCH,
isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS),
onToggle = {
toggleCorpDNS {
// (jonathan) TODO: Error handling
}
})
val settings: StateFlow<List<SettingBundle>> = MutableStateFlow(emptyList())
val settings: StateFlow<List<SettingBundle>> = MutableStateFlow(emptyList())
init {
viewModelScope.launch {
// Monitor our prefs for changes and update the displayed values accordingly
Notifier.prefs.collect { prefs ->
useDNSSetting.isOn?.set(prefs?.CorpDNS)
useDNSSetting.enabled.set(prefs != null)
}
}
init {
viewModelScope.launch {
// Monitor our prefs for changes and update the displayed values accordingly
Notifier.prefs.collect { prefs ->
useDNSSetting.isOn?.set(prefs?.CorpDNS)
useDNSSetting.enabled.set(prefs != null)
}
}
viewModelScope.launch {
mdmSettings.collect { mdmSettings ->
settings.set(
viewModelScope.launch {
mdmSettings.collect { mdmSettings ->
settings.set(
listOf(
SettingBundle(
settings =
listOf(
SettingBundle(
settings = listOf(
useDNSSetting,
)
),
// General settings, always enabled
SettingBundle(settings = footerSettings(mdmSettings))
)
)
}
}
viewModelScope.launch {
Notifier.netmap.collect { netmap ->
isAdmin.set(netmap?.SelfNode?.isAdmin ?: false)
}
}
useDNSSetting,
)),
// General settings, always enabled
SettingBundle(settings = footerSettings(mdmSettings))))
}
}
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(
titleRes = R.string.about,
SettingType.NAV,
onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true)
), Setting(
titleRes = R.string.bug_report,
SettingType.NAV,
onClick = { navigation.onNavigateToBugReport() },
enabled = MutableStateFlow(true)
), mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Setting(
ComposableStringFormatter(R.string.managed_by_orgName, it),
SettingType.NAV,
onClick = { navigation.onNavigateToManagedBy() },
enabled = MutableStateFlow(true)
)
}, if (BuildConfig.DEBUG) {
Setting(
enabled = MutableStateFlow(true))
},
if (BuildConfig.DEBUG) {
Setting(
titleRes = R.string.mdm_settings,
SettingType.NAV,
onClick = { navigation.onNavigateToMDMSettings() },
enabled = MutableStateFlow(true)
)
} else {
null
}
)
enabled = MutableStateFlow(true))
} else {
null
})
}

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

Loading…
Cancel
Save