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

* android: use scaffold consistently across all views

Updates tailscale/corp#18202

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

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

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

Updates tailscale/corp#18202

Standardize code formatting using ktfmt default settings.

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

* android: update readme for new code formatting guidelines

Updates tailscale/corp#18202

Mandate the use of ktfmt in the default configuration.

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

---------

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

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

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

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

@ -6,16 +6,6 @@ package com.tailscale.ipn;
import android.net.NetworkCapabilities; import android.net.NetworkCapabilities;
import android.net.NetworkRequest; import android.net.NetworkRequest;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
// Tailscale DNS Config retrieval // Tailscale DNS Config retrieval
// //
// Tailscale's DNS support can either override the local DNS servers with a set of servers // Tailscale's DNS support can either override the local DNS servers with a set of servers
@ -44,19 +34,19 @@ public class DnsConfig {
return getDnsConfigs().trim(); return getDnsConfigs().trim();
} }
private String getDnsConfigs(){ private String getDnsConfigs() {
synchronized(this) { synchronized (this) {
return this.dnsConfigs; return this.dnsConfigs;
} }
} }
void updateDNSFromNetwork(String dnsConfigs){ void updateDNSFromNetwork(String dnsConfigs) {
synchronized(this) { synchronized (this) {
this.dnsConfigs = dnsConfigs; this.dnsConfigs = dnsConfigs;
} }
} }
NetworkRequest getDNSConfigNetworkRequest(){ NetworkRequest getDNSConfigNetworkRequest() {
// Request networks that are able to reach the Internet. // Request networks that are able to reach the Internet.
return new NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build(); return new NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build();
} }

@ -4,33 +4,33 @@
package com.tailscale.ipn; package com.tailscale.ipn;
import android.app.Activity; import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.res.Configuration;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.provider.OpenableColumns; import android.provider.OpenableColumns;
import android.net.Uri;
import android.content.pm.PackageManager;
import java.util.List;
import java.util.ArrayList;
import org.gioui.GioView; import org.gioui.GioView;
import java.util.List;
public final class IPNActivity extends Activity { public final class IPNActivity extends Activity {
final static int WRITE_STORAGE_RESULT = 1000; final static int WRITE_STORAGE_RESULT = 1000;
private GioView view; private GioView view;
@Override public void onCreate(Bundle state) { @Override
public void onCreate(Bundle state) {
super.onCreate(state); super.onCreate(state);
view = new GioView(this); view = new GioView(this);
setContentView(view); setContentView(view);
handleIntent(); handleIntent();
} }
@Override public void onNewIntent(Intent i) { @Override
public void onNewIntent(Intent i) {
setIntent(i); setIntent(i);
handleIntent(); handleIntent();
} }
@ -91,41 +91,47 @@ public final class IPNActivity extends Activity {
App.onShareIntent(nfiles, types, mimes, items, names, sizes); App.onShareIntent(nfiles, types, mimes, items, names, sizes);
} }
@Override public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) { @Override
switch (reqCode) { public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
case WRITE_STORAGE_RESULT: if (reqCode == WRITE_STORAGE_RESULT) {
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) { if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
App.onWriteStorageGranted(); App.onWriteStorageGranted();
} }
} }
} }
@Override public void onDestroy() { @Override
public void onDestroy() {
view.destroy(); view.destroy();
super.onDestroy(); super.onDestroy();
} }
@Override public void onStart() { @Override
public void onStart() {
super.onStart(); super.onStart();
view.start(); view.start();
} }
@Override public void onStop() { @Override
public void onStop() {
view.stop(); view.stop();
super.onStop(); super.onStop();
} }
@Override public void onConfigurationChanged(Configuration c) { @Override
public void onConfigurationChanged(Configuration c) {
super.onConfigurationChanged(c); super.onConfigurationChanged(c);
view.configurationChanged(); view.configurationChanged();
} }
@Override public void onLowMemory() { @Override
public void onLowMemory() {
super.onLowMemory(); super.onLowMemory();
view.onLowMemory(); GioView.onLowMemory();
} }
@Override public void onBackPressed() { @Override
public void onBackPressed() {
if (!view.backPressed()) if (!view.backPressed())
super.onBackPressed(); super.onBackPressed();
} }

@ -7,8 +7,8 @@ import android.content.BroadcastReceiver;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import androidx.work.WorkManager;
import androidx.work.OneTimeWorkRequest; import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import java.util.Objects; import java.util.Objects;

@ -3,17 +3,12 @@
package com.tailscale.ipn; package com.tailscale.ipn;
import android.util.Log;
import android.os.Build;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.VpnService; import android.net.VpnService;
import android.os.Build;
import android.system.OsConstants; import android.system.OsConstants;
import androidx.work.WorkManager;
import androidx.work.OneTimeWorkRequest;
import org.gioui.GioActivity;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat; import androidx.core.app.NotificationManagerCompat;
@ -22,9 +17,10 @@ public class IPNService extends VpnService {
public static final String ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN"; public static final String ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN";
public static final String ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"; public static final String ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN";
@Override public int onStartCommand(Intent intent, int flags, int startId) { @Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_STOP_VPN.equals(intent.getAction())) { if (intent != null && ACTION_STOP_VPN.equals(intent.getAction())) {
((App)getApplicationContext()).autoConnect = false; ((App) getApplicationContext()).autoConnect = false;
close(); close();
return START_NOT_STICKY; return START_NOT_STICKY;
} }
@ -39,7 +35,7 @@ public class IPNService extends VpnService {
return START_STICKY; return START_STICKY;
} }
requestVPN(); requestVPN();
App app = ((App)getApplicationContext()); App app = ((App) getApplicationContext());
if (app.vpnReady && app.autoConnect) { if (app.vpnReady && app.autoConnect) {
connect(); connect();
} }
@ -51,12 +47,14 @@ public class IPNService extends VpnService {
disconnect(); disconnect();
} }
@Override public void onDestroy() { @Override
public void onDestroy() {
close(); close();
super.onDestroy(); super.onDestroy();
} }
@Override public void onRevoke() { @Override
public void onRevoke() {
close(); close();
super.onRevoke(); super.onRevoke();
} }
@ -70,7 +68,6 @@ public class IPNService extends VpnService {
try { try {
b.addDisallowedApplication(name); b.addDisallowedApplication(name);
} catch (PackageManager.NameNotFoundException e) { } catch (PackageManager.NameNotFoundException e) {
return;
} }
} }
@ -134,5 +131,6 @@ public class IPNService extends VpnService {
private native void requestVPN(); private native void requestVPN();
private native void disconnect(); private native void disconnect();
private native void connect(); private native void connect();
} }

@ -43,7 +43,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var notifierScope: CoroutineScope? = null private var notifierScope: CoroutineScope? = null
@ -72,53 +71,34 @@ class MainActivity : ComponentActivity() {
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") }, onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
) )
val exitNodePickerNav = ExitNodePickerNav(onNavigateHome = { val exitNodePickerNav =
navController.popBackStack( ExitNodePickerNav(
route = "main", inclusive = false onNavigateHome = {
) navController.popBackStack(route = "main", inclusive = false)
}, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }) },
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") })
composable("main") { composable("main") { MainView(navigation = mainViewNav) }
MainView(navigation = mainViewNav) composable("settings") { Settings(settingsNav) }
}
composable("settings") {
Settings(settingsNav)
}
navigation(startDestination = "list", route = "exitNodes") { navigation(startDestination = "list", route = "exitNodes") {
composable("list") { composable("list") { ExitNodePicker(exitNodePickerNav) }
ExitNodePicker(exitNodePickerNav)
}
composable( composable(
"mullvad/{countryCode}", arguments = listOf(navArgument("countryCode") { "mullvad/{countryCode}",
type = NavType.StringType arguments = listOf(navArgument("countryCode") { type = NavType.StringType })) {
})
) {
MullvadExitNodePicker( MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
)
} }
} }
composable( composable(
"peerDetails/{nodeId}", "peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType }) arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
) {
PeerDetails(it.arguments?.getString("nodeId") ?: "") PeerDetails(it.arguments?.getString("nodeId") ?: "")
} }
composable("bugReport") { composable("bugReport") { BugReportView() }
BugReportView() composable("about") { AboutView() }
} composable("mdmSettings") { MDMSettingsDebugView() }
composable("about") { composable("managedBy") { ManagedByView() }
AboutView() composable("userSwitcher") { UserSwitcherView() }
}
composable("mdmSettings") {
MDMSettingsDebugView()
}
composable("managedBy") {
ManagedByView()
}
composable("userSwitcher") {
UserSwitcherView()
}
} }
} }
} }
@ -128,13 +108,7 @@ class MainActivity : ComponentActivity() {
// Watch the model's browseToURL and launch the browser when it changes // Watch the model's browseToURL and launch the browser when it changes
// This will trigger the login flow // This will trigger the login flow
lifecycleScope.launch { lifecycleScope.launch {
Notifier.browseToURL.collect { url -> Notifier.browseToURL.collect { url -> url?.let { Dispatchers.Main.run { login(it) } } }
url?.let {
Dispatchers.Main.run {
login(it)
}
}
}
} }
} }
@ -167,11 +141,11 @@ class MainActivity : ComponentActivity() {
override fun onStop() { override fun onStop() {
Notifier.stop() Notifier.stop()
super.onStop() super.onStop()
val restrictionsManager = this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager)) IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager))
} }
private fun requestVpnPermission() { private fun requestVpnPermission() {
val vpnIntent = VpnService.prepare(this) val vpnIntent = VpnService.prepare(this)
if (vpnIntent != null) { if (vpnIntent != null) {
@ -188,7 +162,6 @@ class MainActivity : ComponentActivity() {
} }
} }
class VpnPermissionContract : ActivityResultContract<Unit, Boolean>() { class VpnPermissionContract : ActivityResultContract<Unit, Boolean>() {
override fun createIntent(context: Context, input: Unit): Intent { override fun createIntent(context: Context, input: Unit): Intent {
return VpnService.prepare(context) ?: Intent() return VpnService.prepare(context) ?: Intent()

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

@ -10,7 +10,7 @@ import android.service.quicksettings.TileService;
public class QuickToggleService extends TileService { public class QuickToggleService extends TileService {
// lock protects the static fields below it. // lock protects the static fields below it.
private static Object lock = new Object(); private static final Object lock = new Object();
// Active tracks whether the VPN is active. // Active tracks whether the VPN is active.
private static boolean active; private static boolean active;
// Ready tracks whether the tailscale backend is // Ready tracks whether the tailscale backend is
@ -19,33 +19,6 @@ public class QuickToggleService extends TileService {
// currentTile tracks getQsTile while service is listening. // currentTile tracks getQsTile while service is listening.
private static Tile currentTile; private static Tile currentTile;
@Override public void onStartListening() {
synchronized (lock) {
currentTile = getQsTile();
}
updateTile();
}
@Override public void onStopListening() {
synchronized (lock) {
currentTile = null;
}
}
@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 static void updateTile() { private static void updateTile() {
Tile t; Tile t;
boolean act; boolean act;
@ -74,6 +47,36 @@ public class QuickToggleService extends TileService {
updateTile(); updateTile();
} }
@Override
public void onStartListening() {
synchronized (lock) {
currentTile = getQsTile();
}
updateTile();
}
@Override
public void onStopListening() {
synchronized (lock) {
currentTile = null;
}
}
@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() { private void onTileClick() {
boolean act; boolean act;
synchronized (lock) { synchronized (lock) {

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

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

@ -30,11 +30,9 @@ class MDMSettings(private val restrictionsManager: RestrictionsManager? = null)
"always" -> { "always" -> {
AlwaysNeverUserDecidesValue.Always AlwaysNeverUserDecidesValue.Always
} }
"never" -> { "never" -> {
AlwaysNeverUserDecidesValue.Never AlwaysNeverUserDecidesValue.Never
} }
else -> { else -> {
AlwaysNeverUserDecidesValue.UserDecides AlwaysNeverUserDecidesValue.UserDecides
} }
@ -50,7 +48,6 @@ class MDMSettings(private val restrictionsManager: RestrictionsManager? = null)
"hide" -> { "hide" -> {
ShowHideValue.Hide ShowHideValue.Hide
} }
else -> { else -> {
ShowHideValue.Show ShowHideValue.Show
} }
@ -63,7 +60,10 @@ class MDMSettings(private val restrictionsManager: RestrictionsManager? = null)
return it.applicationRestrictions.getStringArray(setting.key) return it.applicationRestrictions.getStringArray(setting.key)
} }
} }
return App.getApplication().encryptedPrefs.getStringSet(setting.key, HashSet<String>()) return App.getApplication()
?.toTypedArray()?.sortedArray() .encryptedPrefs
.getStringSet(setting.key, HashSet<String>())
?.toTypedArray()
?.sortedArray()
} }
} }

@ -24,7 +24,8 @@ enum class StringArraySetting(val key: String, val localizedTitle: String) {
// A setting representing a String value which is set to either `always`, `never` or `user-decides`. // A setting representing a String value which is set to either `always`, `never` or `user-decides`.
enum class AlwaysNeverUserDecidesSetting(val key: String, val localizedTitle: String) { enum class AlwaysNeverUserDecidesSetting(val key: String, val localizedTitle: String) {
AllowIncomingConnections("AllowIncomingConnections", "Allow Incoming Connections"), AllowIncomingConnections("AllowIncomingConnections", "Allow Incoming Connections"),
DetectThirdPartyAppConflicts("DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps"), DetectThirdPartyAppConflicts(
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps"),
ExitNodeAllowLANAccess("ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node"), ExitNodeAllowLANAccess("ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node"),
PostureChecking("PostureChecking", "Enable Posture Checking"), PostureChecking("PostureChecking", "Enable Posture Checking"),
UseTailscaleDNSSettings("UseTailscaleDNSSettings", "Use Tailscale DNS Settings"), UseTailscaleDNSSettings("UseTailscaleDNSSettings", "Use Tailscale DNS Settings"),

@ -13,7 +13,8 @@ object Links {
const val DOCS_URL = "https://tailscale.com/kb/" const val DOCS_URL = "https://tailscale.com/kb/"
const val START_GUIDE_URL = "https://tailscale.com/kb/1017/install/" const val START_GUIDE_URL = "https://tailscale.com/kb/1017/install/"
const val LICENSES_URL = "https://tailscale.com/licenses/android" const val LICENSES_URL = "https://tailscale.com/licenses/android"
const val DELETE_ACCOUNT_URL = "https://login.tailscale.com/login?next_url=%2Fadmin%2Fsettings%2Fgeneral" const val DELETE_ACCOUNT_URL =
"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 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 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_TAILSCALE_KB_URL = "https://tailscale.com/kb/installation/"

@ -45,7 +45,9 @@ private object Endpoint {
} }
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
/** /**
@ -65,9 +67,7 @@ class Client(private val scope: CoroutineScope) {
get(Endpoint.PREFS, responseHandler = responseHandler) get(Endpoint.PREFS, responseHandler = responseHandler)
} }
fun editPrefs( fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit
) {
val body = Json.encodeToString(prefs).toByteArray() val body = Json.encodeToString(prefs).toByteArray()
return patch(Endpoint.PREFS, body, responseHandler = responseHandler) return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
} }
@ -84,11 +84,17 @@ class Client(private val scope: CoroutineScope) {
return put(Endpoint.PROFILES, responseHandler = responseHandler) return put(Endpoint.PROFILES, responseHandler = responseHandler)
} }
fun deleteProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result<String>) -> Unit = {}) { fun deleteProfile(
profile: IpnLocal.LoginProfile,
responseHandler: (Result<String>) -> Unit = {}
) {
return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler) return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
} }
fun switchProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result<String>) -> Unit = {}) { fun switchProfile(
profile: IpnLocal.LoginProfile,
responseHandler: (Result<String>) -> Unit = {}
) {
return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler) return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
} }
@ -101,7 +107,9 @@ class Client(private val scope: CoroutineScope) {
} }
private inline fun <reified T> get( private inline fun <reified T> get(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) { ) {
Request( Request(
scope = scope, scope = scope,
@ -109,12 +117,14 @@ class Client(private val scope: CoroutineScope) {
path = path, path = path,
body = body, body = body,
responseType = typeOf<T>(), responseType = typeOf<T>(),
responseHandler = responseHandler responseHandler = responseHandler)
).execute() .execute()
} }
private inline fun <reified T> put( private inline fun <reified T> put(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) { ) {
Request( Request(
scope = scope, scope = scope,
@ -122,12 +132,14 @@ class Client(private val scope: CoroutineScope) {
path = path, path = path,
body = body, body = body,
responseType = typeOf<T>(), responseType = typeOf<T>(),
responseHandler = responseHandler responseHandler = responseHandler)
).execute() .execute()
} }
private inline fun <reified T> post( private inline fun <reified T> post(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) { ) {
Request( Request(
scope = scope, scope = scope,
@ -135,12 +147,14 @@ class Client(private val scope: CoroutineScope) {
path = path, path = path,
body = body, body = body,
responseType = typeOf<T>(), responseType = typeOf<T>(),
responseHandler = responseHandler responseHandler = responseHandler)
).execute() .execute()
} }
private inline fun <reified T> patch( private inline fun <reified T> patch(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) { ) {
Request( Request(
scope = scope, scope = scope,
@ -148,20 +162,21 @@ class Client(private val scope: CoroutineScope) {
path = path, path = path,
body = body, body = body,
responseType = typeOf<T>(), responseType = typeOf<T>(),
responseHandler = responseHandler responseHandler = responseHandler)
).execute() .execute()
} }
private inline fun <reified T> delete( private inline fun <reified T> delete(
path: String, noinline responseHandler: (Result<T>) -> Unit path: String,
noinline responseHandler: (Result<T>) -> Unit
) { ) {
Request( Request(
scope = scope, scope = scope,
method = "DELETE", method = "DELETE",
path = path, path = path,
responseType = typeOf<T>(), responseType = typeOf<T>(),
responseHandler = responseHandler responseHandler = responseHandler)
).execute() .execute()
} }
} }
@ -214,14 +229,15 @@ class Request<T>(
fun onResponse(respData: ByteArray) { fun onResponse(respData: ByteArray) {
Log.d(TAG, "Response for request: $fullPath") Log.d(TAG, "Response for request: $fullPath")
val response: Result<T> = when (responseType) { val response: Result<T> =
when (responseType) {
typeOf<String>() -> Result.success(respData.decodeToString() as T) typeOf<String>() -> Result.success(respData.decodeToString() as T)
else -> try { else ->
try {
Result.success( Result.success(
jsonDecoder.decodeFromStream( jsonDecoder.decodeFromStream(
Json.serializersModule.serializer(responseType), respData.inputStream() Json.serializersModule.serializer(responseType), respData.inputStream())
) as T as T)
)
} catch (t: Throwable) { } catch (t: Throwable) {
// If we couldn't parse the response body, assume it's an error response // If we couldn't parse the response body, assume it's an error response
try { try {
@ -235,8 +251,6 @@ class Request<T>(
} }
// The response handler will invoked internally by the request parser // The response handler will invoked internally by the request parser
scope.launch { scope.launch { responseHandler(response) }
responseHandler(response)
}
} }
} }

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

@ -80,41 +80,49 @@ class Ipn {
field = value field = value
RouteAllSet = true RouteAllSet = true
} }
var CorpDNS: Boolean? = null var CorpDNS: Boolean? = null
set(value) { set(value) {
field = value field = value
CorpDNSSet = true CorpDNSSet = true
} }
var ExitNodeID: StableNodeID? = null var ExitNodeID: StableNodeID? = null
set(value) { set(value) {
field = value field = value
ExitNodeIDSet = true ExitNodeIDSet = true
} }
var ExitNodeAllowLANAccess: Boolean? = null var ExitNodeAllowLANAccess: Boolean? = null
set(value) { set(value) {
field = value field = value
ExitNodeAllowLANAccessSet = true ExitNodeAllowLANAccessSet = true
} }
var WantRunning: Boolean? = null var WantRunning: Boolean? = null
set(value) { set(value) {
field = value field = value
WantRunningSet = true WantRunningSet = true
} }
var ShieldsUp: Boolean? = null var ShieldsUp: Boolean? = null
set(value) { set(value) {
field = value field = value
ShieldsUpSet = true ShieldsUpSet = true
} }
var AdvertiseRoutes: Boolean? = null var AdvertiseRoutes: Boolean? = null
set(value) { set(value) {
field = value field = value
AdvertiseRoutesSet = true AdvertiseRoutesSet = true
} }
var ForceDaemon: Boolean? = null var ForceDaemon: Boolean? = null
set(value) { set(value) {
field = value field = value
ForceDaemonSet = true ForceDaemonSet = true
} }
var Hostname: Boolean? = null var Hostname: Boolean? = null
set(value) { set(value) {
field = value field = value

@ -32,7 +32,7 @@ class Netmap {
} }
fun getPeer(id: StableNodeID): Tailcfg.Node? { fun getPeer(id: StableNodeID): Tailcfg.Node? {
if(id == SelfNode.StableID) { if (id == SelfNode.StableID) {
return SelfNode return SelfNode
} }
return Peers?.find { it.StableID == id } return Peers?.find { it.StableID == id }

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

@ -59,7 +59,9 @@ object Notifier {
// Wait for the notifier to be ready // Wait for the notifier to be ready
isReady.await() isReady.await()
val mask = val mask =
NotifyWatchOpt.Netmap.value or NotifyWatchOpt.Prefs.value or NotifyWatchOpt.InitialState.value NotifyWatchOpt.Netmap.value or
NotifyWatchOpt.Prefs.value or
NotifyWatchOpt.InitialState.value
startIPNBusWatcher(mask) startIPNBusWatcher(mask)
Log.d(TAG, "Stopped") Log.d(TAG, "Stopped")
} }
@ -95,8 +97,11 @@ object Notifier {
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which // NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// what we want to see on the Notify bus // what we want to see on the Notify bus
private enum class NotifyWatchOpt(val value: Int) { private enum class NotifyWatchOpt(val value: Int) {
EngineUpdates(1), InitialState(2), Prefs(4), Netmap(8), NoPrivateKey(16), InitialTailFSShares( EngineUpdates(1),
32 InitialState(2),
) Prefs(4),
Netmap(8),
NoPrivateKey(16),
InitialTailFSShares(32)
} }
} }

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

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.theme package com.tailscale.ipn.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@ -10,38 +9,34 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
private val LightColors =
private val LightColors = lightColorScheme( lightColorScheme(
primary = ts_color_light_primary, primary = ts_color_light_primary,
onPrimary = ts_color_light_background, onPrimary = ts_color_light_background,
secondary = ts_color_light_secondary, secondary = ts_color_light_secondary,
onSecondary = ts_color_light_background, onSecondary = ts_color_light_background,
secondaryContainer = ts_color_light_tintedBackground, secondaryContainer = ts_color_light_tintedBackground,
surface = ts_color_light_background, surface = ts_color_light_background,
) )
private val DarkColors = darkColorScheme( private val DarkColors =
darkColorScheme(
primary = ts_color_dark_primary, primary = ts_color_dark_primary,
onPrimary = ts_color_dark_background, onPrimary = ts_color_dark_background,
secondary = ts_color_dark_secondary, secondary = ts_color_dark_secondary,
onSecondary = ts_color_dark_background, onSecondary = ts_color_dark_background,
secondaryContainer = ts_color_dark_tintedBackground, secondaryContainer = ts_color_dark_tintedBackground,
surface = ts_color_dark_background, surface = ts_color_dark_background,
) )
@Composable @Composable
fun AppTheme( fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
useDarkTheme: Boolean = isSystemInDarkTheme(), val colors =
content: @Composable() () -> Unit if (!useDarkTheme) {
) {
val colors = if (!useDarkTheme) {
LightColors LightColors
} else { } else {
DarkColors DarkColors
} }
MaterialTheme( MaterialTheme(colorScheme = colors, content = content)
colorScheme = colors,
content = content
)
} }

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

@ -1,27 +1,31 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
class DisplayAddress(val ip: String) { class DisplayAddress(val ip: String) {
enum class addrType { enum class addrType {
V4, V6, MagicDNS V4,
V6,
MagicDNS
} }
val type: addrType = when { val type: addrType =
when {
ip.isIPV6() -> addrType.V6 ip.isIPV6() -> addrType.V6
ip.isIPV4() -> addrType.V4 ip.isIPV4() -> addrType.V4
else -> addrType.MagicDNS else -> addrType.MagicDNS
} }
val typeString: String = when (type) { val typeString: String =
when (type) {
addrType.V4 -> "IPv4" addrType.V4 -> "IPv4"
addrType.V6 -> "IPv6" addrType.V6 -> "IPv6"
addrType.MagicDNS -> "MagicDNS" addrType.MagicDNS -> "MagicDNS"
} }
val address: String = when (type) { val address: String =
when (type) {
addrType.MagicDNS -> ip addrType.MagicDNS -> ip
else -> ip.split("/").first() else -> ip.split("/").first()
} }

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

@ -35,19 +35,13 @@ object LoadingIndicator {
content() content()
val isLoading = loading.collectAsState().value val isLoading = loading.collectAsState().value
if (isLoading) { if (isLoading) {
Box( Box(Modifier.matchParentSize().background(Color.Gray.copy(alpha = 0.5f)))
Modifier
.matchParentSize()
.background(Color.Gray.copy(alpha = 0.5f))
)
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally) {
) {
CircularProgressIndicator() CircularProgressIndicator()
} }
} }
} }
} }

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
@ -11,7 +10,6 @@ import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>) data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>)
typealias GroupedPeers = MutableMap<UserID, MutableList<Tailcfg.Node>> typealias GroupedPeers = MutableMap<UserID, MutableList<Tailcfg.Node>>
@ -28,10 +26,10 @@ class PeerCategorizer(scope: CoroutineScope) {
netmap?.let { netmap?.let {
peerSets = regenerateGroupedPeers(netmap) peerSets = regenerateGroupedPeers(netmap)
lastSearchResult = peerSets lastSearchResult = peerSets
} ?: run { }
?: run {
peerSets = emptyList() peerSets = emptyList()
lastSearchResult = emptyList() lastSearchResult = emptyList()
} }
} }
} }
@ -55,10 +53,13 @@ class PeerCategorizer(scope: CoroutineScope) {
val me = netmap.currentUserProfile() val me = netmap.currentUserProfile()
val peerSets = grouped.map { (userId, peers) -> val peerSets =
grouped
.map { (userId, peers) ->
val profile = netmap.userProfile(userId) val profile = netmap.userProfile(userId)
PeerSet(profile, peers.sortedBy { it.ComputedName }) PeerSet(profile, peers.sortedBy { it.ComputedName })
}.sortedBy { }
.sortedBy {
if (it.user?.ID == me?.ID) { if (it.user?.ID == me?.ID) {
"" ""
} else { } else {
@ -78,12 +79,14 @@ class PeerCategorizer(scope: CoroutineScope) {
return lastSearchResult return lastSearchResult
} }
// We can optimize out typing... If the search term starts with the last search term, we can just search the last result // We can optimize out typing... If the search term starts with the last search term, we can
val setsToSearch = // just search the last result
if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets val setsToSearch = if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets
this.searchTerm = searchTerm this.searchTerm = searchTerm
val matchingSets = setsToSearch.map { peerSet -> val matchingSets =
setsToSearch
.map { peerSet ->
val user = peerSet.user val user = peerSet.user
val peers = peerSet.peers val peers = peerSet.peers
@ -99,9 +102,9 @@ class PeerCategorizer(scope: CoroutineScope) {
} else { } else {
null null
} }
}.filterNotNull() }
.filterNotNull()
return matchingSets return matchingSets
} }
} }

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

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

@ -8,7 +8,6 @@ import java.time.Instant
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Date import java.util.Date
class TimeUtil { class TimeUtil {
fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter { fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter {
@ -28,11 +27,17 @@ class TimeUtil {
// 2 hours, as does 179 minutes... Close enough for what this is used for. // 2 hours, as does 179 minutes... Close enough for what this is used for.
return when (diff) { return when (diff) {
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute) 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 61..7200 ->
in 7201..172800 -> ComposableStringFormatter(R.string.in_x_hours, diff / 3600) // 2 hours to 24 hours ComposableStringFormatter(R.string.in_x_minutes, diff / 60) // 1 minute to 1 hour
in 172801..5184000 -> ComposableStringFormatter(R.string.in_x_days, diff / 86400) // 2 Days to 60 days in 7201..172800 ->
in 5184001..124416000 -> ComposableStringFormatter(R.string.in_x_months, diff / 2592000) // ~2 months to 2 years ComposableStringFormatter(R.string.in_x_hours, diff / 3600) // 2 hours to 24 hours
else -> ComposableStringFormatter(R.string.in_x_years, diff.toDouble() / 31536000.0) // 2 years to n years (in decimal) 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)
} }
} }
@ -48,4 +53,3 @@ class TimeUtil {
return Date.from(i) return Date.from(i)
} }
} }

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

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

@ -11,13 +11,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -37,30 +38,22 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.util.Header
import com.tailscale.ipn.ui.util.defaultPaddingModifier import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.BugReportViewModel import com.tailscale.ipn.ui.viewModel.BugReportViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@Composable @Composable
fun BugReportView(model: BugReportViewModel = viewModel()) { fun BugReportView(model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.surface) { Scaffold(topBar = { Header(R.string.bug_report_title) }) { innerPadding ->
Column(modifier = defaultPaddingModifier().fillMaxWidth().fillMaxHeight()) { Column(modifier = Modifier.padding(innerPadding).padding(8.dp).fillMaxWidth().fillMaxHeight()) {
ClickableText(
Header(title = R.string.bug_report_title) text = contactText(),
Spacer(modifier = Modifier.height(8.dp))
ClickableText(text = contactText(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
onClick = { onClick = { handler.openUri(Links.SUPPORT_URL) })
handler.openUri(Links.SUPPORT_URL)
})
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -73,8 +66,7 @@ fun BugReportView(model: BugReportViewModel = viewModel()) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Left, textAlign = TextAlign.Left,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall)
)
} }
} }
} }
@ -85,26 +77,22 @@ fun ReportIdRow(bugReportIdFlow: StateFlow<String>) {
val bugReportId = bugReportIdFlow.collectAsState() val bugReportId = bugReportIdFlow.collectAsState()
Row( Row(
modifier = settingsRowModifier() modifier =
settingsRowModifier()
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(bugReportId.value)) }), .clickable(
verticalAlignment = Alignment.CenterVertically onClick = { localClipboardManager.setText(AnnotatedString(bugReportId.value)) }),
) { verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(10f)) { Box(Modifier.weight(10f)) {
Text( Text(
text = bugReportId.value, text = bugReportId.value,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = defaultPaddingModifier() modifier = defaultPaddingModifier())
)
} }
Box(Modifier.weight(1f)) { Box(Modifier.weight(1f)) {
Icon( Icon(Icons.Outlined.Share, null, modifier = Modifier.width(24.dp).height(24.dp))
Icons.Outlined.Share, null, modifier = Modifier
.width(24.dp)
.height(24.dp)
)
} }
} }
} }

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

@ -11,7 +11,9 @@ import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R import com.tailscale.ipn.R
enum class ErrorDialogType { enum class ErrorDialogType {
LOGOUT_FAILED, SWITCH_USER_FAILED, ADD_PROFILE_FAILED; LOGOUT_FAILED,
SWITCH_USER_FAILED,
ADD_PROFILE_FAILED;
val message: Int val message: Int
get() { get() {
@ -25,15 +27,12 @@ enum class ErrorDialogType {
val title: Int = R.string.error val title: Int = R.string.error
val buttonText: Int = R.string.ok val buttonText: Int = R.string.ok
} }
@Composable @Composable
fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) { fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
ErrorDialog(title = type.title, ErrorDialog(
message = type.message, title = type.title, message = type.message, buttonText = type.buttonText, onDismiss = action)
buttonText = type.buttonText,
onDismiss = action)
} }
@Composable @Composable
@ -45,16 +44,9 @@ fun ErrorDialog(
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { title = { Text(text = stringResource(id = title)) },
Text(text = stringResource(id = title)) text = { Text(text = stringResource(id = message)) },
},
text = {
Text(text = stringResource(id = message))
},
confirmButton = { confirmButton = {
PrimaryActionButton(onClick = onDismiss) { PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) }
Text(text = stringResource(id = buttonText)) })
}
}
)
} }

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

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

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -30,9 +29,9 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -60,7 +59,6 @@ import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
// Navigation actions for the MainView // Navigation actions for the MainView
data class MainViewNavigation( data class MainViewNavigation(
val onNavigateToSettings: () -> Unit, val onNavigateToSettings: () -> Unit,
@ -68,20 +66,15 @@ data class MainViewNavigation(
val onNavigateToExitNodes: () -> Unit val onNavigateToExitNodes: () -> Unit
) )
@Composable @Composable
fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) { fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.secondaryContainer) { Scaffold { _ ->
Column( Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center) {
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Center
) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState) val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null) val user = viewModel.loggedInUser.collectAsState(initial = null)
Row(modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false) val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) { if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) {
@ -91,9 +84,9 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
StateDisplay(viewModel.stateRes, viewModel.userName) StateDisplay(viewModel.stateRes, viewModel.userName)
Box(modifier = Modifier Box(
.weight(1f) modifier = Modifier.weight(1f).clickable { navigation.onNavigateToSettings() },
.clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) { contentAlignment = Alignment.CenterEnd) {
when (user.value) { when (user.value) {
null -> SettingsButton(user.value) { navigation.onNavigateToSettings() } null -> SettingsButton(user.value) { navigation.onNavigateToSettings() }
else -> Avatar(profile = user.value, size = 36) else -> Avatar(profile = user.value, size = 36)
@ -101,7 +94,6 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
} }
} }
when (state.value) { when (state.value) {
Ipn.State.Running -> { Ipn.State.Running -> {
@ -115,14 +107,8 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) }) onSearch = { viewModel.searchPeers(it) })
} }
Ipn.State.Starting -> StartingView() Ipn.State.Starting -> StartingView()
else -> else -> ConnectView(user.value, { viewModel.toggleVpn() }, { viewModel.login {} })
ConnectView(
user.value,
{ viewModel.toggleVpn() },
{ viewModel.login {} }
)
} }
} }
} }
@ -133,15 +119,20 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val prefs = viewModel.prefs.collectAsState() val prefs = viewModel.prefs.collectAsState()
val netmap = viewModel.netmap.collectAsState() val netmap = viewModel.netmap.collectAsState()
val exitNodeId = prefs.value?.ExitNodeID val exitNodeId = prefs.value?.ExitNodeID
val exitNode = exitNodeId?.let { id -> val exitNode =
netmap.value?.Peers?.find { it.StableID == id }?.let { peer -> exitNodeId?.let { id ->
netmap.value
?.Peers
?.find { it.StableID == id }
?.let { peer ->
peer.Hostinfo.Location?.let { location -> peer.Hostinfo.Location?.let { location ->
"${location.Country?.flag()} ${location.Country} - ${location.City}" "${location.Country?.flag()} ${location.Country} - ${location.City}"
} ?: peer.Name } ?: peer.Name
} }
} }
Box(modifier = Modifier Box(
.clickable { navAction() } modifier =
Modifier.clickable { navAction() }
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.background(MaterialTheme.colorScheme.secondaryContainer) .background(MaterialTheme.colorScheme.secondaryContainer)
@ -149,12 +140,11 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
Column(modifier = Modifier.padding(6.dp)) { Column(modifier = Modifier.padding(6.dp)) {
Text( Text(
text = stringResource(id = R.string.exit_node), text = stringResource(id = R.string.exit_node),
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium)
)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(
Text(text = exitNode text = exitNode ?: stringResource(id = R.string.none),
?: stringResource(id = R.string.none), style = MaterialTheme.typography.bodyMedium) style = MaterialTheme.typography.bodyMedium)
Icon( Icon(
Icons.Outlined.ArrowDropDown, Icons.Outlined.ArrowDropDown,
null, null,
@ -173,11 +163,16 @@ fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
when (tailnet.isEmpty()) { when (tailnet.isEmpty()) {
false -> { false -> {
Text(text = tailnet, style = MaterialTheme.typography.titleMedium) Text(text = tailnet, style = MaterialTheme.typography.titleMedium)
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary) Text(
text = stateStr,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary)
} }
true -> { true -> {
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) Text(
text = stateStr,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary)
} }
} }
} }
@ -187,10 +182,7 @@ fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) { fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
// (jonathan) TODO: On iOS this is the users avatar or a letter avatar. // (jonathan) TODO: On iOS this is the users avatar or a letter avatar.
IconButton( IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
modifier = Modifier.size(24.dp),
onClick = { action() }
) {
Icon( Icon(
Icons.Outlined.Settings, Icons.Outlined.Settings,
null, null,
@ -202,16 +194,13 @@ fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
fun StartingView() { fun StartingView() {
// (jonathan) TODO: On iOS this is the game-of-life Tailscale animation. // (jonathan) TODO: On iOS this is the game-of-life Tailscale animation.
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally) {
) { Text(
Text(text = stringResource(id = R.string.starting), text = stringResource(id = R.string.starting),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary)
)
} }
} }
@ -219,15 +208,12 @@ fun StartingView() {
fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAction: () -> Unit) { fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAction: () -> Unit) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier modifier =
.background(MaterialTheme.colorScheme.secondaryContainer) Modifier.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(8.dp) .padding(8.dp)
.fillMaxWidth(0.7f) .fillMaxWidth(0.7f)
.fillMaxHeight(), .fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy( verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
8.dp,
alignment = Alignment.CenterVertically
),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
if (user != null && !user.isEmpty()) { if (user != null && !user.isEmpty()) {
@ -235,16 +221,14 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
painter = painterResource(id = R.drawable.power), painter = painterResource(id = R.drawable.power),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(48.dp), modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.secondary tint = MaterialTheme.colorScheme.secondary)
)
Text( Text(
text = stringResource(id = R.string.not_connected), text = stringResource(id = R.string.not_connected),
fontSize = MaterialTheme.typography.titleMedium.fontSize, fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontFamily = MaterialTheme.typography.titleMedium.fontFamily fontFamily = MaterialTheme.typography.titleMedium.fontFamily)
)
val tailnetName = user.NetworkProfile?.DomainName ?: "" val tailnetName = user.NetworkProfile?.DomainName ?: ""
Text( Text(
stringResource(id = R.string.connect_to_tailnet, tailnetName), stringResource(id = R.string.connect_to_tailnet, tailnetName),
@ -257,8 +241,7 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
PrimaryActionButton(onClick = connectAction) { PrimaryActionButton(onClick = connectAction) {
Text( Text(
text = stringResource(id = R.string.connect), text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize fontSize = MaterialTheme.typography.titleMedium.fontSize)
)
} }
} else { } else {
TailscaleLogoView(Modifier.size(50.dp)) TailscaleLogoView(Modifier.size(50.dp))
@ -267,20 +250,17 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
text = stringResource(id = R.string.welcome_to_tailscale), text = stringResource(id = R.string.welcome_to_tailscale),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center textAlign = TextAlign.Center)
)
Text( Text(
stringResource(R.string.login_to_join_your_tailnet), stringResource(R.string.login_to_join_your_tailnet),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center textAlign = TextAlign.Center)
)
Spacer(modifier = Modifier.size(1.dp)) Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = loginAction) { PrimaryActionButton(onClick = loginAction) {
Text( Text(
text = stringResource(id = R.string.log_in), text = stringResource(id = R.string.log_in),
fontSize = MaterialTheme.typography.titleMedium.fontSize fontSize = MaterialTheme.typography.titleMedium.fontSize)
)
} }
} }
} }
@ -325,62 +305,58 @@ fun PeerList(
onActiveChange = {}, onActiveChange = {},
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
leadingIcon = { Icon(Icons.Outlined.Search, null) }, leadingIcon = { Icon(Icons.Outlined.Search, null) },
trailingIcon = { if (searchTermStr.isNotEmpty()) ClearButton({ onSearch("") }) else CloseButton() }, trailingIcon = {
if (searchTermStr.isNotEmpty()) ClearButton({ onSearch("") }) else CloseButton()
},
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 2.dp, shadowElevation = 2.dp,
colors = SearchBarDefaults.colors(), colors = SearchBarDefaults.colors(),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()) {
) {
LazyColumn( LazyColumn(
modifier = modifier =
Modifier Modifier.fillMaxSize().background(MaterialTheme.colorScheme.secondaryContainer),
.fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer),
) { ) {
peerList.value.forEach { peerSet -> peerList.value.forEach { peerSet ->
item { item {
ListItem(headlineContent = { ListItem(
headlineContent = {
Text(text = peerSet.user?.DisplayName Text(
?: stringResource(id = R.string.unknown_user), style = MaterialTheme.typography.titleLarge) text =
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
style = MaterialTheme.typography.titleLarge)
}) })
} }
peerSet.peers.forEach { peer -> peerSet.peers.forEach { peer ->
item { item {
ListItem( ListItem(
modifier = Modifier.clickable { modifier = Modifier.clickable { onNavigateToPeerDetails(peer) },
onNavigateToPeerDetails(peer)
},
headlineContent = { headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
// By definition, SelfPeer is online since we will not show the peer list unless you're connected. // By definition, SelfPeer is online since we will not show the peer list
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running) // unless you're connected.
val color: Color = if ((peer.Online == true) || isSelfAndRunning) { val isSelfAndRunning =
(peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
val color: Color =
if ((peer.Online == true) || isSelfAndRunning) {
ts_color_light_green ts_color_light_green
} else { } else {
Color.Gray Color.Gray
} }
Box(modifier = Modifier Box(
.size(8.dp) modifier =
.background(color = color, shape = RoundedCornerShape(percent = 50))) {} Modifier.size(8.dp)
.background(
color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium) Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
} }
}, },
supportingContent = { supportingContent = {
Text( Text(
text = peer.Addresses?.first()?.split("/")?.first() text = peer.Addresses?.first()?.split("/")?.first() ?: "",
?: "", style = MaterialTheme.typography.bodyMedium)
style = MaterialTheme.typography.bodyMedium
)
}, },
trailingContent = { trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) })
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
)
} }
} }
} }

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

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -23,7 +22,6 @@ import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MullvadExitNodePicker( fun MullvadExitNodePicker(
@ -38,27 +36,24 @@ fun MullvadExitNodePicker(
val any = nodes.first() val any = nodes.first()
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(topBar = { Scaffold(topBar = { TopAppBar(title = { Text("${countryCode.flag()} ${any.country}") }) }) {
TopAppBar(title = { Text("${countryCode.flag()} ${any.country}") }) innerPadding ->
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (nodes.size > 1) { if (nodes.size > 1) {
val bestAvailableNode = bestAvailableByCountry.value[countryCode]!! val bestAvailableNode = bestAvailableByCountry.value[countryCode]!!
item { item {
ExitNodeItem( ExitNodeItem(
model, ExitNodePickerViewModel.ExitNode( model,
ExitNodePickerViewModel.ExitNode(
id = bestAvailableNode.id, id = bestAvailableNode.id,
label = stringResource(R.string.best_available), label = stringResource(R.string.best_available),
online = bestAvailableNode.online, online = bestAvailableNode.online,
selected = false, selected = false,
) ))
)
} }
} }
items(nodes) { node -> items(nodes) { node -> ExitNodeItem(model, node) }
ExitNodeItem(model, node)
}
} }
} }
} }

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -19,7 +18,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -34,61 +33,43 @@ import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
@Composable @Composable
fun PeerDetails( fun PeerDetails(
nodeId: String, model: PeerDetailsViewModel = viewModel( nodeId: String,
factory = PeerDetailsViewModelFactory(nodeId) model: PeerDetailsViewModel = viewModel(factory = PeerDetailsViewModelFactory(nodeId))
)
) { ) {
Surface(color = MaterialTheme.colorScheme.surface) { Scaffold(
topBar = {
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxHeight()
) {
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(8.dp),
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = model.nodeName, text = model.nodeName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary)
)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Box( Box(
modifier = Modifier modifier =
.size(8.dp) Modifier.size(8.dp)
.background( .background(
color = model.connectedColor, color = model.connectedColor,
shape = RoundedCornerShape(percent = 50) shape = RoundedCornerShape(percent = 50))) {}
)
) {}
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text( Text(
text = stringResource(id = model.connectedStrRes), text = stringResource(id = model.connectedStrRes),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary)
)
} }
} }
}) { innerPadding ->
Spacer(modifier = Modifier.size(8.dp)) Column(modifier = Modifier.padding(innerPadding).fillMaxHeight()) {
Text( Text(
text = stringResource(id = R.string.addresses_section), text = stringResource(id = R.string.addresses_section),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary)
)
Column(modifier = settingsRowModifier()) { Column(modifier = settingsRowModifier()) {
model.addresses.forEach { model.addresses.forEach { AddressRow(address = it.address, type = it.typeString) }
AddressRow(address = it.address, type = it.typeString)
}
} }
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
@ -107,10 +88,9 @@ fun AddressRow(address: String, type: String) {
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
Row( Row(
modifier = Modifier modifier =
.padding(horizontal = 8.dp, vertical = 4.dp) Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) }) .clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })) {
) {
Column { Column {
Text(text = address, style = MaterialTheme.typography.titleMedium) Text(text = address, style = MaterialTheme.typography.titleMedium)
Text(text = type, style = MaterialTheme.typography.bodyMedium) Text(text = type, style = MaterialTheme.typography.bodyMedium)
@ -123,16 +103,10 @@ fun AddressRow(address: String, type: String) {
@Composable @Composable
fun ValueRow(title: String, value: String) { fun ValueRow(title: String, value: String) {
Row( Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).fillMaxWidth()) {
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth()
) {
Text(text = title, style = MaterialTheme.typography.titleMedium) Text(text = title, style = MaterialTheme.typography.titleMedium)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = value, style = MaterialTheme.typography.bodyMedium) Text(text = value, style = MaterialTheme.typography.bodyMedium)
} }
} }
} }

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -15,7 +14,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -35,8 +34,6 @@ import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.theme.ts_color_dark_desctrutive_text import com.tailscale.ipn.ui.theme.ts_color_dark_desctrutive_text
import com.tailscale.ipn.ui.util.ChevronRight
import com.tailscale.ipn.ui.util.Header
import com.tailscale.ipn.ui.util.defaultPaddingModifier import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.Setting import com.tailscale.ipn.ui.viewModel.Setting
@ -45,7 +42,6 @@ import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.SettingsViewModelFactory import com.tailscale.ipn.ui.viewModel.SettingsViewModelFactory
@Composable @Composable
fun Settings( fun Settings(
settingsNav: SettingsNav, settingsNav: SettingsNav,
@ -55,15 +51,10 @@ fun Settings(
val user = viewModel.loggedInUser.collectAsState().value val user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value val isAdmin = viewModel.isAdmin.collectAsState().value
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxHeight()) { Scaffold(topBar = { Header(title = R.string.settings_title) }) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).fillMaxHeight()) {
Column(modifier = defaultPaddingModifier().fillMaxHeight()) { UserView(
profile = user,
Header(title = R.string.settings_title)
Spacer(modifier = Modifier.height(8.dp))
UserView(profile = user,
actionState = UserActionState.NAV, actionState = UserActionState.NAV,
onClick = viewModel.navigation.onNavigateToUserSwitcher) onClick = viewModel.navigation.onNavigateToUserSwitcher)
if (isAdmin) { if (isAdmin) {
@ -76,9 +67,7 @@ fun Settings(
val settings = viewModel.settings.collectAsState().value val settings = viewModel.settings.collectAsState().value
settings.forEach { settingBundle -> settings.forEach { settingBundle ->
Column(modifier = settingsRowModifier()) { Column(modifier = settingsRowModifier()) {
settingBundle.title?.let { settingBundle.title?.let { SettingTitle(it) }
SettingTitle(it)
}
settingBundle.settings.forEach { SettingRow(it) } settingBundle.settings.forEach { SettingRow(it) }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -87,7 +76,6 @@ fun Settings(
} }
} }
@Composable @Composable
fun UserView( fun UserView(
profile: IpnLocal.LoginProfile?, profile: IpnLocal.LoginProfile?,
@ -97,16 +85,12 @@ fun UserView(
) { ) {
Column { Column {
Row(modifier = settingsRowModifier().padding(8.dp)) { Row(modifier = settingsRowModifier().padding(8.dp)) {
Box(modifier = defaultPaddingModifier()) { Avatar(profile = profile, size = 36) }
Box(modifier = defaultPaddingModifier()) {
Avatar(profile = profile, size = 36)
}
Column(verticalArrangement = Arrangement.Center) { Column(verticalArrangement = Arrangement.Center) {
Text( Text(
text = profile?.UserProfile?.DisplayName ?: "", text = profile?.UserProfile?.DisplayName ?: "",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium)
)
Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium) Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium)
} }
} }
@ -114,24 +98,16 @@ fun UserView(
if (isAdmin) { if (isAdmin) {
Column(modifier = Modifier.padding(horizontal = 12.dp)) { Column(modifier = Modifier.padding(horizontal = 12.dp)) {
ClickableText( ClickableText(
text = adminText, text = adminText, style = MaterialTheme.typography.bodySmall, onClick = { onClick() })
style = MaterialTheme.typography.bodySmall,
onClick = {
onClick()
})
} }
} }
} }
} }
@Composable @Composable
fun SettingTitle(title: String) { fun SettingTitle(title: String) {
Text( Text(
text = title, text = title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp))
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(8.dp)
)
} }
@Composable @Composable
@ -140,35 +116,42 @@ fun SettingRow(setting: Setting) {
val swVal = setting.isOn?.collectAsState()?.value ?: false val swVal = setting.isOn?.collectAsState()?.value ?: false
val txtVal = setting.value?.collectAsState()?.value ?: "" val txtVal = setting.value?.collectAsState()?.value ?: ""
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) { Row(
modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() },
verticalAlignment = Alignment.CenterVertically) {
when (setting.type) { when (setting.type) {
SettingType.NAV_WITH_TEXT -> { SettingType.NAV_WITH_TEXT -> {
Text(setting.title.getString(), Text(
setting.title.getString(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary) color =
if (setting.destructive) ts_color_dark_desctrutive_text
else MaterialTheme.colorScheme.primary)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium) Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
} }
} }
SettingType.TEXT -> { SettingType.TEXT -> {
Text(setting.title.getString(), Text(
setting.title.getString(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary) color =
if (setting.destructive) ts_color_dark_desctrutive_text
else MaterialTheme.colorScheme.primary)
} }
SettingType.SWITCH -> { SettingType.SWITCH -> {
Text(setting.title.getString()) Text(setting.title.getString())
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled) Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
} }
} }
SettingType.NAV -> { SettingType.NAV -> {
Text(setting.title.getString(), Text(
setting.title.getString(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary) color =
if (setting.destructive) ts_color_dark_desctrutive_text
else MaterialTheme.colorScheme.primary)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium) Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
} }
@ -196,8 +179,6 @@ fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
ClickableText( ClickableText(
text = adminStr, text = adminStr,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
onClick = { onClick = { onNavigateToAdminConsole() })
onNavigateToAdminConsole()
})
} }
} }

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

@ -26,37 +26,37 @@ fun TailscaleLogoView(modifier: Modifier) {
BoxWithConstraints(modifier) { BoxWithConstraints(modifier) {
Column(verticalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) { Column(verticalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) { Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = secondaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = secondaryColor) })
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = secondaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = secondaryColor) })
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = secondaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = secondaryColor) })
} }
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) { Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = primaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = primaryColor) })
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = primaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = primaryColor) })
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = primaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = primaryColor) })
} }
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) { Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = secondaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = secondaryColor) })
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = primaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = primaryColor) })
Canvas(modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), onDraw = { Canvas(
drawCircle(color = secondaryColor) modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
}) onDraw = { drawCircle(color = secondaryColor) })
} }
} }
} }

@ -6,12 +6,11 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -20,8 +19,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.Header
import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.settingsRowModifier import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel
@ -33,20 +30,20 @@ fun UserSwitcherView(viewModel: UserSwitcherViewModel = viewModel()) {
val users = viewModel.loginProfiles.collectAsState().value val users = viewModel.loginProfiles.collectAsState().value
val currentUser = viewModel.loggedInUser.collectAsState().value val currentUser = viewModel.loggedInUser.collectAsState().value
Surface(modifier = Modifier.fillMaxHeight(), color = MaterialTheme.colorScheme.background) { Scaffold(topBar = { Header(R.string.accounts) }) { innerPadding ->
Column(modifier = defaultPaddingModifier().fillMaxWidth(), Column(
modifier = Modifier.padding(innerPadding).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) { verticalArrangement = Arrangement.spacedBy(8.dp)) {
val showDialog = viewModel.showDialog.collectAsState().value val showDialog = viewModel.showDialog.collectAsState().value
// Show the error overlay if need be // Show the error overlay if need be
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) } showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
Header(title = R.string.accounts)
Column(modifier = settingsRowModifier()) { Column(modifier = settingsRowModifier()) {
// When switch is invoked, this stores the ID of the user we're trying to switch to // 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 // 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. // 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 // (jonathan) TODO: This user switch is not immediate. We may need to represent the
// "switching users" state globally (if ipnState is insufficient) // "switching users" state globally (if ipnState is insufficient)

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

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

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -13,12 +12,12 @@ import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import java.util.TreeMap
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.TreeMap
data class ExitNodePickerNav( data class ExitNodePickerNav(
val onNavigateHome: () -> Unit, val onNavigateHome: () -> Unit,
@ -46,55 +45,58 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
) )
val tailnetExitNodes: StateFlow<List<ExitNode>> = MutableStateFlow(emptyList()) val tailnetExitNodes: StateFlow<List<ExitNode>> = MutableStateFlow(emptyList())
val mullvadExitNodesByCountryCode: StateFlow<Map<String, List<ExitNode>>> = MutableStateFlow( val mullvadExitNodesByCountryCode: StateFlow<Map<String, List<ExitNode>>> =
TreeMap() MutableStateFlow(TreeMap())
) val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap())
val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(
TreeMap()
)
val anyActive: StateFlow<Boolean> = MutableStateFlow(false) val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
init { init {
viewModelScope.launch { viewModelScope.launch {
Notifier.netmap.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) } Notifier.netmap
.stateIn(viewModelScope).collect { (netmap, prefs) -> .combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
.stateIn(viewModelScope)
.collect { (netmap, prefs) ->
val exitNodeId = prefs?.ExitNodeID val exitNodeId = prefs?.ExitNodeID
netmap?.Peers?.let { peers -> netmap?.Peers?.let { peers ->
val allNodes = peers.filter { it.isExitNode }.map { val allNodes =
peers
.filter { it.isExitNode }
.map {
ExitNode( ExitNode(
id = it.StableID, id = it.StableID,
label = it.Name, label = it.Name,
online = it.Online ?: false, online = it.Online ?: false,
selected = it.StableID == exitNodeId, selected = it.StableID == exitNodeId,
mullvad = it.Name.endsWith(".mullvad.ts.net."), mullvad = it.Name.endsWith(".mullvad.ts.net."),
priority = it.Hostinfo?.Location?.Priority ?: 0, priority = it.Hostinfo.Location?.Priority ?: 0,
countryCode = it.Hostinfo?.Location?.CountryCode ?: "", countryCode = it.Hostinfo.Location?.CountryCode ?: "",
country = it.Hostinfo?.Location?.Country ?: "", country = it.Hostinfo.Location?.Country ?: "",
city = it.Hostinfo?.Location?.City ?: "", city = it.Hostinfo.Location?.City ?: "",
) )
} }
val tailnetNodes = allNodes.filter { !it.mullvad } val tailnetNodes = allNodes.filter { !it.mullvad }
tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> a.label.compareTo(b.label) })
a.label.compareTo(
b.label
)
})
val mullvadExitNodes = allNodes.filter { val mullvadExitNodes =
allNodes
.filter {
// Pick all mullvad nodes that are online or the currently selected // Pick all mullvad nodes that are online or the currently selected
it.mullvad && (it.selected || it.online) it.mullvad && (it.selected || it.online)
}.groupBy { }
.groupBy {
// Group by countryCode // Group by countryCode
it.countryCode it.countryCode
}.mapValues { (_, nodes) -> }
.mapValues { (_, nodes) ->
// Group by city // Group by city
nodes.groupBy { nodes
it.city .groupBy { it.city }
}.mapValues { (_, nodes) -> .mapValues { (_, nodes) ->
// Pick one node per city, either the selected one or the best // Pick one node per city, either the selected one or the best
// available // available
nodes.sortedWith { a, b -> nodes
.sortedWith { a, b ->
if (a.selected && !b.selected) { if (a.selected && !b.selected) {
-1 -1
} else if (b.selected && !a.selected) { } else if (b.selected && !a.selected) {
@ -102,12 +104,16 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
} else { } else {
b.priority.compareTo(a.priority) b.priority.compareTo(a.priority)
} }
}.first() }
}.values.sortedBy { it.city.lowercase() } .first()
}
.values
.sortedBy { it.city.lowercase() }
} }
mullvadExitNodesByCountryCode.set(mullvadExitNodes) mullvadExitNodesByCountryCode.set(mullvadExitNodes)
val bestAvailableByCountry = mullvadExitNodes.mapValues { (_, nodes) -> val bestAvailableByCountry =
mullvadExitNodes.mapValues { (_, nodes) ->
nodes.minByOrNull { -1 * it.priority }!! nodes.minByOrNull { -1 * it.priority }!!
} }
mullvadBestAvailableByCountry.set(bestAvailableByCountry) mullvadBestAvailableByCountry.set(bestAvailableByCountry)

@ -73,7 +73,8 @@ open class IpnViewModel : ViewModel() {
} }
Client(viewModelScope).currentProfile { result -> Client(viewModelScope).currentProfile { result ->
result.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) } result
.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") } .onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
} }
} }

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -46,7 +45,6 @@ class MainViewModel : IpnViewModel() {
return loggedInUser.value?.Name ?: "" return loggedInUser.value?.Name ?: ""
} }
init { init {
viewModelScope.launch { viewModelScope.launch {
Notifier.state.collect { state -> Notifier.state.collect { state ->
@ -65,9 +63,7 @@ class MainViewModel : IpnViewModel() {
fun searchPeers(searchTerm: String) { fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm) this.searchTerm.set(searchTerm)
viewModelScope.launch { viewModelScope.launch { peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm)) }
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm))
}
} }
} }

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

@ -19,12 +19,15 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
enum class SettingType { NAV, SWITCH, NAV_WITH_TEXT, TEXT } enum class SettingType {
NAV,
SWITCH,
NAV_WITH_TEXT,
TEXT
}
class ComposableStringFormatter(@StringRes val stringRes: Int, vararg val params: Any) { class ComposableStringFormatter(@StringRes val stringRes: Int, vararg val params: Any) {
@Composable @Composable fun getString(): String = stringResource(id = stringRes, *params)
fun getString(): String = stringResource(id = stringRes, *params)
} }
// Represents a bundle of settings values that should be grouped together under a title // Represents a bundle of settings values that should be grouped together under a title
@ -43,7 +46,6 @@ data class SettingBundle(val title: String? = null, val settings: List<Setting>)
// isOn and onToggle, while navigation settings should supply an onClick and an optional // isOn and onToggle, while navigation settings should supply an onClick and an optional
// value // value
data class Setting( data class Setting(
val title: ComposableStringFormatter, val title: ComposableStringFormatter,
val type: SettingType, val type: SettingType,
val destructive: Boolean = false, val destructive: Boolean = false,
@ -68,8 +70,7 @@ data class Setting(
value = value, value = value,
isOn = isOn, isOn = isOn,
onClick = onClick, onClick = onClick,
onToggle = onToggle onToggle = onToggle)
)
} }
data class SettingsNav( data class SettingsNav(
@ -77,7 +78,8 @@ data class SettingsNav(
val onNavigateToAbout: () -> Unit, val onNavigateToAbout: () -> Unit,
val onNavigateToMDMSettings: () -> Unit, val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit, val onNavigateToManagedBy: () -> Unit,
val onNavigateToUserSwitcher: () -> Unit) val onNavigateToUserSwitcher: () -> Unit
)
class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelProvider.Factory { class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
@ -89,7 +91,9 @@ class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() {
// Display name for the logged in user // Display name for the logged in user
var isAdmin: StateFlow<Boolean> = MutableStateFlow(false) var isAdmin: StateFlow<Boolean> = MutableStateFlow(false)
val useDNSSetting = Setting(R.string.use_ts_dns, val useDNSSetting =
Setting(
R.string.use_ts_dns,
SettingType.SWITCH, SettingType.SWITCH,
isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS), isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS),
onToggle = { onToggle = {
@ -114,51 +118,46 @@ class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() {
settings.set( settings.set(
listOf( listOf(
SettingBundle( SettingBundle(
settings = listOf( settings =
listOf(
useDNSSetting, useDNSSetting,
) )),
),
// General settings, always enabled // General settings, always enabled
SettingBundle(settings = footerSettings(mdmSettings)) SettingBundle(settings = footerSettings(mdmSettings))))
)
)
} }
} }
viewModelScope.launch { viewModelScope.launch {
Notifier.netmap.collect { netmap -> Notifier.netmap.collect { netmap -> isAdmin.set(netmap?.SelfNode?.isAdmin ?: false) }
isAdmin.set(netmap?.SelfNode?.isAdmin ?: false)
}
} }
} }
private fun footerSettings(mdmSettings: MDMSettings): List<Setting> = listOfNotNull( private fun footerSettings(mdmSettings: MDMSettings): List<Setting> =
listOfNotNull(
Setting( Setting(
titleRes = R.string.about, titleRes = R.string.about,
SettingType.NAV, SettingType.NAV,
onClick = { navigation.onNavigateToAbout() }, onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true) enabled = MutableStateFlow(true)),
), Setting( Setting(
titleRes = R.string.bug_report, titleRes = R.string.bug_report,
SettingType.NAV, SettingType.NAV,
onClick = { navigation.onNavigateToBugReport() }, onClick = { navigation.onNavigateToBugReport() },
enabled = MutableStateFlow(true) enabled = MutableStateFlow(true)),
), mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let { mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Setting( Setting(
ComposableStringFormatter(R.string.managed_by_orgName, it), ComposableStringFormatter(R.string.managed_by_orgName, it),
SettingType.NAV, SettingType.NAV,
onClick = { navigation.onNavigateToManagedBy() }, onClick = { navigation.onNavigateToManagedBy() },
enabled = MutableStateFlow(true) enabled = MutableStateFlow(true))
) },
}, if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Setting( Setting(
titleRes = R.string.mdm_settings, titleRes = R.string.mdm_settings,
SettingType.NAV, SettingType.NAV,
onClick = { navigation.onNavigateToMDMSettings() }, onClick = { navigation.onNavigateToMDMSettings() },
enabled = MutableStateFlow(true) enabled = MutableStateFlow(true))
)
} else { } else {
null null
} })
)
} }

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

Loading…
Cancel
Save