all: add Makefile fmt and fmt-check targets, format all source code

Signed-off-by: Michael Nahkies <michael@nahkies.co.nz>
pull/700/head
Michael Nahkies 3 months ago committed by Brad Fitzpatrick
parent 53b746220b
commit 981f5e8770

@ -316,6 +316,14 @@ checkandroidsdk: ## Check that Android SDK is installed
test: gradle-dependencies ## Run the Android tests test: gradle-dependencies ## Run the Android tests
(cd android && ./gradlew test) (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 .PHONY: emulator
emulator: ## Start an android emulator instance emulator: ## Start an android emulator instance
@echo "Checking installed SDK packages..." @echo "Checking installed SDK packages..."

@ -1,6 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn package com.tailscale.ipn
import android.Manifest import android.Manifest
import android.app.Application import android.app.Application
import android.app.Notification import android.app.Notification
@ -37,6 +38,10 @@ import com.tailscale.ipn.ui.viewModel.AppViewModelFactory
import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -48,12 +53,10 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import libtailscale.Libtailscale 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 { class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object { companion object {
private const val FILE_CHANNEL_ID = "tailscale-files" private const val FILE_CHANNEL_ID = "tailscale-files"
// Key to store the SAF URI in EncryptedSharedPreferences. // Key to store the SAF URI in EncryptedSharedPreferences.
@ -70,26 +73,34 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
return appInstance return appInstance
} }
} }
val dns = DnsConfig() val dns = DnsConfig()
private lateinit var connectivityManager: ConnectivityManager private lateinit var connectivityManager: ConnectivityManager
private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver
private lateinit var app: libtailscale.Application private lateinit var app: libtailscale.Application
override val viewModelStore: ViewModelStore override val viewModelStore: ViewModelStore
get() = appViewModelStore get() = appViewModelStore
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() } private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
var healthNotifier: HealthNotifier? = null var healthNotifier: HealthNotifier? = null
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this) override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this)
override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK
override fun log(s: String, s1: String) { override fun log(s: String, s1: String) {
Log.d(s, s1) Log.d(s, s1)
} }
fun getLibtailscaleApp(): libtailscale.Application { fun getLibtailscaleApp(): libtailscale.Application {
if (!isInitialized) { if (!isInitialized) {
initOnce() // Calls the synchronized initialization logic initOnce() // Calls the synchronized initialization logic
} }
return app return app
} }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
appInstance = this appInstance = this
@ -113,6 +124,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
getString(R.string.health_channel_description), getString(R.string.health_channel_description),
NotificationManagerCompat.IMPORTANCE_HIGH) NotificationManagerCompat.IMPORTANCE_HIGH)
} }
override fun onTerminate() { override fun onTerminate() {
super.onTerminate() super.onTerminate()
Notifier.stop() Notifier.stop()
@ -121,7 +133,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
viewModelStore.clear() viewModelStore.clear()
unregisterReceiver(mdmChangeReceiver) unregisterReceiver(mdmChangeReceiver)
} }
@Volatile private var isInitialized = false @Volatile private var isInitialized = false
@Synchronized @Synchronized
private fun initOnce() { private fun initOnce() {
if (isInitialized) { if (isInitialized) {
@ -130,6 +144,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
initializeApp() initializeApp()
isInitialized = true isInitialized = true
} }
private fun initializeApp() { private fun initializeApp() {
// Check if a directory URI has already been stored. // Check if a directory URI has already been stored.
val storedUri = getStoredDirectoryUri() val storedUri = getStoredDirectoryUri()
@ -244,6 +259,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
} }
fun getStoredDirectoryUri(): Uri? { fun getStoredDirectoryUri(): Uri? {
val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null) val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null)
return uriString?.let { Uri.parse(it) } return uriString?.let { Uri.parse(it) }
@ -258,6 +274,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
QuickToggleService.updateTile() QuickToggleService.updateTile()
TSLog.d("App", "Set Tile Ready: $ableToStartVPN") TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
} }
override fun getModelName(): String { override fun getModelName(): String {
val manu = Build.MANUFACTURER val manu = Build.MANUFACTURER
var model = Build.MODEL var model = Build.MODEL
@ -268,10 +285,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
} }
return "$manu $model" return "$manu $model"
} }
override fun getOSVersion(): String = Build.VERSION.RELEASE override fun getOSVersion(): String = Build.VERSION.RELEASE
override fun isChromeOS(): Boolean { override fun isChromeOS(): Boolean {
return packageManager.hasSystemFeature("android.hardware.type.pc") return packageManager.hasSystemFeature("android.hardware.type.pc")
} }
override fun getInterfacesAsString(): String { override fun getInterfacesAsString(): String {
val interfaces: ArrayList<NetworkInterface> = val interfaces: ArrayList<NetworkInterface> =
java.util.Collections.list(NetworkInterface.getNetworkInterfaces()) java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
@ -303,11 +323,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
} }
return sb.toString() return sb.toString()
} }
@Throws( @Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean { override fun getSyspolicyBooleanValue(key: String): Boolean {
return getSyspolicyStringValue(key) == "true" return getSyspolicyStringValue(key) == "true"
} }
@Throws( @Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringValue(key: String): String { override fun getSyspolicyStringValue(key: String): String {
@ -317,6 +339,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
} }
return setting.value?.toString() ?: "" return setting.value?.toString() ?: ""
} }
@Throws( @Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringArrayJSONValue(key: String): String { override fun getSyspolicyStringArrayJSONValue(key: String): String {
@ -332,6 +355,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
throw MDMSettings.NoSuchKeyException() throw MDMSettings.NoSuchKeyException()
} }
} }
fun notifyPolicyChanged() { fun notifyPolicyChanged() {
app.notifyPolicyChanged() app.notifyPolicyChanged()
} }
@ -374,9 +398,11 @@ open class UninitializedApp : Application() {
} }
} }
} }
protected fun setUnprotectedInstance(instance: UninitializedApp) { protected fun setUnprotectedInstance(instance: UninitializedApp) {
appInstance = instance appInstance = instance
} }
protected fun setAbleToStartVPN(rdy: Boolean) { protected fun setAbleToStartVPN(rdy: Boolean) {
getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply() getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply()
} }
@ -384,9 +410,11 @@ open class UninitializedApp : Application() {
fun isAbleToStartVPN(): Boolean { fun isAbleToStartVPN(): Boolean {
return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false) return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false)
} }
private fun getUnencryptedPrefs(): SharedPreferences { private fun getUnencryptedPrefs(): SharedPreferences {
return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE) return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE)
} }
fun startVPN() { fun startVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN } 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 // FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will
@ -411,6 +439,7 @@ open class UninitializedApp : Application() {
TSLog.e(TAG, "startVPN hit exception: $e") TSLog.e(TAG, "startVPN hit exception: $e")
} }
} }
fun stopVPN() { fun stopVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN } val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
try { try {
@ -421,6 +450,7 @@ open class UninitializedApp : Application() {
TSLog.e(TAG, "stopVPN hit exception in startService(): $e") TSLog.e(TAG, "stopVPN hit exception in startService(): $e")
} }
} }
fun restartVPN() { fun restartVPN() {
val intent = val intent =
Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN } Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN }
@ -432,12 +462,14 @@ open class UninitializedApp : Application() {
TSLog.e(TAG, "restartVPN hit exception in startService(): $e") TSLog.e(TAG, "restartVPN hit exception in startService(): $e")
} }
} }
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) { fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
val channel = NotificationChannel(id, name, importance) val channel = NotificationChannel(id, name, importance)
channel.description = description channel.description = description
notificationManager = NotificationManagerCompat.from(this) notificationManager = NotificationManagerCompat.from(this)
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
fun notifyStatus( fun notifyStatus(
vpnRunning: Boolean, vpnRunning: Boolean,
hideDisconnectAction: Boolean, hideDisconnectAction: Boolean,
@ -445,6 +477,7 @@ open class UninitializedApp : Application() {
) { ) {
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName)) notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName))
} }
fun notifyStatus(notification: Notification) { fun notifyStatus(notification: Notification) {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) { PackageManager.PERMISSION_GRANTED) {
@ -459,6 +492,7 @@ open class UninitializedApp : Application() {
} }
notificationManager.notify(STATUS_NOTIFICATION_ID, notification) notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
} }
fun buildStatusNotification( fun buildStatusNotification(
vpnRunning: Boolean, vpnRunning: Boolean,
hideDisconnectAction: Boolean, hideDisconnectAction: Boolean,
@ -504,6 +538,7 @@ open class UninitializedApp : Application() {
} }
return builder.build() return builder.build()
} }
fun updateUserDisallowedPackageNames(packageNames: List<String>) { fun updateUserDisallowedPackageNames(packageNames: List<String>) {
if (packageNames.any { it.isEmpty() }) { if (packageNames.any { it.isEmpty() }) {
TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)") TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)")
@ -512,6 +547,7 @@ open class UninitializedApp : Application() {
getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply() getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply()
this.restartVPN() this.restartVPN()
} }
fun disallowedPackageNames(): List<String> { fun disallowedPackageNames(): List<String> {
val mdmDisallowed = val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()

@ -12,12 +12,12 @@ import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
import java.util.UUID
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.util.UUID
open class IPNService : VpnService(), libtailscale.IPNService { open class IPNService : VpnService(), libtailscale.IPNService {
private val TAG = "IPNService" private val TAG = "IPNService"
@ -47,7 +47,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
START_NOT_STICKY START_NOT_STICKY
} }
ACTION_RESTART_VPN -> { ACTION_RESTART_VPN -> {
app.setWantRunning(false){ app.setWantRunning(false) {
close() close()
app.startVPN() app.startVPN()
} }

@ -224,7 +224,7 @@ class MainActivity : ComponentActivity() {
appViewModel.directoryPickerLauncher = directoryPickerLauncher appViewModel.directoryPickerLauncher = directoryPickerLauncher
setContent { setContent {
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { appViewModel.triggerDirectoryPicker.collect { showDialog = true } } LaunchedEffect(Unit) { appViewModel.triggerDirectoryPicker.collect { showDialog = true } }

@ -61,7 +61,8 @@ object MDMSettings {
// Handled on the backend // Handled on the backend
val deviceSerialNumber = 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 = val useTailscaleDNSSettings =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings") AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")

@ -14,6 +14,9 @@ import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.InputStreamAdapter
import com.tailscale.ipn.util.TSLog 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -23,9 +26,6 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import libtailscale.FilePart import libtailscale.FilePart
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf
private object Endpoint { private object Endpoint {
const val DEBUG = "debug" const val DEBUG = "debug"

@ -4,9 +4,9 @@
package com.tailscale.ipn.ui.model package com.tailscale.ipn.ui.model
import android.net.Uri import android.net.Uri
import java.util.UUID
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import java.util.UUID
class Ipn { class Ipn {

@ -15,9 +15,9 @@ import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.PeerSettingInfo import com.tailscale.ipn.ui.viewModel.PeerSettingInfo
import java.util.Date
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import java.util.Date
class Tailcfg { class Tailcfg {
@Serializable @Serializable

@ -24,4 +24,3 @@ class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale
outputStream.close() outputStream.close()
} }
} }

@ -145,8 +145,7 @@ fun LoginView(
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Go), capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Go),
keyboardActions = keyboardActions = KeyboardActions(onGo = { onSubmitAction(textVal) }))
KeyboardActions(onGo = { onSubmitAction(textVal) }))
}) })
ListItem( ListItem(

@ -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.AppVersion
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set 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.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.AppViewModel
@Composable @Composable
fun SettingsView( fun SettingsView(

@ -147,9 +147,11 @@ open class IpnViewModel : ViewModel() {
/** /**
* Order of operations: * Order of operations:
* 1. editPrefs() with maskedPrefs (to allow ControlURL override), WantRunning=true, LoggedOut=false if AuthKey != null * 1. editPrefs() with maskedPrefs (to allow ControlURL override), WantRunning=true,
* LoggedOut=false if AuthKey != null
* 2. start() starts the LocalBackend state machine * 2. start() starts the LocalBackend state machine
* 3. startLoginInteractive() is currently required for bother interactive and non-interactive (using auth key) login * 3. startLoginInteractive() is currently required for bother interactive and non-interactive
* (using auth key) login
* *
* Any failure shortcircuits the chain and invokes completionHandler once. * Any failure shortcircuits the chain and invokes completionHandler once.
*/ */

@ -1,6 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.net.VpnService import android.net.VpnService
@ -10,7 +11,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -21,12 +21,12 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
import java.time.Duration
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Duration
class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory { class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@ -91,18 +90,23 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() {
// Icon displayed in the button to present the health view // Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = MutableStateFlow(null) val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
fun updateSearchTerm(term: String) { fun updateSearchTerm(term: String) {
_searchTerm.value = term _searchTerm.value = term
} }
fun hidePeerDropdownMenu() { fun hidePeerDropdownMenu() {
expandedMenuPeer.set(null) expandedMenuPeer.set(null)
} }
fun copyIpAddress(peer: Tailcfg.Node, clipboardManager: ClipboardManager) { fun copyIpAddress(peer: Tailcfg.Node, clipboardManager: ClipboardManager) {
clipboardManager.setText(AnnotatedString(peer.primaryIPv4Address ?: "")) clipboardManager.setText(AnnotatedString(peer.primaryIPv4Address ?: ""))
} }
fun startPing(peer: Tailcfg.Node) { fun startPing(peer: Tailcfg.Node) {
this.pingViewModel.startPing(peer) this.pingViewModel.startPing(peer)
} }
fun onPingDismissal() { fun onPingDismissal() {
this.pingViewModel.handleDismissal() this.pingViewModel.handleDismissal()
} }
@ -112,7 +116,9 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() {
val v = MDMSettings.authKey.flow.value.value val v = MDMSettings.authKey.flow.value.value
return v != null && v != "" return v != null && v != ""
} }
private val peerCategorizer = PeerCategorizer() private val peerCategorizer = PeerCategorizer()
init { init {
viewModelScope.launch { viewModelScope.launch {
var previousState: State? = null var previousState: State? = null
@ -173,9 +179,11 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() {
App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) }
} }
} }
fun maybeRequestVpnPermission() { fun maybeRequestVpnPermission() {
_requestVpnPermission.value = true _requestVpnPermission.value = true
} }
fun showVPNPermissionLauncherIfUnauthorized() { fun showVPNPermissionLauncherIfUnauthorized() {
val vpnIntent = VpnService.prepare(App.get()) val vpnIntent = VpnService.prepare(App.get())
TSLog.d("VpnPermissions", "vpnIntent=$vpnIntent") TSLog.d("VpnPermissions", "vpnIntent=$vpnIntent")
@ -215,15 +223,19 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() {
} }
} }
} }
fun searchPeers(searchTerm: String) { fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm) this.searchTerm.set(searchTerm)
} }
fun enableSearchAutoFocus() { fun enableSearchAutoFocus() {
autoFocusSearch = true autoFocusSearch = true
} }
fun disableSearchAutoFocus() { fun disableSearchAutoFocus() {
autoFocusSearch = false autoFocusSearch = false
} }
fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) { fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
// No intent means we're already authorized // No intent means we're already authorized
vpnPermissionLauncher = launcher vpnPermissionLauncher = launcher

@ -1,6 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.util package com.tailscale.ipn.util
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
@ -9,6 +10,11 @@ import androidx.documentfile.provider.DocumentFile
import com.tailscale.ipn.TaildropDirectoryStore import com.tailscale.ipn.TaildropDirectoryStore
import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.InputStreamAdapter
import com.tailscale.ipn.ui.util.OutputStreamAdapter import com.tailscale.ipn.ui.util.OutputStreamAdapter
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -17,11 +23,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import libtailscale.Libtailscale import libtailscale.Libtailscale
import org.json.JSONObject import org.json.JSONObject
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
data class SafFile(val fd: Int, val uri: String) data class SafFile(val fd: Int, val uri: String)
object ShareFileHelper : libtailscale.ShareFileHelper { object ShareFileHelper : libtailscale.ShareFileHelper {
@ -96,6 +98,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
val os = context.contentResolver.openOutputStream(file.uri, "rw") val os = context.contentResolver.openOutputStream(file.uri, "rw")
return file.uri.toString() to os return file.uri.toString() to os
} }
@Throws(IOException::class) @Throws(IOException::class)
private fun openWriterFD(fileName: String, offset: Long): Pair<String, SeekableOutputStream> { private fun openWriterFD(fileName: String, offset: Long): Pair<String, SeekableOutputStream> {
val ctx = appContext ?: throw IOException("App context not initialized") val ctx = appContext ?: throw IOException("App context not initialized")
@ -114,6 +117,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0) if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0)
return file.uri.toString() to SeekableOutputStream(fos, pfd) return file.uri.toString() to SeekableOutputStream(fos, pfd)
} }
private val currentUri = ConcurrentHashMap<String, String>() private val currentUri = ConcurrentHashMap<String, String>()
@Throws(IOException::class) @Throws(IOException::class)
@ -143,6 +147,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
currentUri[fileName] = uri currentUri[fileName] = uri
return uri return uri
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun renameFile(oldPath: String, targetName: String): String { override fun renameFile(oldPath: String, targetName: String): String {
val ctx = appContext ?: throw IOException("not initialized") val ctx = appContext ?: throw IOException("not initialized")
@ -190,6 +195,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
cleanupPartials(dir, targetName) cleanupPartials(dir, targetName)
return dest.uri.toString() return dest.uri.toString()
} }
private fun lengthOfUri(ctx: Context, uri: Uri): Long = private fun lengthOfUri(ctx: Context, uri: Uri): Long =
ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 } ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 }
// delete any stray “.partial” files for this base name // delete any stray “.partial” files for this base name
@ -201,6 +207,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
} }
} }
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun deleteFile(uri: String) { override fun deleteFile(uri: String) {
runBlocking { waitUntilTaildropDirReady() } runBlocking { waitUntilTaildropDirReady() }
@ -213,6 +220,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
throw IOException("DeleteFile: delete() returned false for $uri") throw IOException("DeleteFile: delete() returned false for $uri")
} }
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getFileInfo(fileName: String): String { override fun getFileInfo(fileName: String): String {
val context = appContext ?: throw IOException("app context not initialized") val context = appContext ?: throw IOException("app context not initialized")
@ -227,9 +235,11 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
val modTime = file.lastModified() val modTime = file.lastModified()
return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}""" return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}"""
} }
private fun jsonEscape(s: String): String { private fun jsonEscape(s: String): String {
return JSONObject.quote(s) return JSONObject.quote(s)
} }
fun generateNewFilename(filename: String): String { fun generateNewFilename(filename: String): String {
val dotIndex = filename.lastIndexOf('.') val dotIndex = filename.lastIndexOf('.')
val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename
@ -237,6 +247,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
val uuid = UUID.randomUUID() val uuid = UUID.randomUUID()
return "$baseName-$uuid$extension" return "$baseName-$uuid$extension"
} }
fun listPartialFiles(suffix: String): Array<String> { fun listPartialFiles(suffix: String): Array<String> {
val context = appContext ?: return emptyArray() val context = appContext ?: return emptyArray()
val rootUri = savedUri ?: return emptyArray() val rootUri = savedUri ?: return emptyArray()
@ -246,6 +257,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
.mapNotNull { it.name } .mapNotNull { it.name }
.toTypedArray() .toTypedArray()
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun listFilesJSON(suffix: String): String { override fun listFilesJSON(suffix: String): String {
val list = listPartialFiles(suffix) val list = listPartialFiles(suffix)
@ -254,6 +266,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
} }
return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]") return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]")
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun openFileReader(name: String): libtailscale.InputStream { override fun openFileReader(name: String): libtailscale.InputStream {
val context = appContext ?: throw IOException("app context not initialized") val context = appContext ?: throw IOException("app context not initialized")
@ -282,11 +295,15 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
private val pfd: ParcelFileDescriptor private val pfd: ParcelFileDescriptor
) : OutputStream() { ) : OutputStream() {
private var closed = false private var closed = false
override fun write(b: Int) = fos.write(b) override fun write(b: Int) = fos.write(b)
override fun write(b: ByteArray) = fos.write(b) override fun write(b: ByteArray) = fos.write(b)
override fun write(b: ByteArray, off: Int, len: Int) { override fun write(b: ByteArray, off: Int, len: Int) {
fos.write(b, off, len) fos.write(b, off, len)
} }
override fun close() { override fun close() {
if (!closed) { if (!closed) {
closed = true closed = true
@ -299,6 +316,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
} }
} }
} }
override fun flush() = fos.flush() override fun flush() = fos.flush()
} }
} }
Loading…
Cancel
Save