diff --git a/README.md b/README.md index 67135ad..cf6b9f5 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 5d7adea..897c5e0 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -25,20 +25,20 @@ android:required="false" /> + android:allowBackup="false" + android:banner="@drawable/tv_banner" + android:icon="@mipmap/ic_launcher" + android:label="Tailscale" + android:roundIcon="@mipmap/ic_launcher_round"> + android:theme="@style/Theme.GioApp" + android:windowSoftInputMode="adjustResize"> @@ -84,18 +84,18 @@ + android:exported="false" + android:permission="android.permission.BIND_VPN_SERVICE"> + android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> diff --git a/android/src/main/java/com/tailscale/ipn/App.java b/android/src/main/java/com/tailscale/ipn/App.java index 95eaf2f..7789bb7 100644 --- a/android/src/main/java/com/tailscale/ipn/App.java +++ b/android/src/main/java/com/tailscale/ipn/App.java @@ -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 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 diff --git a/android/src/main/java/com/tailscale/ipn/DnsConfig.java b/android/src/main/java/com/tailscale/ipn/DnsConfig.java index 19209de..3460739 100644 --- a/android/src/main/java/com/tailscale/ipn/DnsConfig.java +++ b/android/src/main/java/com/tailscale/ipn/DnsConfig.java @@ -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(); + } } diff --git a/android/src/main/java/com/tailscale/ipn/IPNActivity.java b/android/src/main/java/com/tailscale/ipn/IPNActivity.java index 4501d43..bb53822 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNActivity.java +++ b/android/src/main/java/com/tailscale/ipn/IPNActivity.java @@ -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 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 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(); + } } diff --git a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java index 37c91eb..7838c43 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java +++ b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java @@ -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; diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.java b/android/src/main/java/com/tailscale/ipn/IPNService.java index 0e5a47f..f72f6cc 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.java +++ b/android/src/main/java/com/tailscale/ipn/IPNService.java @@ -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(); } diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 3334457..5463422 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -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() { - 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 + } } diff --git a/android/src/main/java/com/tailscale/ipn/Peer.java b/android/src/main/java/com/tailscale/ipn/Peer.java index a5c49e7..9bbb025 100644 --- a/android/src/main/java/com/tailscale/ipn/Peer.java +++ b/android/src/main/java/com/tailscale/ipn/Peer.java @@ -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); + } } diff --git a/android/src/main/java/com/tailscale/ipn/QuickToggleService.java b/android/src/main/java/com/tailscale/ipn/QuickToggleService.java index f77a30c..93bd730 100644 --- a/android/src/main/java/com/tailscale/ipn/QuickToggleService.java +++ b/android/src/main/java/com/tailscale/ipn/QuickToggleService.java @@ -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); + } } diff --git a/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java index 5defe23..a4514a9 100644 --- a/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java +++ b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java @@ -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(); } } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java b/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java index 2d2a378..ebe270a 100644 --- a/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java +++ b/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java @@ -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(); } diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt index e842690..543b483 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt @@ -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? { - restrictionsManager?.let { - if (it.applicationRestrictions.containsKey(setting.key)) { - return it.applicationRestrictions.getStringArray(setting.key) - } - } - return App.getApplication().encryptedPrefs.getStringSet(setting.key, HashSet()) - ?.toTypedArray()?.sortedArray() + fun get(setting: StringArraySetting): Array? { + restrictionsManager?.let { + if (it.applicationRestrictions.containsKey(setting.key)) { + return it.applicationRestrictions.getStringArray(setting.key) + } } + return App.getApplication() + .encryptedPrefs + .getStringSet(setting.key, HashSet()) + ?.toTypedArray() + ?.sortedArray() + } } diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt index 0165382..d3e26c6 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt @@ -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"), -} \ No newline at end of file + currentUser("current-user"), + otherUsers("other-users"), + taggedDevices("tagged-devices"), +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/Links.kt b/android/src/main/java/com/tailscale/ipn/ui/Links.kt index c72cc3f..5141dae 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/Links.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/Links.kt @@ -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" } diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index 0ba9fbe..09ef706 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -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) -> Unit + typealias BugReportIdHandler = (Result) -> Unit + typealias PrefsHandler = (Result) -> Unit /** @@ -53,190 +55,202 @@ typealias PrefsHandler = (Result) -> 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) -> Unit - ) { - val body = Json.encodeToString(prefs).toByteArray() - return patch(Endpoint.PREFS, body, responseHandler = responseHandler) - } + fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result) -> Unit) { + val body = Json.encodeToString(prefs).toByteArray() + return patch(Endpoint.PREFS, body, responseHandler = responseHandler) + } - fun profiles(responseHandler: (Result>) -> Unit) { - get(Endpoint.PROFILES, responseHandler = responseHandler) - } + fun profiles(responseHandler: (Result>) -> Unit) { + get(Endpoint.PROFILES, responseHandler = responseHandler) + } - fun currentProfile(responseHandler: (Result) -> Unit) { - return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler) - } + fun currentProfile(responseHandler: (Result) -> Unit) { + return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler) + } - fun addProfile(responseHandler: (Result) -> Unit = {}) { - return put(Endpoint.PROFILES, responseHandler = responseHandler) - } + fun addProfile(responseHandler: (Result) -> Unit = {}) { + return put(Endpoint.PROFILES, responseHandler = responseHandler) + } - fun deleteProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result) -> Unit = {}) { - return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler) - } + fun deleteProfile( + profile: IpnLocal.LoginProfile, + responseHandler: (Result) -> Unit = {} + ) { + return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler) + } - fun switchProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result) -> Unit = {}) { - return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler) - } + fun switchProfile( + profile: IpnLocal.LoginProfile, + responseHandler: (Result) -> Unit = {} + ) { + return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler) + } - fun startLoginInteractive(responseHandler: (Result) -> Unit) { - return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler) - } + fun startLoginInteractive(responseHandler: (Result) -> Unit) { + return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler) + } - fun logout(responseHandler: (Result) -> Unit) { - return post(Endpoint.LOGOUT, responseHandler = responseHandler) - } + fun logout(responseHandler: (Result) -> Unit) { + return post(Endpoint.LOGOUT, responseHandler = responseHandler) + } - private inline fun get( - path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit - ) { - Request( - scope = scope, - method = "GET", - path = path, - body = body, - responseType = typeOf(), - responseHandler = responseHandler - ).execute() - } + private inline fun get( + path: String, + body: ByteArray? = null, + noinline responseHandler: (Result) -> Unit + ) { + Request( + scope = scope, + method = "GET", + path = path, + body = body, + responseType = typeOf(), + responseHandler = responseHandler) + .execute() + } - private inline fun put( - path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit - ) { - Request( - scope = scope, - method = "PUT", - path = path, - body = body, - responseType = typeOf(), - responseHandler = responseHandler - ).execute() - } + private inline fun put( + path: String, + body: ByteArray? = null, + noinline responseHandler: (Result) -> Unit + ) { + Request( + scope = scope, + method = "PUT", + path = path, + body = body, + responseType = typeOf(), + responseHandler = responseHandler) + .execute() + } - private inline fun post( - path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit - ) { - Request( - scope = scope, - method = "POST", - path = path, - body = body, - responseType = typeOf(), - responseHandler = responseHandler - ).execute() - } + private inline fun post( + path: String, + body: ByteArray? = null, + noinline responseHandler: (Result) -> Unit + ) { + Request( + scope = scope, + method = "POST", + path = path, + body = body, + responseType = typeOf(), + responseHandler = responseHandler) + .execute() + } - private inline fun patch( - path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit - ) { - Request( - scope = scope, - method = "PATCH", - path = path, - body = body, - responseType = typeOf(), - responseHandler = responseHandler - ).execute() - } + private inline fun patch( + path: String, + body: ByteArray? = null, + noinline responseHandler: (Result) -> Unit + ) { + Request( + scope = scope, + method = "PATCH", + path = path, + body = body, + responseType = typeOf(), + responseHandler = responseHandler) + .execute() + } - private inline fun delete( - path: String, noinline responseHandler: (Result) -> Unit - ) { - Request( - scope = scope, - method = "DELETE", - path = path, - responseType = typeOf(), - responseHandler = responseHandler - ).execute() - } + private inline fun delete( + path: String, + noinline responseHandler: (Result) -> Unit + ) { + Request( + scope = scope, + method = "DELETE", + path = path, + responseType = typeOf(), + responseHandler = responseHandler) + .execute() + } } class Request( - private val scope: CoroutineScope, - private val method: String, - path: String, - private val body: ByteArray? = null, - private val responseType: KType, - private val responseHandler: (Result) -> Unit + private val scope: CoroutineScope, + private val method: String, + path: String, + private val body: ByteArray? = null, + private val responseType: KType, + private val responseHandler: (Result) -> 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() + private val jsonDecoder = Json { ignoreUnknownKeys = true } + private val isReady = CompletableDeferred() - // 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 = when (responseType) { - typeOf() -> Result.success(respData.decodeToString() as T) - else -> try { + val response: Result = + when (responseType) { + typeOf() -> 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(respData.inputStream()) - throw Exception(error.error) + val error = + jsonDecoder.decodeFromStream(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) } + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt index a655ab8..6795017 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt @@ -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?) + @Serializable data class HostEntry(val addr: Addr?, val hosts: List?) - @Serializable - data class OSConfig( - val hosts: List? = null, - val nameservers: List? = null, - val searchDomains: List? = null, - val matchDomains: List? = null, - ) { - val isEmpty: Boolean - get() = (hosts.isNullOrEmpty()) && - (nameservers.isNullOrEmpty()) && - (searchDomains.isNullOrEmpty()) && - (matchDomains.isNullOrEmpty()) - } + @Serializable + data class OSConfig( + val hosts: List? = null, + val nameservers: List? = null, + val searchDomains: List? = null, + val matchDomains: List? = 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? = null) + @Serializable + data class Resolver(var Addr: String? = null, var BootstrapResolution: List? = null) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index ab723cd..65f1130 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -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? = null, - var ClientVersion: Tailcfg.ClientVersion? = null, - var TailFSShares: Map? = 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? = null, - var AdvertiseTags: List? = 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? = null, + var ClientVersion: Tailcfg.ClientVersion? = null, + var TailFSShares: Map? = 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? = null, + var AdvertiseTags: List? = 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, + ) - @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, - ) - - @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 = "", + ) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt index 06ec633..cbbe7ad 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt @@ -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? = null, - val Tags: List? = null, - val PrimaryRoutes: List? = null, - val Addrs: List? = null, - val Online: Boolean, - val ExitNode: Boolean, - val ExitNodeOption: Boolean, - val Active: Boolean, - val PeerAPIURL: List? = null, - val Capabilities: List? = null, - val SSH_HostKeys: List? = 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? = null, + val Tags: List? = null, + val PrimaryRoutes: List? = null, + val Addrs: List? = null, + val Online: Boolean, + val ExitNode: Boolean, + val ExitNodeOption: Boolean, + val Active: Boolean, + val PeerAPIURL: List? = null, + val Capabilities: List? = null, + val SSH_HostKeys: List? = 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? = null, - ) + @Serializable + data class ExitNodeStatus( + val ID: StableNodeID, + val Online: Boolean, + val TailscaleIPs: List? = 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? = null, - val Self: PeerStatus? = null, - val ExitNodeStatus: ExitNodeStatus? = null, - val Health: List? = null, - val CurrentTailnet: TailnetStatus? = null, - val CertDomains: List? = null, - val Peer: Map? = null, - val User: Map? = 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? = null, + val Self: PeerStatus? = null, + val ExitNodeStatus: ExitNodeStatus? = null, + val Health: List? = null, + val CurrentTailnet: TailnetStatus? = null, + val CertDomains: List? = null, + val Peer: Map? = null, + val User: Map? = 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? = null, - var StateID: ULong? = null, - ) + @Serializable + data class NetworkLockStatus( + var Enabled: Boolean, + var PublicKey: String, + var NodeKey: String, + var NodeKeySigned: Boolean, + var FilteredPeers: List? = null, + var StateID: ULong? = null, + ) - @Serializable - data class TKAFilteredPeer( - var Name: String, - var TailscaleIPs: List, - var NodeKey: String, - ) + @Serializable + data class TKAFilteredPeer( + var Name: String, + var TailscaleIPs: List, + 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() } + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt index 24aa244..77322cb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt @@ -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? = null, - var Expiry: Time, - var Domain: String, - var UserProfiles: Map, - 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? = null, + var Expiry: Time, + var Domain: String, + var UserProfiles: Map, + 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 + } + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index baa417f..a7e689f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -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? = null, - var Services: List? = 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? = null, + var Services: List? = 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? = null, - var AllowedIPs: List? = null, - var Endpoints: List? = null, - var Hostinfo: Hostinfo, - var Created: Time, - var LastSeen: Time? = null, - var Online: Boolean? = null, - var Capabilities: List? = 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? = null, + var AllowedIPs: List? = null, + var Endpoints: List? = null, + var Hostinfo: Hostinfo, + var Created: Time, + var LastSeen: Time? = null, + var Online: Boolean? = null, + var Capabilities: List? = 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? = null, - var Routes: Map?>? = null, - var FallbackResolvers: List? = null, - var Domains: List? = null, - var Nameservers: List? = null - ) + @Serializable + data class DNSConfig( + var Resolvers: List? = null, + var Routes: Map?>? = null, + var FallbackResolvers: List? = null, + var Domains: List? = null, + var Nameservers: List? = null + ) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt index 67ab28c..6eec247 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt @@ -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) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt index 391415d..7eceb32 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt @@ -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() + private val TAG = Notifier::class.simpleName + private val decoder = Json { ignoreUnknownKeys = true } + private val isReady = CompletableDeferred() - val state: StateFlow = MutableStateFlow(Ipn.State.NoState) - val netmap: StateFlow = MutableStateFlow(null) - val prefs: StateFlow = MutableStateFlow(null) - val engineStatus: StateFlow = MutableStateFlow(null) - val tailFSShares: StateFlow?> = MutableStateFlow(null) - val browseToURL: StateFlow = MutableStateFlow(null) - val loginFinished: StateFlow = MutableStateFlow(null) - val version: StateFlow = MutableStateFlow(null) + val state: StateFlow = MutableStateFlow(Ipn.State.NoState) + val netmap: StateFlow = MutableStateFlow(null) + val prefs: StateFlow = MutableStateFlow(null) + val engineStatus: StateFlow = MutableStateFlow(null) + val tailFSShares: StateFlow?> = MutableStateFlow(null) + val browseToURL: StateFlow = MutableStateFlow(null) + val loginFinished: StateFlow = MutableStateFlow(null) + val version: StateFlow = 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 = 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 = 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(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(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) + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt b/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt index e9d5da7..18a2db3 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt @@ -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 diff --git a/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt index d68a16e..23febee 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt @@ -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 - ) -} \ No newline at end of file + MaterialTheme(colorScheme = colors, content = content) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/ComposableStringFormatter.kt b/android/src/main/java/com/tailscale/ipn/ui/util/ComposableStringFormatter.kt index d6eed6d..e70c399 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/ComposableStringFormatter.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/ComposableStringFormatter.kt @@ -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) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt b/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt index 4eae891..87bb03b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt @@ -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 -} \ No newline at end of file + 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 +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Flag.kt b/android/src/main/java/com/tailscale/ipn/ui/util/Flag.kt index 8e5b0fb..d2a5504 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/Flag.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/Flag.kt @@ -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)) -} \ No newline at end of file + 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)) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt b/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt index 97db6ba..d473eb7 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt @@ -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() } - } + } } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt index 096b4db..d6149c0 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt @@ -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) typealias GroupedPeers = MutableMap> class PeerCategorizer(scope: CoroutineScope) { - var peerSets: List = emptyList() - var lastSearchResult: List = 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 = emptyList() + var lastSearchResult: List = 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 { + val peers: List = netmap.Peers ?: return emptyList() + val selfNode = netmap.SelfNode + var grouped = mutableMapOf>() + + 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 { - val peers: List = netmap.Peers ?: return emptyList() - val selfNode = netmap.SelfNode - var grouped = mutableMapOf>() - - 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 { - if (searchTerm.isEmpty()) { - return peerSets - } + fun groupedAndFilteredPeers(searchTerm: String = ""): List { + 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() -} \ No newline at end of file + return matchingSets + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/StateFlow.kt b/android/src/main/java/com/tailscale/ipn/ui/util/StateFlow.kt index 66f3cfe..7073279 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/StateFlow.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/StateFlow.kt @@ -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 StateFlow.set(v: T) { - (this as MutableStateFlow).value = v -} \ No newline at end of file + (this as MutableStateFlow).value = v +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt b/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt index f559493..8d75cb0 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt @@ -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) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt b/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt index c864a4a..a9c56dd 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt @@ -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) + } } - diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt index 05146e5..6d8df91 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt @@ -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 - ) - ) -} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt index 576d423..aef518a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt @@ -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) } - } + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt index 2f917a3..f71e4ca 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt @@ -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) { - 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 } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt index ec5c00b..d762edd 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt @@ -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 - ) -} \ No newline at end of file +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)) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt index d2c6e86..4065dbd 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt @@ -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)) } + }) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt index 72915da..0be44ef 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -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) - } - } - }) - } -} \ No newline at end of file + 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) + } + } + }) + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt index 745426c..191f7a9 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt @@ -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 -> - 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 -> + MDMSettingView( + title = booleanSetting.localizedTitle, + caption = booleanSetting.key, + valueDescription = mdmSettings.get(booleanSetting).toString()) + } - items(enumValues()) { stringSetting -> - MDMSettingView( - title = stringSetting.localizedTitle, - caption = stringSetting.key, - valueDescription = mdmSettings.get(stringSetting).toString() - ) - } + items(enumValues()) { stringSetting -> + MDMSettingView( + title = stringSetting.localizedTitle, + caption = stringSetting.key, + valueDescription = mdmSettings.get(stringSetting).toString()) + } - items(enumValues()) { showHideSetting -> - MDMSettingView( - title = showHideSetting.localizedTitle, - caption = showHideSetting.key, - valueDescription = mdmSettings.get(showHideSetting).toString() - ) - } + items(enumValues()) { showHideSetting -> + MDMSettingView( + title = showHideSetting.localizedTitle, + caption = showHideSetting.key, + valueDescription = mdmSettings.get(showHideSetting).toString()) + } - items(enumValues()) { anuSetting -> - MDMSettingView( - title = anuSetting.localizedTitle, - caption = anuSetting.key, - valueDescription = mdmSettings.get(anuSetting).toString() - ) - } + items(enumValues()) { anuSetting -> + MDMSettingView( + title = anuSetting.localizedTitle, + caption = anuSetting.key, + valueDescription = mdmSettings.get(anuSetting).toString()) + } - items(enumValues()) { stringArraySetting -> - MDMSettingView( - title = stringArraySetting.localizedTitle, - caption = stringArraySetting.key, - valueDescription = mdmSettings.get(stringArraySetting).toString() - ) - } - } + items(enumValues()) { 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 - ) - } -} \ No newline at end of file + fontWeight = FontWeight.SemiBold) + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 3ebdb62..a6f5221 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -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, 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, - peers: StateFlow>, - state: StateFlow, - selfPeer: StableNodeID, - onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, - onSearch: (String) -> Unit + searchTerm: StateFlow, + peers: StateFlow>, + state: StateFlow, + selfPeer: StableNodeID, + onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, + onSearch: (String) -> Unit ) { - val peerList = peers.collectAsState(initial = emptyList()) - 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()) + 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) }) + } } + } } - } + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt index 2f21354..147a06b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt @@ -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) + } } - } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt index de291eb..67e673e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt @@ -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) } } + } } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt index 9b26556..be770cf 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt @@ -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) } + } } - - diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index 068ca62..8f76556 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -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() }) + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt new file mode 100644 index 0000000..3624240 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt @@ -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, + ) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt index 88e3f68..734c69f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt @@ -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) }) + } } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt index 1875491..e0af570 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt @@ -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(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(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) + } } - } + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt index 80c87b6..646ed8a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt @@ -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 + } } - } + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt index 188e3cc..3037a8e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt @@ -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 = MutableStateFlow("") + val bugReportID: StateFlow = 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)") } } + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt index 8dfffd4..90c0e79 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt @@ -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 create(modelClass: Class): T { - return ExitNodePickerViewModel(nav) as T - } + override fun create(modelClass: Class): 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> = MutableStateFlow(emptyList()) - val mullvadExitNodesByCountryCode: StateFlow>> = MutableStateFlow( - TreeMap() - ) - val mullvadBestAvailableByCountry: StateFlow> = MutableStateFlow( - TreeMap() - ) - val anyActive: StateFlow = MutableStateFlow(false) + val tailnetExitNodes: StateFlow> = MutableStateFlow(emptyList()) + val mullvadExitNodesByCountryCode: StateFlow>> = + MutableStateFlow(TreeMap()) + val mullvadBestAvailableByCountry: StateFlow> = MutableStateFlow(TreeMap()) + val anyActive: StateFlow = 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() } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index 4168421..633e6aa 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -25,180 +25,181 @@ import kotlinx.coroutines.launch * notifications, managing login/logout, updating preferences, etc. */ open class IpnViewModel : ViewModel() { - companion object { - val mdmSettings: StateFlow = MutableStateFlow(MDMSettings()) - } + companion object { + val mdmSettings: StateFlow = MutableStateFlow(MDMSettings()) + } - protected val TAG = this::class.simpleName + protected val TAG = this::class.simpleName - val loggedInUser: StateFlow = MutableStateFlow(null) - val loginProfiles: StateFlow?> = MutableStateFlow(null) + val loggedInUser: StateFlow = MutableStateFlow(null) + val loginProfiles: StateFlow?> = 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) -> 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) -> 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) -> 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) -> Unit) { - Client(viewModelScope).switchProfile(profile) { - startVPN() - completionHandler(it) - } + } + + fun logout(completionHandler: (Result) -> 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) -> Unit) { - Client(viewModelScope).addProfile { - if (it.isSuccess) { - login {} - } - startVPN() - completionHandler(it) - } + fun switchProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result) -> Unit) { + Client(viewModelScope).switchProfile(profile) { + startVPN() + completionHandler(it) } - - fun deleteProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result) -> Unit) { - Client(viewModelScope).deleteProfile(profile) { - viewModelScope.launch { loadUserProfiles() } - completionHandler(it) - } + } + + fun addProfile(completionHandler: (Result) -> 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) -> Unit) { - Ipn.MaskedPrefs().WantRunning = wantRunning - Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback) + fun deleteProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result) -> 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) -> Unit) { + Ipn.MaskedPrefs().WantRunning = wantRunning + Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback) + } + + fun toggleCorpDNS(callback: (Result) -> Unit) { + val prefs = + Notifier.prefs.value + ?: run { + callback(Result.failure(Exception("no prefs"))) + return@toggleCorpDNS + } - fun toggleCorpDNS(callback: (Result) -> 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) -> Unit) { + val prefs = + Notifier.prefs.value + ?: run { + callback(Result.failure(Exception("no prefs"))) + return@toggleShieldsUp + } - fun toggleShieldsUp(callback: (Result) -> 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) -> Unit) { + val prefs = + Notifier.prefs.value + ?: run { + callback(Result.failure(Exception("no prefs"))) + return@toggleRouteAll + } - fun toggleRouteAll(callback: (Result) -> 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) + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 9110cec..b945216 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -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 = MutableStateFlow(State.NoState.userStringRes()) - - // The expected state of the VPN toggle - val vpnToggleState: StateFlow = MutableStateFlow(false) - - // The list of peers - val peers: StateFlow> = MutableStateFlow(emptyList()) + // The user readable state of the system + val stateRes: StateFlow = 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 = MutableStateFlow(false) - val prefs = Notifier.prefs - val netmap = Notifier.netmap + // The list of peers + val peers: StateFlow> = MutableStateFlow(emptyList()) - // The active search term for filtering peers - val searchTerm: StateFlow = MutableStateFlow("") + // The current state of the IPN for determining view visibility + val ipnState = Notifier.state - // The peerID of the local node - val selfPeerId: StateFlow = MutableStateFlow("") + val prefs = Notifier.prefs + val netmap = Notifier.netmap - private val peerCategorizer = PeerCategorizer(viewModelScope) + // The active search term for filtering peers + val searchTerm: StateFlow = MutableStateFlow("") - val userName: String - get() { - return loggedInUser.value?.Name ?: "" - } + // The peerID of the local node + val selfPeerId: StateFlow = 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 + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt index d31469d..d91c427 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt @@ -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 create(modelClass: Class): T { - return PeerDetailsViewModel(nodeId) as T - } + override fun create(modelClass: Class): T { + return PeerDetailsViewModel(nodeId) as T + } } class PeerDetailsViewModel(val nodeId: StableNodeID) : IpnViewModel() { - var addresses: List = emptyList() - var info: List = emptyList() - - val nodeName: String - val connectedStrRes: Int - val connectedColor: Color + var addresses: List = emptyList() + var info: List = 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 + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt index 894507a..9e1ccf5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -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) // 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 = MutableStateFlow(true), - val value: StateFlow? = null, - val isOn: StateFlow? = null, - val onClick: () -> Unit = {}, - val onToggle: (Boolean) -> Unit = {} + val title: ComposableStringFormatter, + val type: SettingType, + val destructive: Boolean = false, + val enabled: StateFlow = MutableStateFlow(true), + val value: StateFlow? = null, + val isOn: StateFlow? = null, + val onClick: () -> Unit = {}, + val onToggle: (Boolean) -> Unit = {} ) { - constructor( - titleRes: Int, - type: SettingType, - enabled: StateFlow = MutableStateFlow(false), - value: StateFlow? = null, - isOn: StateFlow? = 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 = MutableStateFlow(false), + value: StateFlow? = null, + isOn: StateFlow? = 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 create(modelClass: Class): T { - return SettingsViewModel(navigation) as T - } + override fun create(modelClass: Class): T { + return SettingsViewModel(navigation) as T + } } class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() { - // Display name for the logged in user - var isAdmin: StateFlow = 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 = 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> = MutableStateFlow(emptyList()) + val settings: StateFlow> = 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 = listOfNotNull( + viewModelScope.launch { + Notifier.netmap.collect { netmap -> isAdmin.set(netmap?.SelfNode?.isAdmin ?: false) } + } + } + + private fun footerSettings(mdmSettings: MDMSettings): List = + 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 + }) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt index 11662d7..2250c2e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt @@ -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 = MutableStateFlow(null)