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 <kari@tailscale.com>
Co-authored-by: Percy Wegmann <percy@tailscale.com>

* 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 <percy@tailscale.com>

* Revert "android: temporarily remove quick settings tile"

This reverts commit edb3f5b0c5.

Signed-off-by: Percy Wegmann <percy@tailscale.com>

---------

Signed-off-by: Percy Wegmann <percy@tailscale.com>
Co-authored-by: Percy Wegmann <percy@tailscale.com>
pull/388/head
kari-ts 4 weeks ago committed by GitHub
parent 698fb868a7
commit f684bf696d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -46,6 +46,9 @@
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<activity
android:name="ShareActivity"
@ -80,7 +83,6 @@
</intent-filter>
</activity>
<receiver
android:name="IPNReceiver"
android:exported="true">
@ -98,6 +100,16 @@
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<service
android:name=".QuickToggleService"
android:exported="true"
android:icon="@drawable/ic_tile"
android:label="@string/tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<meta-data
android:name="android.content.APP_RESTRICTIONS"

@ -5,31 +5,22 @@ 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.Context
import android.content.Intent
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.NetworkCapabilities
import android.net.NetworkRequest
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.os.Environment
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
@ -55,7 +46,7 @@ import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
class App : Application(), libtailscale.AppContext {
class App : UninitializedApp(), libtailscale.AppContext {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object {
@ -63,14 +54,13 @@ class App : Application(), libtailscale.AppContext {
const val STATUS_NOTIFICATION_ID = 1
private const val PEER_TAG = "peer"
private const val FILE_CHANNEL_ID = "tailscale-files"
private const val FILE_NOTIFICATION_ID = 2
private const val TAG = "App"
private val networkConnectivityRequest =
NetworkRequest.Builder()
.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) {
@ -78,8 +68,13 @@ class App : Application(), libtailscale.AppContext {
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 {
fun get(): App {
appInstance.initOnce()
return appInstance
}
}
@ -98,6 +93,25 @@ class App : Application(), libtailscale.AppContext {
override fun onCreate() {
super.onCreate()
appInstance = this
setUnprotectedInstance(this)
}
override fun onTerminate() {
super.onTerminate()
Notifier.stop()
applicationScope.cancel()
}
private var isInitialized = false
@Synchronized
private fun initOnce() {
if (isInitialized) {
return
}
isInitialized = true
val dataDir = this.filesDir.absolutePath
// Set this to enable direct mode for taildrop whereby downloads will be saved directly
@ -105,7 +119,6 @@ class App : Application(), libtailscale.AppContext {
// 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)
@ -116,18 +129,16 @@ 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(vpnRunning)
}
}
}
override fun onTerminate() {
super.onTerminate()
Notifier.stop()
applicationScope.cancel()
}
fun setWantRunning(wantRunning: Boolean) {
val callback: (Result<Ipn.Prefs>) -> 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())
}
}

@ -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";

@ -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"
}
}

@ -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)
}

@ -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
}
}

@ -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);
}
}

@ -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();
}
}

@ -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();
}
}

@ -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>(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

@ -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)
@ -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)
}
}
}

@ -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))
}
}

@ -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

@ -242,4 +242,7 @@
<string name="welcome2">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.</string>
<string name="scan_to_connect_to_your_tailnet">Scan this QR code to log in to your tailnet</string>
<!-- Strings for the IPNReceiver -->
<string name="title_connection_failed">Tailscale Connection Failed</string>
<string name="body_open_tailscale">Tap here to open Tailscale.</string>
</resources>

Loading…
Cancel
Save