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

* android: use scaffold consistently across all views

Updates tailscale/corp#18202

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

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

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

Updates tailscale/corp#18202

Standardize code formatting using ktfmt default settings.

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

* android: update readme for new code formatting guidelines

Updates tailscale/corp#18202

Mandate the use of ktfmt in the default configuration.

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

---------

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

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

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

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

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

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

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

@ -3,17 +3,12 @@
package com.tailscale.ipn;
import android.util.Log;
import android.os.Build;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.VpnService;
import android.os.Build;
import android.system.OsConstants;
import androidx.work.WorkManager;
import androidx.work.OneTimeWorkRequest;
import org.gioui.GioActivity;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
@ -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_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())) {
((App)getApplicationContext()).autoConnect = false;
((App) getApplicationContext()).autoConnect = false;
close();
return START_NOT_STICKY;
}
@ -39,7 +35,7 @@ public class IPNService extends VpnService {
return START_STICKY;
}
requestVPN();
App app = ((App)getApplicationContext());
App app = ((App) getApplicationContext());
if (app.vpnReady && app.autoConnect) {
connect();
}
@ -51,12 +47,14 @@ public class IPNService extends VpnService {
disconnect();
}
@Override public void onDestroy() {
@Override
public void onDestroy() {
close();
super.onDestroy();
}
@Override public void onRevoke() {
@Override
public void onRevoke() {
close();
super.onRevoke();
}
@ -70,7 +68,6 @@ public class IPNService extends VpnService {
try {
b.addDisallowedApplication(name);
} catch (PackageManager.NameNotFoundException e) {
return;
}
}
@ -134,5 +131,6 @@ public class IPNService extends VpnService {
private native void requestVPN();
private native void disconnect();
private native void connect();
}

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

@ -8,9 +8,10 @@ import android.app.Fragment;
import android.content.Intent;
public class Peer extends Fragment {
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
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);
}
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 {
// 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.
private static boolean active;
// Ready tracks whether the tailscale backend is
@ -19,33 +19,6 @@ public class QuickToggleService extends TileService {
// currentTile tracks getQsTile while service is listening.
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() {
Tile t;
boolean act;
@ -74,6 +47,36 @@ public class QuickToggleService extends TileService {
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() {
boolean act;
synchronized (lock) {

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

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

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

@ -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`.
enum class AlwaysNeverUserDecidesSetting(val key: String, val localizedTitle: String) {
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"),
PostureChecking("PostureChecking", "Enable Posture Checking"),
UseTailscaleDNSSettings("UseTailscaleDNSSettings", "Use Tailscale DNS Settings"),

@ -13,7 +13,8 @@ object Links {
const val DOCS_URL = "https://tailscale.com/kb/"
const val START_GUIDE_URL = "https://tailscale.com/kb/1017/install/"
const val LICENSES_URL = "https://tailscale.com/licenses/android"
const val DELETE_ACCOUNT_URL = "https://login.tailscale.com/login?next_url=%2Fadmin%2Fsettings%2Fgeneral"
const val DELETE_ACCOUNT_URL =
"https://login.tailscale.com/login?next_url=%2Fadmin%2Fsettings%2Fgeneral"
const val TAILNET_LOCK_KB_URL = "https://tailscale.com/kb/1226/tailnet-lock/"
const val KEY_EXPIRY_KB_URL = "https://tailscale.com/kb/1028/key-expiry/"
const val INSTALL_TAILSCALE_KB_URL = "https://tailscale.com/kb/installation/"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -8,7 +8,6 @@ import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.Date
class TimeUtil {
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.
return when (diff) {
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
in 61..7200 -> ComposableStringFormatter(R.string.in_x_minutes, diff / 60) // 1 minute to 1 hour
in 7201..172800 -> ComposableStringFormatter(R.string.in_x_hours, diff / 3600) // 2 hours to 24 hours
in 172801..5184000 -> ComposableStringFormatter(R.string.in_x_days, diff / 86400) // 2 Days to 60 days
in 5184001..124416000 -> ComposableStringFormatter(R.string.in_x_months, diff / 2592000) // ~2 months to 2 years
else -> ComposableStringFormatter(R.string.in_x_years, diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
in 61..7200 ->
ComposableStringFormatter(R.string.in_x_minutes, diff / 60) // 1 minute to 1 hour
in 7201..172800 ->
ComposableStringFormatter(R.string.in_x_hours, diff / 3600) // 2 hours to 24 hours
in 172801..5184000 ->
ComposableStringFormatter(R.string.in_x_days, diff / 86400) // 2 Days to 60 days
in 5184001..124416000 ->
ComposableStringFormatter(R.string.in_x_months, diff / 2592000) // ~2 months to 2 years
else ->
ComposableStringFormatter(
R.string.in_x_years, diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
}
}
@ -48,4 +53,3 @@ class TimeUtil {
return Date.from(i)
}
}

@ -14,17 +14,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -36,59 +33,43 @@ import com.tailscale.ipn.ui.Links
@Composable
fun AboutView() {
Surface(color = MaterialTheme.colorScheme.surface) {
Scaffold { _ ->
Column(
verticalArrangement = Arrangement.spacedBy(
space = 20.dp, alignment = Alignment.CenterVertically
),
verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.safeContentPadding()
) {
modifier = Modifier.fillMaxWidth().fillMaxHeight().safeContentPadding()) {
Image(
modifier = Modifier
.width(100.dp)
modifier =
Modifier.width(100.dp)
.height(100.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black)
.padding(15.dp),
painter = painterResource(id = R.drawable.ic_tile),
contentDescription = stringResource(R.string.app_icon_content_description)
)
contentDescription = stringResource(R.string.app_icon_content_description))
Column(
verticalArrangement = Arrangement.spacedBy(
space = 2.dp, alignment = Alignment.CenterVertically
), horizontalAlignment = Alignment.CenterHorizontally
) {
verticalArrangement =
Arrangement.spacedBy(space = 2.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.about_view_title),
fontWeight = FontWeight.SemiBold,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
color = MaterialTheme.colorScheme.primary
)
color = MaterialTheme.colorScheme.primary)
Text(
text = BuildConfig.VERSION_NAME,
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
color = MaterialTheme.colorScheme.secondary
)
color = MaterialTheme.colorScheme.secondary)
}
Column(
verticalArrangement = Arrangement.spacedBy(
space = 4.dp, alignment = Alignment.CenterVertically
), horizontalAlignment = Alignment.CenterHorizontally
) {
OpenURLButton(
stringResource(R.string.acknowledgements), Links.LICENSES_URL
)
OpenURLButton(
stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL
)
OpenURLButton(
stringResource(R.string.terms_of_service), Links.TERMS_URL
)
verticalArrangement =
Arrangement.spacedBy(space = 4.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally) {
OpenURLButton(stringResource(R.string.acknowledgements), Links.LICENSES_URL)
OpenURLButton(stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL)
OpenURLButton(stringResource(R.string.terms_of_service), Links.TERMS_URL)
}
Text(
@ -96,24 +77,7 @@ fun AboutView() {
fontWeight = FontWeight.Normal,
fontSize = MaterialTheme.typography.labelMedium.fontSize,
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 com.tailscale.ipn.ui.model.IpnLocal
@OptIn(ExperimentalCoilApi::class)
@Composable
fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(size.dp)
modifier =
Modifier.size(size.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiaryContainer)
) {
.background(MaterialTheme.colorScheme.tertiaryContainer)) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size((size * .8f).dp)
)
modifier = Modifier.size((size * .8f).dp))
profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage(model = url, contentDescription = null)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -73,7 +73,8 @@ open class IpnViewModel : ViewModel() {
}
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}") }
}
}

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.viewModelScope
@ -46,7 +45,6 @@ class MainViewModel : IpnViewModel() {
return loggedInUser.value?.Name ?: ""
}
init {
viewModelScope.launch {
Notifier.state.collect { state ->
@ -65,9 +63,7 @@ class MainViewModel : IpnViewModel() {
fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm)
viewModelScope.launch {
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm))
}
viewModelScope.launch { 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)
class PeerDetailsViewModelFactory(private val nodeId: StableNodeID) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PeerDetailsViewModel(nodeId) as T
@ -34,25 +33,17 @@ class PeerDetailsViewModel(val nodeId: StableNodeID) : IpnViewModel() {
init {
val peer = Notifier.netmap.value?.getPeer(nodeId)
peer?.Addresses?.let {
addresses = it.map { addr ->
DisplayAddress(addr)
}
}
peer?.Name?.let {
addresses = listOf(DisplayAddress(it)) + addresses
}
peer?.Addresses?.let { addresses = it.map { addr -> DisplayAddress(addr) } }
peer?.Name?.let { addresses = listOf(DisplayAddress(it)) + addresses }
peer?.let { p ->
info = listOf(
info =
listOf(
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 ?: ""
connectedStrRes = if (peer?.Online == true) R.string.connected else R.string.not_connected
connectedColor = if (peer?.Online == true) ts_color_light_green else Color.Gray

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

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

Loading…
Cancel
Save