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 also correct states shown on QuickSettings and persistent notification.
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.
Persistently store 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
  • Loading branch information
kari-ts committed May 9, 2024
1 parent 7f66c37 commit d93373b
Show file tree
Hide file tree
Showing 19 changed files with 129 additions and 79 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.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
14 changes: 8 additions & 6 deletions android/src/main/java/com/tailscale/ipn/IPNService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -20,8 +18,13 @@ 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)
Expand All @@ -30,15 +33,14 @@ open class IPNService : VpnService(), libtailscale.IPNService {
sendBroadcast(i)
}
Libtailscale.requestVPN(this)
app.setWantRunning(true)
App.getApplication().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
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
45 changes: 37 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,25 @@
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 String ABLE_TO_START_VPN_KEY = "ableToStartVPN";

// 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 +33,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 +46,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("quicktoggle", Context.MODE_PRIVATE);
}

static void setVPNRunning(Context ctx, boolean running) {
synchronized (lock) {
ready = rdy;
isRunning = running;
}
updateTile(ctx);
}
Expand All @@ -65,9 +92,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 +112,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
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ object Notifier {
private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true }

// Global App State
val connStatus: StateFlow<Boolean> = MutableStateFlow(false)
val readyToPrepareVPN: StateFlow<Boolean> = MutableStateFlow(false)

// General IPN Bus State
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
Loading

0 comments on commit d93373b

Please sign in to comment.