From 7470fcc1730163fe115cf84ceff75cf6cbd74248 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Thu, 21 Mar 2024 11:03:56 -0700 Subject: [PATCH] android: disconnect (#228) * android: fix connect Kotlinize IPNService and App Call connect in IPNService Add observers for readiness to prepare VPN, and quick tile readiness Start Notifier in App, since new state flows need to be observed outside of activity lifecycle Next: fixing quick tiles Updates tailscale/corp#18202 Signed-off-by: kari-ts * android: disconnect Use localapi to disconnect Updates tailscale/corp#18202 Signed-off-by: kari-ts --------- Signed-off-by: kari-ts --- .../src/main/java/com/tailscale/ipn/App.java | 477 ----------------- .../src/main/java/com/tailscale/ipn/App.kt | 478 ++++++++++++++++++ .../java/com/tailscale/ipn/DnsConfig.java | 6 +- .../java/com/tailscale/ipn/IPNService.java | 137 ----- .../main/java/com/tailscale/ipn/IPNService.kt | 130 +++++ .../java/com/tailscale/ipn/MainActivity.kt | 6 + .../com/tailscale/ipn/StartVPNWorker.java | 2 +- .../java/com/tailscale/ipn/StopVPNWorker.java | 4 +- .../java/com/tailscale/ipn/mdm/MDMSettings.kt | 10 +- .../com/tailscale/ipn/ui/notifier/Notifier.kt | 11 + .../play/java/com/tailscale/ipn/Google.java | 47 +- libtailscale/tailscale.go | 4 - 12 files changed, 661 insertions(+), 651 deletions(-) delete mode 100644 android/src/main/java/com/tailscale/ipn/App.java create mode 100644 android/src/main/java/com/tailscale/ipn/App.kt delete mode 100644 android/src/main/java/com/tailscale/ipn/IPNService.java create mode 100644 android/src/main/java/com/tailscale/ipn/IPNService.kt diff --git a/android/src/main/java/com/tailscale/ipn/App.java b/android/src/main/java/com/tailscale/ipn/App.java deleted file mode 100644 index 81021d9..0000000 --- a/android/src/main/java/com/tailscale/ipn/App.java +++ /dev/null @@ -1,477 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package com.tailscale.ipn; - -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.ContentResolver; -import android.content.ContentValues; -import android.content.Context; -import android.content.Intent; -import android.content.RestrictionsManager; -import android.content.SharedPreferences; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.content.pm.Signature; -import android.content.res.Configuration; -import android.net.ConnectivityManager; -import android.net.LinkProperties; -import android.net.Network; -import android.net.Uri; -import android.net.VpnService; -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 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 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 com.tailscale.ipn.ui.localapi.Request; -import com.tailscale.ipn.ui.notifier.Notifier; - -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.Collections; -import java.util.List; -import java.util.Objects; - -import libtailscale.Libtailscale; - -public class App extends Application implements libtailscale.AppContext { - static final String STATUS_CHANNEL_ID = "tailscale-status"; - static final int STATUS_NOTIFICATION_ID = 1; - static final String NOTIFY_CHANNEL_ID = "tailscale-notify"; - static final int NOTIFY_NOTIFICATION_ID = 2; - private static final String PEER_TAG = "peer"; - private static final String FILE_CHANNEL_ID = "tailscale-files"; - private static final int FILE_NOTIFICATION_ID = 3; - - private static final Handler mainHandler = new Handler(Looper.getMainLooper()); - static App _application; - public DnsConfig dns = new DnsConfig(); - public boolean autoConnect = false; - public boolean vpnReady = false; - private ConnectivityManager connectivityManager; - - public static App getApplication() { - // TODO: this should be injected to MDMSettings by grabbing it from the activity's context - // rather than being a static singleton. - return _application; - } - - private libtailscale.Application app; - - public libtailscale.Application getTailscaleApp() { - return app; - } - - private static boolean isEmpty(String str) { - return str == null || str.length() == 0; - } - - static void startActivityForResult(Activity act, Intent intent, int request) { - Fragment f = act.getFragmentManager().findFragmentByTag(PEER_TAG); - f.startActivityForResult(intent, request); - } - - public DnsConfig getDnsConfigObj() { - return this.dns; - } - - @Override - public String getPlatformDNSConfig() { - return dns.getDnsConfigAsString(); - } - - @Override - public boolean isPlayVersion() { - return MaybeGoogle.isGoogle(); - } - - @Override - public void log(String s, String s1) { - Log.d(s, s1); - } - - @Override - public void onCreate() { - super.onCreate(); - - String dataDir = this.getFilesDir().getAbsolutePath(); - app = Libtailscale.start(dataDir, this); - Request.setApp(app); - Notifier.setApp(app); - - 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()); - Libtailscale.onDnsConfigChanged(); - } - - @Override - public void onLost(Network network) { - super.onLost(network); - Libtailscale.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 - ); - } - - 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(); - } - - public 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; - } - - public 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; - } - - // 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(); - } - }); - } - - public 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) { - Libtailscale.onVPNPrepared(); - } else { - startActivityForResult(act, intent, reqCode); - } - } - }); - } - - 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); - } - - // 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; - public String getInterfacesAsString() { - List interfaces; - try { - interfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); - } catch (Exception e) { - return ""; - } - - 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"); - } - - 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); - } - - 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. - // Check the enum cases for these two before looking for a StringSetting. - try { - AlwaysNeverUserDecidesSetting anuSetting = AlwaysNeverUserDecidesSetting.valueOf(key); - return mdmSettings.get(anuSetting).getValue(); - } catch (IllegalArgumentException eanu) { // AlwaysNeverUserDecidesSetting does not exist - try { - ShowHideSetting showHideSetting = ShowHideSetting.valueOf(key); - return mdmSettings.get(showHideSetting).getValue(); - } catch (IllegalArgumentException esh) { - try { - StringSetting stringSetting = StringSetting.valueOf(key); - 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."); - return ""; - } - } - } - } -} diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt new file mode 100644 index 0000000..ce04a46 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -0,0 +1,478 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package com.tailscale.ipn + +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.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.content.RestrictionsManager +import android.content.SharedPreferences +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.Uri +import android.net.VpnService +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 androidx.browser.customtabs.CustomTabsIntent +import androidx.core.app.ActivityCompat +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 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 com.tailscale.ipn.ui.localapi.Client +import com.tailscale.ipn.ui.localapi.Request +import com.tailscale.ipn.ui.model.Ipn +import com.tailscale.ipn.ui.notifier.Notifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import libtailscale.Libtailscale +import java.io.File +import java.io.IOException +import java.net.InetAddress +import java.net.NetworkInterface +import java.security.GeneralSecurityException +import java.util.Locale +import java.util.Objects + +class App : Application(), libtailscale.AppContext { + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + var dnsConfigObj = DnsConfig() + + companion object { + const val STATUS_CHANNEL_ID = "tailscale-status" + const val STATUS_NOTIFICATION_ID = 1 + const val NOTIFY_CHANNEL_ID = "tailscale-notify" + const val NOTIFY_NOTIFICATION_ID = 2 + private const val PEER_TAG = "peer" + private const val FILE_CHANNEL_ID = "tailscale-files" + private const val FILE_NOTIFICATION_ID = 3 + private const val TAG = "App" + private val mainHandler = Handler(Looper.getMainLooper()) + lateinit var appInstance: App + + private fun isEmpty(str: String?) = str.isNullOrEmpty() + + @JvmStatic + fun startActivityForResult(act: Activity, intent: Intent?, request: Int) { + val f: Fragment = act.getFragmentManager().findFragmentByTag(PEER_TAG) + f.startActivityForResult(intent, request) + } + + @JvmStatic + fun getApplication(): App { + return appInstance + } + } + + val dns = DnsConfig() + var autoConnect = false + var vpnReady = false + private lateinit var connectivityManager: ConnectivityManager + private lateinit var app: libtailscale.Application + + override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString + + override fun isPlayVersion(): Boolean = MaybeGoogle.isGoogle() + + override fun log(s: String, s1: String) { + Log.d(s, s1) + } + + override fun onCreate() { + super.onCreate() + val dataDir = this.filesDir.absolutePath + app = Libtailscale.start(dataDir, this) + Request.setApp(app) + Notifier.setApp(app) + connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + 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) + appInstance = this + applicationScope.launch { + Notifier.tileReady.collect { isTileReady -> setTileReady(isTileReady) } + } + } + + override fun onTerminate() { + super.onTerminate() + Notifier.stop() + applicationScope.cancel() + } + + fun setWantRunning(wantRunning: Boolean) { + val callback: (Result) -> Unit = { result -> + result.exceptionOrNull()?.let { error -> + Log.e(TAG, "Set want running: failed to update preferences: ${error.message}") + } + } + Client(applicationScope) + .editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback) + } + + // 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 fun setAndRegisterNetworkCallbacks() { + connectivityManager.requestNetwork( + dnsConfigObj.dnsConfigNetworkRequest, + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + val sb = StringBuilder() + val linkProperties: LinkProperties? = connectivityManager.getLinkProperties(network) + val dnsList: MutableList = + linkProperties?.getDnsServers() ?: mutableListOf() + for (ip in dnsList) { + sb.append(ip.getHostAddress()).append(" ") + } + val searchDomains: String? = linkProperties?.getDomains() + if (searchDomains != null) { + sb.append("\n") + sb.append(searchDomains) + } + dnsConfigObj.updateDNSFromNetwork(sb.toString()) + Libtailscale.onDnsConfigChanged() + } + + override fun onLost(network: Network) { + super.onLost(network) + Libtailscale.onDnsConfigChanged() + } + }) + } + + fun startVPN() { + val intent = Intent(this, IPNService::class.java) + intent.setAction(IPNService.ACTION_REQUEST_VPN) + startService(intent) + } + + fun stopVPN() { + val intent = Intent(this, IPNService::class.java) + 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. + @Throws(IOException::class, GeneralSecurityException::class) + override fun encryptToPref(prefKey: String?, plaintext: String?) { + getEncryptedPrefs().edit().putString(prefKey, plaintext).commit() + } + + // decryptFromPref decrypts a encrypted preference using the Jetpack Security + // library and returns the plaintext. + @Throws(IOException::class, GeneralSecurityException::class) + override fun decryptFromPref(prefKey: String?): String? { + return getEncryptedPrefs().getString(prefKey, null) + } + + @Throws(IOException::class, GeneralSecurityException::class) + fun getEncryptedPrefs(): SharedPreferences { + val key = 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) + } + + fun setTileReady(ready: Boolean) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return + } + QuickToggleService.setReady(this, ready) + Log.d("App", "Set Tile Ready: $ready $autoConnect") + vpnReady = ready + if (ready && autoConnect) { + startVPN() + } + } + + fun setTileStatus(status: Boolean) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return + } + QuickToggleService.setStatus(this, status) + } + + fun getHostname(): String { + val userConfiguredDeviceName = getUserConfiguredDeviceName() + if (!userConfiguredDeviceName.isNullOrEmpty()) return userConfiguredDeviceName + + return modelName + } + + override fun getModelName(): String { + val manu = Build.MANUFACTURER + var model = Build.MODEL + // Strip manufacturer from model. + val idx = model.toLowerCase(Locale.getDefault()).indexOf(manu.toLowerCase(Locale.getDefault())) + if (idx != -1) { + model = model.substring(idx + manu.length).trim() + } + return "$manu $model" + } + + override fun getOSVersion(): String = Build.VERSION.RELEASE + + // get user defined nickname from Settings + // returns null if not available + private fun getUserConfiguredDeviceName(): String? { + val nameFromSystemDevice = Settings.Secure.getString(contentResolver, "device_name") + if (!nameFromSystemDevice.isNullOrEmpty()) return nameFromSystemDevice + return null + } + + // attachPeer adds a Peer fragment for tracking the Activity + // lifecycle. + fun attachPeer(act: Activity) { + act.runOnUiThread( + Runnable { + val ft: FragmentTransaction = act.getFragmentManager().beginTransaction() + ft.add(Peer(), PEER_TAG) + ft.commit() + act.getFragmentManager().executePendingTransactions() + }) + } + + override fun isChromeOS(): Boolean { + return packageManager.hasSystemFeature("android.hardware.type.pc") + } + + fun prepareVPN(act: Activity, reqCode: Int) { + act.runOnUiThread( + Runnable { + val intent: Intent? = VpnService.prepare(act) + if (intent == null) { + Libtailscale.onVPNPrepared() + } else { + startActivityForResult(act, intent, reqCode) + } + }) + } + + fun showURL(act: Activity, url: String?) { + act.runOnUiThread( + Runnable { + val builder: CustomTabsIntent.Builder = CustomTabsIntent.Builder() + val headerColor = -0xb69b6b + builder.setToolbarColor(headerColor) + val intent: CustomTabsIntent = builder.build() + intent.launchUrl(act, Uri.parse(url)) + }) + } + + @get:Throws(Exception::class) + val packageCertificate: ByteArray? + // getPackageSignatureFingerprint returns the first package signing certificate, if any. + get() { + val info: PackageInfo + info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES) + for (signature in info.signatures) { + return signature.toByteArray() + } + return null + } + + fun requestWriteStoragePermission(act: Activity) { + 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( + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), IPNActivity.WRITE_STORAGE_RESULT) + } + + @Throws(IOException::class) + fun insertMedia(name: String?, mimeType: String): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver: ContentResolver = getContentResolver() + val contentValues = ContentValues() + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name) + if ("" != mimeType) { + contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + } + val root: Uri = MediaStore.Files.getContentUri("external") + resolver.insert(root, contentValues).toString() + } else { + val dir: File = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + dir.mkdirs() + val f = File(dir, name) + Uri.fromFile(f).toString() + } + } + + @Throws(IOException::class) + fun openUri(uri: String?, mode: String?): Int? { + val resolver: ContentResolver = getContentResolver() + return mode?.let { resolver.openFileDescriptor(Uri.parse(uri), it)?.detachFd() } + } + + fun deleteUri(uri: String?) { + val resolver: ContentResolver = getContentResolver() + resolver.delete(Uri.parse(uri), null, null) + } + + fun notifyFile(uri: String?, msg: String?) { + val viewIntent: Intent + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + viewIntent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)) + } else { + // uri is a file:// which is not allowed to be shared outside the app. + viewIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS) + } + val pending: PendingIntent = + PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT) + val builder: NotificationCompat.Builder = + 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) + val nm: NotificationManagerCompat = NotificationManagerCompat.from(this) + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED) { + // TODO: Consider calling + // ActivityCompat#requestPermissions + // here to request the missing permissions, and then overriding + // public void onRequestPermissionsResult(int requestCode, String[] permissions, + // int[] grantResults) + // to handle the case where the user grants the permission. See the documentation + // for ActivityCompat#requestPermissions for more details. + return + } + nm.notify(FILE_NOTIFICATION_ID, builder.build()) + } + + fun createNotificationChannel(id: String?, name: String?, importance: Int) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val channel = NotificationChannel(id, name, importance) + val nm: NotificationManagerCompat = NotificationManagerCompat.from(this) + nm.createNotificationChannel(channel) + } + + override fun getInterfacesAsString(): String { + val interfaces: ArrayList = + java.util.Collections.list(NetworkInterface.getNetworkInterfaces()) + + val sb = StringBuilder() + for (nif in interfaces) { + try { + sb.append( + String.format( + Locale.ROOT, + "%s %d %d %b %b %b %b %b |", + nif.name, + nif.index, + nif.mtu, + nif.isUp, + nif.supportsMulticast(), + nif.isLoopback, + nif.isPointToPoint, + nif.supportsMulticast())) + + for (ia in nif.interfaceAddresses) { + val parts = ia.toString().split("/", limit = 0) + if (parts.size > 1) { + sb.append(String.format(Locale.ROOT, "%s/%d ", parts[1], ia.networkPrefixLength)) + } + } + } catch (e: Exception) { + continue + } + sb.append("\n") + } + + return sb.toString() + } + + fun isTV(): Boolean { + val mm = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + return mm.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION + } + + /* + The following methods are called by the syspolicy handler from Go via JNI. + */ + fun getSyspolicyBooleanValue(key: String?): Boolean? { + val manager: RestrictionsManager = + this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager + val mdmSettings = MDMSettings(manager) + val setting: BooleanSetting? = key?.let { BooleanSetting.valueOf(it) } + return setting?.let { mdmSettings.get(it) } + } + + fun getSyspolicyStringValue(key: String): String { + val manager: RestrictionsManager = + this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager + val mdmSettings = MDMSettings(manager) + + // Before looking for a StringSetting matching the given key, Go could also be + // asking us for either a AlwaysNeverUserDecidesSetting or a ShowHideSetting. + // Check the enum cases for these two before looking for a StringSetting. + return try { + val anuSetting: AlwaysNeverUserDecidesSetting = AlwaysNeverUserDecidesSetting.valueOf(key) + mdmSettings.get(anuSetting).value + } catch (eanu: IllegalArgumentException) { // AlwaysNeverUserDecidesSetting does not exist + try { + val showHideSetting: ShowHideSetting = ShowHideSetting.valueOf(key) + mdmSettings.get(showHideSetting).value + } catch (esh: IllegalArgumentException) { + try { + val stringSetting: StringSetting = StringSetting.valueOf(key) + val value: String? = mdmSettings.get(stringSetting) + Objects.requireNonNullElse(value, "") + } catch (estr: IllegalArgumentException) { + Log.d("MDM", "$key is not defined on Android. Returning empty.") + "" + } + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/DnsConfig.java b/android/src/main/java/com/tailscale/ipn/DnsConfig.java index 3460739..ff1bb7b 100644 --- a/android/src/main/java/com/tailscale/ipn/DnsConfig.java +++ b/android/src/main/java/com/tailscale/ipn/DnsConfig.java @@ -31,7 +31,11 @@ public class DnsConfig { // // an empty string means the current DNS configuration could not be retrieved. String getDnsConfigAsString() { - return getDnsConfigs().trim(); + String dnsConfig = getDnsConfigs(); + if (dnsConfig != null) { + return getDnsConfigs().trim(); + } + return ""; } private String getDnsConfigs() { diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.java b/android/src/main/java/com/tailscale/ipn/IPNService.java deleted file mode 100644 index 2678a77..0000000 --- a/android/src/main/java/com/tailscale/ipn/IPNService.java +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package com.tailscale.ipn; - -import android.app.PendingIntent; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.VpnService; -import android.os.Build; -import android.system.OsConstants; - -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; - -import java.util.UUID; - -import libtailscale.Libtailscale; - -public class IPNService extends VpnService implements libtailscale.IPNService { - public static final String ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN"; - public static final String ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"; - - private final String randomID = UUID.randomUUID().toString(); - - @Override - public String id() { - return randomID; - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent != null && ACTION_STOP_VPN.equals(intent.getAction())) { - ((App) getApplicationContext()).autoConnect = false; - close(); - return START_NOT_STICKY; - } - if (intent != null && "android.net.VpnService".equals(intent.getAction())) { - // Start VPN and connect to it due to Always-on VPN - Intent i = new Intent(IPNReceiver.INTENT_CONNECT_VPN); - i.setPackage(getPackageName()); - i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class); - sendBroadcast(i); - Libtailscale.requestVPN(this); - return START_STICKY; - } - Libtailscale.requestVPN(this); - App app = ((App) getApplicationContext()); - return START_STICKY; - } - - private void close() { - stopForeground(true); - Libtailscale.serviceDisconnect(this); - } - - @Override - public void onDestroy() { - close(); - super.onDestroy(); - } - - @Override - public void onRevoke() { - close(); - super.onRevoke(); - } - - private PendingIntent configIntent() { - return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class), - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - } - - private void disallowApp(VpnService.Builder b, String name) { - try { - b.addDisallowedApplication(name); - } catch (PackageManager.NameNotFoundException e) { - } - } - - public libtailscale.VPNServiceBuilder newBuilder() { - VpnService.Builder b = new VpnService.Builder() - .setConfigureIntent(configIntent()) - .allowFamily(OsConstants.AF_INET) - .allowFamily(OsConstants.AF_INET6); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) - b.setMetered(false); // Inherit the metered status from the underlying networks. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) - b.setUnderlyingNetworks(null); // Use all available networks. - - // RCS/Jibe https://github.com/tailscale/tailscale/issues/2322 - this.disallowApp(b, "com.google.android.apps.messaging"); - - // Stadia https://github.com/tailscale/tailscale/issues/3460 - this.disallowApp(b, "com.google.stadia.android"); - - // Android Auto https://github.com/tailscale/tailscale/issues/3828 - this.disallowApp(b, "com.google.android.projection.gearhead"); - - // GoPro https://github.com/tailscale/tailscale/issues/2554 - this.disallowApp(b, "com.gopro.smarty"); - - // Sonos https://github.com/tailscale/tailscale/issues/2548 - this.disallowApp(b, "com.sonos.acr"); - this.disallowApp(b, "com.sonos.acr2"); - - // Google Chromecast https://github.com/tailscale/tailscale/issues/3636 - this.disallowApp(b, "com.google.android.apps.chromecast.app"); - - return new VPNServiceBuilder(b); - } - - public void notify(String title, String message) { - NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(title) - .setContentText(message) - .setContentIntent(configIntent()) - .setAutoCancel(true) - .setOnlyAlertOnce(true) - .setPriority(NotificationCompat.PRIORITY_DEFAULT); - - NotificationManagerCompat nm = NotificationManagerCompat.from(this); - nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build()); - } - - public void updateStatusNotification(String title, String message) { - NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(title) - .setContentText(message) - .setContentIntent(configIntent()) - .setPriority(NotificationCompat.PRIORITY_LOW); - - startForeground(App.STATUS_NOTIFICATION_ID, builder.build()); - } -} diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt new file mode 100644 index 0000000..1a33df3 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -0,0 +1,130 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package com.tailscale.ipn + +import android.app.PendingIntent +import android.content.Intent +import android.content.pm.PackageManager +import android.net.VpnService +import android.os.Build +import android.system.OsConstants +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import libtailscale.Libtailscale +import java.util.UUID + +open class IPNService : VpnService(), libtailscale.IPNService { + private val randomID: String = UUID.randomUUID().toString() + override fun id(): String { + return randomID + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if (intent != null && ACTION_STOP_VPN == intent.getAction()) { + (getApplicationContext() as App).autoConnect = false + close() + return START_NOT_STICKY + } + val app = getApplicationContext() as App + if (intent != null && "android.net.VpnService" == intent.getAction()) { + // Start VPN and connect to it due to Always-on VPN + val i = Intent(IPNReceiver.INTENT_CONNECT_VPN) + i.setPackage(getPackageName()) + i.setClass(getApplicationContext(), IPNReceiver::class.java) + sendBroadcast(i) + Libtailscale.requestVPN(this) + app.setWantRunning(true) + return START_STICKY + } + Libtailscale.requestVPN(this) + if (app.vpnReady && app.autoConnect) { + app.setWantRunning(true); + } + return START_STICKY + } + + + private fun close() { + stopForeground(true) + Libtailscale.serviceDisconnect(this) + } + + override fun onDestroy() { + close() + super.onDestroy() + } + + override fun onRevoke() { + close() + super.onRevoke() + } + + private fun configIntent(): PendingIntent { + return PendingIntent.getActivity(this, 0, Intent(this, IPNActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + private fun disallowApp(b: Builder, name: String) { + try { + b.addDisallowedApplication(name) + } catch (e: PackageManager.NameNotFoundException) { + } + } + + override fun newBuilder(): VPNServiceBuilder { + val b: Builder = Builder() + .setConfigureIntent(configIntent()) + .allowFamily(OsConstants.AF_INET) + .allowFamily(OsConstants.AF_INET6) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) b.setMetered(false) // Inherit the metered status from the underlying networks. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) b.setUnderlyingNetworks(null) // Use all available networks. + + // RCS/Jibe https://github.com/tailscale/tailscale/issues/2322 + disallowApp(b, "com.google.android.apps.messaging") + + // Stadia https://github.com/tailscale/tailscale/issues/3460 + disallowApp(b, "com.google.stadia.android") + + // Android Auto https://github.com/tailscale/tailscale/issues/3828 + disallowApp(b, "com.google.android.projection.gearhead") + + // GoPro https://github.com/tailscale/tailscale/issues/2554 + disallowApp(b, "com.gopro.smarty") + + // Sonos https://github.com/tailscale/tailscale/issues/2548 + disallowApp(b, "com.sonos.acr") + disallowApp(b, "com.sonos.acr2") + + // Google Chromecast https://github.com/tailscale/tailscale/issues/3636 + disallowApp(b, "com.google.android.apps.chromecast.app") + return VPNServiceBuilder(b) + } + + fun notify(title: String?, message: String?) { + val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(message) + .setContentIntent(configIntent()) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + val nm: NotificationManagerCompat = NotificationManagerCompat.from(this) + nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build()) + } + + fun updateStatusNotification(title: String?, message: String?) { + val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(message) + .setContentIntent(configIntent()) + .setPriority(NotificationCompat.PRIORITY_LOW) + startForeground(App.STATUS_NOTIFICATION_ID, builder.build()) + } + + companion object { + const val ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN" + const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN" + } +} diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index dac52aa..76fec34 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -43,6 +43,7 @@ import com.tailscale.ipn.ui.viewModel.SettingsNav import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import com.tailscale.ipn.App class MainActivity : ComponentActivity() { private var notifierScope: CoroutineScope? = null @@ -104,6 +105,11 @@ class MainActivity : ComponentActivity() { } } } + lifecycleScope.launch { + Notifier.readyToPrepareVPN.collect { isReady -> + if (isReady) App.getApplication().prepareVPN(this@MainActivity, -1) + } + } } init { diff --git a/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java index a4514a9..4179151 100644 --- a/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java +++ b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java @@ -27,7 +27,7 @@ public final class StartVPNWorker extends Worker { App app = ((App) getApplicationContext()); // We will start the VPN from the background - app.autoConnect = true; + app.setAutoConnect(true); // We need to make sure we prepare the VPN Service, just in case it isn't prepared. Intent intent = VpnService.prepare(app); diff --git a/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java b/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java index ebe270a..5d1913a 100644 --- a/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java +++ b/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java @@ -18,9 +18,7 @@ public final class StopVPNWorker extends Worker { @Override public Result doWork() { - disconnect(); + App.getApplication().setWantRunning(false); return Result.success(); } - - private native void disconnect(); } diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt index 543b483..440581e 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt @@ -13,18 +13,18 @@ class MDMSettings(private val restrictionsManager: RestrictionsManager? = null) return it.applicationRestrictions.getBoolean(setting.key) } } - return App.getApplication().encryptedPrefs.getBoolean(setting.key, false) + return App.getApplication().getEncryptedPrefs().getBoolean(setting.key, false) } fun get(setting: StringSetting): String? { return restrictionsManager?.applicationRestrictions?.getString(setting.key) - ?: App.getApplication().encryptedPrefs.getString(setting.key, null) + ?: App.getApplication().getEncryptedPrefs().getString(setting.key, null) } fun get(setting: AlwaysNeverUserDecidesSetting): AlwaysNeverUserDecidesValue { val storedString: String = restrictionsManager?.applicationRestrictions?.getString(setting.key) - ?: App.getApplication().encryptedPrefs.getString(setting.key, null) + ?: App.getApplication().getEncryptedPrefs().getString(setting.key, null) ?: "user-decides" return when (storedString) { "always" -> { @@ -42,7 +42,7 @@ class MDMSettings(private val restrictionsManager: RestrictionsManager? = null) fun get(setting: ShowHideSetting): ShowHideValue { val storedString: String = restrictionsManager?.applicationRestrictions?.getString(setting.key) - ?: App.getApplication().encryptedPrefs.getString(setting.key, null) + ?: App.getApplication().getEncryptedPrefs().getString(setting.key, null) ?: "show" return when (storedString) { "hide" -> { @@ -61,7 +61,7 @@ class MDMSettings(private val restrictionsManager: RestrictionsManager? = null) } } return App.getApplication() - .encryptedPrefs + .getEncryptedPrefs() .getStringSet(setting.key, HashSet()) ?.toTypedArray() ?.sortedArray() 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 d8021fd..9b8babb 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 @@ -38,6 +38,8 @@ object Notifier { val loginFinished: StateFlow = MutableStateFlow(null) val version: StateFlow = MutableStateFlow(null) val vpnPermissionGranted: StateFlow = MutableStateFlow(null) + val tileReady: StateFlow = MutableStateFlow(false) + val readyToPrepareVPN: StateFlow = MutableStateFlow(false) private lateinit var app: libtailscale.Application private var manager: libtailscale.NotificationManager? = null @@ -67,6 +69,15 @@ object Notifier { notify.LoginFinished?.let { loginFinished.set(it.property) } notify.Version?.let(version::set) } + var previousState: Ipn.State? = null + state.collect { currstate -> + readyToPrepareVPN.set( + previousState != null && + previousState!! <= Ipn.State.Stopped && + currstate > Ipn.State.Stopped) + tileReady.set(currstate >= Ipn.State.Stopped) + previousState = currstate + } Log.d(TAG, "Stopped") } } diff --git a/android/src/play/java/com/tailscale/ipn/Google.java b/android/src/play/java/com/tailscale/ipn/Google.java index 44a4787..86fd893 100644 --- a/android/src/play/java/com/tailscale/ipn/Google.java +++ b/android/src/play/java/com/tailscale/ipn/Google.java @@ -14,29 +14,30 @@ import com.google.android.gms.auth.api.signin.GoogleSignInOptions; // Google implements helpers for Google services. public final class Google { - static String getIdTokenForActivity(Activity act) { - GoogleSignInAccount acc = GoogleSignIn.getLastSignedInAccount(act); - return acc.getIdToken(); - } + static String getIdTokenForActivity(Activity act) { + GoogleSignInAccount acc = GoogleSignIn.getLastSignedInAccount(act); + return acc.getIdToken(); + } - static void googleSignIn(Activity act, String serverOAuthID, int reqCode) { - act.runOnUiThread(new Runnable() { - @Override public void run() { - GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) - .requestIdToken(serverOAuthID) - .requestEmail() - .build(); - GoogleSignInClient client = GoogleSignIn.getClient(act, gso); - Intent signInIntent = client.getSignInIntent(); - App.startActivityForResult(act, signInIntent, reqCode); - } - }); - } + static void googleSignIn(Activity act, String serverOAuthID, int reqCode) { + act.runOnUiThread(new Runnable() { + @Override + public void run() { + GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken(serverOAuthID) + .requestEmail() + .build(); + GoogleSignInClient client = GoogleSignIn.getClient(act, gso); + Intent signInIntent = client.getSignInIntent(); + App.startActivityForResult(act, signInIntent, reqCode); + } + }); + } - static void googleSignOut(Context ctx) { - GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) - .build(); - GoogleSignInClient client = GoogleSignIn.getClient(ctx, gso); - client.signOut(); - } + static void googleSignOut(Context ctx) { + GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .build(); + GoogleSignInClient client = GoogleSignIn.getClient(ctx, gso); + client.signOut(); + } } diff --git a/libtailscale/tailscale.go b/libtailscale/tailscale.go index 4d5d14f..37b33a4 100644 --- a/libtailscale/tailscale.go +++ b/libtailscale/tailscale.go @@ -30,10 +30,6 @@ const ( customLoginServerPrefKey = "customloginserver" ) -type ConnectEvent struct { - Enable bool -} - func newApp(dataDir string, appCtx AppContext) Application { a := &App{ dataDir: dataDir,