diff --git a/android/build.gradle b/android/build.gradle
index be6cd06..e88214d 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -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'
}
-
-
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index 6c00544..5d7adea 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -1,85 +1,104 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/java/com/tailscale/ipn/App.java b/android/src/main/java/com/tailscale/ipn/App.java
index 7afb8b9..95eaf2f 100644
--- a/android/src/main/java/com/tailscale/ipn/App.java
+++ b/android/src/main/java/com/tailscale/ipn/App.java
@@ -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 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 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 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 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 "";
}
}
}
- }
+ }
}
diff --git a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java
index a299500..37c91eb 100644
--- a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java
+++ b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java
@@ -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());
}
}
diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt
index 52a7c56..3334457 100644
--- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt
+++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt
@@ -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() {
+ 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
+ }
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt
index 8800e7d..0ba9fbe 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt
@@ -66,7 +66,7 @@ class Client(private val scope: CoroutineScope) {
}
fun editPrefs(
- prefs: Ipn.MaskedPrefs, responseHandler: (Result) -> Unit
+ prefs: Ipn.MaskedPrefs, responseHandler: (Result) -> 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) -> Unit = {}) {
+ return put(Endpoint.PROFILES, responseHandler = responseHandler)
+ }
+
+ fun deleteProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result) -> Unit = {}) {
+ return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
+ }
+
+ fun switchProfile(profile: IpnLocal.LoginProfile, responseHandler: (Result) -> Unit = {}) {
+ return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
+ }
+
fun startLoginInteractive(responseHandler: (Result) -> Unit) {
return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler)
}
@@ -89,77 +101,77 @@ class Client(private val scope: CoroutineScope) {
}
private inline fun get(
- path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit
+ path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit
) {
Request(
- scope = scope,
- method = "GET",
- path = path,
- body = body,
- responseType = typeOf(),
- responseHandler = responseHandler
+ scope = scope,
+ method = "GET",
+ path = path,
+ body = body,
+ responseType = typeOf(),
+ responseHandler = responseHandler
).execute()
}
private inline fun put(
- path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit
+ path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit
) {
Request(
- scope = scope,
- method = "PUT",
- path = path,
- body = body,
- responseType = typeOf(),
- responseHandler = responseHandler
+ scope = scope,
+ method = "PUT",
+ path = path,
+ body = body,
+ responseType = typeOf(),
+ responseHandler = responseHandler
).execute()
}
private inline fun post(
- path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit
+ path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit
) {
Request(
- scope = scope,
- method = "POST",
- path = path,
- body = body,
- responseType = typeOf(),
- responseHandler = responseHandler
+ scope = scope,
+ method = "POST",
+ path = path,
+ body = body,
+ responseType = typeOf(),
+ responseHandler = responseHandler
).execute()
}
private inline fun patch(
- path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit
+ path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit
) {
Request(
- scope = scope,
- method = "PATCH",
- path = path,
- body = body,
- responseType = typeOf(),
- responseHandler = responseHandler
+ scope = scope,
+ method = "PATCH",
+ path = path,
+ body = body,
+ responseType = typeOf(),
+ responseHandler = responseHandler
).execute()
}
private inline fun delete(
- path: String, noinline responseHandler: (Result) -> Unit
+ path: String, noinline responseHandler: (Result) -> Unit
) {
Request(
- scope = scope,
- method = "DELETE",
- path = path,
- responseType = typeOf(),
- responseHandler = responseHandler
+ scope = scope,
+ method = "DELETE",
+ path = path,
+ responseType = typeOf(),
+ responseHandler = responseHandler
).execute()
}
}
class Request(
- private val scope: CoroutineScope,
- private val method: String,
- path: String,
- private val body: ByteArray? = null,
- private val responseType: KType,
- private val responseHandler: (Result) -> Unit
+ private val scope: CoroutineScope,
+ private val method: String,
+ path: String,
+ private val body: ByteArray? = null,
+ private val responseType: KType,
+ private val responseHandler: (Result) -> Unit
) {
private val fullPath = "/localapi/v0/$path"
@@ -206,15 +218,15 @@ class Request(
typeOf() -> 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(respData.inputStream())
+ jsonDecoder.decodeFromStream(respData.inputStream())
throw Exception(error.error)
} catch (t: Throwable) {
Result.failure(t)
diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt
index b74cf64..391415d 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt
@@ -40,6 +40,11 @@ object Notifier {
val loginFinished: StateFlow = MutableStateFlow(null)
val version: StateFlow = MutableStateFlow(null)
+ // Indicates whether or not we have granted permission to use the VPN. This must be
+ // explicitly set by the main activity. null indicates that we have not yet
+ // checked.
+ val vpnPermissionGranted: StateFlow = MutableStateFlow(null)
+
// 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
)
}
}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt b/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt
index 6f240a0..e9d5da7 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt
@@ -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)
\ No newline at end of file
+var ts_color_dark_green = Color(0xFF33C27F)
diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt b/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt
index 64db621..f559493 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt
@@ -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)
-}
\ No newline at end of file
+}
+
+@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,
+ )
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt
index d018195..576d423 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt
@@ -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)
}
}
-}
\ No newline at end of file
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt
index 487c00c..2f917a3 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt
@@ -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
}
-
diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Buttons.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt
similarity index 51%
rename from android/src/main/java/com/tailscale/ipn/ui/util/Buttons.kt
rename to android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt
index 95bb574..ec5c00b 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/util/Buttons.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt
@@ -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
)
}
\ No newline at end of file
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt
new file mode 100644
index 0000000..d2c6e86
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt
@@ -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))
+ }
+ }
+ )
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
index fc2c481..3ebdb62 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
@@ -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, 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,
- peers: StateFlow>,
- state: StateFlow,
- selfPeer: StableNodeID,
- onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
- onSearch: (String) -> Unit
+ searchTerm: StateFlow,
+ peers: StateFlow>,
+ state: StateFlow,
+ selfPeer: StableNodeID,
+ onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
+ onSearch: (String) -> Unit
) {
val peerList = peers.collectAsState(initial = emptyList())
- 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)
- })
+ )
}
}
}
}
}
-}
\ No newline at end of file
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt
index 5a4298f..068ca62 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt
@@ -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()
+ })
+ }
}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt
new file mode 100644
index 0000000..1875491
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt
@@ -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(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)
+ }
+ }
+ }
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt
new file mode 100644
index 0000000..80c87b6
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt
@@ -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
+ }
+ }
+ }
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt
index 4290498..4168421 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt
@@ -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 = MutableStateFlow(null)
val loginProfiles: StateFlow?> = 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) -> 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) -> 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) -> Unit) {
+ Client(viewModelScope).switchProfile(profile) {
+ startVPN()
+ completionHandler(it)
+ }
+ }
+
+ fun addProfile(completionHandler: (Result) -> Unit) {
+ Client(viewModelScope).addProfile {
+ if (it.isSuccess) {
+ login {}
}
+ startVPN()
+ completionHandler(it)
+ }
+ }
+
+ fun deleteProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result) -> Unit) {
+ Client(viewModelScope).deleteProfile(profile) {
+ viewModelScope.launch { loadUserProfiles() }
+ completionHandler(it)
}
}
@@ -100,10 +164,12 @@ open class IpnViewModel : ViewModel() {
}
fun toggleCorpDNS(callback: (Result) -> 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) -> 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) -> 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
diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt
index 392e351..9110cec 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt
@@ -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 = MutableStateFlow("")
// The peerID of the local node
- val selfPeerId = Notifier.netmap.value?.SelfNode?.StableID ?: ""
+ val selfPeerId: StateFlow = 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 {
diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt
index afb1ef8..894507a 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt
@@ -19,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)
// 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 = MutableStateFlow(false),
- val value: StateFlow? = null,
- val isOn: StateFlow? = null,
- val onClick: () -> Unit = {},
- val onToggle: (Boolean) -> Unit = {}
+
+ val title: ComposableStringFormatter,
+ val type: SettingType,
+ val destructive: Boolean = false,
+ val enabled: StateFlow = MutableStateFlow(true),
+ val value: StateFlow? = null,
+ val isOn: StateFlow? = null,
+ val onClick: () -> Unit = {},
+ val onToggle: (Boolean) -> Unit = {}
) {
constructor(
- titleRes: Int,
- type: SettingType,
- enabled: StateFlow = MutableStateFlow(false),
- value: StateFlow? = null,
- isOn: StateFlow? = null,
- onClick: () -> Unit = {},
- onToggle: (Boolean) -> Unit = {}
+ titleRes: Int,
+ type: SettingType,
+ enabled: StateFlow = MutableStateFlow(false),
+ value: StateFlow? = null,
+ isOn: StateFlow? = null,
+ onClick: () -> Unit = {},
+ onToggle: (Boolean) -> Unit = {}
) : this(
- title = ComposableStringFormatter(titleRes),
- type = type,
- enabled = enabled,
- value = value,
- isOn = isOn,
- onClick = onClick,
- onToggle = onToggle
+ 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 create(modelClass: Class): 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 = 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> = 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 = 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
+ }
)
}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt
new file mode 100644
index 0000000..11662d7
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt
@@ -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 = 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)
+ }
+ }
+ })
+}
diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml
index e1c6d93..3fe5c6b 100644
--- a/android/src/main/res/values/strings.xml
+++ b/android/src/main/res/values/strings.xml
@@ -13,6 +13,7 @@
%s
More
offline
+ OK
Tailscale
@@ -73,6 +74,14 @@
in %d days
in %d months
in %.1f years
+ Accounts
+ Unable to logout at this time. Please try again.
+ Error
+ Accounts
+ Add Another Account…
+ Reauthenticate
+ Unable to switch users. Please try again.
+ Unable to add a new profile. Please try again.
Choose Exit Node