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