From f684bf696d6e90982306eb419f8e6af8d0dea01a Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Fri, 10 May 2024 14:05:09 -0700 Subject: [PATCH] android: fix quick settings tile status (#377) * android: fix quick settings tile https://github.com/tailscale/tailscale-android/pull/358 updated the Quick Settings tile to only depend on ipn state. This was only partially correct in the sense that we made changes to only check for whether the state was > stopped and not whether Tailscale was on. This checks for two states, whether Tailscale is on, and whether the tile is ready to be used. The former requires ipn state to be >= Starting, and the latter checks whether ipn state is > RequiresMachineAuth. Tile readiness determines whether an intent is to open MainActivity or whether an intent to connect/disconnect VPN is sent. Whether Tailscale is on or off determines whether the tile status is active or not. We lazily initialize App to avoid starting Tailscale when unnecessary - for example, when viewing the QuickSettings tile, there's no need to start Tailscale's backend. We also persistently store a flag indicating whether VPN can be started by quick settings tile: this allows us to start the VPN from the quick settings tile even when the application was previously stopped. Updates tailscale/tailscale#11920 Co-authored-by: kari-ts Co-authored-by: Percy Wegmann * android: simplify IPNService lifecycle Reserves use of IPNReceiver only for external requests to start the VPN. Updates tailscale/corp#19860 Signed-off-by: Percy Wegmann * Revert "android: temporarily remove quick settings tile" This reverts commit edb3f5b0c52ae5cb9cc2fa89310e395df5c08c6e. Signed-off-by: Percy Wegmann --------- Signed-off-by: Percy Wegmann Co-authored-by: Percy Wegmann --- android/src/main/AndroidManifest.xml | 14 +- .../src/main/java/com/tailscale/ipn/App.kt | 321 +++++++++--------- .../java/com/tailscale/ipn/IPNReceiver.java | 3 + .../main/java/com/tailscale/ipn/IPNService.kt | 65 ++-- .../java/com/tailscale/ipn/MainActivity.kt | 28 +- .../src/main/java/com/tailscale/ipn/Peer.java | 4 +- .../com/tailscale/ipn/QuickToggleService.java | 46 +-- .../com/tailscale/ipn/StartVPNWorker.java | 73 ++-- .../java/com/tailscale/ipn/StopVPNWorker.java | 7 +- .../ipn/mdm/MDMSettingsDefinitions.kt | 6 +- .../com/tailscale/ipn/ui/notifier/Notifier.kt | 8 - .../tailscale/ipn/ui/util/AndroidTVUtil.kt | 7 +- .../ipn/ui/viewModel/IpnViewModel.kt | 16 +- android/src/main/res/values/strings.xml | 3 + 14 files changed, 307 insertions(+), 294 deletions(-) diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index c25ef1c..19fe620 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -46,6 +46,9 @@ + + + - @@ -98,6 +100,16 @@ + + + + + updateConnStatus(connStatus) } + Notifier.state.collect { state -> + val ableToStartVPN = state > Ipn.State.NeedsMachineAuth + val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running + updateConnStatus(ableToStartVPN, vpnRunning) + QuickToggleService.setVPNRunning(vpnRunning) + } } } - override fun onTerminate() { - super.onTerminate() - Notifier.stop() - applicationScope.cancel() - } - fun setWantRunning(wantRunning: Boolean) { val callback: (Result) -> Unit = { result -> result.fold( @@ -162,7 +173,7 @@ class App : Application(), libtailscale.AppContext { } if (dns.updateDNSFromNetwork(sb.toString())) { - Libtailscale.onDNSConfigChanged(linkProperties?.getInterfaceName()) + Libtailscale.onDNSConfigChanged(linkProperties?.interfaceName) } } @@ -175,12 +186,6 @@ class App : Application(), libtailscale.AppContext { }) } - fun startVPN() { - val intent = Intent(this, IPNService::class.java) - intent.setAction(IPNService.ACTION_REQUEST_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) @@ -207,31 +212,29 @@ class App : Application(), libtailscale.AppContext { EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) } - fun updateConnStatus(ready: Boolean) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - return - } - QuickToggleService.setReady(this, ready) - Log.d("App", "Set Tile Ready: $ready") - val action = if (ready) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN - val intent = Intent(this, IPNReceiver::class.java).apply { this.action = action } + /* + * setAbleToStartVPN remembers whether or not we're able to start the VPN + * by storing this in a shared preference. This allows us to check this + * value without needing a fully initialized instance of the application. + */ + private fun updateConnStatus(ableToStartVPN: Boolean, vpnRunning: Boolean) { + setAbleToStartVPN(ableToStartVPN) + QuickToggleService.updateTile() + Log.d("App", "Set Tile Ready: $ableToStartVPN") + val action = if (ableToStartVPN) IPNService.ACTION_STOP_VPN else IPNService.ACTION_START_VPN + val intent = Intent(this, IPNService::class.java).apply { this.action = action } val pendingIntent: PendingIntent = PendingIntent.getBroadcast( this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - if (ready) { - startVPN() - } val notificationMessage = - if (ready) getString(R.string.connected) else getString(R.string.not_connected) + if (vpnRunning) getString(R.string.connected) else getString(R.string.not_connected) notify( - "Tailscale", notificationMessage, ready, STATUS_CHANNEL_ID, pendingIntent, STATUS_NOTIFICATION_ID) - } - - fun getHostname(): String { - val userConfiguredDeviceName = getUserConfiguredDeviceName() - if (!userConfiguredDeviceName.isNullOrEmpty()) return userConfiguredDeviceName - - return modelName + "Tailscale", + notificationMessage, + vpnRunning, + STATUS_CHANNEL_ID, + pendingIntent, + STATUS_NOTIFICATION_ID) } override fun getModelName(): String { @@ -247,127 +250,22 @@ class App : Application(), libtailscale.AppContext { 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.fragmentManager.beginTransaction() - ft.add(Peer(), PEER_TAG) - ft.commit() - act.fragmentManager.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) { - startVPN() - } 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 = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES) - for (signature in info.signatures) { - return signature.toByteArray() + // We do this with UI in case it's our first time starting the VPN. + act.runOnUiThread { + val prepareIntent = VpnService.prepare(this) + if (prepareIntent == null) { + // No intent here means that we already have permission to be a VPN. + startVPN() + } else { + // An intent here means that we need to prompt for permission to be a VPN. + startActivityForResult(act, prepareIntent, reqCode) } - return null - } - - @Throws(IOException::class) - fun openUri(uri: String?, mode: String?): Int? { - val resolver: ContentResolver = contentResolver - return mode?.let { resolver.openFileDescriptor(Uri.parse(uri), it)?.detachFd() } - } - - fun deleteUri(uri: String?) { - val resolver: ContentResolver = contentResolver - 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) - notify( - getString(R.string.file_notification), msg, true, FILE_CHANNEL_ID, pending, FILE_NOTIFICATION_ID) - } - - fun createNotificationChannel(id: String?, name: String?, importance: Int) { - val channel = NotificationChannel(id, name, importance) - val nm: NotificationManagerCompat = NotificationManagerCompat.from(this) - nm.createNotificationChannel(channel) - } - - fun notify( - title: String?, - message: String?, - usesEnabledIcon: Boolean, - channel: String, - intent: PendingIntent?, - notificationID: Int - ) { - val icon = if (usesEnabledIcon) R.drawable.ic_notification else R.drawable.ic_notification_disabled - val builder: NotificationCompat.Builder = - NotificationCompat.Builder(this, channel) - .setSmallIcon(icon) - .setContentTitle(title) - .setContentText(message) - .setContentIntent(intent) - .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(notificationID, builder.build()) } override fun getInterfacesAsString(): String { @@ -405,12 +303,7 @@ class App : Application(), libtailscale.AppContext { return sb.toString() } - fun isTV(): Boolean { - val mm = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager - return mm.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION - } - - fun prepareDownloadsFolder(): File { + private fun prepareDownloadsFolder(): File { var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) try { @@ -461,3 +354,93 @@ class App : Application(), libtailscale.AppContext { } } } + +/** + * UninitializedApp contains all of the methods of App that can be used without having to initialize + * the Go backend. This is useful when you want to access functions on the App without creating side + * effects from starting the Go backend (such as launching the VPN). + */ +open class UninitializedApp : Application() { + companion object { + // Key for shared preference that tracks whether or not we're able to start + // the VPN (i.e. we're logged in and machine is authorized). + private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN" + + // File for shared preferences that are not encrypted. + private const val UNENCRYPTED_PREFERENCES = "unencrypted" + + private lateinit var appInstance: UninitializedApp + + @JvmStatic + fun get(): UninitializedApp { + return appInstance + } + } + + protected fun setUnprotectedInstance(instance: UninitializedApp) { + appInstance = instance + } + + protected fun setAbleToStartVPN(rdy: Boolean) { + getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply() + } + + /** This function can be called without initializing the App. */ + fun isAbleToStartVPN(): Boolean { + return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false) + } + + private fun getUnencryptedPrefs(): SharedPreferences { + return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE) + } + + fun startVPN() { + val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN } + startService(intent) + } + + fun stopVPN() { + val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN } + startService(intent) + } + + fun createNotificationChannel(id: String?, name: String?, importance: Int) { + val channel = NotificationChannel(id, name, importance) + val nm: NotificationManagerCompat = NotificationManagerCompat.from(this) + nm.createNotificationChannel(channel) + } + + fun notify( + title: String?, + message: String?, + usesEnabledIcon: Boolean, + channel: String, + intent: PendingIntent?, + notificationID: Int + ) { + val icon = + if (usesEnabledIcon) R.drawable.ic_notification else R.drawable.ic_notification_disabled + val builder: NotificationCompat.Builder = + NotificationCompat.Builder(this, channel) + .setSmallIcon(icon) + .setContentTitle(title) + .setContentText(message) + .setContentIntent(intent) + .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(notificationID, builder.build()) + } +} diff --git a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java index 7838c43..af5c3f1 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java +++ b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java @@ -12,6 +12,9 @@ import androidx.work.WorkManager; import java.util.Objects; +/** + * IPNReceiver allows external applications to start the VPN. + */ public class IPNReceiver extends BroadcastReceiver { public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN"; diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index 4313bb1..f050334 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -8,8 +8,6 @@ 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 @@ -20,25 +18,48 @@ open class IPNService : VpnService(), libtailscale.IPNService { return randomID } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val app = applicationContext as App - if (intent != null && "android.net.VpnService" == intent.action) { - // Start VPN and connect to it due to Always-on VPN - val i = Intent(IPNReceiver.INTENT_CONNECT_VPN) - i.setPackage(packageName) - i.setClass(applicationContext, IPNReceiver::class.java) - sendBroadcast(i) - } - Libtailscale.requestVPN(this) - app.setWantRunning(true) - return START_STICKY + override fun onCreate() { + super.onCreate() + // grab app to make sure it initializes + App.get() } - override public fun close() { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = + when (intent?.action) { + ACTION_STOP_VPN -> { + App.get().setWantRunning(false) + close() + START_NOT_STICKY + } + ACTION_START_VPN -> { + App.get().setWantRunning(true) + Libtailscale.requestVPN(this) + START_STICKY + } + "android.net.VpnService" -> { + // This means we were started by Android due to Always On VPN. + // Get the application to make sure it's been initialized, then + // request the VPN. + App.get() + Libtailscale.requestVPN(this) + START_STICKY + } + else -> { + // This means that we were restarted after the service was killed + // (potentially due to OOM). + if (UninitializedApp.get().isAbleToStartVPN()) { + App.get() + Libtailscale.requestVPN(this) + START_STICKY + } else { + START_NOT_STICKY + } + } + } + + override fun close() { stopForeground(true) Libtailscale.serviceDisconnect(this) - val app = applicationContext as App - app.setWantRunning(false) } override fun onDestroy() { @@ -71,10 +92,10 @@ open class IPNService : VpnService(), libtailscale.IPNService { .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. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + b.setMetered(false) // Inherit the metered status from the underlying networks. + } + b.setUnderlyingNetworks(null) // Use all available networks. // RCS/Jibe https://github.com/tailscale/tailscale/issues/2322 disallowApp(b, "com.google.android.apps.messaging") @@ -98,7 +119,7 @@ open class IPNService : VpnService(), libtailscale.IPNService { } companion object { - const val ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN" + const val ACTION_START_VPN = "com.tailscale.ipn.START_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 4759197..6ad6da0 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -11,12 +11,10 @@ import android.content.RestrictionsManager import android.content.pm.ActivityInfo import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK -import android.net.Uri import android.net.VpnService import android.os.Bundle import android.provider.Settings import android.util.Log -import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher @@ -100,6 +98,9 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // grab app to make sure it initializes + App.get() + // (jonathan) TODO: Force the app to be portrait on small screens until we have // proper landscape layout support if (!isLandscapeCapable()) { @@ -231,9 +232,10 @@ class MainActivity : ComponentActivity() { } } lifecycleScope.launch { - Notifier.readyToPrepareVPN.collect { isReady -> - if (isReady) - App.getApplication().prepareVPN(this@MainActivity, RequestCodes.requestPrepareVPN) + Notifier.state.collect { state -> + if (state > Ipn.State.Stopped) { + App.get().prepareVPN(this@MainActivity, RequestCodes.requestPrepareVPN) + } } } } @@ -274,7 +276,7 @@ class MainActivity : ComponentActivity() { private fun login(urlString: String) { // Launch coroutine to listen for state changes. When the user completes login, relaunch // MainActivity to bring the app back to focus. - App.getApplication().applicationScope.launch { + App.get().applicationScope.launch { try { Notifier.state.collect { state -> if (state > Ipn.State.NeedsMachineAuth) { @@ -315,9 +317,7 @@ class MainActivity : ComponentActivity() { super.onResume() val restrictionsManager = this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager - lifecycleScope.launch(Dispatchers.IO) { - MDMSettings.update(App.getApplication(), restrictionsManager) - } + lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) } } override fun onStart() { @@ -332,9 +332,7 @@ class MainActivity : ComponentActivity() { super.onStop() val restrictionsManager = this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager - lifecycleScope.launch(Dispatchers.IO) { - MDMSettings.update(App.getApplication(), restrictionsManager) - } + lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) } } private fun requestVpnPermission() { @@ -351,9 +349,9 @@ class MainActivity : ComponentActivity() { private fun openApplicationSettings() { val intent = - Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, packageName) - } + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) } diff --git a/android/src/main/java/com/tailscale/ipn/Peer.java b/android/src/main/java/com/tailscale/ipn/Peer.java index 5f87a0e..8201cc0 100644 --- a/android/src/main/java/com/tailscale/ipn/Peer.java +++ b/android/src/main/java/com/tailscale/ipn/Peer.java @@ -18,9 +18,9 @@ public class Peer extends Fragment { public void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == RequestCodes.requestPrepareVPN) { if (resultCode == resultOK) { - App.getApplication().startVPN(); + UninitializedApp.get().startVPN(); } else { - App.getApplication().setWantRunning(false); + App.get().setWantRunning(false); // notify VPN revoked } } diff --git a/android/src/main/java/com/tailscale/ipn/QuickToggleService.java b/android/src/main/java/com/tailscale/ipn/QuickToggleService.java index addf1dd..7b8355f 100644 --- a/android/src/main/java/com/tailscale/ipn/QuickToggleService.java +++ b/android/src/main/java/com/tailscale/ipn/QuickToggleService.java @@ -4,7 +4,6 @@ package com.tailscale.ipn; import android.app.PendingIntent; -import android.content.Context; import android.content.Intent; import android.os.Build; import android.service.quicksettings.Tile; @@ -13,37 +12,37 @@ import android.service.quicksettings.TileService; public class QuickToggleService extends TileService { // lock protects the static fields below it. private static final Object lock = new Object(); - // Ready tracks whether the tailscale backend is - // ready to switch on/off. - private static boolean ready; + + // isRunning tracks whether the VPN is running. + private static boolean isRunning; + // currentTile tracks getQsTile while service is listening. private static Tile currentTile; - // Request code for opening activity. - private static int reqCode = 0; - private static void updateTile(Context ctx) { + public static void updateTile() { + var app = UninitializedApp.get(); Tile t; boolean act; synchronized (lock) { t = currentTile; - act = ready; + act = isRunning && app.isAbleToStartVPN(); } if (t == null) { return; } t.setLabel("Tailscale"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - t.setSubtitle(act ? ctx.getString(R.string.connected) : ctx.getString(R.string.not_connected)); + t.setSubtitle(act ? app.getString(R.string.connected) : app.getString(R.string.not_connected)); } t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE); t.updateTile(); } - static void setReady(Context ctx, boolean rdy) { + static void setVPNRunning(boolean running) { synchronized (lock) { - ready = rdy; + isRunning = running; } - updateTile(ctx); + updateTile(); } @Override @@ -51,7 +50,7 @@ public class QuickToggleService extends TileService { synchronized (lock) { currentTile = getQsTile(); } - updateTile(this.getApplicationContext()); + updateTile(); } @Override @@ -65,15 +64,18 @@ public class QuickToggleService extends TileService { public void onClick() { boolean r; synchronized (lock) { - r = ready; + r = UninitializedApp.get().isAbleToStartVPN(); } if (r) { + // Get the application to make sure it initializes + App.get(); onTileClick(); } else { // Start main activity. Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName()); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startActivityAndCollapse(PendingIntent.getActivity(this, reqCode, i, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)); + // Request code for opening activity. + startActivityAndCollapse(PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)); } else { startActivityAndCollapse(i); } @@ -81,13 +83,15 @@ public class QuickToggleService extends TileService { } private void onTileClick() { - boolean act; + UninitializedApp app = UninitializedApp.get(); + boolean needsToStop; synchronized (lock) { - act = ready; + needsToStop = app.isAbleToStartVPN() && isRunning; + } + if (needsToStop) { + app.stopVPN(); + } else { + app.startVPN(); } - Intent i = new Intent(act ? IPNReceiver.INTENT_DISCONNECT_VPN : IPNReceiver.INTENT_CONNECT_VPN); - i.setPackage(getPackageName()); - i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class); - sendBroadcast(i); } } diff --git a/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java index 11dde35..5c95ae2 100644 --- a/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java +++ b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java @@ -11,52 +11,53 @@ import android.content.Intent; import android.net.VpnService; import android.os.Build; +import androidx.annotation.NonNull; import androidx.work.Worker; import androidx.work.WorkerParameters; +/** + * A worker that exists to support IPNReceiver. + */ public final class StartVPNWorker extends Worker { - public StartVPNWorker( - Context appContext, - WorkerParameters workerParams) { + public StartVPNWorker(Context appContext, WorkerParameters workerParams) { super(appContext, workerParams); } + @NonNull @Override public Result doWork() { - App app = ((App) getApplicationContext()); - // We need to make sure we prepare the VPN Service, just in case it isn't prepared. - Intent intent = VpnService.prepare(app); - if (intent == null) { - // If null then the VPN is already prepared and/or it's just been prepared because we have permission - app.startVPN(); - return Result.success(); - } else { - // This VPN possibly doesn't have permission, we need to display a notification which when clicked launches the intent provided. - android.util.Log.e("StartVPNWorker", "Tailscale doesn't have permission from the system to start VPN. Launching the intent provided."); - - // Send notification - NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE); - String channelId = "start_vpn_channel"; - - // Use createNotificationChannel method from App.java - app.createNotificationChannel(channelId, "Start VPN Channel", NotificationManager.IMPORTANCE_DEFAULT); - - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0); - PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags); - - Notification notification = new Notification.Builder(app, channelId) - .setContentTitle("Tailscale Connection Failed") - .setContentText("Tap here to renew permission.") - .setSmallIcon(R.drawable.ic_notification) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .build(); - - notificationManager.notify(1, notification); - - return Result.failure(); + UninitializedApp app = UninitializedApp.get(); + boolean ableToStartVPN = app.isAbleToStartVPN(); + if (ableToStartVPN) { + if (VpnService.prepare(app) == null) { + // We're ready and have permissions, start the VPN + app.startVPN(); + return Result.success(); + } } + + // We aren't ready to start the VPN or don't have permission, open the Tailscale app. + android.util.Log.e("StartVPNWorker", "Tailscale isn't ready to start the VPN, notify the user."); + + // Send notification + NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE); + String channelId = "start_vpn_channel"; + + // Use createNotificationChannel method from App.java + app.createNotificationChannel(channelId, "Start VPN Channel", NotificationManager.IMPORTANCE_DEFAULT); + + // Use prepareIntent if available. + Intent intent = app.getPackageManager().getLaunchIntentForPackage(app.getPackageName()); + assert intent != null; + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags); + + Notification notification = new Notification.Builder(app, channelId).setContentTitle(app.getString(R.string.title_connection_failed)).setContentText(app.getString(R.string.body_open_tailscale)).setSmallIcon(R.drawable.ic_notification).setContentIntent(pendingIntent).setAutoCancel(true).build(); + + notificationManager.notify(1, notification); + + return Result.failure(); } } diff --git a/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java b/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java index 5d1913a..7bb2e17 100644 --- a/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java +++ b/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java @@ -5,9 +5,13 @@ package com.tailscale.ipn; import android.content.Context; +import androidx.annotation.NonNull; import androidx.work.Worker; import androidx.work.WorkerParameters; +/** + * A worker that exists to support IPNReceiver. + */ public final class StopVPNWorker extends Worker { public StopVPNWorker( @@ -16,9 +20,10 @@ public final class StopVPNWorker extends Worker { super(appContext, workerParams); } + @NonNull @Override public Result doWork() { - App.getApplication().setWantRunning(false); + UninitializedApp.get().stopVPN(); return Result.success(); } } diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt index 4fad2a6..5c2cee4 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt @@ -44,7 +44,7 @@ class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) : override fun getFrom(bundle: Bundle?, app: App): AlwaysNeverUserDecides { val storedString = bundle?.getString(key) - ?: App.getApplication().getEncryptedPrefs().getString(key, null) + ?: App.get().getEncryptedPrefs().getString(key, null) ?: "user-decides" return when (storedString) { "always" -> { @@ -64,9 +64,7 @@ class ShowHideMDMSetting(key: String, localizedTitle: String) : MDMSetting(ShowHide.Show, key, localizedTitle) { override fun getFrom(bundle: Bundle?, app: App): ShowHide { val storedString = - bundle?.getString(key) - ?: App.getApplication().getEncryptedPrefs().getString(key, null) - ?: "show" + bundle?.getString(key) ?: App.get().getEncryptedPrefs().getString(key, null) ?: "show" return when (storedString) { "hide" -> { ShowHide.Hide 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 46bf06d..5025c1a 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 @@ -30,10 +30,6 @@ object Notifier { private val TAG = Notifier::class.simpleName private val decoder = Json { ignoreUnknownKeys = true } - // Global App State - val connStatus: StateFlow = MutableStateFlow(false) - val readyToPrepareVPN: StateFlow = MutableStateFlow(false) - // General IPN Bus State val state: StateFlow = MutableStateFlow(Ipn.State.NoState) val netmap: StateFlow = MutableStateFlow(null) @@ -80,10 +76,6 @@ object Notifier { notify.FilesWaiting?.let(filesWaiting::set) notify.IncomingFiles?.let(incomingFiles::set) } - state.collect { currstate -> - readyToPrepareVPN.set(currstate > Ipn.State.Stopped) - connStatus.set(currstate > Ipn.State.Stopped) - } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt b/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt index ae20af0..bad37f3 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt @@ -9,13 +9,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp -import com.tailscale.ipn.App +import com.tailscale.ipn.UninitializedApp import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV object AndroidTVUtil { fun isAndroidTV(): Boolean { - return (App.appInstance.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEVISION) || - App.appInstance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) + val pm = UninitializedApp.get().packageManager + return (pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) || + pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index 7b19b32..b61fa75 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -3,12 +3,10 @@ package com.tailscale.ipn.ui.viewModel -import android.content.Intent import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.tailscale.ipn.App -import com.tailscale.ipn.IPNReceiver +import com.tailscale.ipn.UninitializedApp import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.model.Ipn @@ -70,17 +68,11 @@ open class IpnViewModel : ViewModel() { } fun startVPN() { - val context = App.getApplication().applicationContext - val intent = Intent(context, IPNReceiver::class.java) - intent.action = IPNReceiver.INTENT_CONNECT_VPN - context.sendBroadcast(intent) + UninitializedApp.get().startVPN() } - fun stopVPN() { - val context = App.getApplication().applicationContext - val intent = Intent(context, IPNReceiver::class.java) - intent.action = IPNReceiver.INTENT_DISCONNECT_VPN - context.sendBroadcast(intent) + private fun stopVPN() { + UninitializedApp.get().stopVPN() } // Login/Logout diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 8fe5bb4..da52c90 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -242,4 +242,7 @@ All connections are device-to-device, so we never see your data. We collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network. Scan this QR code to log in to your tailnet + + Tailscale Connection Failed + Tap here to open Tailscale.