Skip to content

Commit

Permalink
android: fix quick settings tile
Browse files Browse the repository at this point in the history
#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: oxtoacart
  • Loading branch information
kari-ts committed May 9, 2024
1 parent 7f66c37 commit 30b60a7
Show file tree
Hide file tree
Showing 19 changed files with 152 additions and 80 deletions.
4 changes: 2 additions & 2 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ android {
defaultConfig {
minSdkVersion 26
targetSdkVersion 34
versionCode 210
versionName "1.65.182-t80df8ffb8-g6a15347453c"
versionCode 213
versionName "1.65.192-te968b0ecd-g48543799b1a"
}

compileOptions {
Expand Down
89 changes: 52 additions & 37 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,21 @@ class App : Application(), libtailscale.AppContext {
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()
lateinit var appInstance: App
private lateinit var appInstance: App

@JvmStatic
fun startActivityForResult(act: Activity, intent: Intent?, request: Int) {
val f: Fragment = act.fragmentManager.findFragmentByTag(PEER_TAG)
f.startActivityForResult(intent, request)
}

/**
* Initializes the app (if necessary) and returns the singleton app instance. Always use this
* function to obtain an App reference to make sure the app initializes.
*/
@JvmStatic
fun getApplication(): App {
appInstance.initOnce()
return appInstance
}
}
Expand All @@ -98,14 +103,31 @@ class App : Application(), libtailscale.AppContext {

override fun onCreate() {
super.onCreate()
appInstance = this
}

override fun onTerminate() {
super.onTerminate()
Notifier.stop()
applicationScope.cancel()
}

var initialized = false

@Synchronized
private fun initOnce() {
if (initialized) {
return
}
initialized = true

val dataDir = this.filesDir.absolutePath

// Set this to enable direct mode for taildrop whereby downloads will be saved directly
// to the given folder. We will preferentially use <shared>/Downloads and fallback to
// an app local directory "Taildrop" if we cannot create that. This mode does not support
// user notifications for incoming files.
val directFileDir = this.prepareDownloadsFolder()

app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
Request.setApp(app)
Notifier.setApp(app)
Expand All @@ -116,22 +138,20 @@ class App : Application(), libtailscale.AppContext {
STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW)
createNotificationChannel(
FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT)
appInstance = this
applicationScope.launch {
Notifier.connStatus.collect { connStatus -> 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(this@App, vpnRunning)
}
}
}

override fun onTerminate() {
super.onTerminate()
Notifier.stop()
applicationScope.cancel()
}

fun setWantRunning(wantRunning: Boolean) {
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold(
onSuccess = { },
onSuccess = {},
onFailure = { error ->
Log.d("TAG", "Set want running: failed to update preferences: ${error.message}")
})
Expand Down Expand Up @@ -181,7 +201,6 @@ class App : Application(), libtailscale.AppContext {
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)
Expand All @@ -208,33 +227,22 @@ class App : Application(), libtailscale.AppContext {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
}

fun updateConnStatus(ready: Boolean) {
fun updateConnStatus(ableToStartVPN: Boolean, vpnRunning: 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
}
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)
QuickToggleService.setAbleToStartVPN(this, ableToStartVPN)
Log.d("App", "Set Tile Ready: $ableToStartVPN")
val action =
if (ableToStartVPN) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN
val intent = Intent(this, IPNReceiver::class.java).apply { this.action = action }
val pendingIntent: PendingIntent =
PendingIntent.getBroadcast(
this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val notificationMessage =
if (vpnRunning) getString(R.string.connected) else getString(R.string.not_connected)
notify(
"Tailscale",
notificationMessage,
STATUS_CHANNEL_ID,
pendingIntent,
STATUS_NOTIFICATION_ID
)
"Tailscale", notificationMessage, STATUS_CHANNEL_ID, pendingIntent, STATUS_NOTIFICATION_ID)
}

fun getHostname(): String {
Expand Down Expand Up @@ -337,7 +345,8 @@ class App : Application(), libtailscale.AppContext {
}
val pending: PendingIntent =
PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT)
notify(getString(R.string.file_notification), msg, FILE_CHANNEL_ID, pending, FILE_NOTIFICATION_ID)
notify(
getString(R.string.file_notification), msg, FILE_CHANNEL_ID, pending, FILE_NOTIFICATION_ID)
}

fun createNotificationChannel(id: String?, name: String?, importance: Int) {
Expand All @@ -346,7 +355,13 @@ class App : Application(), libtailscale.AppContext {
nm.createNotificationChannel(channel)
}

fun notify(title: String?, message: String?, channel: String, intent: PendingIntent?, notificationID: Int) {
fun notify(
title: String?,
message: String?,
channel: String,
intent: PendingIntent?,
notificationID: Int
) {
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(this, channel)
.setSmallIcon(R.drawable.ic_notification)
Expand Down
34 changes: 27 additions & 7 deletions android/src/main/java/com/tailscale/ipn/IPNService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
package com.tailscale.ipn

import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
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 android.util.Log
import libtailscale.Libtailscale
import java.util.UUID

Expand All @@ -20,25 +21,32 @@ open class IPNService : VpnService(), libtailscale.IPNService {
return randomID
}

override fun onCreate() {
super.onCreate()
// grab app to make sure it initializes
App.getApplication()
}

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)

// If intent is null, the service is restarting because the system is attempting to re-create the killed service. If this is the case, check whether we are able to start the the VPN before starting.
} else if (intent != null || getAbleToStartVPN(this.getApplicationContext())) {
Libtailscale.requestVPN(this)
App.getApplication().setWantRunning(true)
}
Libtailscale.requestVPN(this)
app.setWantRunning(true)
return START_STICKY
}

override public fun close() {
stopForeground(true)
Libtailscale.serviceDisconnect(this)
val app = applicationContext as App
app.setWantRunning(false)
App.getApplication().setWantRunning(false)
}

override fun onDestroy() {
Expand Down Expand Up @@ -100,5 +108,17 @@ open class IPNService : VpnService(), libtailscale.IPNService {
companion object {
const val ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN"
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"

private const val ABLE_TO_START_VPN_KEY = "able_to_start_vpn_key"
private const val PREFERENCES_FILE_KEY = "quicktoggle"

fun getAbleToStartVPN(ctx: Context): Boolean {
val prefs = getSharedPreferences(ctx)
return prefs.getBoolean(ABLE_TO_START_VPN_KEY, false)
}

private fun getSharedPreferences(ctx: Context): SharedPreferences {
return ctx.getSharedPreferences(PREFERENCES_FILE_KEY, Context.MODE_PRIVATE)
}
}
}
18 changes: 10 additions & 8 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -100,6 +98,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// grab app to make sure it initializes
App.getApplication()

// (jonathan) TODO: Force the app to be portrait on small screens until we have
// proper landscape layout support
if (!isLandscapeCapable()) {
Expand Down Expand Up @@ -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.getApplication().prepareVPN(this@MainActivity, RequestCodes.requestPrepareVPN)
}
}
}
}
Expand Down Expand Up @@ -349,9 +351,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)
}
Expand Down
49 changes: 41 additions & 8 deletions android/src/main/java/com/tailscale/ipn/QuickToggleService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,29 @@
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import android.service.quicksettings.Tile;
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;

// 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).
public static final String ABLE_TO_START_VPN_KEY = "ableToStartVPN";

// File 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).
public static final String QUICK_TOGGLE = "quicktoggle";

// currentTile tracks getQsTile while service is listening.
private static Tile currentTile;

// Request code for opening activity.
private static int reqCode = 0;

Expand All @@ -26,7 +37,7 @@ private static void updateTile(Context ctx) {
boolean act;
synchronized (lock) {
t = currentTile;
act = ready;
act = isRunning && getAbleToStartVPN(ctx);
}
if (t == null) {
return;
Expand All @@ -39,9 +50,29 @@ private static void updateTile(Context ctx) {
t.updateTile();
}

static void setReady(Context ctx, boolean rdy) {
/*
* 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.
*/
static void setAbleToStartVPN(Context ctx, boolean rdy) {
SharedPreferences prefs = getSharedPreferences(ctx);
prefs.edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply();
updateTile(ctx);
}

static boolean getAbleToStartVPN(Context ctx) {
SharedPreferences prefs = getSharedPreferences(ctx);
return prefs.getBoolean(ABLE_TO_START_VPN_KEY, false);
}

static SharedPreferences getSharedPreferences(Context ctx) {
return ctx.getSharedPreferences(QUICK_TOGGLE, Context.MODE_PRIVATE);
}

static void setVPNRunning(Context ctx, boolean running) {
synchronized (lock) {
ready = rdy;
isRunning = running;
}
updateTile(ctx);
}
Expand All @@ -65,9 +96,11 @@ public void onStopListening() {
public void onClick() {
boolean r;
synchronized (lock) {
r = ready;
r = getAbleToStartVPN(this);
}
if (r) {
// Get the application to make sure it initializes
App.getApplication();
onTileClick();
} else {
// Start main activity.
Expand All @@ -83,7 +116,7 @@ public void onClick() {
private void onTileClick() {
boolean act;
synchronized (lock) {
act = ready;
act = getAbleToStartVPN(this) && isRunning;
}
Intent i = new Intent(act ? IPNReceiver.INTENT_DISCONNECT_VPN : IPNReceiver.INTENT_CONNECT_VPN);
i.setPackage(getPackageName());
Expand Down
Loading

0 comments on commit 30b60a7

Please sign in to comment.