@ -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 : Uninitialized App( ) , 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 ( ) )
}
}