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,