From e4b0e1f8cd5770ff0185b6f298446e8b844a89ab Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Tue, 19 Mar 2024 09:49:41 -0400 Subject: [PATCH] android: implement fast user switching (#209) Updates tailscale/corp#18202 Updates ENG-2875 Fixes ENG-2863 Adds everything we need to do fast user switching and support multiple accounts. Some work here to make the settings rows and a few other composables common and reusable. Correct the focus and clear behavior on the search bar and corrected the connected in state of SelfNode. Quick fix for requesting VPN permissions on newer Android phones. Signed-off-by: Jonathan Nobels --- android/build.gradle | 4 +- android/src/main/AndroidManifest.xml | 177 ++-- .../src/main/java/com/tailscale/ipn/App.java | 770 +++++++++--------- .../java/com/tailscale/ipn/IPNReceiver.java | 7 +- .../java/com/tailscale/ipn/MainActivity.kt | 79 +- .../com/tailscale/ipn/ui/localapi/Client.kt | 102 ++- .../com/tailscale/ipn/ui/notifier/Notifier.kt | 9 +- .../java/com/tailscale/ipn/ui/theme/Color.kt | 5 +- .../java/com/tailscale/ipn/ui/util/Styles.kt | 41 +- .../java/com/tailscale/ipn/ui/view/Avatar.kt | 26 +- .../tailscale/ipn/ui/view/BugReportView.kt | 17 +- .../ipn/ui/{util => view}/Buttons.kt | 28 +- .../com/tailscale/ipn/ui/view/ErrorDialog.kt | 60 ++ .../com/tailscale/ipn/ui/view/MainView.kt | 350 ++++---- .../com/tailscale/ipn/ui/view/SettingsView.kt | 169 ++-- .../tailscale/ipn/ui/view/UserSwitcherView.kt | 87 ++ .../com/tailscale/ipn/ui/view/UserView.kt | 67 ++ .../ipn/ui/viewModel/IpnViewModel.kt | 122 ++- .../ipn/ui/viewModel/MainViewModel.kt | 39 +- .../ipn/ui/viewModel/SettingsViewModel.kt | 132 +-- .../ipn/ui/viewModel/UserSwitcherViewModel.kt | 47 ++ android/src/main/res/values/strings.xml | 9 + 22 files changed, 1401 insertions(+), 946 deletions(-) rename android/src/main/java/com/tailscale/ipn/ui/{util => view}/Buttons.kt (51%) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt 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