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 2 years 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.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity> </activity>
<activity <activity
android:name="ShareActivity" android:name="ShareActivity"
@ -80,7 +83,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
<receiver <receiver
android:name="IPNReceiver" android:name="IPNReceiver"
android:exported="true"> android:exported="true">
@ -98,6 +100,16 @@
<action android:name="android.net.VpnService" /> <action android:name="android.net.VpnService" />
</intent-filter> </intent-filter>
</service> </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 <meta-data
android:name="android.content.APP_RESTRICTIONS" android:name="android.content.APP_RESTRICTIONS"

@ -5,31 +5,22 @@ package com.tailscale.ipn
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.app.Application import android.app.Application
import android.app.DownloadManager
import android.app.Fragment import android.app.Fragment
import android.app.FragmentTransaction
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.PendingIntent import android.app.PendingIntent
import android.app.UiModeManager
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.LinkProperties import android.net.LinkProperties
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@ -55,7 +46,7 @@ import java.net.NetworkInterface
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import java.util.Locale import java.util.Locale
class App : Application(), libtailscale.AppContext { class App : UninitializedApp(), libtailscale.AppContext {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object { companion object {
@ -63,14 +54,13 @@ class App : Application(), libtailscale.AppContext {
const val STATUS_NOTIFICATION_ID = 1 const val STATUS_NOTIFICATION_ID = 1
private const val PEER_TAG = "peer" private const val PEER_TAG = "peer"
private const val FILE_CHANNEL_ID = "tailscale-files" private const val FILE_CHANNEL_ID = "tailscale-files"
private const val FILE_NOTIFICATION_ID = 2
private const val TAG = "App" private const val TAG = "App"
private val networkConnectivityRequest = private val networkConnectivityRequest =
NetworkRequest.Builder() NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build() .build()
lateinit var appInstance: App private lateinit var appInstance: App
@JvmStatic @JvmStatic
fun startActivityForResult(act: Activity, intent: Intent?, request: Int) { fun startActivityForResult(act: Activity, intent: Intent?, request: Int) {
@ -78,8 +68,13 @@ class App : Application(), libtailscale.AppContext {
f.startActivityForResult(intent, request) 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 @JvmStatic
fun getApplication(): App { fun get(): App {
appInstance.initOnce()
return appInstance return appInstance
} }
} }
@ -98,6 +93,25 @@ class App : Application(), libtailscale.AppContext {
override fun onCreate() { override fun onCreate() {
super.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 val dataDir = this.filesDir.absolutePath
// Set this to enable direct mode for taildrop whereby downloads will be saved directly // 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 // an app local directory "Taildrop" if we cannot create that. This mode does not support
// user notifications for incoming files. // user notifications for incoming files.
val directFileDir = this.prepareDownloadsFolder() val directFileDir = this.prepareDownloadsFolder()
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this) app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
Request.setApp(app) Request.setApp(app)
Notifier.setApp(app) Notifier.setApp(app)
@ -116,18 +129,16 @@ class App : Application(), libtailscale.AppContext {
STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW) STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW)
createNotificationChannel( createNotificationChannel(
FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT) FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT)
appInstance = this
applicationScope.launch { 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) { fun setWantRunning(wantRunning: Boolean) {
val callback: (Result<Ipn.Prefs>) -> Unit = { result -> val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold( result.fold(
@ -162,7 +173,7 @@ class App : Application(), libtailscale.AppContext {
} }
if (dns.updateDNSFromNetwork(sb.toString())) { 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 // encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store. // library and writes it to a global encrypted preference store.
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
@ -207,31 +212,29 @@ class App : Application(), libtailscale.AppContext {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
} }
fun updateConnStatus(ready: Boolean) { /*
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { * setAbleToStartVPN remembers whether or not we're able to start the VPN
return * by storing this in a shared preference. This allows us to check this
} * value without needing a fully initialized instance of the application.
QuickToggleService.setReady(this, ready) */
Log.d("App", "Set Tile Ready: $ready") private fun updateConnStatus(ableToStartVPN: Boolean, vpnRunning: Boolean) {
val action = if (ready) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN setAbleToStartVPN(ableToStartVPN)
val intent = Intent(this, IPNReceiver::class.java).apply { this.action = action } 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 = val pendingIntent: PendingIntent =
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
if (ready) {
startVPN()
}
val notificationMessage = 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( notify(
"Tailscale", notificationMessage, ready, STATUS_CHANNEL_ID, pendingIntent, STATUS_NOTIFICATION_ID) "Tailscale",
} notificationMessage,
vpnRunning,
fun getHostname(): String { STATUS_CHANNEL_ID,
val userConfiguredDeviceName = getUserConfiguredDeviceName() pendingIntent,
if (!userConfiguredDeviceName.isNullOrEmpty()) return userConfiguredDeviceName STATUS_NOTIFICATION_ID)
return modelName
} }
override fun getModelName(): String { override fun getModelName(): String {
@ -247,127 +250,22 @@ class App : Application(), libtailscale.AppContext {
override fun getOSVersion(): String = Build.VERSION.RELEASE 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 { override fun isChromeOS(): Boolean {
return packageManager.hasSystemFeature("android.hardware.type.pc") return packageManager.hasSystemFeature("android.hardware.type.pc")
} }
fun prepareVPN(act: Activity, reqCode: Int) { fun prepareVPN(act: Activity, reqCode: Int) {
act.runOnUiThread( // We do this with UI in case it's our first time starting the VPN.
Runnable { act.runOnUiThread {
val intent: Intent? = VpnService.prepare(act) val prepareIntent = VpnService.prepare(this)
if (intent == null) { if (prepareIntent == null) {
startVPN() // No intent here means that we already have permission to be a VPN.
} else { startVPN()
startActivityForResult(act, intent, reqCode) } else {
} // An intent here means that we need to prompt for permission to be a VPN.
}) startActivityForResult(act, prepareIntent, 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()
} }
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 { override fun getInterfacesAsString(): String {
@ -405,12 +303,7 @@ class App : Application(), libtailscale.AppContext {
return sb.toString() return sb.toString()
} }
fun isTV(): Boolean { private fun prepareDownloadsFolder(): File {
val mm = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
return mm.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
}
fun prepareDownloadsFolder(): File {
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
try { 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; import java.util.Objects;
/**
* IPNReceiver allows external applications to start the VPN.
*/
public class IPNReceiver extends BroadcastReceiver { public class IPNReceiver extends BroadcastReceiver {
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN"; 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.net.VpnService
import android.os.Build import android.os.Build
import android.system.OsConstants import android.system.OsConstants
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.util.UUID import java.util.UUID
@ -20,25 +18,48 @@ open class IPNService : VpnService(), libtailscale.IPNService {
return randomID return randomID
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onCreate() {
val app = applicationContext as App super.onCreate()
if (intent != null && "android.net.VpnService" == intent.action) { // grab app to make sure it initializes
// Start VPN and connect to it due to Always-on VPN App.get()
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 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) stopForeground(true)
Libtailscale.serviceDisconnect(this) Libtailscale.serviceDisconnect(this)
val app = applicationContext as App
app.setWantRunning(false)
} }
override fun onDestroy() { override fun onDestroy() {
@ -71,10 +92,10 @@ open class IPNService : VpnService(), libtailscale.IPNService {
.setConfigureIntent(configIntent()) .setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET) .allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6) .allowFamily(OsConstants.AF_INET6)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
b.setMetered(false) // Inherit the metered status from the underlying networks. 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. b.setUnderlyingNetworks(null) // Use all available networks.
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322 // RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
disallowApp(b, "com.google.android.apps.messaging") disallowApp(b, "com.google.android.apps.messaging")
@ -98,7 +119,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
} }
companion object { 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" 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.pm.ActivityInfo
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
@ -100,6 +98,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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 // (jonathan) TODO: Force the app to be portrait on small screens until we have
// proper landscape layout support // proper landscape layout support
if (!isLandscapeCapable()) { if (!isLandscapeCapable()) {
@ -231,9 +232,10 @@ class MainActivity : ComponentActivity() {
} }
} }
lifecycleScope.launch { lifecycleScope.launch {
Notifier.readyToPrepareVPN.collect { isReady -> Notifier.state.collect { state ->
if (isReady) if (state > Ipn.State.Stopped) {
App.getApplication().prepareVPN(this@MainActivity, RequestCodes.requestPrepareVPN) App.get().prepareVPN(this@MainActivity, RequestCodes.requestPrepareVPN)
}
} }
} }
} }
@ -274,7 +276,7 @@ class MainActivity : ComponentActivity() {
private fun login(urlString: String) { private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch // Launch coroutine to listen for state changes. When the user completes login, relaunch
// MainActivity to bring the app back to focus. // MainActivity to bring the app back to focus.
App.getApplication().applicationScope.launch { App.get().applicationScope.launch {
try { try {
Notifier.state.collect { state -> Notifier.state.collect { state ->
if (state > Ipn.State.NeedsMachineAuth) { if (state > Ipn.State.NeedsMachineAuth) {
@ -315,9 +317,7 @@ class MainActivity : ComponentActivity() {
super.onResume() super.onResume()
val restrictionsManager = val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
MDMSettings.update(App.getApplication(), restrictionsManager)
}
} }
override fun onStart() { override fun onStart() {
@ -332,9 +332,7 @@ class MainActivity : ComponentActivity() {
super.onStop() super.onStop()
val restrictionsManager = val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
MDMSettings.update(App.getApplication(), restrictionsManager)
}
} }
private fun requestVpnPermission() { private fun requestVpnPermission() {
@ -351,9 +349,9 @@ class MainActivity : ComponentActivity() {
private fun openApplicationSettings() { private fun openApplicationSettings() {
val intent = val intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName) putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
} }
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent) startActivity(intent)
} }

@ -18,9 +18,9 @@ public class Peer extends Fragment {
public void onActivityResult(int requestCode, int resultCode, Intent data) { public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == RequestCodes.requestPrepareVPN) { if (requestCode == RequestCodes.requestPrepareVPN) {
if (resultCode == resultOK) { if (resultCode == resultOK) {
App.getApplication().startVPN(); UninitializedApp.get().startVPN();
} else { } else {
App.getApplication().setWantRunning(false); App.get().setWantRunning(false);
// notify VPN revoked // notify VPN revoked
} }
} }

@ -4,7 +4,6 @@
package com.tailscale.ipn; package com.tailscale.ipn;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Build; import android.os.Build;
import android.service.quicksettings.Tile; import android.service.quicksettings.Tile;
@ -13,37 +12,37 @@ import android.service.quicksettings.TileService;
public class QuickToggleService extends TileService { public class QuickToggleService extends TileService {
// lock protects the static fields below it. // lock protects the static fields below it.
private static final Object lock = new Object(); private static final Object lock = new Object();
// Ready tracks whether the tailscale backend is
// ready to switch on/off. // isRunning tracks whether the VPN is running.
private static boolean ready; private static boolean isRunning;
// currentTile tracks getQsTile while service is listening. // currentTile tracks getQsTile while service is listening.
private static Tile currentTile; 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; Tile t;
boolean act; boolean act;
synchronized (lock) { synchronized (lock) {
t = currentTile; t = currentTile;
act = ready; act = isRunning && app.isAbleToStartVPN();
} }
if (t == null) { if (t == null) {
return; return;
} }
t.setLabel("Tailscale"); t.setLabel("Tailscale");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 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.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
t.updateTile(); t.updateTile();
} }
static void setReady(Context ctx, boolean rdy) { static void setVPNRunning(boolean running) {
synchronized (lock) { synchronized (lock) {
ready = rdy; isRunning = running;
} }
updateTile(ctx); updateTile();
} }
@Override @Override
@ -51,7 +50,7 @@ public class QuickToggleService extends TileService {
synchronized (lock) { synchronized (lock) {
currentTile = getQsTile(); currentTile = getQsTile();
} }
updateTile(this.getApplicationContext()); updateTile();
} }
@Override @Override
@ -65,15 +64,18 @@ public class QuickToggleService extends TileService {
public void onClick() { public void onClick() {
boolean r; boolean r;
synchronized (lock) { synchronized (lock) {
r = ready; r = UninitializedApp.get().isAbleToStartVPN();
} }
if (r) { if (r) {
// Get the application to make sure it initializes
App.get();
onTileClick(); onTileClick();
} else { } else {
// Start main activity. // Start main activity.
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName()); Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 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 { } else {
startActivityAndCollapse(i); startActivityAndCollapse(i);
} }
@ -81,13 +83,15 @@ public class QuickToggleService extends TileService {
} }
private void onTileClick() { private void onTileClick() {
boolean act; UninitializedApp app = UninitializedApp.get();
boolean needsToStop;
synchronized (lock) { 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.net.VpnService;
import android.os.Build; import android.os.Build;
import androidx.annotation.NonNull;
import androidx.work.Worker; import androidx.work.Worker;
import androidx.work.WorkerParameters; import androidx.work.WorkerParameters;
/**
* A worker that exists to support IPNReceiver.
*/
public final class StartVPNWorker extends Worker { public final class StartVPNWorker extends Worker {
public StartVPNWorker( public StartVPNWorker(Context appContext, WorkerParameters workerParams) {
Context appContext,
WorkerParameters workerParams) {
super(appContext, workerParams); super(appContext, workerParams);
} }
@NonNull
@Override @Override
public Result doWork() { public Result doWork() {
App app = ((App) getApplicationContext()); UninitializedApp app = UninitializedApp.get();
// We need to make sure we prepare the VPN Service, just in case it isn't prepared. boolean ableToStartVPN = app.isAbleToStartVPN();
Intent intent = VpnService.prepare(app); if (ableToStartVPN) {
if (intent == null) { if (VpnService.prepare(app) == null) {
// If null then the VPN is already prepared and/or it's just been prepared because we have permission // We're ready and have permissions, start the VPN
app.startVPN(); app.startVPN();
return Result.success(); 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();
} }
// 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 android.content.Context;
import androidx.annotation.NonNull;
import androidx.work.Worker; import androidx.work.Worker;
import androidx.work.WorkerParameters; import androidx.work.WorkerParameters;
/**
* A worker that exists to support IPNReceiver.
*/
public final class StopVPNWorker extends Worker { public final class StopVPNWorker extends Worker {
public StopVPNWorker( public StopVPNWorker(
@ -16,9 +20,10 @@ public final class StopVPNWorker extends Worker {
super(appContext, workerParams); super(appContext, workerParams);
} }
@NonNull
@Override @Override
public Result doWork() { public Result doWork() {
App.getApplication().setWantRunning(false); UninitializedApp.get().stopVPN();
return Result.success(); return Result.success();
} }
} }

@ -44,7 +44,7 @@ class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
override fun getFrom(bundle: Bundle?, app: App): AlwaysNeverUserDecides { override fun getFrom(bundle: Bundle?, app: App): AlwaysNeverUserDecides {
val storedString = val storedString =
bundle?.getString(key) bundle?.getString(key)
?: App.getApplication().getEncryptedPrefs().getString(key, null) ?: App.get().getEncryptedPrefs().getString(key, null)
?: "user-decides" ?: "user-decides"
return when (storedString) { return when (storedString) {
"always" -> { "always" -> {
@ -64,9 +64,7 @@ class ShowHideMDMSetting(key: String, localizedTitle: String) :
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) { MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App): ShowHide { override fun getFrom(bundle: Bundle?, app: App): ShowHide {
val storedString = val storedString =
bundle?.getString(key) bundle?.getString(key) ?: App.get().getEncryptedPrefs().getString(key, null) ?: "show"
?: App.getApplication().getEncryptedPrefs().getString(key, null)
?: "show"
return when (storedString) { return when (storedString) {
"hide" -> { "hide" -> {
ShowHide.Hide ShowHide.Hide

@ -30,10 +30,6 @@ object Notifier {
private val TAG = Notifier::class.simpleName private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true } 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 // General IPN Bus State
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState) val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null) val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
@ -80,10 +76,6 @@ object Notifier {
notify.FilesWaiting?.let(filesWaiting::set) notify.FilesWaiting?.let(filesWaiting::set)
notify.IncomingFiles?.let(incomingFiles::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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.App import com.tailscale.ipn.UninitializedApp
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
object AndroidTVUtil { object AndroidTVUtil {
fun isAndroidTV(): Boolean { fun isAndroidTV(): Boolean {
return (App.appInstance.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEVISION) || val pm = UninitializedApp.get().packageManager
App.appInstance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) return (pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
} }
} }

@ -3,12 +3,10 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App import com.tailscale.ipn.UninitializedApp
import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
@ -70,17 +68,11 @@ open class IpnViewModel : ViewModel() {
} }
fun startVPN() { fun startVPN() {
val context = App.getApplication().applicationContext UninitializedApp.get().startVPN()
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN
context.sendBroadcast(intent)
} }
fun stopVPN() { private fun stopVPN() {
val context = App.getApplication().applicationContext UninitializedApp.get().stopVPN()
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
} }
// Login/Logout // 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="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> <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> </resources>

Loading…
Cancel
Save