android: implement fast user switching (#209)

Updates tailscale/corp#18202
Updates ENG-2875
Fixes ENG-2863

Adds everything we need to do fast user switching and support multiple accounts.

Some work here to make the settings rows and a few other composables common and reusable.

Correct the focus and clear behavior on the search bar and corrected the connected in state of SelfNode.

Quick fix for requesting VPN permissions on newer Android phones.

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

@ -93,7 +93,7 @@ dependencies {
implementation "androidx.navigation:navigation-compose:$nav_version"
// Supporting libraries.
implementation("io.coil-kt:coil-compose:1.3.1")
implementation("io.coil-kt:coil-compose:2.6.0")
// Tailscale dependencies.
implementation ':ipn@aar'
@ -105,5 +105,3 @@ dependencies {
// Non-free dependencies.
playImplementation 'com.google.android.gms:play-services-auth:20.7.0'
}

@ -1,85 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Disable input emulation on ChromeOS -->
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Signal support for Android TV -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<!-- Disable input emulation on ChromeOS -->
<uses-feature
android:name="android.hardware.type.pc"
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">
<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:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
</activity>
<receiver android:name="IPNReceiver"
android:exported="true"
>
<intent-filter>
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
</intent-filter>
</receiver>
<service android:name=".IPNService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.net.VpnService"/>
</intent-filter>
</service>
<service
android:name=".QuickToggleService"
android:icon="@drawable/ic_tile"
android:label="@string/tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE"/>
</intent-filter>
</service>
</application>
</manifest>
<!-- Signal support for Android TV -->
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
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">
<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:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
</activity>
<receiver
android:name="IPNReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
</intent-filter>
</receiver>
<service
android:name=".IPNService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<service
android:name=".QuickToggleService"
android:icon="@drawable/ic_tile"
android:label="@string/tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
</application>
</manifest>

@ -3,447 +3,443 @@
package com.tailscale.ipn;
import android.app.Application;
import android.Manifest;
import android.app.Activity;
import android.app.Application;
import android.app.DownloadManager;
import android.app.Fragment;
import android.app.FragmentTransaction;
import android.app.NotificationChannel;
import android.app.PendingIntent;
import android.app.UiModeManager;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.RestrictionsManager;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.Signature;
import android.content.res.Configuration;
import android.provider.MediaStore;
import android.provider.Settings;
import android.net.ConnectivityManager;
import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkInfo;
import android.net.NetworkRequest;
import android.net.Uri;
import android.net.VpnService;
import android.view.View;
import android.os.Build;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.provider.MediaStore;
import android.provider.Settings;
import android.util.Log;
import android.Manifest;
import android.webkit.MimeTypeMap;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;
import java.io.IOException;
import java.io.File;
import java.io.FileOutputStream;
import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting;
import com.tailscale.ipn.mdm.BooleanSetting;
import com.tailscale.ipn.mdm.MDMSettings;
import com.tailscale.ipn.mdm.ShowHideSetting;
import com.tailscale.ipn.mdm.StringSetting;
import java.lang.StringBuilder;
import org.gioui.Gio;
import java.io.File;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
public class App extends Application {
private static final String PEER_TAG = "peer";
import androidx.core.content.ContextCompat;
static final String STATUS_CHANNEL_ID = "tailscale-status";
static final int STATUS_NOTIFICATION_ID = 1;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
static final int NOTIFY_NOTIFICATION_ID = 2;
import androidx.browser.customtabs.CustomTabsIntent;
private static final String FILE_CHANNEL_ID = "tailscale-files";
private static final int FILE_NOTIFICATION_ID = 3;
import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting;
import com.tailscale.ipn.mdm.BooleanSetting;
import com.tailscale.ipn.mdm.MDMSettings;
import com.tailscale.ipn.mdm.ShowHideSetting;
import com.tailscale.ipn.mdm.StringSetting;
private static final Handler mainHandler = new Handler(Looper.getMainLooper());
import org.gioui.Gio;
private ConnectivityManager connectivityManager;
public DnsConfig dns = new DnsConfig();
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 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;
public DnsConfig dns = new DnsConfig();
public DnsConfig getDnsConfigObj() { return this.dns; }
static App _application;
public static App getApplication() {
return _application;
}
@Override public void onCreate() {
super.onCreate();
// Load and initialize the Go library.
Gio.init(this);
this.connectivityManager = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
setAndRegisterNetworkCallbacks();
createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);
_application = this;
}
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is possible that
// this might return an unusuable network, eg a captive portal.
private void setAndRegisterNetworkCallbacks() {
connectivityManager.requestNetwork(dns.getDNSConfigNetworkRequest(), new ConnectivityManager.NetworkCallback(){
@Override
public void onAvailable(Network network){
super.onAvailable(network);
StringBuilder sb = new StringBuilder("");
LinkProperties linkProperties = connectivityManager.getLinkProperties(network);
List<InetAddress> dnsList = linkProperties.getDnsServers();
for (InetAddress ip : dnsList) {
sb.append(ip.getHostAddress()).append(" ");
}
String searchDomains = linkProperties.getDomains();
if (searchDomains != null) {
sb.append("\n");
sb.append(searchDomains);
}
dns.updateDNSFromNetwork(sb.toString());
onDnsConfigChanged();
}
@Override
public void onLost(Network network) {
super.onLost(network);
onDnsConfigChanged();
}
});
}
public void startVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_REQUEST_VPN);
startService(intent);
}
public void stopVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_STOP_VPN);
startService(intent);
}
// encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store.
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
}
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext.
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
return getEncryptedPrefs().getString(prefKey, null);
}
public SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
MasterKey key = new MasterKey.Builder(this)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
return EncryptedSharedPreferences.create(
this,
"secret_shared_prefs",
key,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
);
}
public boolean autoConnect = false;
public boolean vpnReady = false;
void setTileReady(boolean ready) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setReady(this, ready);
android.util.Log.d("App", "Set Tile Ready: " + ready + " " + autoConnect);
vpnReady = ready;
if (ready && autoConnect) {
startVPN();
}
}
void setTileStatus(boolean status) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setStatus(this, status);
}
String getHostname() {
String userConfiguredDeviceName = getUserConfiguredDeviceName();
if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;
return getModelName();
}
String getModelName() {
String manu = Build.MANUFACTURER;
String model = Build.MODEL;
// Strip manufacturer from model.
int idx = model.toLowerCase().indexOf(manu.toLowerCase());
if (idx != -1) {
model = model.substring(idx + manu.length());
model = model.trim();
}
return manu + " " + model;
}
String getOSVersion() {
return Build.VERSION.RELEASE;
}
// get user defined nickname from Settings
// returns null if not available
private String getUserConfiguredDeviceName() {
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
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) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
ft.add(new Peer(), PEER_TAG);
ft.commit();
act.getFragmentManager().executePendingTransactions();
}
});
}
boolean isChromeOS() {
return getPackageManager().hasSystemFeature("android.hardware.type.pc");
}
void prepareVPN(Activity act, int reqCode) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
Intent intent = VpnService.prepare(act);
if (intent == null) {
onVPNPrepared();
} else {
startActivityForResult(act, intent, reqCode);
}
}
});
}
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 public void run() {
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
int headerColor = 0xff496495;
builder.setToolbarColor(headerColor);
CustomTabsIntent intent = builder.build();
intent.launchUrl(act, Uri.parse(url));
}
});
}
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
byte[] getPackageCertificate() throws Exception {
PackageInfo info;
info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
for (Signature signature : info.signatures) {
return signature.toByteArray();
}
return null;
}
void requestWriteStoragePermission(Activity act) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// We can write files without permission.
return;
}
if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
return;
}
act.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, IPNActivity.WRITE_STORAGE_RESULT);
}
String insertMedia(String name, String mimeType) throws IOException {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
if (!"".equals(mimeType)) {
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
}
Uri root = MediaStore.Files.getContentUri("external");
return resolver.insert(root, contentValues).toString();
} else {
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
dir.mkdirs();
File f = new File(dir, name);
return Uri.fromFile(f).toString();
}
}
int openUri(String uri, String mode) throws IOException {
ContentResolver resolver = getContentResolver();
return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
}
void deleteUri(String uri) {
ContentResolver resolver = getContentResolver();
resolver.delete(Uri.parse(uri), null, null);
}
public void notifyFile(String uri, String msg) {
Intent viewIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
} else {
// uri is a file:// which is not allowed to be shared outside the app.
viewIntent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
}
PendingIntent pending = PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("File received")
.setContentText(msg)
.setContentIntent(pending)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(FILE_NOTIFICATION_ID, builder.build());
}
public void createNotificationChannel(String id, String name, int importance) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(id, name, importance);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
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.
//
// Example:
// rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
// dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
// rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
// r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
// rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
// lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
// v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
//
// Where the fields are:
// name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
String getInterfacesAsString() {
List<NetworkInterface> interfaces;
try {
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
} catch (Exception e) {
return "";
public DnsConfig getDnsConfigObj() {
return this.dns;
}
static App _application;
public static App getApplication() {
return _application;
}
@Override
public void onCreate() {
super.onCreate();
// Load and initialize the Go library.
Gio.init(this);
this.connectivityManager = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
setAndRegisterNetworkCallbacks();
createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);
_application = this;
}
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is possible that
// this might return an unusuable network, eg a captive portal.
private void setAndRegisterNetworkCallbacks() {
connectivityManager.requestNetwork(dns.getDNSConfigNetworkRequest(), new ConnectivityManager.NetworkCallback() {
@Override
public void onAvailable(Network network) {
super.onAvailable(network);
StringBuilder sb = new StringBuilder("");
LinkProperties linkProperties = connectivityManager.getLinkProperties(network);
List<InetAddress> dnsList = linkProperties.getDnsServers();
for (InetAddress ip : dnsList) {
sb.append(ip.getHostAddress()).append(" ");
}
String searchDomains = linkProperties.getDomains();
if (searchDomains != null) {
sb.append("\n");
sb.append(searchDomains);
}
dns.updateDNSFromNetwork(sb.toString());
onDnsConfigChanged();
}
StringBuilder sb = new StringBuilder("");
for (NetworkInterface nif : interfaces) {
try {
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants
// one, so we say the interface has broadcast if it has multicast.
sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
// InterfaceAddress == hostname + "/" + IP
String[] parts = ia.toString().split("/", 0);
if (parts.length > 1) {
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
}
}
} catch (Exception e) {
// TODO(dgentry) should log the exception not silently suppress it.
continue;
@Override
public void onLost(Network network) {
super.onLost(network);
onDnsConfigChanged();
}
});
}
public void startVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_REQUEST_VPN);
startService(intent);
}
public void stopVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_STOP_VPN);
startService(intent);
}
// encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store.
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
}
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext.
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
return getEncryptedPrefs().getString(prefKey, null);
}
public SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
MasterKey key = new MasterKey.Builder(this)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
return EncryptedSharedPreferences.create(
this,
"secret_shared_prefs",
key,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
);
}
public boolean autoConnect = false;
public boolean vpnReady = false;
void setTileReady(boolean ready) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setReady(this, ready);
android.util.Log.d("App", "Set Tile Ready: " + ready + " " + autoConnect);
vpnReady = ready;
if (ready && autoConnect) {
startVPN();
}
}
void setTileStatus(boolean status) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setStatus(this, status);
}
String getHostname() {
String userConfiguredDeviceName = getUserConfiguredDeviceName();
if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;
return getModelName();
}
String getModelName() {
String manu = Build.MANUFACTURER;
String model = Build.MODEL;
// Strip manufacturer from model.
int idx = model.toLowerCase().indexOf(manu.toLowerCase());
if (idx != -1) {
model = model.substring(idx + manu.length());
model = model.trim();
}
return manu + " " + model;
}
String getOSVersion() {
return Build.VERSION.RELEASE;
}
// get user defined nickname from Settings
// returns null if not available
private String getUserConfiguredDeviceName() {
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
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) {
act.runOnUiThread(new Runnable() {
@Override
public void run() {
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
ft.add(new Peer(), PEER_TAG);
ft.commit();
act.getFragmentManager().executePendingTransactions();
}
});
}
boolean isChromeOS() {
return getPackageManager().hasSystemFeature("android.hardware.type.pc");
}
void prepareVPN(Activity act, int reqCode) {
act.runOnUiThread(new Runnable() {
@Override
public void run() {
Intent intent = VpnService.prepare(act);
if (intent == null) {
onVPNPrepared();
} else {
startActivityForResult(act, intent, reqCode);
}
sb.append("\n");
}
});
}
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
public void run() {
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
int headerColor = 0xff496495;
builder.setToolbarColor(headerColor);
CustomTabsIntent intent = builder.build();
intent.launchUrl(act, Uri.parse(url));
}
});
}
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
byte[] getPackageCertificate() throws Exception {
PackageInfo info;
info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
for (Signature signature : info.signatures) {
return signature.toByteArray();
}
return null;
}
void requestWriteStoragePermission(Activity act) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// We can write files without permission.
return;
}
if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
return;
}
act.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, IPNActivity.WRITE_STORAGE_RESULT);
}
String insertMedia(String name, String mimeType) throws IOException {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
if (!"".equals(mimeType)) {
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
}
Uri root = MediaStore.Files.getContentUri("external");
return resolver.insert(root, contentValues).toString();
} else {
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
dir.mkdirs();
File f = new File(dir, name);
return Uri.fromFile(f).toString();
}
}
int openUri(String uri, String mode) throws IOException {
ContentResolver resolver = getContentResolver();
return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
}
void deleteUri(String uri) {
ContentResolver resolver = getContentResolver();
resolver.delete(Uri.parse(uri), null, null);
}
public void notifyFile(String uri, String msg) {
Intent viewIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
} else {
// uri is a file:// which is not allowed to be shared outside the app.
viewIntent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
}
PendingIntent pending = PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("File received")
.setContentText(msg)
.setContentIntent(pending)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(FILE_NOTIFICATION_ID, builder.build());
}
public void createNotificationChannel(String id, String name, int importance) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(id, name, importance);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
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.
//
// Example:
// rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
// dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
// rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
// r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
// rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
// lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
// v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
//
// Where the fields are:
// name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
String getInterfacesAsString() {
List<NetworkInterface> interfaces;
try {
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
} catch (Exception e) {
return "";
}
return sb.toString();
StringBuilder sb = new StringBuilder("");
for (NetworkInterface nif : interfaces) {
try {
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants
// one, so we say the interface has broadcast if it has multicast.
sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
// InterfaceAddress == hostname + "/" + IP
String[] parts = ia.toString().split("/", 0);
if (parts.length > 1) {
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
}
}
} catch (Exception e) {
// TODO(dgentry) should log the exception not silently suppress it.
continue;
}
sb.append("\n");
}
boolean isTV() {
UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE);
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
}
return sb.toString();
}
boolean isTV() {
UiModeManager mm = (UiModeManager) getSystemService(UI_MODE_SERVICE);
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
}
/*
The following methods are called by the syspolicy handler from Go via JNI.
*/
boolean getSyspolicyBooleanValue(String key) {
RestrictionsManager manager = (RestrictionsManager) this.getSystemService(Context.RESTRICTIONS_SERVICE);
MDMSettings mdmSettings = new MDMSettings(manager);
BooleanSetting setting = BooleanSetting.valueOf(key);
return mdmSettings.get(setting);
}
boolean getSyspolicyBooleanValue(String key) {
RestrictionsManager manager = (RestrictionsManager) this.getSystemService(Context.RESTRICTIONS_SERVICE);
MDMSettings mdmSettings = new MDMSettings(manager);
BooleanSetting setting = BooleanSetting.valueOf(key);
return mdmSettings.get(setting);
}
String getSyspolicyStringValue(String key) {
RestrictionsManager manager = (RestrictionsManager) this.getSystemService(Context.RESTRICTIONS_SERVICE);
MDMSettings mdmSettings = new MDMSettings(manager);
String getSyspolicyStringValue(String key) {
RestrictionsManager manager = (RestrictionsManager) this.getSystemService(Context.RESTRICTIONS_SERVICE);
MDMSettings mdmSettings = new MDMSettings(manager);
// Before looking for a StringSetting matching the given key, Go could also be
// asking us for either a AlwaysNeverUserDecidesSetting or a ShowHideSetting.
@ -461,10 +457,10 @@ public class App extends Application {
String value = mdmSettings.get(stringSetting);
return Objects.requireNonNullElse(value, "");
} catch (IllegalArgumentException estr) {
android.util.Log.d("MDM", key+" is not defined on Android. Returning empty.");
android.util.Log.d("MDM", key + " is not defined on Android. Returning empty.");
return "";
}
}
}
}
}
}

@ -6,9 +6,12 @@ package com.tailscale.ipn;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import androidx.work.WorkManager;
import androidx.work.OneTimeWorkRequest;
import java.util.Objects;
public class IPNReceiver extends BroadcastReceiver {
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";
@ -19,9 +22,9 @@ public class IPNReceiver extends BroadcastReceiver {
WorkManager workManager = WorkManager.getInstance(context);
// On the relevant action, start the relevant worker, which can stay active for longer than this receiver can.
if (intent.getAction() == INTENT_CONNECT_VPN) {
if (Objects.equals(intent.getAction(), INTENT_CONNECT_VPN)) {
workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build());
} else if (intent.getAction() == INTENT_DISCONNECT_VPN) {
} else if (Objects.equals(intent.getAction(), INTENT_DISCONNECT_VPN)) {
workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build());
}
}

@ -3,13 +3,17 @@
package com.tailscale.ipn
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.RestrictionsManager
import android.net.Uri
import android.net.VpnService
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContract
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@ -31,6 +35,7 @@ import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.MullvadExitNodePicker
import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.Settings
import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav
@ -50,21 +55,26 @@ class MainActivity : ComponentActivity() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "main") {
val mainViewNav =
MainViewNavigation(onNavigateToSettings = { navController.navigate("settings") },
onNavigateToPeerDetails = {
navController.navigate("peerDetails/${it.StableID}")
},
onNavigateToExitNodes = { navController.navigate("exitNodes") })
MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") },
onNavigateToPeerDetails = {
navController.navigate("peerDetails/${it.StableID}")
},
onNavigateToExitNodes = { navController.navigate("exitNodes") },
)
val settingsNav =
SettingsNav(onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") })
SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
)
val exitNodePickerNav = ExitNodePickerNav(onNavigateHome = {
navController.popBackStack(
route = "main", inclusive = false
route = "main", inclusive = false
)
}, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") })
@ -79,18 +89,18 @@ class MainActivity : ComponentActivity() {
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 })
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })
) {
PeerDetails(it.arguments?.getString("nodeId") ?: "")
}
@ -106,6 +116,9 @@ class MainActivity : ComponentActivity() {
composable("managedBy") {
ManagedByView()
}
composable("userSwitcher") {
UserSwitcherView()
}
}
}
}
@ -136,7 +149,7 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager))
}
@ -145,11 +158,43 @@ class MainActivity : ComponentActivity() {
val scope = CoroutineScope(Dispatchers.IO)
notifierScope = scope
Notifier.start(lifecycleScope)
// (jonathan) TODO: Requesting VPN permissions onStart is a bit aggressive. This should
// be done when the user initiall starts the VPN
requestVpnPermission()
}
override fun onStop() {
Notifier.stop()
super.onStop()
val restrictionsManager = this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager))
}
private fun requestVpnPermission() {
val vpnIntent = VpnService.prepare(this)
if (vpnIntent != null) {
val contract = VpnPermissionContract()
registerForActivityResult(contract) { granted ->
Notifier.vpnPermissionGranted.set(granted)
if (granted) {
Log.i("VPN", "VPN permission granted")
} else {
Log.i("VPN", "VPN permission not granted")
}
}
}
}
}
class VpnPermissionContract : ActivityResultContract<Unit, Boolean>() {
override fun createIntent(context: Context, input: Unit): Intent {
return VpnService.prepare(context) ?: Intent()
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == Activity.RESULT_OK
}
}

@ -66,7 +66,7 @@ class Client(private val scope: CoroutineScope) {
}
fun editPrefs(
prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit
prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit
) {
val body = Json.encodeToString(prefs).toByteArray()
return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
@ -80,6 +80,18 @@ class Client(private val scope: CoroutineScope) {
return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler)
}
fun addProfile(responseHandler: (Result<String>) -> Unit = {}) {
return put(Endpoint.PROFILES, responseHandler = responseHandler)
}
fun deleteProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result<String>) -> Unit = {}) {
return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun switchProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result<String>) -> Unit = {}) {
return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun startLoginInteractive(responseHandler: (Result<String>) -> Unit) {
return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler)
}
@ -89,77 +101,77 @@ 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,
method = "GET",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
scope = scope,
method = "GET",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
private inline fun <reified T> put(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "PUT",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
scope = scope,
method = "PUT",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
private inline fun <reified T> post(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "POST",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
scope = scope,
method = "POST",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
private inline fun <reified T> patch(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "PATCH",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
scope = scope,
method = "PATCH",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
private inline fun <reified T> delete(
path: String, noinline responseHandler: (Result<T>) -> Unit
path: String, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "DELETE",
path = path,
responseType = typeOf<T>(),
responseHandler = responseHandler
scope = scope,
method = "DELETE",
path = path,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
}
class Request<T>(
private val scope: CoroutineScope,
private val method: String,
path: String,
private val body: ByteArray? = null,
private val responseType: KType,
private val responseHandler: (Result<T>) -> Unit
private val scope: CoroutineScope,
private val method: String,
path: String,
private val body: ByteArray? = null,
private val responseType: KType,
private val responseHandler: (Result<T>) -> Unit
) {
private val fullPath = "/localapi/v0/$path"
@ -206,15 +218,15 @@ class Request<T>(
typeOf<String>() -> Result.success(respData.decodeToString() as T)
else -> try {
Result.success(
jsonDecoder.decodeFromStream(
Json.serializersModule.serializer(responseType), respData.inputStream()
) as T
jsonDecoder.decodeFromStream(
Json.serializersModule.serializer(responseType), respData.inputStream()
) as T
)
} catch (t: Throwable) {
// If we couldn't parse the response body, assume it's an error response
try {
val error =
jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream())
jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream())
throw Exception(error.error)
} catch (t: Throwable) {
Result.failure(t)

@ -40,6 +40,11 @@ object Notifier {
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null)
// Indicates whether or not we have granted permission to use the VPN. This must be
// explicitly set by the main activity. null indicates that we have not yet
// checked.
val vpnPermissionGranted: StateFlow<Boolean?> = MutableStateFlow(null)
// Called by the backend when the localAPI is ready to accept requests.
@JvmStatic
@Suppress("unused")
@ -54,7 +59,7 @@ 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")
}
@ -91,7 +96,7 @@ object Notifier {
// 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
32
)
}
}

@ -13,9 +13,12 @@ val ts_color_light_tintedBackground = Color(0xFFF7F5F4)
val ts_color_light_blue = Color(0xFF4B70CC)
val ts_color_light_green = Color(0xFF1EA672)
var ts_color_dark_desctrutive_text = Color(0xFFFF0000)
var ts_color_light_desctrutive_text = Color(0xFFBB0000)
val ts_color_dark_primary = Color(0xFFFAF9F8)
val ts_color_dark_secondary = Color(0xFFAFACAB)
val ts_color_dark_background = Color(0xFF232222)
val ts_color_dark_tintedBackground = Color(0xFF2E2D2D)
val ts_color_dark_blue = Color(0xFF4B70CC)
var ts_color_dark_green = Color(0xFF33C27F)
var ts_color_dark_green = Color(0xFF33C27F)

@ -7,14 +7,21 @@ 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
import com.tailscale.ipn.R
@Composable
fun settingsRowModifier(): Modifier {
@ -27,4 +34,34 @@ fun settingsRowModifier(): Modifier {
@Composable
fun defaultPaddingModifier(): Modifier {
return Modifier.padding(8.dp)
}
}
@Composable
fun Header(@StringRes title: Int) {
Text(
text = stringResource(id = title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium
)
}
@Composable
fun ChevronRight() {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
@Composable
fun CheckedIndicator() {
Icon(Icons.Default.CheckCircle, null)
}
@Composable
fun LoadingIndicator(size: Int = 32) {
CircularProgressIndicator(
modifier = Modifier.width(size.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.secondary,
)
}

@ -3,7 +3,6 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
@ -18,7 +17,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import coil.compose.AsyncImage
import com.tailscale.ipn.ui.model.IpnLocal
@ -32,20 +31,15 @@ fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50) {
.clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiaryContainer)
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size((size * .8f).dp)
)
profile?.UserProfile?.ProfilePicURL?.let { url ->
val painter = rememberImagePainter(data = url)
Image(
painter = painter,
contentDescription = null,
modifier = Modifier.size(size.dp)
)
} ?: run {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size((size * .8f).dp)
)
AsyncImage(model = url, contentDescription = null)
}
}
}
}

@ -37,6 +37,7 @@ 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
@ -48,18 +49,9 @@ fun BugReportView(model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.surface) {
Column(
modifier = defaultPaddingModifier()
.fillMaxWidth()
.fillMaxHeight()
) {
Text(
text = stringResource(id = R.string.bug_report_title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium
)
Column(modifier = defaultPaddingModifier().fillMaxWidth().fillMaxHeight()) {
Header(title = R.string.bug_report_title)
Spacer(modifier = Modifier.height(8.dp))
@ -136,4 +128,3 @@ fun contactText(): AnnotatedString {
}
return annotatedString
}

@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
@ -17,20 +17,20 @@ import com.tailscale.ipn.ui.theme.ts_color_light_blue
@Composable
fun PrimaryActionButton(
onClick: () -> Unit,
content: @Composable RowScope.() -> Unit
onClick: () -> Unit,
content: @Composable RowScope.() -> Unit
) {
Button(
onClick = onClick,
colors = ButtonColors(
containerColor = ts_color_light_blue,
contentColor = Color.White,
disabledContainerColor = MaterialTheme.colorScheme.secondary,
disabledContentColor = MaterialTheme.colorScheme.onSecondary
),
contentPadding = PaddingValues(vertical = 12.dp),
modifier = Modifier
.fillMaxWidth(),
content = content
onClick = onClick,
colors = ButtonColors(
containerColor = ts_color_light_blue,
contentColor = Color.White,
disabledContainerColor = MaterialTheme.colorScheme.secondary,
disabledContentColor = MaterialTheme.colorScheme.onSecondary
),
contentPadding = PaddingValues(vertical = 12.dp),
modifier = Modifier
.fillMaxWidth(),
content = content
)
}

@ -0,0 +1,60 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.annotation.StringRes
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
enum class ErrorDialogType {
LOGOUT_FAILED, SWITCH_USER_FAILED, ADD_PROFILE_FAILED;
val message: Int
get() {
return when (this) {
LOGOUT_FAILED -> R.string.logout_failed
SWITCH_USER_FAILED -> R.string.switch_user_failed
ADD_PROFILE_FAILED -> R.string.add_profile_failed
}
}
val title: Int = R.string.error
val buttonText: Int = R.string.ok
}
@Composable
fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
ErrorDialog(title = type.title,
message = type.message,
buttonText = type.buttonText,
onDismiss = action)
}
@Composable
fun ErrorDialog(
@StringRes title: Int,
@StringRes message: Int,
@StringRes buttonText: Int = R.string.ok,
onDismiss: () -> Unit = {}
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(text = stringResource(id = title))
},
text = {
Text(text = stringResource(id = message))
},
confirmButton = {
PrimaryActionButton(onClick = onDismiss) {
Text(text = stringResource(id = buttonText))
}
}
)
}

@ -21,6 +21,8 @@ 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.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
@ -40,6 +42,7 @@ 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.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -53,7 +56,6 @@ import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.ts_color_light_green
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.PrimaryActionButton
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.StateFlow
@ -61,58 +63,66 @@ import kotlinx.coroutines.flow.StateFlow
// Navigation actions for the MainView
data class MainViewNavigation(
val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit
val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit
)
@Composable
fun MainView(navigation: MainViewNavigation, model: MainViewModel = viewModel()) {
fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
Column(
modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Center
) {
val state = model.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = model.loggedInUser.collectAsState(initial = null)
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null)
Row(
modifier = Modifier
Row(modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
val isOn = model.vpnToggleState.collectAsState(initial = false)
Switch(onCheckedChange = { model.toggleVpn() }, checked = isOn.value)
Spacer(Modifier.size(3.dp))
StateDisplay(model.stateRes, model.userName)
Box(
modifier = Modifier
verticalAlignment = Alignment.CenterVertically) {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) {
Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
Spacer(Modifier.size(3.dp))
}
StateDisplay(viewModel.stateRes, viewModel.userName)
Box(modifier = Modifier
.weight(1f)
.clickable { navigation.onNavigateToSettings() },
contentAlignment = Alignment.CenterEnd
) {
Avatar(profile = user.value, size = 36)
.clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) {
when (user.value) {
null -> SettingsButton(user.value) { navigation.onNavigateToSettings() }
else -> Avatar(profile = user.value, size = 36)
}
}
}
when (state.value) {
Ipn.State.Running -> {
ExitNodeStatus(navigation.onNavigateToExitNodes, model)
PeerList(searchTerm = model.searchTerm,
state = model.ipnState,
peers = model.peers,
selfPeer = model.selfPeerId,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { model.searchPeers(it) })
val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "")
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
PeerList(
searchTerm = viewModel.searchTerm,
state = viewModel.ipnState,
peers = viewModel.peers,
selfPeer = selfPeerId.value,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
}
Ipn.State.Starting -> StartingView()
else -> ConnectView(user.value, { model.toggleVpn() }, { model.login() }
)
else ->
ConnectView(
user.value,
{ viewModel.toggleVpn() },
{ viewModel.login {} }
)
}
}
}
@ -131,24 +141,23 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
}
}
Box(modifier = Modifier
.clickable { navAction() }
.padding(horizontal = 8.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.background(MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) {
.clickable { navAction() }
.padding(horizontal = 8.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.background(MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) {
Column(modifier = Modifier.padding(6.dp)) {
Text(
text = stringResource(id = R.string.exit_node),
style = MaterialTheme.typography.titleMedium
text = stringResource(id = R.string.exit_node),
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,
Icons.Outlined.ArrowDropDown,
null,
)
}
}
@ -161,22 +170,30 @@ fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
val stateStr = stringResource(id = stateVal.value)
Column(modifier = Modifier.padding(7.dp)) {
Text(text = tailnet, style = MaterialTheme.typography.titleMedium)
Text(
text = stateStr,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary
)
when (tailnet.isEmpty()) {
false -> {
Text(text = tailnet, style = MaterialTheme.typography.titleMedium)
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
}
true -> {
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary)
}
}
}
}
@Composable
fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
// (jonathan) TODO: On iOS this is the users avatar or a letter avatar.
IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
IconButton(
modifier = Modifier.size(24.dp),
onClick = { action() }
) {
Icon(
Icons.Outlined.Settings,
null,
Icons.Outlined.Settings,
null,
)
}
}
@ -185,16 +202,15 @@ 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),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(id = R.string.starting),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
Text(text = stringResource(id = R.string.starting),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
@ -203,66 +219,67 @@ 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)
.padding(8.dp)
.fillMaxWidth(0.7f)
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(
8.dp, alignment = Alignment.CenterVertically
),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(8.dp)
.fillMaxWidth(0.7f)
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(
8.dp,
alignment = Alignment.CenterVertically
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (user != null && !user.isEmpty()) {
Icon(
painter = painterResource(id = R.drawable.power),
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.secondary
painter = painterResource(id = R.drawable.power),
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.secondary
)
Text(
text = stringResource(id = R.string.not_connected),
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
fontFamily = MaterialTheme.typography.titleMedium.fontFamily
text = stringResource(id = R.string.not_connected),
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
fontFamily = MaterialTheme.typography.titleMedium.fontFamily
)
val tailnetName = user.NetworkProfile?.DomainName ?: ""
Text(
stringResource(id = R.string.connect_to_tailnet, tailnetName),
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center,
stringResource(id = R.string.connect_to_tailnet, tailnetName),
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = connectAction) {
Text(
text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize
text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize
)
}
} else {
TailscaleLogoView(Modifier.size(50.dp))
Spacer(modifier = Modifier.size(1.dp))
Text(
text = stringResource(id = R.string.welcome_to_tailscale),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
text = stringResource(id = R.string.welcome_to_tailscale),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
Text(
stringResource(R.string.login_to_join_your_tailnet),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center
stringResource(R.string.login_to_join_your_tailnet),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = loginAction) {
Text(
text = stringResource(id = R.string.log_in),
fontSize = MaterialTheme.typography.titleMedium.fontSize
text = stringResource(id = R.string.log_in),
fontSize = MaterialTheme.typography.titleMedium.fontSize
)
}
}
@ -270,88 +287,103 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
}
}
@Composable
fun ClearButton(onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Clear, null)
}
}
@Composable
fun CloseButton() {
val focusManager = LocalFocusManager.current
IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Close, null)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeerList(
searchTerm: StateFlow<String>,
peers: StateFlow<List<PeerSet>>,
state: StateFlow<Ipn.State>,
selfPeer: StableNodeID,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit
searchTerm: StateFlow<String>,
peers: StateFlow<List<PeerSet>>,
state: StateFlow<Ipn.State>,
selfPeer: StableNodeID,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit
) {
val peerList = peers.collectAsState(initial = emptyList<PeerSet>())
var searching = false
val searchTermStr by searchTerm.collectAsState(initial = "")
val stateVal = state.collectAsState(initial = Ipn.State.NoState)
SearchBar(
query = searchTermStr,
onQueryChange = onSearch,
onSearch = onSearch,
active = true,
onActiveChange = { searching = it },
shape = RoundedCornerShape(10.dp),
leadingIcon = { Icon(Icons.Outlined.Search, null) },
tonalElevation = 2.dp,
shadowElevation = 2.dp,
colors = SearchBarDefaults.colors(),
modifier = Modifier.fillMaxWidth()
query = searchTermStr,
onQueryChange = onSearch,
onSearch = onSearch,
active = true,
onActiveChange = {},
shape = RoundedCornerShape(10.dp),
leadingIcon = { Icon(Icons.Outlined.Search, null) },
trailingIcon = { if (searchTermStr.isNotEmpty()) ClearButton({ onSearch("") }) else CloseButton() },
tonalElevation = 2.dp,
shadowElevation = 2.dp,
colors = SearchBarDefaults.colors(),
modifier = Modifier.fillMaxWidth()
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer),
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer),
) {
peerList.value.forEach { peerSet ->
item {
ListItem(headlineContent = {
Text(
text = peerSet.user?.DisplayName
?: stringResource(id = R.string.unknown_user),
style = MaterialTheme.typography.titleLarge
)
Text(text = peerSet.user?.DisplayName
?: stringResource(id = R.string.unknown_user), style = MaterialTheme.typography.titleLarge)
})
}
peerSet.peers.forEach { peer ->
item {
ListItem(modifier = Modifier.clickable {
onNavigateToPeerDetails(peer)
}, headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
// By definition, SelfPeer is online since we will not show the peer list unless you're connected.
val isSelfAndRunning =
(peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
val color: Color = if ((peer.Online == true) || isSelfAndRunning) {
ts_color_light_green
} else {
Color.Gray
ListItem(
modifier = Modifier.clickable {
onNavigateToPeerDetails(peer)
},
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
// By definition, SelfPeer is online since we will not show the peer list unless you're connected.
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
val color: Color = if ((peer.Online == true) || isSelfAndRunning) {
ts_color_light_green
} else {
Color.Gray
}
Box(modifier = Modifier
.size(8.dp)
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
}
},
supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first()
?: "",
style = MaterialTheme.typography.bodyMedium
)
},
trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
Box(
modifier = Modifier
.size(8.dp)
.background(
color = color, shape = RoundedCornerShape(percent = 50)
)
) {}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = peer.ComputedName,
style = MaterialTheme.typography.titleMedium
)
}
}, supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
style = MaterialTheme.typography.bodyMedium
)
}, trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
})
)
}
}
}
}
}
}
}

@ -11,14 +11,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
@ -33,14 +28,15 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
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.model.IpnLocal
import com.tailscale.ipn.ui.util.PrimaryActionButton
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
@ -52,42 +48,27 @@ import com.tailscale.ipn.ui.viewModel.SettingsViewModelFactory
@Composable
fun Settings(
settingsNav: SettingsNav,
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModelFactory(settingsNav))
settingsNav: SettingsNav,
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModelFactory(settingsNav))
) {
val handler = LocalUriHandler.current
val user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxHeight()) {
Column(modifier = defaultPaddingModifier().fillMaxHeight()) {
Text(
text = stringResource(id = R.string.settings_title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium
)
Header(title = R.string.settings_title)
Spacer(modifier = Modifier.height(8.dp))
// The login/logout button here is probably in the wrong location, but we need something
// somewhere for the time being. FUS should probably be implemented for V0 given that
// it's relatively simple to do so with localAPI. On iOS, the UI for user switching is
// all in the FUS screen.
viewModel.user?.let { user ->
UserView(profile = user, viewModel.isAdmin, adminText(), onClick = {
handler.openUri(Links.ADMIN_URL)
})
Spacer(modifier = Modifier.height(8.dp))
PrimaryActionButton(onClick = { viewModel.logout() }) {
Text(text = stringResource(id = R.string.log_out))
}
} ?: run {
Button(onClick = { viewModel.login() }) {
Text(text = stringResource(id = R.string.log_in))
}
UserView(profile = user,
actionState = UserActionState.NAV,
onClick = viewModel.navigation.onNavigateToUserSwitcher)
if (isAdmin) {
Spacer(modifier = Modifier.height(4.dp))
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
Spacer(modifier = Modifier.height(8.dp))
@ -96,27 +77,9 @@ fun Settings(
settings.forEach { settingBundle ->
Column(modifier = settingsRowModifier()) {
settingBundle.title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(8.dp)
)
}
settingBundle.settings.forEach { setting ->
when (setting.type) {
SettingType.NAV -> {
SettingsNavRow(setting)
}
SettingType.SWITCH -> {
SettingsSwitchRow(setting)
}
SettingType.NAV_WITH_TEXT -> {
SettingsNavRow(setting)
}
}
SettingTitle(it)
}
settingBundle.settings.forEach { SettingRow(it) }
}
Spacer(modifier = Modifier.height(8.dp))
}
@ -124,12 +87,13 @@ fun Settings(
}
}
@Composable
fun UserView(
profile: IpnLocal.LoginProfile?,
isAdmin: Boolean,
adminText: AnnotatedString,
onClick: () -> Unit
profile: IpnLocal.LoginProfile?,
isAdmin: Boolean,
adminText: AnnotatedString,
onClick: () -> Unit
) {
Column {
Row(modifier = settingsRowModifier().padding(8.dp)) {
@ -140,8 +104,8 @@ fun UserView(
Column(verticalArrangement = Arrangement.Center) {
Text(
text = profile?.UserProfile?.DisplayName ?: "",
style = MaterialTheme.typography.titleMedium
text = profile?.UserProfile?.DisplayName ?: "",
style = MaterialTheme.typography.titleMedium
)
Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium)
}
@ -150,11 +114,11 @@ 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()
})
}
}
@ -162,38 +126,61 @@ fun UserView(
}
@Composable
fun SettingsNavRow(setting: Setting) {
val txtVal = setting.value?.collectAsState()?.value ?: ""
val enabled = setting.enabled.collectAsState().value
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }) {
Text(setting.title.getString())
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
fun SettingTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(8.dp)
)
}
@Composable
fun SettingsSwitchRow(setting: Setting) {
val swVal = setting.isOn?.collectAsState()?.value ?: false
fun SettingRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value
val swVal = setting.isOn?.collectAsState()?.value ?: false
val txtVal = setting.value?.collectAsState()?.value ?: ""
Row(
modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() },
verticalAlignment = Alignment.CenterVertically
) {
Text(setting.title.getString())
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) {
when (setting.type) {
SettingType.NAV_WITH_TEXT -> {
Text(setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
}
SettingType.TEXT -> {
Text(setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary)
}
SettingType.SWITCH -> {
Text(setting.title.getString())
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
}
}
SettingType.NAV -> {
Text(setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color = if (setting.destructive) ts_color_dark_desctrutive_text else MaterialTheme.colorScheme.primary)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
ChevronRight()
}
}
}
}
@Composable
fun adminText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val adminStr = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.settings_admin_prefix))
}
@ -204,5 +191,13 @@ fun adminText(): AnnotatedString {
}
pop()
}
return annotatedString
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
ClickableText(
text = adminStr,
style = MaterialTheme.typography.bodySmall,
onClick = {
onNavigateToAdminConsole()
})
}
}

@ -0,0 +1,87 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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(),
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
// we get our first netmap update back with the new userId for SelfNode.
// (jonathan) TODO: This user switch is not immediate. We may need to represent the
// "switching users" state globally (if ipnState is insufficient)
val nextUserId = remember { mutableStateOf<String?>(null) }
users?.forEach { user ->
if (user.ID == currentUser?.ID) {
UserView(profile = user, actionState = UserActionState.CURRENT)
} else {
val state =
if (user.ID == nextUserId.value) UserActionState.SWITCHING
else UserActionState.NONE
UserView(
profile = user,
actionState = state,
onClick = {
nextUserId.value = user.ID
viewModel.switchProfile(user) {
if (it.isFailure) {
viewModel.showDialog.set(ErrorDialogType.LOGOUT_FAILED)
nextUserId.value = null
}
}
})
}
}
SettingRow(viewModel.addProfileSetting)
}
Spacer(modifier = Modifier.height(8.dp))
Column(modifier = settingsRowModifier()) {
SettingRow(viewModel.loginSetting)
SettingRow(viewModel.logoutSetting)
}
}
}
}

@ -0,0 +1,67 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.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 }
@Composable
fun UserView(
profile: IpnLocal.LoginProfile?,
onClick: () -> Unit = {},
actionState: UserActionState = UserActionState.NONE
) {
Column {
Row(modifier = settingsRowModifier().clickable { onClick() }) {
profile?.let {
Box(modifier = defaultPaddingModifier()) {
Avatar(profile = profile, size = 36)
}
Column(modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center) {
Text(
text = profile.UserProfile.DisplayName
?: "", style = MaterialTheme.typography.titleMedium
)
Text(text = profile.Name ?: "", style = MaterialTheme.typography.bodyMedium)
}
} ?: run {
Box(modifier = Modifier.weight(1f)) {
Text(text = stringResource(id = R.string.accounts), style = MaterialTheme.typography.titleMedium)
}
}
when (actionState) {
UserActionState.CURRENT -> CheckedIndicator()
UserActionState.SWITCHING -> LoadingIndicator(size = 32)
UserActionState.NAV -> ChevronRight()
UserActionState.NONE -> Unit
}
}
}
}

@ -3,13 +3,17 @@
package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.UserID
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
@ -26,9 +30,13 @@ open class IpnViewModel : ViewModel() {
}
protected val TAG = this::class.simpleName
val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)
val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null)
// The userId associated with the current node. ie: The logged in user.
var selfNodeUserId: UserID? = null
init {
viewModelScope.launch {
Notifier.state.collect {
@ -39,6 +47,20 @@ open class IpnViewModel : ViewModel() {
}
}
}
// This will observe the userId of the current node and reload our user profiles if
// we discover it has changed (e.g. due to a login or user switch)
viewModelScope.launch {
Notifier.netmap.collect {
it?.SelfNode?.User.let {
if (it != selfNodeUserId) {
selfNodeUserId = it
viewModelScope.launch { loadUserProfiles() }
}
}
}
}
viewModelScope.launch { loadUserProfiles() }
Log.d(TAG, "Created")
}
@ -51,29 +73,71 @@ open class IpnViewModel : ViewModel() {
}
Client(viewModelScope).currentProfile { result ->
result.onSuccess(loggedInUser::set).onFailure {
Log.e(TAG, "Error loading current profile: ${it.message}")
}
result.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
}
}
fun toggleVpn() {
when (Notifier.state.value) {
Ipn.State.Running -> stopVPN()
else -> startVPN()
}
}
fun login() {
private fun startVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN
context.sendBroadcast(intent)
}
fun stopVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
}
fun login(completionHandler: (Result<String>) -> Unit = {}) {
Client(viewModelScope).startLoginInteractive { result ->
result.onSuccess {
Log.d(TAG, "Login started: $it")
}.onFailure {
Log.e(TAG, "Error starting login: ${it.message}")
}
result
.onSuccess { Log.d(TAG, "Login started: $it") }
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result)
}
}
fun logout() {
fun logout(completionHandler: (Result<String>) -> Unit = {}) {
Client(viewModelScope).logout { result ->
result.onSuccess {
Log.d(TAG, "Logout started: $it")
}.onFailure {
Log.e(TAG, "Error starting logout: ${it.message}")
result
.onSuccess { Log.d(TAG, "Logout started: $it") }
.onFailure { Log.e(TAG, "Error starting logout: ${it.message}") }
completionHandler(result)
}
}
fun switchProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).switchProfile(profile) {
startVPN()
completionHandler(it)
}
}
fun addProfile(completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).addProfile {
if (it.isSuccess) {
login {}
}
startVPN()
completionHandler(it)
}
}
fun deleteProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).deleteProfile(profile) {
viewModelScope.launch { loadUserProfiles() }
completionHandler(it)
}
}
@ -100,10 +164,12 @@ open class IpnViewModel : ViewModel() {
}
fun toggleCorpDNS(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = Notifier.prefs.value ?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleCorpDNS
}
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleCorpDNS
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.CorpDNS = !prefs.CorpDNS
@ -111,10 +177,12 @@ open class IpnViewModel : ViewModel() {
}
fun toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = Notifier.prefs.value ?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp
}
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ShieldsUp = !prefs.ShieldsUp
@ -122,10 +190,12 @@ open class IpnViewModel : ViewModel() {
}
fun toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = Notifier.prefs.value ?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll
}
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = !prefs.RouteAll

@ -4,12 +4,10 @@
package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
@ -39,10 +37,16 @@ class MainViewModel : IpnViewModel() {
val searchTerm: StateFlow<String> = MutableStateFlow("")
// The peerID of the local node
val selfPeerId = Notifier.netmap.value?.SelfNode?.StableID ?: ""
val selfPeerId: StateFlow<StableNodeID> = MutableStateFlow("")
private val peerCategorizer = PeerCategorizer(viewModelScope)
val userName: String
get() {
return loggedInUser.value?.Name ?: ""
}
init {
viewModelScope.launch {
Notifier.state.collect { state ->
@ -54,6 +58,7 @@ class MainViewModel : IpnViewModel() {
viewModelScope.launch {
Notifier.netmap.collect { netmap ->
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
selfPeerId.set(netmap?.SelfNode?.StableID ?: "")
}
}
}
@ -64,32 +69,6 @@ class MainViewModel : IpnViewModel() {
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm))
}
}
val userName: String
get() {
return loggedInUser.value?.Name ?: ""
}
fun toggleVpn() {
when (Notifier.state.value) {
State.Running -> stopVPN()
else -> startVPN()
}
}
private fun startVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN
context.sendBroadcast(intent)
}
fun stopVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
}
}
private fun State?.userStringRes(): Int {

@ -19,7 +19,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
enum class SettingType { NAV, SWITCH, NAV_WITH_TEXT }
enum class SettingType { NAV, SWITCH, NAV_WITH_TEXT, TEXT }
class ComposableStringFormatter(@StringRes val stringRes: Int, vararg val params: Any) {
@ -43,39 +43,41 @@ 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 enabled: StateFlow<Boolean> = MutableStateFlow(false),
val value: StateFlow<String?>? = null,
val isOn: StateFlow<Boolean?>? = null,
val onClick: () -> Unit = {},
val onToggle: (Boolean) -> Unit = {}
val title: ComposableStringFormatter,
val type: SettingType,
val destructive: Boolean = false,
val enabled: StateFlow<Boolean> = MutableStateFlow(true),
val value: StateFlow<String?>? = null,
val isOn: StateFlow<Boolean?>? = null,
val onClick: () -> Unit = {},
val onToggle: (Boolean) -> Unit = {}
) {
constructor(
titleRes: Int,
type: SettingType,
enabled: StateFlow<Boolean> = MutableStateFlow(false),
value: StateFlow<String?>? = null,
isOn: StateFlow<Boolean?>? = null,
onClick: () -> Unit = {},
onToggle: (Boolean) -> Unit = {}
titleRes: Int,
type: SettingType,
enabled: StateFlow<Boolean> = MutableStateFlow(false),
value: StateFlow<String?>? = null,
isOn: StateFlow<Boolean?>? = null,
onClick: () -> Unit = {},
onToggle: (Boolean) -> Unit = {}
) : this(
title = ComposableStringFormatter(titleRes),
type = type,
enabled = enabled,
value = value,
isOn = isOn,
onClick = onClick,
onToggle = onToggle
title = ComposableStringFormatter(titleRes),
type = type,
enabled = enabled,
value = value,
isOn = isOn,
onClick = onClick,
onToggle = onToggle
)
}
data class SettingsNav(
val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit,
val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit,
)
val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit,
val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit,
val onNavigateToUserSwitcher: () -> Unit)
class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@ -83,20 +85,18 @@ class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelP
}
}
class SettingsViewModel(private val navigation: SettingsNav) : IpnViewModel() {
val user = loggedInUser.value
class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() {
// Display name for the logged in user
val isAdmin = Notifier.netmap.value?.SelfNode?.isAdmin ?: false
var isAdmin: StateFlow<Boolean> = MutableStateFlow(false)
val useDNSSetting = Setting(R.string.use_ts_dns,
SettingType.SWITCH,
isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS),
onToggle = {
toggleCorpDNS {
// (jonathan) TODO: Error handling
}
})
SettingType.SWITCH,
isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS),
onToggle = {
toggleCorpDNS {
// (jonathan) TODO: Error handling
}
})
val settings: StateFlow<List<SettingBundle>> = MutableStateFlow(emptyList())
@ -110,49 +110,55 @@ class SettingsViewModel(private val navigation: SettingsNav) : IpnViewModel() {
}
viewModelScope.launch {
IpnViewModel.mdmSettings.collect { mdmSettings ->
mdmSettings.collect { mdmSettings ->
settings.set(
listOf(
SettingBundle(
settings = listOf(
useDNSSetting,
)
),
// General settings, always enabled
SettingBundle(settings = footerSettings(mdmSettings))
)
listOf(
SettingBundle(
settings = listOf(
useDNSSetting,
)
),
// General settings, always enabled
SettingBundle(settings = footerSettings(mdmSettings))
)
)
}
}
viewModelScope.launch {
Notifier.netmap.collect { netmap ->
isAdmin.set(netmap?.SelfNode?.isAdmin ?: false)
}
}
}
private fun footerSettings(mdmSettings: MDMSettings): List<Setting> = listOfNotNull(
Setting(
titleRes = R.string.about,
SettingType.NAV,
onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true)
), Setting(
Setting(
titleRes = R.string.about,
SettingType.NAV,
onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true)
), Setting(
titleRes = R.string.bug_report,
SettingType.NAV,
onClick = { navigation.onNavigateToBugReport() },
enabled = MutableStateFlow(true)
), mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Setting(
), mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Setting(
ComposableStringFormatter(R.string.managed_by_orgName, it),
SettingType.NAV,
onClick = { navigation.onNavigateToManagedBy() },
enabled = MutableStateFlow(true)
)
}, if (BuildConfig.DEBUG) {
Setting(
)
}, if (BuildConfig.DEBUG) {
Setting(
titleRes = R.string.mdm_settings,
SettingType.NAV,
onClick = { navigation.onNavigateToMDMSettings() },
enabled = MutableStateFlow(true)
)
} else {
null
}
)
} else {
null
}
)
}

@ -0,0 +1,47 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.ErrorDialogType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class UserSwitcherViewModel() : IpnViewModel() {
// Set to a non-null value to show the appropriate error dialog
val showDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
val loginSetting =
Setting(
title = ComposableStringFormatter(R.string.reauthenticate),
type = SettingType.NAV,
onClick = { login {} })
val logoutSetting =
Setting(
title = ComposableStringFormatter(R.string.log_out),
destructive = true,
type = SettingType.TEXT,
onClick = {
logout {
if (it.isFailure) {
showDialog.set(ErrorDialogType.LOGOUT_FAILED)
}
}
})
val addProfileSetting =
Setting(
title = ComposableStringFormatter(R.string.add_account),
type = SettingType.NAV,
onClick = {
addProfile {
if (it.isFailure) {
showDialog.set(ErrorDialogType.ADD_PROFILE_FAILED)
}
}
})
}

@ -13,6 +13,7 @@
<string name="template">%s</string>
<string name="more">More</string>
<string name="offline">offline</string>
<string name="ok">OK</string>
<!-- Strings for the about screen -->
<string name="app_name">Tailscale</string>
@ -73,6 +74,14 @@
<string name="in_x_days">in %d days</string>
<string name="in_x_months">in %d months</string>
<string name="in_x_years">in %.1f years</string>
<string name="user_switcher">Accounts</string>
<string name="logout_failed">Unable to logout at this time. Please try again.</string>
<string name="error">Error</string>
<string name="accounts">Accounts</string>
<string name="add_account">Add Another Account…</string>
<string name="reauthenticate">Reauthenticate</string>
<string name="switch_user_failed">Unable to switch users. Please try again.</string>
<string name="add_profile_failed">Unable to add a new profile. Please try again.</string>
<!-- Strings for ExitNode picker -->
<string name="choose_exit_node">Choose Exit Node</string>

Loading…
Cancel
Save