Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,14 @@ checkandroidsdk: ## Check that Android SDK is installed
test: gradle-dependencies ## Run the Android tests
(cd android && ./gradlew test)

.PHONY: fmt
fmt: gradle-dependencies ## Format the Android code
(cd android && ./gradlew ktfmtFormat)

.PHONY: fmt-check
fmt-check: gradle-dependencies ## Check the Android code is formatted
(cd android && ./gradlew ktfmtCheck)

.PHONY: emulator
emulator: ## Start an android emulator instance
@echo "Checking installed SDK packages..."
Expand Down
46 changes: 41 additions & 5 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn

import android.Manifest
import android.app.Application
import android.app.Notification
Expand Down Expand Up @@ -37,6 +38,10 @@ import com.tailscale.ipn.ui.viewModel.AppViewModelFactory
import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog
import java.io.IOException
import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand All @@ -48,12 +53,10 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import libtailscale.Libtailscale
import java.io.IOException
import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale

class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

companion object {
private const val FILE_CHANNEL_ID = "tailscale-files"
// Key to store the SAF URI in EncryptedSharedPreferences.
Expand All @@ -70,26 +73,34 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
return appInstance
}
}

val dns = DnsConfig()
private lateinit var connectivityManager: ConnectivityManager
private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver
private lateinit var app: libtailscale.Application
override val viewModelStore: ViewModelStore
get() = appViewModelStore

private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
var healthNotifier: HealthNotifier? = null

override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString

override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this)

override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK

override fun log(s: String, s1: String) {
Log.d(s, s1)
}

fun getLibtailscaleApp(): libtailscale.Application {
if (!isInitialized) {
initOnce() // Calls the synchronized initialization logic
}
return app
}

override fun onCreate() {
super.onCreate()
appInstance = this
Expand All @@ -113,6 +124,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
getString(R.string.health_channel_description),
NotificationManagerCompat.IMPORTANCE_HIGH)
}

override fun onTerminate() {
super.onTerminate()
Notifier.stop()
Expand All @@ -121,7 +133,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
viewModelStore.clear()
unregisterReceiver(mdmChangeReceiver)
}

@Volatile private var isInitialized = false

@Synchronized
private fun initOnce() {
if (isInitialized) {
Expand All @@ -130,6 +144,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
initializeApp()
isInitialized = true
}

private fun initializeApp() {
// Check if a directory URI has already been stored.
val storedUri = getStoredDirectoryUri()
Expand Down Expand Up @@ -244,6 +259,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
}

fun getStoredDirectoryUri(): Uri? {
val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null)
return uriString?.let { Uri.parse(it) }
Expand All @@ -258,6 +274,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
QuickToggleService.updateTile()
TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
}

override fun getModelName(): String {
val manu = Build.MANUFACTURER
var model = Build.MODEL
Expand All @@ -268,10 +285,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
}
return "$manu $model"
}

override fun getOSVersion(): String = Build.VERSION.RELEASE

override fun isChromeOS(): Boolean {
return packageManager.hasSystemFeature("android.hardware.type.pc")
}

override fun getInterfacesAsString(): String {
val interfaces: ArrayList<NetworkInterface> =
java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
Expand Down Expand Up @@ -303,11 +323,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
}
return sb.toString()
}

@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean {
return getSyspolicyStringValue(key) == "true"
}

@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringValue(key: String): String {
Expand All @@ -317,6 +339,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
}
return setting.value?.toString() ?: ""
}

@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringArrayJSONValue(key: String): String {
Expand All @@ -332,6 +355,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
throw MDMSettings.NoSuchKeyException()
}
}

fun notifyPolicyChanged() {
app.notifyPolicyChanged()
}
Expand Down Expand Up @@ -374,19 +398,23 @@ open class UninitializedApp : Application() {
}
}
}

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 }
// FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will
Expand All @@ -411,6 +439,7 @@ open class UninitializedApp : Application() {
TSLog.e(TAG, "startVPN hit exception: $e")
}
}

fun stopVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
try {
Expand All @@ -421,6 +450,7 @@ open class UninitializedApp : Application() {
TSLog.e(TAG, "stopVPN hit exception in startService(): $e")
}
}

fun restartVPN() {
val intent =
Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN }
Expand All @@ -432,19 +462,22 @@ open class UninitializedApp : Application() {
TSLog.e(TAG, "restartVPN hit exception in startService(): $e")
}
}

fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
val channel = NotificationChannel(id, name, importance)
channel.description = description
notificationManager = NotificationManagerCompat.from(this)
notificationManager.createNotificationChannel(channel)
}

fun notifyStatus(
vpnRunning: Boolean,
hideDisconnectAction: Boolean,
exitNodeName: String? = null
) {
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName))
}

fun notifyStatus(notification: Notification) {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
Expand All @@ -459,6 +492,7 @@ open class UninitializedApp : Application() {
}
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
}

fun buildStatusNotification(
vpnRunning: Boolean,
hideDisconnectAction: Boolean,
Expand Down Expand Up @@ -504,6 +538,7 @@ open class UninitializedApp : Application() {
}
return builder.build()
}

fun updateUserDisallowedPackageNames(packageNames: List<String>) {
if (packageNames.any { it.isEmpty() }) {
TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)")
Expand All @@ -512,6 +547,7 @@ open class UninitializedApp : Application() {
getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply()
this.restartVPN()
}

fun disallowedPackageNames(): List<String> {
val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
Expand Down Expand Up @@ -553,4 +589,4 @@ open class UninitializedApp : Application() {
// Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128
"com.google.android.apps.scone",
)
}
}
4 changes: 2 additions & 2 deletions android/src/main/java/com/tailscale/ipn/IPNService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.util.TSLog
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import libtailscale.Libtailscale
import java.util.UUID

open class IPNService : VpnService(), libtailscale.IPNService {
private val TAG = "IPNService"
Expand Down Expand Up @@ -47,7 +47,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
START_NOT_STICKY
}
ACTION_RESTART_VPN -> {
app.setWantRunning(false){
app.setWantRunning(false) {
close()
app.startVPN()
}
Expand Down
2 changes: 1 addition & 1 deletion android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ class MainActivity : ComponentActivity() {
appViewModel.directoryPickerLauncher = directoryPickerLauncher

setContent {
var showDialog by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) }

LaunchedEffect(Unit) { appViewModel.triggerDirectoryPicker.collect { showDialog = true } }

Expand Down
3 changes: 2 additions & 1 deletion android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ object MDMSettings {

// Handled on the backend
val deviceSerialNumber =
StringMDMSetting("DeviceSerialNumber", "Serial number of the device that is running Tailscale")
StringMDMSetting(
"DeviceSerialNumber", "Serial number of the device that is running Tailscale")

val useTailscaleDNSSettings =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")
Expand Down
6 changes: 3 additions & 3 deletions android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.InputStreamAdapter
import com.tailscale.ipn.util.TSLog
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
Expand All @@ -23,9 +26,6 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer
import libtailscale.FilePart
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf

private object Endpoint {
const val DEBUG = "debug"
Expand Down
2 changes: 1 addition & 1 deletion android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
package com.tailscale.ipn.ui.model

import android.net.Uri
import java.util.UUID
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.UUID

class Ipn {

Expand Down
2 changes: 1 addition & 1 deletion android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,6 @@ class Netmap {

fun hasCap(capability: String): Boolean {
return AllCaps.contains(capability)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.PeerSettingInfo
import java.util.Date
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import java.util.Date

class Tailcfg {
@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,3 @@ class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale
outputStream.close()
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,7 @@ fun LoginView(
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Go),
keyboardActions =
KeyboardActions(onGo = { onSubmitAction(textVal) }))
keyboardActions = KeyboardActions(onGo = { onSubmitAction(textVal) }))
})

ListItem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AppVersion
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.AppViewModel

@Composable
fun SettingsView(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi
val capabilityIsOwner = "https://tailscale.com/cap/is-owner"
val isOwner = netmapState?.hasCap(capabilityIsOwner) == true

Scaffold(
Scaffold(
topBar = {
Header(
R.string.accounts,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,4 @@ class AppViewModel(application: Application, private val taildropPrompt: Flow<Un
fun setVpnPrepared(isPrepared: Boolean) {
_vpnPrepared.value = isPrepared
}
}
}
Loading