Merge branch 'main' of github.com:tailscale/tailscale-android into kari/search

# Conflicts:
#	android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt
kari/search
kari-ts 1 year ago
commit 3aaff20959

@ -6,7 +6,7 @@ on:
- main
pull_request:
branches:
- '*'
- "*"
jobs:
build:
@ -15,20 +15,23 @@ jobs:
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Check out code
uses: actions/checkout@v3
- uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
- name: Switch to Java 17 # Note: 17 is pre-installed on ubuntu-latest
uses: actions/setup-java@v3
with:
distribution: "temurin"
java-version: "17"
- name: Check out code
uses: actions/checkout@v3
- uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- name: Switch to Java 17 # Note: 17 is pre-installed on ubuntu-latest
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
# Clean should essentially be a no-op, but make sure that it works.
- name: Clean
run: make clean
- name: Build APKs
run: make tailscale-debug.apk
- name: Build APKs
run: make tailscale-debug.apk
- name: Run tests
run: make test
- name: Run tests
run: make test

2
.gitignore vendored

@ -38,3 +38,5 @@ tailscale.jks
libtailscale.aar
libtailscale-sources.jar
.DS_Store
tailscale.version

@ -8,27 +8,16 @@
# The docker image to use for the build environment. Changing this
# will force a rebuild of the docker image. If there is an existing image
# with this name, it will be used.
DOCKER_IMAGE=tailscale-android-build-amd64
#
# The convention here is tailscale-android-build-amd64-<date>
DOCKER_IMAGE=tailscale-android-build-amd64-191124
export TS_USE_TOOLCHAIN=1
DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab
RELEASE_TV_AAB=tailscale-tv-release.aab
LIBTAILSCALE=android/libs/libtailscale.aar
TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200)
OUR_VERSION=$(shell git describe --dirty --exclude "*" --always --abbrev=200)
TAILSCALE_VERSION_ABBREV=$(shell ./version/tailscale-version.sh 11)
OUR_VERSION_ABBREV=$(shell git describe --exclude "*" --always --abbrev=11)
VERSION_LONG=$(TAILSCALE_VERSION_ABBREV)-g$(OUR_VERSION_ABBREV)
# Extract the long version build.gradle's versionName and strip quotes.
VERSIONNAME=$(patsubst "%",%,$(lastword $(shell grep versionName android/build.gradle)))
# Extract the x.y.z part for the short version.
VERSIONNAME_SHORT=$(shell echo $(VERSIONNAME) | cut -d - -f 1)
TAILSCALE_COMMIT=$(shell echo $(TAILSCALE_VERSION) | cut -d - -f 2 | cut -d t -f 2)
# Extract the version code from build.gradle.
VERSIONCODE=$(lastword $(shell grep versionCode android/build.gradle))
VERSIONCODE_PLUSONE=$(shell expr $(VERSIONCODE) + 1)
VERSION_LDFLAGS=-X tailscale.com/version.longStamp=$(VERSIONNAME) -X tailscale.com/version.shortStamp=$(VERSIONNAME_SHORT) -X tailscale.com/version.gitCommitStamp=$(TAILSCALE_COMMIT) -X tailscale.com/version.extraGitCommitStamp=$(OUR_VERSION)
FULL_LDFLAGS=$(VERSION_LDFLAGS) -w
ifeq ($(shell uname),Linux)
ANDROID_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip"
ANDROID_TOOLS_SUM="bd1aa17c7ef10066949c88dc6c9c8d536be27f992a1f3b5a584f9bd2ba5646a0 commandlinetools-linux-9477386_latest.zip"
@ -69,6 +58,23 @@ else
export PATH := $(JAVA_HOME)/bin:$(PATH)
endif
AVD_BASE_IMAGE := "system-images;android-33;google_apis;"
export HOST_ARCH=$(shell uname -m)
ifeq ($(HOST_ARCH),aarch64)
AVD_IMAGE := "$(AVD_BASE_IMAGE)arm64-v8a"
else ifeq ($(HOST_ARCH),arm64)
AVD_IMAGE := "$(AVD_BASE_IMAGE)arm64-v8a"
else
AVD_IMAGE := "$(AVD_BASE_IMAGE)x86_64"
endif
AVD ?= tailscale-$(HOST_ARCH)
export AVD_IMAGE
export AVD
# Use our toolchain or the one that is specified, do not perform dynamic toolchain switching.
GOTOOLCHAIN=local
export GOTOOLCHAIN
# TOOLCHAINDIR is set by fdoid CI and used by tool/* scripts.
TOOLCHAINDIR ?=
export TOOLCHAINDIR
@ -91,36 +97,43 @@ tailscale-debug: $(DEBUG_APK) ## Build the debug APK
# Builds the release AAB and signs it (phone/tablet/chromeOS variant)
.PHONY: release
release: update-version jarsign-env $(RELEASE_AAB) ## Build the release AAB
release: jarsign-env $(RELEASE_AAB) ## Build the release AAB
@jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(JKS_PATH) -storepass $(JKS_PASSWORD) $(RELEASE_AAB) tailscale
# Builds the release AAB and signs it (androidTV variant)
.PHONY: release-tv
release-tv: update-version jarsign-env $(RELEASE_TV_AAB) ## Build the release AAB
release-tv: jarsign-env $(RELEASE_TV_AAB) ## Build the release AAB
@jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(JKS_PATH) -storepass $(JKS_PASSWORD) $(RELEASE_TV_AAB) tailscale
# gradle-dependencies groups together the android sources and libtailscale needed to assemble tests/debug/release builds.
.PHONY: gradle-dependencies
gradle-dependencies: $(shell find android -type f -not -path "android/build/*" -not -path '*/.*') $(LIBTAILSCALE)
gradle-dependencies: $(shell find android -type f -not -path "android/build/*" -not -path '*/.*') $(LIBTAILSCALE) tailscale.version
$(DEBUG_APK): gradle-dependencies
$(DEBUG_APK): version gradle-dependencies
(cd android && ./gradlew test assembleDebug)
install -C android/build/outputs/apk/debug/android-debug.apk $@
$(RELEASE_AAB): gradle-dependencies
$(RELEASE_AAB): version gradle-dependencies
@echo "Building release AAB"
(cd android && ./gradlew test bundleRelease)
install -C ./android/build/outputs/bundle/release/android-release.aab $@
$(RELEASE_TV_AAB): gradle-dependencies
$(RELEASE_TV_AAB): version gradle-dependencies
@echo "Building TV release AAB"
(cd android && ./gradlew test bundleRelease_tv)
install -C ./android/build/outputs/bundle/release_tv/android-release_tv.aab $@
tailscale-test.apk: gradle-dependencies
tailscale-test.apk: version gradle-dependencies
(cd android && ./gradlew assembleApplicationTestAndroidTest)
install -C ./android/build/outputs/apk/androidTest/applicationTest/android-applicationTest-androidTest.apk $@
tailscale.version: go.mod go.sum $(wildcard .git/HEAD)
$(shell ./tool/go run tailscale.com/cmd/mkversion > tailscale.version)
.PHONY: version
version: tailscale.version ## print the current version information
cat tailscale.version
#
# Go Builds:
#
@ -129,14 +142,15 @@ android/libs:
mkdir -p android/libs
$(GOBIN)/gomobile: $(GOBIN)/gobind go.mod go.sum
go install golang.org/x/mobile/cmd/gomobile
./tool/go install golang.org/x/mobile/cmd/gomobile
$(GOBIN)/gobind: go.mod go.sum
go install golang.org/x/mobile/cmd/gobind
./tool/go install golang.org/x/mobile/cmd/gobind
$(LIBTAILSCALE): Makefile android/libs $(shell find libtailscale -name *.go) go.mod go.sum $(GOBIN)/gomobile
gomobile bind -target android -androidapi 26 \
-ldflags "$(FULL_LDFLAGS)" \
$(LIBTAILSCALE): Makefile android/libs $(shell find libtailscale -name *.go) go.mod go.sum $(GOBIN)/gomobile tailscale.version
$(GOBIN)/gomobile bind -target android -androidapi 26 \
-tags "$$(./build-tags.sh)" \
-ldflags "-w $$(./version-ldflags.sh)" \
-o $@ ./libtailscale
.PHONY: libtailscale
@ -157,6 +171,7 @@ env:
@echo ANDROID_STUDIO_ROOT=$(ANDROID_STUDIO_ROOT)
@echo JAVA_HOME=$(JAVA_HOME)
@echo TOOLCHAINDIR=$(TOOLCHAINDIR)
@echo AVD_IMAGE="$(AVD_IMAGE)"
# Ensure that JKS_PATH and JKS_PASSWORD are set before we attempt a build
# that requires signing.
@ -180,28 +195,25 @@ androidpath:
@echo 'export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH'
.PHONY: tag_release
tag_release: ## Tag the current commit with the current version
git tag -a "$(VERSION_LONG)" -m "OSS and Version updated to ${VERSION_LONG}"
tag_release: tailscale.version ## Tag the current commit with the current version
source tailscale.version && git tag -a "$${VERSION_LONG}" -m "OSS and Version updated to $${VERSION_LONG}"
.PHONY: bumposs ## Bump to the latest oss and update teh versions.
bumposs: update-oss update-version
git commit -sm "android: bumping OSS" -m "OSS and Version updated to ${VERSION_LONG}" android/build.gradle go.mod go.sum
git tag -a "$(VERSION_LONG)" -m "OSS and Version updated to ${VERSION_LONG}"
.PHONY: bumposs ## Bump to the latest oss and update the versions.
bumposs: update-oss tailscale.version
source tailscale.version && git commit -sm "android: bump OSS" -m "OSS and Version updated to $${VERSION_LONG}" go.toolchain.rev android/build.gradle go.mod go.sum
source tailscale.version && git tag -a "$${VERSION_LONG}" -m "OSS and Version updated to $${VERSION_LONG}"
.PHONY: bump_version_code
bump_version_code: ## Bump the version code in build.gradle
sed -i'.bak' 's/versionCode .*/versionCode $(VERSIONCODE_PLUSONE)/' android/build.gradle && rm android/build.gradle.bak
.PHONY: update-version
update-version: ## Update the version in build.gradle
sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android/build.gradle && rm android/build.gradle.bak
sed -i'.bak' "s/versionCode .*/versionCode $$(expr $$(awk '/versionCode ([0-9]+)/{print $$2}' android/build.gradle) + 1)/" android/build.gradle && rm android/build.gradle.bak
.PHONY: update-oss
update-oss: ## Update the tailscale.com go module and update the version in build.gradle
GOPROXY=direct go get tailscale.com@main
go run tailscale.com/cmd/printdep --go > go.toolchain.rev
go mod tidy -compat=1.22
update-oss: ## Update the tailscale.com go module
GOPROXY=direct ./tool/go get tailscale.com@main
./tool/go mod tidy -compat=1.23
./tool/go run tailscale.com/cmd/printdep --go > go.toolchain.rev.new
mv go.toolchain.rev.new go.toolchain.rev
# Get the commandline tools package, this provides (among other things) the sdkmanager binary.
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager:
@ -235,6 +247,21 @@ checkandroidsdk: ## Check that Android SDK is installed
test: gradle-dependencies ## Run the Android tests
(cd android && ./gradlew test)
.PHONY: emulator
emulator: ## Start an android emulator instance
@echo "Checking installed SDK packages..."
@if ! $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q "$(AVD_IMAGE)"; then \
echo "$(AVD_IMAGE) not found, installing..."; \
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager "$(AVD_IMAGE)"; \
fi
@echo "Checking if AVD exists..."
@if ! $(ANDROID_HOME)/cmdline-tools/latest/bin/avdmanager list avd | grep -q "$(AVD)"; then \
echo "AVD $(AVD) not found, creating..."; \
$(ANDROID_HOME)/cmdline-tools/latest/bin/avdmanager create avd -n "$(AVD)" -k "$(AVD_IMAGE)"; \
fi
@echo "Starting emulator..."
@$(ANDROID_HOME)/emulator/emulator -avd "$(AVD)" -logcat-output /dev/stdout -netdelay none -netspeed full
.PHONY: install
install: $(DEBUG_APK) ## Install the debug APK on a connected device
adb install -r $<
@ -265,7 +292,7 @@ docker-all: docker-build-image docker-run-build $(DOCKER_IMAGE)
.PHONY: docker-shell
docker-shell: ## Builds a docker image with the android build env and opens a shell
docker build -f docker/DockerFile.amd64-shell -t tailscale-android-shell-amd64 .
docker run -v --rm $(CURDIR):/build/tailscale-android -it tailscale-android-shell-amd64
docker run --rm -v $(CURDIR):/build/tailscale-android -it tailscale-android-shell-amd64
.PHONY: docker-remove-shell-image
docker-remove-shell-image: ## Removes all docker shell image
@ -273,8 +300,12 @@ docker-remove-shell-image: ## Removes all docker shell image
.PHONY: clean
clean: ## Remove build artifacts. Does not purge docker build envs. Use dockerRemoveEnv for that.
@echo "Cleaning up old build artifacts"
-rm -rf android/build $(DEBUG_APK) $(RELEASE_AAB) $(RELEASE_TV_AAB) $(LIBTAILSCALE) android/libs *.apk *.aab
@echo "Cleaning cached toolchain"
-rm -rf $(HOME)/.cache/tailscale-go{,.extracted}
-pkill -f gradle
-rm tailscale.version
.PHONY: help
help: ## Show this help

@ -63,13 +63,13 @@ and XML files in Android Studio. Enable "Format on Save".
If you wish to avoid installing software on your host system, a Docker based development strategy is available, you can build and start a shell with:
```sh
make dockershell
make docker-shell
```
Several other makefile recipes are available for setting up the proper build environment and running builds.
Note that the docker makefile recipes s will preserve the image and remove container on completion.
If changes are made to the build environment or toolchain, cached docker images may need to be rebuilt.
Note that the docker makefile recipes s will preserve the image and remove container on completion.
If changes are made to the build environment or toolchain, cached docker images may need to be rebuilt.
The docker build image name is parameterized in the makefile and changing it provides a simple means to do this.
### Nix

@ -11,7 +11,7 @@ buildscript {
}
}
dependencies {
classpath 'com.android.tools.build:gradle:8.5.1'
classpath 'com.android.tools.build:gradle:8.6.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0")
@ -37,8 +37,17 @@ android {
defaultConfig {
minSdkVersion 26
targetSdkVersion 34
versionCode 241
versionName "1.73.13-taf3d3c433-g536e1adcc42"
versionCode 242
versionName getVersionProperty("VERSION_LONG")
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// This setting, which defaults to 'true', will cause Tailscale to fall
// back to the Google DNS servers if it cannot determine what the
// operating system's DNS configuration is.
//
// Set it to false either here or in your local.properties file to
// disable this behaviour.
buildConfigField "boolean", "USE_GOOGLE_DNS_FALLBACK", getLocalProperty("tailscale.useGoogleDnsFallback", "true")
}
compileOptions {
@ -46,6 +55,10 @@ android {
targetCompatibility JavaVersion.VERSION_17
}
lintOptions {
warningsAsErrors true
}
kotlinOptions {
jvmTarget = "17"
}
@ -54,6 +67,7 @@ android {
jvmTarget = "17"
}
buildFeatures {
buildConfig true
compose true
}
composeOptions {
@ -66,9 +80,9 @@ android {
applicationTest {
initWith debug
manifestPlaceholders.leanbackRequired = false
buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername")+"\""
buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword")+"\""
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret")+"\""
buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername", "")+"\""
buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword", "")+"\""
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret", "")+"\""
}
debug {
manifestPlaceholders.leanbackRequired = false
@ -99,7 +113,7 @@ dependencies {
implementation 'androidx.core:core-ktx:1.13.1'
implementation "androidx.browser:browser:1.8.0"
implementation "androidx.security:security-crypto:1.1.0-alpha06"
implementation "androidx.work:work-runtime:2.9.0"
implementation "androidx.work:work-runtime:2.9.1"
// Kotlin dependencies.
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
@ -110,20 +124,21 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
// Compose dependencies.
def composeBom = platform('androidx.compose:compose-bom:2024.06.00')
def composeBom = platform('androidx.compose:compose-bom:2024.09.03')
implementation composeBom
implementation 'androidx.compose.material3:material3:1.2.1'
implementation 'androidx.compose.material:material-icons-core:1.6.8'
implementation "androidx.compose.ui:ui:1.6.8"
implementation "androidx.compose.ui:ui-tooling:1.6.8"
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3'
implementation 'androidx.activity:activity-compose:1.9.0'
implementation 'androidx.compose.material3:material3:1.3.0'
implementation 'androidx.compose.material:material-icons-core:1.7.3'
implementation "androidx.compose.ui:ui:1.7.3"
implementation "androidx.compose.ui:ui-tooling:1.7.3"
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
implementation 'androidx.activity:activity-compose:1.9.2'
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
implementation "androidx.core:core-splashscreen:1.1.0-rc01"
implementation "androidx.compose.animation:animation:1.7.4"
// Navigation dependencies.
def nav_version = "2.7.7"
def nav_version = "2.8.2"
implementation "androidx.navigation:navigation-compose:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
@ -138,7 +153,7 @@ dependencies {
// Integration Tests
androidTestImplementation composeBom
androidTestImplementation 'androidx.test:runner:1.6.1'
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
@ -151,17 +166,30 @@ dependencies {
// Unit Tests
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.12.0'
testImplementation 'org.mockito:mockito-inline:5.2.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.4.0'
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.compose.ui:ui-tooling-preview")
}
def getLocalProperty(key) {
def getLocalProperty(key, defaultValue) {
try {
Properties properties = new Properties()
properties.load(project.file('local.properties').newDataInputStream())
return properties.getProperty(key)
return properties.getProperty(key) ?: defaultValue
} catch(Throwable ignored) {
return ""
return defaultValue
}
}
def getVersionProperty(key) {
// tailscale.version is created / updated by the makefile, it is in a loosely
// Makfile/envfile format, which is also loosely a properties file format.
// make tailscale.version
def versionProps = new Properties()
versionProps.load(project.file('../tailscale.version').newDataInputStream())
return versionProps.getProperty(key).replaceAll('^\"|\"$', '')
}

@ -3,6 +3,13 @@
native <methods>;
}
# Keep Tailcale classes for debuggability, but especially
# keep the classes with syspolicy MDM keys, some of which
# get used only by the Go backend. (The second rule is redundant,
# but explicit.)
-keep class com.tailscale.ipn.** { *; }
-keep class com.tailscale.ipn.mdm.** { *; }
# Keep specific classes from Tink library
-keep class com.google.crypto.tink.** { *; }
@ -18,4 +25,4 @@
# Keep Joda-Time classes
-keep class org.joda.time.** { *; }
-dontwarn org.joda.time.**
-dontwarn org.joda.time.**

@ -17,6 +17,11 @@ import androidx.test.uiautomator.UiSelector
import dev.turingcomplete.kotlinonetimepassword.HmacAlgorithm
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordGenerator
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import org.apache.commons.codec.binary.Base32
import org.junit.After
import org.junit.Assert
@ -24,11 +29,6 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@RunWith(AndroidJUnit4::class)
@LargeTest
@ -59,18 +59,18 @@ class MainActivityTest {
timeStep = 30,
timeStepUnit = TimeUnit.SECONDS)
val githubTOTP = TimeBasedOneTimePasswordGenerator(github2FASecret, config)
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
Log.d(TAG, "Wait for VPN permission prompt and accept")
device.find(By.text("Connection request"))
device.find(By.text("OK")).click()
Log.d(TAG, "Click through Get Started screen")
device.find(By.text("Get Started"))
device.find(By.text("Get Started")).click()
Log.d(TAG, "Wait for VPN permission prompt and accept")
device.find(By.text("Connection request"))
device.find(By.text("OK")).click()
asNecessary(
timeout = 2.minutes,
2.minutes,
{
Log.d(TAG, "Log in")
device.find(By.text("Log in")).click()
@ -93,7 +93,6 @@ class MainActivityTest {
},
{
Log.d(TAG, "Make sure GitHub page has loaded")
device.find(By.text("New to GitHub"))
device.find(By.text("Username or email address"))
device.find(By.text("Sign in"))
},
@ -115,10 +114,15 @@ class MainActivityTest {
.setText(githubTOTP.generate())
device.find(UiSelector().instance(0).className(Button::class.java)).click()
},
{
Log.d(TAG, "Authorizing Tailscale")
device.find(By.text("Authorize tailscale")).click()
},
{
Log.d(TAG, "Accept Tailscale app")
device.find(By.text("Learn more about OAuth"))
// Sleep a little to give button time to activate
Thread.sleep(5.seconds.inWholeMilliseconds)
device.find(UiSelector().instance(1).className(Button::class.java)).click()
},
@ -126,8 +130,7 @@ class MainActivityTest {
Log.d(TAG, "Connect device")
device.find(By.text("Connect device"))
device.find(UiSelector().instance(0).className(Button::class.java)).click()
},
)
})
try {
Log.d(TAG, "Accept Permission (Either Storage or Notifications)")

@ -7,15 +7,13 @@ import android.app.Application
import android.app.Notification
import android.app.NotificationChannel
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.Environment
import android.util.Log
@ -28,6 +26,7 @@ import androidx.lifecycle.ViewModelStoreOwner
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.MDMSettingsChangedReceiver
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.localapi.Request
import com.tailscale.ipn.ui.model.Ipn
@ -35,17 +34,19 @@ import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import libtailscale.Libtailscale
import java.io.File
import java.io.IOException
import java.net.InetAddress
import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
@ -56,11 +57,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
companion object {
private const val FILE_CHANNEL_ID = "tailscale-files"
private const val TAG = "App"
private val networkConnectivityRequest =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()
private lateinit var appInstance: App
/**
@ -76,28 +72,42 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
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
lateinit var vpnViewModel: VpnViewModel
private set
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
var healthNotifier: HealthNotifier? = null
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
override fun isPlayVersion(): Boolean = MaybeGoogle.isGoogle()
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
setUnprotectedInstance(this)
mdmChangeReceiver = MDMSettingsChangedReceiver()
val filter = IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED)
registerReceiver(mdmChangeReceiver, filter)
createNotificationChannel(
STATUS_CHANNEL_ID,
getString(R.string.vpn_status),
@ -113,8 +123,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
getString(R.string.health_channel_name),
getString(R.string.health_channel_description),
NotificationManagerCompat.IMPORTANCE_HIGH)
appInstance = this
setUnprotectedInstance(this)
}
override fun onTerminate() {
@ -123,17 +131,22 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
notificationManager.cancelAll()
applicationScope.cancel()
viewModelStore.clear()
unregisterReceiver(mdmChangeReceiver)
}
private var isInitialized = false
@Volatile private var isInitialized = false
@Synchronized
private fun initOnce() {
if (isInitialized) {
return
}
initializeApp()
isInitialized = true
}
private fun initializeApp() {
val dataDir = this.filesDir.absolutePath
// Set this to enable direct mode for taildrop whereby downloads will be saved directly
@ -145,76 +158,56 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
healthNotifier = HealthNotifier(Notifier.health, applicationScope)
healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
setAndRegisterNetworkCallbacks()
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
initViewModels()
applicationScope.launch {
Notifier.state.collect { state ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
// If VPN is stopped, show a disconnected notification. If it is running as a foregrround
// service, IPNService will show a connected notification.
if (state == Ipn.State.Stopped) {
notifyStatus(false)
}
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
updateConnStatus(ableToStartVPN)
QuickToggleService.setVPNRunning(vpnRunning)
Notifier.state.collect { _ ->
combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled ->
Pair(state, forceEnabled)
}
.collect { (state, hideDisconnectAction) ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
// If VPN is stopped, show a disconnected notification. If it is running as a
// foreground
// service, IPNService will show a connected notification.
if (state == Ipn.State.Stopped) {
notifyStatus(vpnRunning = false, hideDisconnectAction = hideDisconnectAction.value)
}
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
updateConnStatus(ableToStartVPN)
QuickToggleService.setVPNRunning(vpnRunning)
// Update notification status when VPN is running
if (vpnRunning) {
notifyStatus(vpnRunning = true, hideDisconnectAction = hideDisconnectAction.value)
}
}
}
}
initViewModels()
applicationScope.launch {
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
}
}
private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
}
fun setWantRunning(wantRunning: Boolean) {
fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold(
onSuccess = {},
onSuccess = { onSuccess?.invoke() },
onFailure = { error ->
Log.d("TAG", "Set want running: failed to update preferences: ${error.message}")
TSLog.d("TAG", "Set want running: failed to update preferences: ${error.message}")
})
}
Client(applicationScope)
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback)
}
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is
// possible that this might return an unusuable network, eg a captive portal.
private fun setAndRegisterNetworkCallbacks() {
connectivityManager.requestNetwork(
networkConnectivityRequest,
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
val sb = StringBuilder()
val linkProperties: LinkProperties? = connectivityManager.getLinkProperties(network)
val dnsList: MutableList<InetAddress> = linkProperties?.dnsServers ?: mutableListOf()
for (ip in dnsList) {
sb.append(ip.hostAddress).append(" ")
}
val searchDomains: String? = linkProperties?.domains
if (searchDomains != null) {
sb.append("\n")
sb.append(searchDomains)
}
if (dns.updateDNSFromNetwork(sb.toString())) {
Libtailscale.onDNSConfigChanged(linkProperties?.interfaceName)
}
}
override fun onLost(network: Network) {
super.onLost(network)
if (dns.updateDNSFromNetwork("")) {
Libtailscale.onDNSConfigChanged("")
}
}
})
}
// 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)
@ -249,7 +242,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
private fun updateConnStatus(ableToStartVPN: Boolean) {
setAbleToStartVPN(ableToStartVPN)
QuickToggleService.updateTile()
Log.d("App", "Set Tile Ready: $ableToStartVPN")
TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
}
override fun getModelName(): String {
@ -312,14 +305,14 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
downloads.mkdirs()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to create downloads folder: $e")
TSLog.e(TAG, "Failed to create downloads folder: $e")
downloads = File(this.filesDir, "Taildrop")
try {
if (!downloads.exists()) {
downloads.mkdirs()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to create Taildrop folder: $e")
TSLog.e(TAG, "Failed to create Taildrop folder: $e")
downloads = File("")
}
}
@ -354,7 +347,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val list = setting.value as? List<*>
return Json.encodeToString(list)
} catch (e: Exception) {
Log.d("MDM", "$key value cannot be serialized to JSON. Throwing NoSuchKeyException.")
TSLog.d("MDM", "$key value cannot be serialized to JSON. Throwing NoSuchKeyException.")
throw MDMSettings.NoSuchKeyException()
}
}
@ -389,6 +382,8 @@ open class UninitializedApp : Application() {
private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat
lateinit var vpnViewModel: VpnViewModel
@JvmStatic
fun get(): UninitializedApp {
return appInstance
@ -414,16 +409,27 @@ open class UninitializedApp : Application() {
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
// be updated rather than creating multiple redundant instances.
val pendingIntent =
PendingIntent.getService(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+
)
try {
startForegroundService(intent)
pendingIntent.send()
} catch (foregroundServiceStartException: IllegalStateException) {
Log.e(
TSLog.e(
TAG,
"startVPN hit ForegroundServiceStartNotAllowedException in startForegroundService(): $foregroundServiceStartException")
"startVPN hit ForegroundServiceStartNotAllowedException: $foregroundServiceStartException")
} catch (securityException: SecurityException) {
Log.e(TAG, "startVPN hit SecurityException in startForegroundService(): $securityException")
TSLog.e(TAG, "startVPN hit SecurityException: $securityException")
} catch (e: Exception) {
Log.e(TAG, "startVPN hit exception in startForegroundService(): $e")
TSLog.e(TAG, "startVPN hit exception: $e")
}
}
@ -432,16 +438,32 @@ open class UninitializedApp : Application() {
try {
startService(intent)
} catch (illegalStateException: IllegalStateException) {
Log.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException")
TSLog.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException")
} catch (e: Exception) {
Log.e(TAG, "stopVPN hit exception in startService(): $e")
TSLog.e(TAG, "stopVPN hit exception in startService(): $e")
}
}
// Calls stopVPN() followed by startVPN() to restart the VPN.
fun restartVPN() {
// Register a receiver to listen for the completion of stopVPN
val stopReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
// Ensure stop intent is complete
if (intent?.action == IPNService.ACTION_STOP_VPN) {
// Unregister receiver after receiving the broadcast
context?.unregisterReceiver(this)
// Now start the VPN
startVPN()
}
}
}
// Register the receiver before stopping VPN
val intentFilter = IntentFilter(IPNService.ACTION_STOP_VPN)
this.registerReceiver(stopReceiver, intentFilter)
stopVPN()
startVPN()
}
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
@ -451,8 +473,8 @@ open class UninitializedApp : Application() {
notificationManager.createNotificationChannel(channel)
}
fun notifyStatus(vpnRunning: Boolean) {
notifyStatus(buildStatusNotification(vpnRunning))
fun notifyStatus(vpnRunning: Boolean, hideDisconnectAction: Boolean) {
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction))
}
fun notifyStatus(notification: Notification) {
@ -470,7 +492,7 @@ open class UninitializedApp : Application() {
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
}
fun buildStatusNotification(vpnRunning: Boolean): Notification {
fun buildStatusNotification(vpnRunning: Boolean, hideDisconnectAction: Boolean): Notification {
val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
val action =
@ -492,24 +514,27 @@ open class UninitializedApp : Application() {
PendingIntent.getActivity(
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(icon)
.setContentTitle("Tailscale")
.setContentText(message)
.setAutoCancel(!vpnRunning)
.setOnlyAlertOnce(!vpnRunning)
.setOngoing(vpnRunning)
.setSilent(true)
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.addAction(NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build())
.setContentIntent(pendingIntent)
.build()
val builder =
NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(icon)
.setContentTitle(getString(R.string.app_name))
.setContentText(message)
.setAutoCancel(!vpnRunning)
.setOnlyAlertOnce(!vpnRunning)
.setOngoing(vpnRunning)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
if (!vpnRunning || !hideDisconnectAction) {
builder.addAction(
NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build())
}
return builder.build()
}
fun addUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) {
Log.e(TAG, "addUserDisallowedPackageName called with empty packageName")
TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName")
return
}
@ -524,7 +549,7 @@ open class UninitializedApp : Application() {
fun removeUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) {
Log.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
return
}
@ -542,7 +567,7 @@ open class UninitializedApp : Application() {
val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (mdmDisallowed.isNotEmpty()) {
Log.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
return builtInDisallowedPackageNames + mdmDisallowed
}
val userDisallowed =
@ -550,6 +575,10 @@ open class UninitializedApp : Application() {
return builtInDisallowedPackageNames + userDisallowed
}
fun getAppScopedViewModel(): VpnViewModel {
return vpnViewModel
}
val builtInDisallowedPackageNames: List<String> =
listOf(
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
@ -572,5 +601,7 @@ open class UninitializedApp : Application() {
"com.vna.service.vvm",
"com.dish.vvm",
"com.comcast.modesto.vvm.client",
// Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128
"com.google.android.apps.scone",
)
}

@ -0,0 +1,35 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.content.Context
import android.os.Build
import android.util.Log
object AppSourceChecker {
const val TAG = "AppSourceChecker"
fun getInstallSource(context: Context): String {
val packageManager = context.packageManager
val packageName = context.packageName
Log.d(TAG, "Package name: $packageName")
val installerPackageName =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
packageManager.getInstallSourceInfo(packageName).installingPackageName
} else {
@Suppress("deprecation") packageManager.getInstallerPackageName(packageName)
}
Log.d(TAG, "Installer package name: $installerPackageName")
return when (installerPackageName) {
"com.android.vending" -> "googleplay"
"org.fdroid.fdroid" -> "fdroid"
"com.amazon.venezia" -> "amazon"
null -> "unknown"
else -> "unknown($installerPackageName)"
}
}
}

@ -8,35 +8,51 @@ import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.system.OsConstants
import android.util.Log
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 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"
private val randomID: String = UUID.randomUUID().toString()
private lateinit var app: App
val scope = CoroutineScope(Dispatchers.IO)
override fun id(): String {
return randomID
}
override fun updateVpnStatus(status: Boolean) {
app.getAppScopedViewModel().setVpnActive(status)
}
override fun onCreate() {
super.onCreate()
// grab app to make sure it initializes
App.get()
app = App.get()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
when (intent?.action) {
ACTION_STOP_VPN -> {
App.get().setWantRunning(false)
app.setWantRunning(false)
close()
START_NOT_STICKY
}
ACTION_START_VPN -> {
showForegroundNotification()
App.get().setWantRunning(true)
scope.launch {
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
showForegroundNotification(hideDisconnectAction.value)
}
app.setWantRunning(true)
Libtailscale.requestVPN(this)
START_STICKY
}
@ -44,8 +60,12 @@ open class IPNService : VpnService(), libtailscale.IPNService {
// This means we were started by Android due to Always On VPN.
// We show a non-foreground notification because we weren't
// started as a foreground service.
App.get().notifyStatus(true)
App.get().setWantRunning(true)
scope.launch {
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
app.notifyStatus(true, hideDisconnectAction.value)
}
app.setWantRunning(true)
Libtailscale.requestVPN(this)
START_STICKY
}
@ -53,7 +73,11 @@ open class IPNService : VpnService(), libtailscale.IPNService {
// This means that we were restarted after the service was killed
// (potentially due to OOM).
if (UninitializedApp.get().isAbleToStartVPN()) {
showForegroundNotification()
scope.launch {
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
showForegroundNotification(hideDisconnectAction.value)
}
App.get()
Libtailscale.requestVPN(this)
START_STICKY
@ -64,27 +88,39 @@ open class IPNService : VpnService(), libtailscale.IPNService {
}
override fun close() {
stopForeground(STOP_FOREGROUND_REMOVE)
app.setWantRunning(false) {}
Notifier.setState(Ipn.State.Stopping)
disconnectVPN()
Libtailscale.serviceDisconnect(this)
}
override fun disconnectVPN() {
stopSelf()
}
override fun onDestroy() {
close()
updateVpnStatus(false)
super.onDestroy()
}
override fun onRevoke() {
close()
updateVpnStatus(false)
super.onRevoke()
}
private fun showForegroundNotification() {
private fun setVpnPrepared(isPrepared: Boolean) {
app.getAppScopedViewModel().setVpnPrepared(isPrepared)
}
private fun showForegroundNotification(hideDisconnectAction: Boolean) {
try {
startForeground(
UninitializedApp.STATUS_NOTIFICATION_ID,
UninitializedApp.get().buildStatusNotification(true))
UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction))
} catch (e: Exception) {
Log.e(TAG, "Failed to start foreground service: $e")
TSLog.e(TAG, "Failed to start foreground service: $e")
}
}
@ -100,7 +136,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
try {
b.addDisallowedApplication(name)
} catch (e: PackageManager.NameNotFoundException) {
Log.d(TAG, "Failed to add disallowed application: $e")
TSLog.d(TAG, "Failed to add disallowed application: $e")
}
}
@ -122,7 +158,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
// Tailscale,
// then only allow those apps.
for (packageName in includedPackages) {
Log.d(TAG, "Including app: $packageName")
TSLog.d(TAG, "Including app: $packageName")
b.addAllowedApplication(packageName)
}
} else {
@ -130,7 +166,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
// - any app that the user manually disallowed in the GUI
// - any app that we disallowed via hard-coding
for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) {
Log.d(TAG, "Disallowing app: $disallowedPackageName")
TSLog.d(TAG, "Disallowing app: $disallowedPackageName")
disallowApp(b, disallowedPackageName)
}
}

@ -16,7 +16,6 @@ import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
@ -67,6 +66,7 @@ import com.tailscale.ipn.ui.view.MullvadInfoView
import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.PermissionsView
import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.SearchView
import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
import com.tailscale.ipn.ui.view.TailnetLockSetupView
@ -78,6 +78,7 @@ import com.tailscale.ipn.ui.viewModel.MainViewModelFactory
import com.tailscale.ipn.ui.viewModel.PingViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
@ -89,7 +90,7 @@ class MainActivity : ComponentActivity() {
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by lazy {
val app = App.get()
vpnViewModel = app.vpnViewModel
vpnViewModel = app.getAppScopedViewModel()
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
}
private lateinit var vpnViewModel: VpnViewModel
@ -128,16 +129,16 @@ class MainActivity : ComponentActivity() {
vpnPermissionLauncher =
registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) {
Log.d("VpnPermission", "VPN permission granted")
TSLog.d("VpnPermission", "VPN permission granted")
vpnViewModel.setVpnPrepared(true)
App.get().startVPN()
} else {
if (isAnotherVpnActive(this)) {
Log.d("VpnPermission", "Another VPN is likely active")
TSLog.d("VpnPermission", "Another VPN is likely active")
showOtherVPNConflictDialog()
} else {
Log.d("VpnPermission", "Permission was denied by the user")
viewModel.setVpnPrepared(false)
TSLog.d("VpnPermission", "Permission was denied by the user")
vpnViewModel.setVpnPrepared(false)
}
}
}
@ -174,7 +175,8 @@ class MainActivity : ComponentActivity() {
navController.navigate("peerDetails/${it.StableID}")
},
onNavigateToExitNodes = { navController.navigate("exitNodes") },
onNavigateToHealth = { navController.navigate("health") })
onNavigateToHealth = { navController.navigate("health") },
onNavigateToSearch = { navController.navigate("search") })
val settingsNav =
SettingsNav(
@ -214,6 +216,12 @@ class MainActivity : ComponentActivity() {
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
}
composable("search") {
SearchView(
viewModel = viewModel,
navController = navController,
onNavigateBack = { navController.popBackStack() })
}
composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("health") { HealthView(backTo("main")) }
@ -231,7 +239,7 @@ class MainActivity : ComponentActivity() {
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
PeerDetails(
backTo("main"),
{ navController.popBackStack() },
it.arguments?.getString("nodeId") ?: "",
PingViewModel())
}
@ -357,7 +365,7 @@ class MainActivity : ComponentActivity() {
}
}
} catch (e: Exception) {
Log.e(TAG, "Login: failed to start MainActivity: $e")
TSLog.e(TAG, "Login: failed to start MainActivity: $e")
}
}
@ -371,7 +379,7 @@ class MainActivity : ComponentActivity() {
val fallbackIntent = Intent(Intent.ACTION_VIEW, url)
startActivity(fallbackIntent)
} catch (e: Exception) {
Log.e(TAG, "Login: failed to open browser: $e")
TSLog.e(TAG, "Login: failed to open browser: $e")
}
}
}

@ -1,35 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Activity;
import java.lang.reflect.Method;
public class MaybeGoogle {
static boolean isGoogle() {
return getGoogle() != null;
}
static String getIdTokenForActivity(Activity act) {
Class<?> google = getGoogle();
if (google == null) {
return "";
}
try {
Method method = google.getMethod("getIdTokenForActivity", Activity.class);
return (String) method.invoke(null, act);
} catch (Exception e) {
return "";
}
}
private static Class getGoogle() {
try {
return Class.forName("com.tailscale.ipn.Google");
} catch (ClassNotFoundException e) {
return null;
}
}
}

@ -0,0 +1,168 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.util.Log
import com.tailscale.ipn.util.TSLog
import libtailscale.Libtailscale
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
object NetworkChangeCallback {
private const val TAG = "NetworkChangeCallback"
private data class NetworkInfo(var caps: NetworkCapabilities, var linkProps: LinkProperties)
private val lock = ReentrantLock()
private val activeNetworks = mutableMapOf<Network, NetworkInfo>() // keyed by Network
// monitorDnsChanges sets up a network callback to monitor changes to the
// system's network state and update the DNS configuration when interfaces
// become available or properties of those interfaces change.
fun monitorDnsChanges(connectivityManager: ConnectivityManager, dns: DnsConfig) {
val networkConnectivityRequest =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()
// Use registerNetworkCallback to listen for updates from all networks, and
// then update DNS configs for the best network when LinkProperties are changed.
// Per
// https://developer.android.com/reference/android/net/ConnectivityManager.NetworkCallback#onAvailable(android.net.Network), this happens after all other updates.
//
// Note that we can't use registerDefaultNetworkCallback because the
// default network used by Tailscale will always show up with capability
// NOT_VPN=false, and we must filter out NOT_VPN networks to avoid routing
// loops.
connectivityManager.registerNetworkCallback(
networkConnectivityRequest,
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
TSLog.d(TAG, "onAvailable: network ${network}")
lock.withLock {
activeNetworks[network] = NetworkInfo(NetworkCapabilities(), LinkProperties())
}
}
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
super.onCapabilitiesChanged(network, capabilities)
lock.withLock { activeNetworks[network]?.caps = capabilities }
}
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
super.onLinkPropertiesChanged(network, linkProperties)
lock.withLock {
activeNetworks[network]?.linkProps = linkProperties
maybeUpdateDNSConfig("onLinkPropertiesChanged", dns)
}
}
override fun onLost(network: Network) {
super.onLost(network)
TSLog.d(TAG, "onLost: network ${network}")
lock.withLock {
activeNetworks.remove(network)
maybeUpdateDNSConfig("onLost", dns)
}
}
})
}
// pickNonMetered returns the first non-metered network in the list of
// networks, or the first network if none are non-metered.
private fun pickNonMetered(networks: Map<Network, NetworkInfo>): Network? {
for ((network, info) in networks) {
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
return network
}
}
return networks.keys.firstOrNull()
}
// pickDefaultNetwork returns a non-VPN network to use as the 'default'
// network; one that is used as a gateway to the internet and from which we
// obtain our DNS servers.
private fun pickDefaultNetwork(): Network? {
// Filter the list of all networks to those that have the INTERNET
// capability, are not VPNs, and have a non-zero number of DNS servers
// available.
val networks =
activeNetworks.filter { (_, info) ->
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) &&
info.linkProps.dnsServers.isNotEmpty() == true
}
// If we have one; just return it; otherwise, prefer networks that are also
// not metered (i.e. cell modems).
val nonMeteredNetwork = pickNonMetered(networks)
if (nonMeteredNetwork != null) {
return nonMeteredNetwork
}
// Okay, less good; just return the first network that has the INTERNET and
// NOT_VPN capabilities; even though this interface doesn't have any DNS
// servers set, we'll use our DNS fallback servers to make queries. It's
// strictly better to return an interface + use the DNS fallback servers
// than to return nothing and not be able to route traffic.
for ((network, info) in activeNetworks) {
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
Log.w(
TAG,
"no networks available that also have DNS servers set; falling back to first network ${network}")
return network
}
}
// Otherwise, return nothing; we don't want to return a VPN network since
// it could result in a routing loop, and a non-INTERNET network isn't
// helpful.
Log.w(TAG, "no networks available to pick a default network")
return null
}
// maybeUpdateDNSConfig will maybe update our DNS configuration based on the
// current set of active Networks.
private fun maybeUpdateDNSConfig(why: String, dns: DnsConfig) {
val defaultNetwork = pickDefaultNetwork()
if (defaultNetwork == null) {
TSLog.d(TAG, "${why}: no default network available; not updating DNS config")
return
}
val info = activeNetworks[defaultNetwork]
if (info == null) {
Log.w(
TAG,
"${why}: [unexpected] no info available for default network; not updating DNS config")
return
}
val sb = StringBuilder()
for (ip in info.linkProps.dnsServers) {
sb.append(ip.hostAddress).append(" ")
}
val searchDomains: String? = info.linkProps.domains
if (searchDomains != null) {
sb.append("\n")
sb.append(searchDomains)
}
if (dns.updateDNSFromNetwork(sb.toString())) {
TSLog.d(
TAG,
"${why}: updated DNS config for network ${defaultNetwork} (${info.linkProps.interfaceName})")
Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName)
}
}
}

@ -60,6 +60,7 @@ public class QuickToggleService extends TileService {
}
}
@SuppressWarnings("deprecation")
@Override
public void onClick() {
boolean r;
@ -77,6 +78,7 @@ public class QuickToggleService extends TileService {
// Request code for opening activity.
startActivityAndCollapse(PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
} else {
// Deprecated, but still required for older versions.
startActivityAndCollapse(i);
}
}

@ -8,20 +8,24 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.TaildropView
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.random.Random
// ShareActivity is the entry point for Taildrop share intents
@ -47,7 +51,7 @@ class ShareActivity : ComponentActivity() {
super.onStart()
// Ensure our app instance is initialized
App.get()
loadFiles()
lifecycleScope.launch { withContext(Dispatchers.IO) { loadFiles() } }
}
override fun onNewIntent(intent: Intent) {
@ -59,7 +63,7 @@ class ShareActivity : ComponentActivity() {
// Loads the files from the intent.
fun loadFiles() {
if (intent == null) {
Log.e(TAG, "Share failure - No intent found")
TSLog.e(TAG, "Share failure - No intent found")
return
}
@ -83,43 +87,42 @@ class ShareActivity : ComponentActivity() {
}
}
else -> {
Log.e(TAG, "No extras found in intent - nothing to share")
TSLog.e(TAG, "No extras found in intent - nothing to share")
null
}
}
val pendingFiles: List<Ipn.OutgoingFile> =
uris?.filterNotNull()?.mapNotNull {
contentResolver?.query(it, null, null, null, null)?.let { c ->
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = c.getColumnIndex(OpenableColumns.SIZE)
c.moveToFirst()
val name: String =
c.getString(nameCol)
?: run {
// For some reason, some content resolvers don't return a name.
// Try to build a name from a random integer plus file extension
// (if type can be determined), else just a random integer.
val rand = Random.nextLong()
contentResolver.getType(it)?.let { mimeType ->
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let {
extension ->
"$rand.$extension"
} ?: "$rand"
} ?: "$rand"
val pendingFiles: List<Ipn.OutgoingFile> =
uris?.filterNotNull()?.mapNotNull { uri ->
contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)
if (cursor.moveToFirst()) {
val name: String = cursor.getString(nameCol)
?: generateFallbackName(uri)
val size: Long = cursor.getLong(sizeCol)
Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply {
this.uri = uri
}
val size = c.getLong(sizeCol)
c.close()
val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size)
file.uri = it
file
}
} ?: emptyList()
} else {
TSLog.e(TAG, "Cursor is empty for URI: $uri")
null
}
}
} ?: emptyList()
if (pendingFiles.isEmpty()) {
Log.e(TAG, "Share failure - no files extracted from intent")
TSLog.e(TAG, "Share failure - no files extracted from intent")
}
requestedTransfers.set(pendingFiles)
}
private fun generateFallbackName(uri: Uri): String {
val randomId = Random.nextLong()
val mimeType = contentResolver?.getType(uri)
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
return if (extension != null) "$randomId.$extension" else randomId.toString()
}
}

@ -15,6 +15,8 @@ import androidx.annotation.NonNull;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import com.tailscale.ipn.util.TSLog;
/**
* A worker that exists to support IPNReceiver.
*/
@ -38,7 +40,7 @@ public final class StartVPNWorker extends Worker {
}
// 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.");
TSLog.e("StartVPNWorker", "Tailscale isn't ready to start the VPN, notify the user.");
// Send notification
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);

@ -0,0 +1,21 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.mdm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.RestrictionsManager
import com.tailscale.ipn.App
import com.tailscale.ipn.util.TSLog
class MDMSettingsChangedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == android.content.Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) {
TSLog.d("syspolicy", "MDM settings changed")
val restrictionsManager = context?.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
MDMSettings.update(App.get(), restrictionsManager)
}
}
}

@ -46,9 +46,26 @@ class StringMDMSetting(key: String, localizedTitle: String) :
class StringArrayListMDMSetting(key: String, localizedTitle: String) :
MDMSetting<List<String>?>(null, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle) = bundle.getStringArrayList(key)
override fun getFromPrefs(prefs: SharedPreferences) =
prefs.getStringSet(key, HashSet<String>())?.toList()
override fun getFromBundle(bundle: Bundle): List<String>? {
// Try to retrieve the value as a String[] first
val stringArray = bundle.getStringArray(key)
if (stringArray != null) {
return stringArray.toList()
}
// Optionally, handle other types if necessary
val stringArrayList = bundle.getStringArrayList(key)
if (stringArrayList != null) {
return stringArrayList
}
// If neither String[] nor ArrayList<String> is found, return null
return null
}
override fun getFromPrefs(prefs: SharedPreferences): List<String>? {
return prefs.getStringSet(key, HashSet<String>())?.toList()
}
}
class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :

@ -4,7 +4,6 @@
package com.tailscale.ipn.ui.localapi
import android.content.Context
import android.util.Log
import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.model.Errors
import com.tailscale.ipn.ui.model.Ipn
@ -13,6 +12,8 @@ import com.tailscale.ipn.ui.model.IpnState
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 com.tailscale.ipn.App
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -67,6 +68,11 @@ typealias PingResultHandler = (Result<IpnState.PingResult>) -> Unit
class Client(private val scope: CoroutineScope) {
private val TAG = Client::class.simpleName
// Access libtailscale.Application lazily
private val app: libtailscale.Application by lazy {
App.get().getLibtailscaleApp()
}
fun start(options: Ipn.Options, responseHandler: (Result<Unit>) -> Unit) {
val body = Json.encodeToString(options).toByteArray()
return post(Endpoint.START, body, responseHandler = responseHandler)
@ -175,7 +181,7 @@ class Client(private val scope: CoroutineScope) {
})
} catch (e: Exception) {
parts.forEach { it.body.close() }
Log.e(TAG, "Error creating file upload body: $e")
TSLog.e(TAG, "Error creating file upload body: $e")
responseHandler(Result.failure(e))
return
}
@ -307,7 +313,7 @@ class Request<T>(
@OptIn(ExperimentalSerializationApi::class)
fun execute() {
scope.launch(Dispatchers.IO) {
Log.d(TAG, "Executing request:${method}:${fullPath} on app $app")
TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app")
try {
val resp =
if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts)
@ -350,7 +356,7 @@ class Request<T>(
// The response handler will invoked internally by the request parser
scope.launch { responseHandler(response) }
} catch (e: Exception) {
Log.e(TAG, "Error executing request:${method}:${fullPath}: $e")
TSLog.e(TAG, "Error executing request:${method}:${fullPath}: $e")
scope.launch { responseHandler(Result.failure(e)) }
}
}

@ -18,7 +18,11 @@ class Ipn {
NeedsMachineAuth(3),
Stopped(4),
Starting(5),
Running(6);
Running(6),
// Stopping represents a state where a request to stop Tailscale has been issue but has not
// completed. This state allows UI to optimistically reflect a stopped state, and to fallback if
// necessary.
Stopping(7);
companion object {
fun fromInt(value: Int): State {
@ -158,7 +162,7 @@ class Ipn {
ForceDaemonSet = true
}
var Hostname: Boolean? = null
var Hostname: String? = null
set(value) {
field = value
HostnameSet = true

@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
import java.net.URL
class IpnState {
@Serializable
@ -123,9 +124,29 @@ class IpnLocal {
val UserProfile: Tailcfg.UserProfile,
val NetworkProfile: Tailcfg.NetworkProfile? = null,
val LocalUserID: String,
var ControlURL: String? = null,
) {
fun isEmpty(): Boolean {
return ID.isEmpty()
}
// Returns true if the profile uses a custom control server (not Tailscale SaaS).
private fun isUsingCustomControlServer(): Boolean {
return ControlURL != null && ControlURL != "https://controlplane.tailscale.com"
}
// Returns the hostname of the custom control server, if any was set.
//
// Returns null if the ControlURL provided by the backend is an invalid URL, and
// a hostname cannot be extracted.
fun customControlServerHostname(): String? {
if (!isUsingCustomControlServer()) return null
return try {
URL(ControlURL).host
} catch (e: Exception) {
null
}
}
}
}

@ -22,7 +22,7 @@ object Permissions {
@Composable
get() {
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
return all.zip(permissionStates.permissions).filter { (permission, state) ->
return all.zip(permissionStates.permissions).filter { (_, state) ->
!state.status.isGranted && !state.status.shouldShowRationale
}
}

@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.notifier
import android.Manifest
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import com.tailscale.ipn.App
@ -13,11 +12,14 @@ import com.tailscale.ipn.R
import com.tailscale.ipn.UninitializedApp.Companion.notificationManager
import com.tailscale.ipn.ui.model.Health
import com.tailscale.ipn.ui.model.Health.UnhealthyState
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@ -25,6 +27,7 @@ import kotlinx.coroutines.launch
@OptIn(FlowPreview::class)
class HealthNotifier(
healthStateFlow: StateFlow<Health.State?>,
ipnStateFlow: StateFlow<Ipn.State>,
scope: CoroutineScope,
) {
companion object {
@ -45,11 +48,22 @@ class HealthNotifier(
scope.launch {
healthStateFlow
.distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() }
.combine(ipnStateFlow, ::Pair)
.debounce(5000)
.collect { health ->
Log.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
health?.Warnings?.let {
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
.collect { pair ->
val health = pair.first
val ipnState = pair.second
// When the client is Stopped, no warnings should get added, and any warnings added
// previously should be removed.
if (ipnState == Ipn.State.Stopped) {
TSLog.d(TAG, "Ignoring and dropping all pre-existing health messages in the Stopped state")
dropAllWarnings()
return@collect
} else {
TSLog.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
health?.Warnings?.let {
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
}
}
}
}
@ -62,8 +76,24 @@ class HealthNotifier(
val warningsBeforeAdd = currentWarnings.value
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
val addedWarnings: MutableSet<UnhealthyState> = mutableSetOf()
val removedByNewDependency: MutableSet<UnhealthyState> = mutableSetOf()
val isWarmingUp = warnings.any { it.WarnableCode == "warming-up" }
/**
* dropDependenciesForAddedWarning checks if there is any warning in `warningsBeforeAdd` that
* needs to be removed because the new warning `w` is listed as a dependency of a warning
* already in `warningsBeforeAdd`, and removes it.
*/
fun dropDependenciesForAddedWarning(w: UnhealthyState) {
for (warning in warningsBeforeAdd) {
warning.DependsOn?.let {
if (it.contains(w.WarnableCode)) {
removedByNewDependency.add(warning)
}
}
}
}
for (warning in warnings) {
if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
continue
@ -76,28 +106,37 @@ class HealthNotifier(
continue
} else if (warning.hiddenByDependencies(currentWarnableCodes)) {
// Ignore this warning because a dependency is also unhealthy
Log.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
continue
} else if (!isWarmingUp) {
Log.d(TAG, "Adding health warning: ${warning.WarnableCode}")
TSLog.d(TAG, "Adding health warning: ${warning.WarnableCode}")
this.currentWarnings.set(this.currentWarnings.value + warning)
dropDependenciesForAddedWarning(warning)
if (warning.Severity == Health.Severity.high) {
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
}
} else {
Log.d(TAG, "Ignoring ${warning.WarnableCode} because warming up")
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because warming up")
}
}
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings)
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings).union(removedByNewDependency)
if (warningsToDrop.isNotEmpty()) {
Log.d(TAG, "Dropping health warnings with codes $warningsToDrop")
TSLog.d(TAG, "Dropping health warnings with codes $warningsToDrop")
this.removeNotifications(warningsToDrop)
}
currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop))
this.updateIcon()
}
/**
* Sets the icon displayed to represent the overall health state.
*
* - If there are any high severity warnings, or warnings that affect internet connectivity,
* a warning icon is displayed.
* - If there are any other kind of warnings, an info icon is displayed.
* - If there are no warnings at all, no icon is set.
*/
private fun updateIcon() {
if (currentWarnings.value.isEmpty()) {
this.currentIcon.set(null)
@ -113,7 +152,7 @@ class HealthNotifier(
}
private fun sendNotification(title: String, text: String, code: String) {
Log.d(TAG, "Sending notification for $code")
TSLog.d(TAG, "Sending notification for $code")
val notification =
NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
@ -125,14 +164,24 @@ class HealthNotifier(
if (ActivityCompat.checkSelfPermission(
App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "Notification permission not granted")
TSLog.d(TAG, "Notification permission not granted")
return
}
notificationManager.notify(code.hashCode(), notification)
}
/**
* Removes all warnings currently displayed, including any system notifications, and
* updates the icon (causing it to be set to null since the set of warnings is empty).
*/
private fun dropAllWarnings() {
removeNotifications(this.currentWarnings.value)
this.currentWarnings.set(emptySet())
this.updateIcon()
}
private fun removeNotifications(warnings: Set<UnhealthyState>) {
Log.d(TAG, "Removing notifications for $warnings")
TSLog.d(TAG, "Removing notifications for $warnings")
for (warning in warnings) {
notificationManager.cancel(warning.WarnableCode.hashCode())
}

@ -3,7 +3,6 @@
package com.tailscale.ipn.ui.notifier
import android.util.Log
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.model.Empty
import com.tailscale.ipn.ui.model.Health
@ -11,6 +10,7 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.Notify
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@ -33,7 +33,8 @@ object Notifier {
private val decoder = Json { ignoreUnknownKeys = true }
// General IPN Bus State
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
private val _state = MutableStateFlow(Ipn.State.NoState)
val state: StateFlow<Ipn.State> = _state
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
@ -51,14 +52,16 @@ object Notifier {
private lateinit var app: libtailscale.Application
private var manager: libtailscale.NotificationManager? = null
@Synchronized
@JvmStatic
fun setApp(newApp: libtailscale.Application) {
app = newApp
}
@Synchronized
@OptIn(ExperimentalSerializationApi::class)
fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting")
TSLog.d(TAG, "Starting Notifier")
if (!::app.isInitialized) {
App.get()
}
@ -67,7 +70,8 @@ object Notifier {
NotifyWatchOpt.Netmap.value or
NotifyWatchOpt.Prefs.value or
NotifyWatchOpt.InitialState.value or
NotifyWatchOpt.InitialHealthState.value
NotifyWatchOpt.InitialHealthState.value or
NotifyWatchOpt.RateLimitNetmaps.value
manager =
app.watchNotifications(mask.toLong()) { notification ->
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
@ -88,7 +92,7 @@ object Notifier {
}
fun stop() {
Log.d(TAG, "Stopping")
TSLog.d(TAG, "Stopping Notifier")
manager?.let {
it.stop()
manager = null
@ -106,5 +110,10 @@ object Notifier {
InitialTailFSShares(32),
InitialOutgoingFiles(64),
InitialHealthState(128),
RateLimitNetmaps(256),
}
fun setState(newState: Ipn.State) {
_state.value = newState
}
}

@ -44,7 +44,8 @@ fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable
// margins in list items.
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp))
val systemUiController = rememberSystemUiController()
// TODO: Migrate to Activity.enableEdgeToEdge
@Suppress("deprecation") val systemUiController = rememberSystemUiController()
DisposableEffect(systemUiController, useDarkTheme) {
systemUiController.setStatusBarColor(color = colors.surfaceContainer)
@ -446,7 +447,6 @@ val ColorScheme.disabled: Color
val ColorScheme.searchBarColors: TextFieldColors
@Composable
get() {
val defaults = OutlinedTextFieldDefaults.colors()
return OutlinedTextFieldDefaults.colors(
focusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,

@ -15,7 +15,7 @@ import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
object AndroidTVUtil {
fun isAndroidTV(): Boolean {
val pm = UninitializedApp.get().packageManager
return (pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
return (pm.hasSystemFeature(@Suppress("deprecation") PackageManager.FEATURE_TELEVISION) ||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
}
}

@ -0,0 +1,20 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import com.tailscale.ipn.BuildConfig
class AppVersion {
companion object {
// Returns the short version of the build version, which is what users typically expect.
// For instance, if the build version is "1.75.80-t8fdffb8da-g2daeee584df",
// this function returns "1.75.80".
fun Short(): String {
// Split the full version string by hyphen (-)
val parts = BuildConfig.VERSION_NAME.split("-")
// Return only the part before the first hyphen
return parts[0]
}
}
}

@ -3,53 +3,59 @@
package com.tailscale.ipn.ui.util
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.titledListItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
@Composable
fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) {
val localClipboardManager = LocalClipboardManager.current
val modifier =
if (isAndroidTV()) {
Modifier
} else {
Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) }
}
val isFocused = remember { mutableStateOf(false) }
val localClipboardManager = LocalClipboardManager.current
val interactionSource = remember { MutableInteractionSource() }
ListItem(
colors = MaterialTheme.colorScheme.titledListItem,
modifier = modifier,
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
supportingContent =
subtitle?.let {
{
Text(
it,
modifier = Modifier.padding(top = 8.dp),
style = MaterialTheme.typography.bodyMedium)
}
},
trailingContent = {
Icon(
painterResource(R.drawable.clipboard),
stringResource(R.string.copy_to_clipboard),
modifier = Modifier.width(24.dp).height(24.dp))
})
}
ListItem(
modifier = Modifier
.focusable(interactionSource = interactionSource)
.onFocusChanged { focusState -> isFocused.value = focusState.isFocused }
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current
) { localClipboardManager.setText(AnnotatedString(value)) }
.background(
if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
else Color.Transparent
),
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
supportingContent = subtitle?.let {
{ Text(it, modifier = Modifier.padding(top = 8.dp), style = MaterialTheme.typography.bodyMedium) }
},
trailingContent = {
Icon(
painterResource(R.drawable.clipboard),
contentDescription = stringResource(R.string.copy_to_clipboard),
modifier = Modifier.size(24.dp)
)
}
)
}

@ -0,0 +1,18 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.ui.Modifier
/// Applies different modifiers to the receiver based on a condition.
inline fun Modifier.conditional(
condition: Boolean,
ifTrue: Modifier.() -> Modifier,
ifFalse: Modifier.() -> Modifier = { this },
): Modifier =
if (condition) {
then(ifTrue(Modifier))
} else {
then(ifFalse(Modifier))
}

@ -3,8 +3,8 @@
package com.tailscale.ipn.ui.util
import android.util.Log
import com.tailscale.ipn.R
import com.tailscale.ipn.util.TSLog
import java.time.Duration
import java.time.Instant
import java.time.format.DateTimeFormatter
@ -108,12 +108,12 @@ object TimeUtil {
'm' -> durationFragment * 60.0
's' -> durationFragment
else -> {
Log.e(TAG, "Invalid duration string: $goDuration")
TSLog.e(TAG, "Invalid duration string: $goDuration")
return null
}
}
} catch (e: NumberFormatException) {
Log.e(TAG, "Invalid duration string: $goDuration")
TSLog.e(TAG, "Invalid duration string: $goDuration")
return null
}
valStr = ""

@ -33,6 +33,7 @@ import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.logoBackground
import com.tailscale.ipn.ui.util.AppVersion
@Composable
fun AboutView(backToSettings: BackNavigation) {
@ -69,9 +70,14 @@ fun AboutView(backToSettings: BackNavigation) {
Text(
modifier =
Modifier.clickable {
// When users tap on the version number, the extended version string
// (including commit hashes) is copied to the clipboard.
// This may be useful for debugging purposes...
localClipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME))
},
text = "${stringResource(R.string.version)} ${BuildConfig.VERSION_NAME}",
// ... but we always display the short version in the UI to avoid user
// confusion.
text = "${stringResource(R.string.version)} ${AppVersion.Short()}",
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontSize = MaterialTheme.typography.bodyMedium.fontSize)
}

@ -3,46 +3,91 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.conditional
@OptIn(ExperimentalCoilApi::class)
@Composable
fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50, action: (() -> Unit)? = null) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(CircleShape)) {
var modifier = Modifier.size((size * .8f).dp)
action?.let {
fun Avatar(
profile: IpnLocal.LoginProfile?,
size: Int = 50,
action: (() -> Unit)? = null,
isFocusable: Boolean = false
) {
var isFocused = remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
// Outer Box for the larger focusable and clickable area
Box(
contentAlignment = Alignment.Center,
modifier =
modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onClick = action)
}
Icon(
imageVector = Icons.Default.Person,
contentDescription = stringResource(R.string.settings_title),
modifier = modifier)
Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) })
.conditional(
AndroidTVUtil.isAndroidTV(),
{
size((size * 1.5f).dp) // Focusable area is larger than the avatar
})
.clip(CircleShape) // Ensure both the focus and click area are circular
.background(
if (isFocused.value) MaterialTheme.colorScheme.surface else Color.Transparent,
)
.onFocusChanged { focusState -> isFocused.value = focusState.isFocused }
.focusable() // Make this outer Box focusable (after onFocusChanged)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = true), // Apply ripple effect inside circular bounds
onClick = {
action?.invoke()
focusManager.clearFocus() // Clear focus after clicking the avatar
})) {
// Inner Box to hold the avatar content (Icon or AsyncImage)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(size.dp).clip(CircleShape)) {
// Always display the default icon as a background layer
Icon(
imageVector = Icons.Default.Person,
contentDescription = stringResource(R.string.settings_title),
modifier =
Modifier.conditional(AndroidTVUtil.isAndroidTV(), { size((size * 0.8f).dp) })
.clip(CircleShape) // Icon size slightly smaller than the Box
)
profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage(model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null)
}
}
// Overlay the profile picture if available
profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage(
model = url,
modifier = Modifier.size(size.dp).clip(CircleShape),
contentDescription = null)
}
}
}
}

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
@ -25,6 +26,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.listItem
@ -137,10 +139,12 @@ fun LoginView(
onValueChange = { textVal = it },
placeholder = {
Text(strings.placeholder, style = MaterialTheme.typography.bodySmall)
})
},
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None)
)
})
ListItem(
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Box(modifier = Modifier.fillMaxWidth()) {

@ -61,7 +61,7 @@ fun ExitNodePicker(
if (forcedExitNodeId != null) {
Text(
text =
managedByOrganization?.let {
managedByOrganization.value?.let {
stringResource(R.string.exit_node_mdm_orgname, it)
} ?: stringResource(R.string.exit_node_mdm),
style = MaterialTheme.typography.bodyMedium,

@ -25,6 +25,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@ -40,7 +42,7 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel(
Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) {
Dialog(onDismissRequest = onDismiss) {
val image by model.qrCode.collectAsState()
val numCode by model.numCode.collectAsState()
Column(
modifier =
Modifier.clip(RoundedCornerShape(10.dp))
@ -51,12 +53,13 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel(
Text(
text = stringResource(R.string.scan_to_connect_to_your_tailnet),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface)
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center)
Box(
modifier =
Modifier.size(200.dp)
.background(MaterialTheme.colorScheme.onSurface)
.fillMaxWidth(),
.background(MaterialTheme.colorScheme.onSurface),
contentAlignment = Alignment.Center) {
image?.let {
Image(
@ -65,7 +68,28 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel(
modifier = Modifier.fillMaxSize())
}
}
Button(onClick = onDismiss) { Text(text = stringResource(R.string.dismiss)) }
Text(
text = stringResource(R.string.enter_code_to_connect_to_tailnet),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface)
numCode?.let {
Box(
modifier =
Modifier
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center) {
Text(
text =it,
style =
MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface)
}
}
Button(onClick = onDismiss, modifier = Modifier.padding(top = 16.dp)) {
Text(text = stringResource(R.string.dismiss))
}
}
}
}
@ -76,5 +100,6 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel(
fun LoginQRViewPreview() {
val vm = LoginQRViewModel()
vm.qrCode.set(vm.generateQRCode("https://tailscale.com", 200, 0))
vm.numCode.set("123456789")
AppTheme { LoginQRView({}, vm) }
}

@ -25,7 +25,10 @@ import com.tailscale.ipn.ui.viewModel.IpnViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MDMSettingsDebugView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) {
fun MDMSettingsDebugView(
backToSettings: BackNavigation,
@Suppress("UNUSED_PARAMETER") model: IpnViewModel = viewModel()
) {
Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {

@ -21,15 +21,13 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
@ -41,7 +39,6 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -59,7 +56,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
@ -87,13 +83,11 @@ import com.tailscale.ipn.ui.theme.exitNodeToggleButton
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.minTextSize
import com.tailscale.ipn.ui.theme.primaryListItem
import com.tailscale.ipn.ui.theme.searchBarColors
import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
import com.tailscale.ipn.ui.theme.warningButton
import com.tailscale.ipn.ui.theme.warningListItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AutoResizingText
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
@ -109,7 +103,8 @@ data class MainViewNavigation(
val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit,
val onNavigateToHealth: () -> Unit
val onNavigateToHealth: () -> Unit,
val onNavigateToSearch: () -> Unit,
)
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@ -142,7 +137,7 @@ fun MainView(
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
// Hide the header only on Android TV when the user needs to login
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
val hideHeader = (/*isAndroidTV() && */ state == Ipn.State.NeedsLogin)
ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem,
@ -187,20 +182,16 @@ fun MainView(
}
},
trailingContent = {
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Box(modifier = Modifier.padding(8.dp), contentAlignment = Alignment.CenterEnd) {
when (user) {
null -> SettingsButton { navigation.onNavigateToSettings() }
else ->
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier.size(42.dp).clip(CircleShape).clickable {
navigation.onNavigateToSettings()
}) {
Avatar(profile = user, size = 36) {
navigation.onNavigateToSettings()
}
}
else -> {
Avatar(
profile = user,
size = 36,
{ navigation.onNavigateToSettings() },
isFocusable = true)
}
}
}
})
@ -224,7 +215,7 @@ fun MainView(
PeerList(
viewModel = viewModel,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
onSearchBarClick = navigation.onNavigateToSearch)
}
Ipn.State.NoState,
Ipn.State.Starting -> StartingView()
@ -232,6 +223,9 @@ fun MainView(
ConnectView(
state,
isPrepared,
// If Tailscale is stopping, don't automatically restart; wait for user to take
// action (eg, if the user connected to another VPN).
state != Ipn.State.Stopping,
user,
{ viewModel.toggleVpn() },
{ viewModel.login() },
@ -242,7 +236,7 @@ fun MainView(
}
}
currentPingDevice?.let { peer ->
currentPingDevice?.let { _ ->
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
PingView(model = viewModel.pingViewModel)
}
@ -407,6 +401,7 @@ fun StartingView() {
fun ConnectView(
state: Ipn.State,
isPrepared: Boolean,
shouldStartAutomatically: Boolean,
user: IpnLocal.LoginProfile?,
connectAction: () -> Unit,
loginAction: () -> Unit,
@ -415,7 +410,7 @@ fun ConnectView(
showVPNPermissionLauncherIfUnauthorized: () -> Unit
) {
LaunchedEffect(isPrepared) {
if (!isPrepared) {
if (!isPrepared && shouldStartAutomatically) {
showVPNPermissionLauncherIfUnauthorized()
}
}
@ -523,7 +518,7 @@ fun ConnectView(
fun PeerList(
viewModel: MainViewModel,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit
onSearchBarClick: () -> Unit
) {
val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
@ -531,154 +526,117 @@ fun PeerList(
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current
var isFocussed by remember { mutableStateOf(false) }
var isListFocussed by remember { mutableStateOf(false) }
val expandedPeer = viewModel.expandedMenuPeer.collectAsState()
val localClipboardManager = LocalClipboardManager.current
val enableSearch = true // !isAndroidTV()
val enableSearch = !isAndroidTV()
if (enableSearch) {
Box(modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) {
OutlinedTextField(
modifier =
Modifier.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp)
.onFocusChanged { isFocussed = it.isFocused },
singleLine = true,
shape = MaterialTheme.shapes.extraLarge,
colors = MaterialTheme.colorScheme.searchBarColors,
leadingIcon = {
Icon(imageVector = Icons.Outlined.Search, contentDescription = "search")
},
trailingIcon = {
if (isFocussed) {
IconButton(
onClick = {
focusManager.clearFocus()
onSearch("")
}) {
Icon(
imageVector =
if (searchTermStr.isEmpty()) Icons.Outlined.Close
else Icons.Outlined.Clear,
contentDescription = "clear search",
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
placeholder = {
Text(
text = stringResource(id = R.string.search),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1)
},
value = searchTermStr,
onValueChange = { onSearch(it) })
Column(modifier = Modifier.fillMaxSize()) {
if (enableSearch) {
SearchWithDynamicSuggestions(viewModel, onSearchBarClick)
Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp))
}
}
LazyColumn(
modifier =
Modifier.fillMaxSize()
.onFocusChanged { isListFocussed = it.isFocused }
.background(color = MaterialTheme.colorScheme.surface)) {
if (showNoResults) {
item {
Spacer(
Modifier.height(16.dp)
.fillMaxSize()
.focusable(false)
.background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle(
stringResource(id = R.string.no_results),
bottomPadding = 8.dp,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Light)
// Peers display
LazyColumn(
modifier =
Modifier.fillMaxWidth()
.weight(1f) // LazyColumn gets the remaining vertical space
.onFocusChanged { isListFocussed = it.isFocused }
.background(color = MaterialTheme.colorScheme.surface)) {
// Handle case when no results are found
if (showNoResults) {
item {
Spacer(
Modifier.height(16.dp)
.fillMaxSize()
.focusable(false)
.background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle(
stringResource(id = R.string.no_results),
bottomPadding = 8.dp,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Light)
}
}
}
var first = true
peerList.forEach { peerSet ->
if (!first) {
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
}
first = false
// Iterate over peer sets to display them
var first = true
peerList.forEach { peerSet ->
if (!first) {
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
}
first = false
// Sticky headers are a bit broken on Android TV - they hide their content
if (isAndroidTV()) {
// if (isAndroidTV()) {
item { NodesSectionHeader(peerSet = peerSet) }
} else {
stickyHeader { NodesSectionHeader(peerSet = peerSet) }
}
/* } else {
stickyHeader { NodesSectionHeader(peerSet = peerSet) }
}*/
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
ListItem(
modifier =
Modifier.combinedClickable(
onClick = { onNavigateToPeerDetails(peer) },
onLongClick = { viewModel.expandedMenuPeer.set(peer) }),
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier.padding(top = 2.dp)
.size(10.dp)
.background(
color = peer.connectedColor(netmap.value),
shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium)
DropdownMenu(
expanded = expandedPeer.value?.StableID == peer.StableID,
onDismissRequest = { viewModel.hidePeerDropdownMenu() }) {
DropdownMenuItem(
leadingIcon = {
Icon(
painter = painterResource(R.drawable.clipboard),
contentDescription = null)
},
text = { Text(text = stringResource(R.string.copy_ip_address)) },
onClick = {
viewModel.copyIpAddress(peer, localClipboardManager)
viewModel.hidePeerDropdownMenu()
})
netmap.value?.let { netMap ->
if (!peer.isSelfNode(netMap)) {
// Don't show the ping item for the self-node
DropdownMenuItem(
leadingIcon = {
Icon(
painter = painterResource(R.drawable.timer),
contentDescription = null)
},
text = { Text(text = stringResource(R.string.ping)) },
onClick = {
viewModel.hidePeerDropdownMenu()
viewModel.startPing(peer)
})
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
ListItem(
modifier =
Modifier.combinedClickable(
onClick = { onNavigateToPeerDetails(peer) },
onLongClick = { viewModel.expandedMenuPeer.set(peer) }),
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier.padding(top = 2.dp)
.size(10.dp)
.background(
color = peer.connectedColor(netmap.value),
shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium)
DropdownMenu(
expanded = expandedPeer.value?.StableID == peer.StableID,
onDismissRequest = { viewModel.hidePeerDropdownMenu() }) {
DropdownMenuItem(
leadingIcon = {
Icon(
painter = painterResource(R.drawable.clipboard),
contentDescription = null)
},
text = { Text(text = stringResource(R.string.copy_ip_address)) },
onClick = {
viewModel.copyIpAddress(peer, localClipboardManager)
viewModel.hidePeerDropdownMenu()
})
netmap.value?.let { netMap ->
if (!peer.isSelfNode(netMap)) {
DropdownMenuItem(
leadingIcon = {
Icon(
painter = painterResource(R.drawable.timer),
contentDescription = null)
},
text = { Text(text = stringResource(R.string.ping)) },
onClick = {
viewModel.hidePeerDropdownMenu()
viewModel.startPing(peer)
})
}
}
}
}
}
},
supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
style =
MaterialTheme.typography.bodyMedium.copy(
lineHeight = MaterialTheme.typography.titleMedium.lineHeight))
})
}
},
supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
style =
MaterialTheme.typography.bodyMedium.copy(
lineHeight = MaterialTheme.typography.titleMedium.lineHeight))
})
}
}
}
}
}
}
@Composable
@ -688,7 +646,7 @@ fun NodesSectionHeader(peerSet: PeerSet) {
Lists.LargeTitle(
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
bottomPadding = 8.dp,
focusable = isAndroidTV(),
focusable = true, // isAndroidTV(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold)
}
@ -734,6 +692,47 @@ fun PromptPermissionsIfNecessary() {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchWithDynamicSuggestions(
viewModel: MainViewModel,
onSearchBarClick: () -> Unit // Callback for navigating to SearchView
) {
// Prevent multiple taps
var isNavigating by remember { mutableStateOf(false) }
// Outer Box to handle clicks
Box(
modifier =
Modifier.fillMaxWidth()
.height(56.dp) // Height matching Material Design search bar
.clip(RoundedCornerShape(28.dp)) // Fully rounded edges
.background(MaterialTheme.colorScheme.surface) // Surface background
.clickable(enabled = !isNavigating) { // Intercept taps
isNavigating = true
onSearchBarClick() // Trigger navigation
}
.padding(horizontal = 16.dp) // Padding for a clean look
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) {
// Search Icon
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp))
Spacer(modifier = Modifier.width(8.dp))
// Placeholder Text
Text(
text = "Search...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f) // Fill remaining space
)
}
}
}
@Preview
@Composable
fun MainViewPreview() {
@ -746,6 +745,7 @@ fun MainViewPreview() {
onNavigateToSettings = {},
onNavigateToPeerDetails = {},
onNavigateToExitNodes = {},
onNavigateToHealth = {}),
onNavigateToHealth = {},
onNavigateToSearch = {}),
vm)
}

@ -23,14 +23,16 @@ import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Suppress("UNUSED_PARAMETER")
@Composable
fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { innerPadding ->
Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { _ ->
Column(
verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.Start,
modifier = Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) {
modifier =
Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) {
val managedByOrganization =
MDMSettings.managedByOrganizationName.flow.collectAsState().value.value
val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value.value

@ -47,7 +47,7 @@ import com.tailscale.ipn.ui.viewModel.PingViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeerDetails(
backToHome: BackNavigation,
onNavigateBack: () -> Unit,
nodeId: String,
pingViewModel: PingViewModel,
model: PeerDetailsViewModel =
@ -90,7 +90,7 @@ fun PeerDetails(
contentDescription = "Ping device")
}
},
onBack = backToHome)
onBack = onNavigateBack)
},
) { innerPadding ->
LazyColumn(

@ -0,0 +1,150 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.tailscale.ipn.ui.viewModel.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchView(
viewModel: MainViewModel,
navController: NavController, // Use NavController for navigation
onNavigateBack: () -> Unit
) {
val searchTerm by viewModel.searchTerm.collectAsState()
val filteredPeers by viewModel.peers.collectAsState()
val netmap by viewModel.netmap.collectAsState()
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
var expanded by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
}
Column(
modifier =
Modifier.fillMaxWidth().focusRequester(focusRequester).clickable {
focusRequester.requestFocus()
keyboardController?.show()
}) {
SearchBar(
modifier = Modifier.fillMaxWidth(),
query = searchTerm,
onQueryChange = { query ->
viewModel.updateSearchTerm(query)
expanded = query.isNotEmpty()
},
onSearch = { query ->
viewModel.updateSearchTerm(query)
focusManager.clearFocus()
keyboardController?.hide()
},
placeholder = { Text("Search") },
leadingIcon = {
IconButton(
onClick = {
focusManager.clearFocus()
onNavigateBack()
}) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
trailingIcon = {
if (searchTerm.isNotEmpty()) {
IconButton(
onClick = {
viewModel.updateSearchTerm("")
focusManager.clearFocus()
keyboardController?.hide()
}) {
Icon(Icons.Default.Clear, contentDescription = "Clear search")
}
}
},
active = expanded,
onActiveChange = { expanded = it },
content = {
Column(Modifier.verticalScroll(rememberScrollState()).fillMaxSize()) {
filteredPeers.forEach { peerSet ->
val userName = peerSet.user?.DisplayName ?: "Unknown User"
peerSet.peers.forEach { peer ->
val deviceName = peer.displayName ?: "Unknown Device"
val ipAddress = peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP"
ListItem(
headlineContent = { Text(userName) },
supportingContent = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
val onlineColor = peer.connectedColor(netmap)
Box(
modifier =
Modifier.size(10.dp)
.background(onlineColor, shape = RoundedCornerShape(50)))
Spacer(modifier = Modifier.size(8.dp))
Text(deviceName)
}
Text(ipAddress)
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier =
Modifier.clickable {
navController.navigate("peerDetails/${peer.StableID}")
}
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp))
}
}
}
})
}
}

@ -39,6 +39,8 @@ import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.AppVersion
@Composable
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) {
@ -95,9 +97,10 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
},
onClick = settingsNav.onNavigateToTailnetLock)
}
Lists.ItemDivider()
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
if (!AndroidTVUtil.isAndroidTV()){
Lists.ItemDivider()
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
}
managedByOrganization.value?.let {
Lists.ItemDivider()
@ -112,7 +115,7 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
Lists.ItemDivider()
Setting.Text(
R.string.about_tailscale,
subtitle = "${stringResource(id = R.string.version)} ${BuildConfig.VERSION_NAME}",
subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}",
onClick = settingsNav.onNavigateToAbout)
// TODO: put a heading for the debug section

@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -21,6 +20,7 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@ -45,40 +45,44 @@ fun Header(
actions: @Composable RowScope.() -> Unit = {},
onBack: (() -> Unit)? = null
) {
val f = FocusRequester()
val focusRequester = remember { FocusRequester() }
if (isAndroidTV()) {
LaunchedEffect(Unit) { f.requestFocus() }
}
if (isAndroidTV()) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
TopAppBar(
title = {
title?.let { title() }
?: Text(
stringResource(titleRes),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface)
},
colors = MaterialTheme.colorScheme.topAppBar,
actions = actions,
navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } },
)
TopAppBar(
title = {
title?.let { title() }
?: Text(
stringResource(titleRes),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
colors = MaterialTheme.colorScheme.topAppBar,
actions = actions,
navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = focusRequester) } },
)
}
@Composable
fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) {
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back to the previous screen",
modifier =
Modifier.focusRequester(focusRequester)
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back to the previous screen",
modifier = Modifier
.focusRequester(focusRequester)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onClick = { action() }))
}
indication = ripple(bounded = false),
onClick = { action() }
)
)
}
}
@Composable

@ -3,6 +3,11 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@ -15,7 +20,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -49,13 +56,19 @@ fun TailnetLockSetupView(
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap {
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") { ExplainerView() }
items(items = statusItems, key = { "status_${it.title}" }) { statusItem ->
Lists.ItemDivider()
LazyColumn(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
item { ExplainerView() }
items(statusItems) { statusItem ->
val interactionSource = remember { MutableInteractionSource() }
ListItem(
modifier =
Modifier.focusable(
interactionSource = interactionSource)
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current
) {},
leadingContent = {
Icon(
painter = painterResource(id = statusItem.icon),
@ -65,18 +78,16 @@ fun TailnetLockSetupView(
headlineContent = { Text(stringResource(statusItem.title)) })
}
item(key = "nodeKey") {
item {
// Node key section
Lists.SectionDivider()
ClipboardValueView(
value = nodeKey,
title = stringResource(R.string.node_key),
subtitle = stringResource(R.string.node_key_explainer))
}
item(key = "tailnetLockKey") {
// Tailnet lock key section
Lists.SectionDivider()
ClipboardValueView(
value = tailnetLockTlPubKey,
title = stringResource(R.string.tailnet_lock_key),
@ -101,7 +112,7 @@ private fun ExplainerView() {
@Composable
fun explainerText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
return buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.tailnet_lock_explainer))
}
@ -117,7 +128,6 @@ fun explainerText(): AnnotatedString {
}
pop()
}
return annotatedString
}
@Composable

@ -5,6 +5,7 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
@ -54,17 +55,27 @@ fun UserView(
leadingContent = { Avatar(profile = profile, size = 36) },
headlineContent = {
AutoResizingText(
text = profile.UserProfile.DisplayName,
text = profile.UserProfile.LoginName,
style = MaterialTheme.typography.titleMedium.short,
minFontSize = MaterialTheme.typography.minTextSize,
overflow = TextOverflow.Ellipsis)
},
supportingContent = {
AutoResizingText(
text = profile.NetworkProfile?.DomainName ?: "",
style = MaterialTheme.typography.bodyMedium.short,
minFontSize = MaterialTheme.typography.minTextSize,
overflow = TextOverflow.Ellipsis)
Column {
AutoResizingText(
text = profile.NetworkProfile?.DomainName ?: "",
style = MaterialTheme.typography.bodyMedium.short,
minFontSize = MaterialTheme.typography.minTextSize,
overflow = TextOverflow.Ellipsis)
profile.customControlServerHostname()?.let {
AutoResizingText(
text = it,
style = MaterialTheme.typography.bodyMedium.short,
minFontSize = MaterialTheme.typography.minTextSize,
overflow = TextOverflow.Ellipsis)
}
}
},
trailingContent = {
when (actionState) {

@ -36,7 +36,9 @@ class LoginWithCustomControlURLViewModel : CustomLoginViewModel() {
// localAPIClient will use the default server if we give it a broken URL,
// but we can make sure we can construct a URL from the input string and
// ensure it has an http/https scheme
when (urlStr.startsWith("http") && urlStr.contains("://") && urlStr.length > 7) {
when (urlStr.startsWith("http", ignoreCase = true) &&
urlStr.contains("://") &&
urlStr.length > 7) {
false -> {
errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL)
return

@ -3,7 +3,6 @@
package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.annotation.StringRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@ -24,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import com.tailscale.ipn.util.TSLog
class DNSSettingsViewModelFactory : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
@ -43,7 +43,7 @@ class DNSSettingsViewModel : IpnViewModel() {
.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
.stateIn(viewModelScope)
.collect { (netmap, prefs) ->
Log.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString())
TSLog.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString())
prefs?.let {
if (it.CorpDNS) {
enablementState.set(DNSEnablementState.ENABLED)

@ -3,7 +3,6 @@
package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.UninitializedApp
@ -16,6 +15,7 @@ import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.AdvertisedRoutesHelper
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@ -130,15 +130,10 @@ open class IpnViewModel : ViewModel() {
}
.collect { nodeState -> _nodeState.value = nodeState }
}
Log.d(TAG, "Created")
TSLog.d(TAG, "Created")
}
// VPN Control
fun setVpnPrepared(prepared: Boolean) {
_vpnPrepared.value = prepared
}
fun startVPN() {
UninitializedApp.get().startVPN()
}
@ -158,8 +153,8 @@ open class IpnViewModel : ViewModel() {
val loginAction = {
Client(viewModelScope).startLoginInteractive { result ->
result
.onSuccess { Log.d(TAG, "Login started: $it") }
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") }
.onSuccess { TSLog.d(TAG, "Login started: $it") }
.onFailure { TSLog.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result)
}
}
@ -170,7 +165,7 @@ open class IpnViewModel : ViewModel() {
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result ->
result
.onSuccess { loginAction() }
.onFailure { Log.e(TAG, "Error setting wantRunning to false: ${it.message}") }
.onFailure { TSLog.e(TAG, "Error setting wantRunning to false: ${it.message}") }
}
}
@ -187,7 +182,7 @@ open class IpnViewModel : ViewModel() {
if (mdmControlURL != null) {
prefs = prefs ?: Ipn.MaskedPrefs()
prefs.ControlURL = mdmControlURL
Log.d(TAG, "Overriding control URL with MDM value: $mdmControlURL")
TSLog.d(TAG, "Overriding control URL with MDM value: $mdmControlURL")
}
prefs?.let {
@ -215,8 +210,8 @@ open class IpnViewModel : ViewModel() {
fun logout(completionHandler: (Result<String>) -> Unit = {}) {
Client(viewModelScope).logout { result ->
result
.onSuccess { Log.d(TAG, "Logout started: $it") }
.onFailure { Log.e(TAG, "Error starting logout: ${it.message}") }
.onSuccess { TSLog.d(TAG, "Logout started: $it") }
.onFailure { TSLog.e(TAG, "Error starting logout: ${it.message}") }
completionHandler(result)
}
}
@ -226,14 +221,14 @@ open class IpnViewModel : ViewModel() {
private fun loadUserProfiles() {
Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure {
Log.e(TAG, "Error loading profiles: ${it.message}")
TSLog.e(TAG, "Error loading profiles: ${it.message}")
}
}
Client(viewModelScope).currentProfile { result ->
result
.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
.onFailure { TSLog.e(TAG, "Error loading current profile: ${it.message}") }
}
}
@ -247,7 +242,7 @@ open class IpnViewModel : ViewModel() {
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result ->
result
.onSuccess { switchProfile() }
.onFailure { Log.e(TAG, "Error setting wantRunning to false: ${it.message}") }
.onFailure { TSLog.e(TAG, "Error setting wantRunning to false: ${it.message}") }
}
}
@ -282,7 +277,7 @@ open class IpnViewModel : ViewModel() {
Client(viewModelScope).setUseExitNode(true) { LoadingIndicator.stop() }
} else {
// This should not be possible. In this state the button is hidden
Log.e(TAG, "No exit node to disable and no prior exit node to enable")
TSLog.e(TAG, "No exit node to disable and no prior exit node to enable")
}
}
@ -297,7 +292,7 @@ open class IpnViewModel : ViewModel() {
}
Client(viewModelScope).editPrefs(newPrefs) { result ->
LoadingIndicator.stop()
Log.d("RunExitNodeViewModel", "Edited prefs: $result")
TSLog.d("RunExitNodeViewModel", "Edited prefs: $result")
}
}
}

@ -20,12 +20,30 @@ import kotlinx.coroutines.launch
class LoginQRViewModel : IpnViewModel() {
val numCode: StateFlow<String?> = MutableStateFlow(null)
val qrCode: StateFlow<ImageBitmap?> = MutableStateFlow(null)
// Remove this once changes to admin console allowing input code to be entered are made.
init {
viewModelScope.launch {
Notifier.browseToURL.collect { url ->
url?.let { qrCode.set(generateQRCode(url, 200, 0)) } ?: run { qrCode.set(null) }
url?.let {
qrCode.set(generateQRCode(url, 200, 0))
// Extract the string after "https://login.tailscale.com/a/"
val prefix = "https://login.tailscale.com/a/"
val code =
if (it.startsWith(prefix)) {
it.removePrefix(prefix)
} else {
null
}
numCode.set(code)
}
?: run {
qrCode.set(null)
numCode.set(null)
}
}
}
}

@ -23,13 +23,18 @@ import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import java.time.Duration
class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(vpnViewModel) as T
@ -38,6 +43,7 @@ class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelPr
}
}
@OptIn(FlowPreview::class)
class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// The user readable state of the system
@ -51,13 +57,15 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
// The list of peers
val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>())
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
val peers: StateFlow<List<PeerSet>> = _peers
// The current state of the IPN for determining view visibility
val ipnState = Notifier.state
// The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("")
private val _searchTerm = MutableStateFlow("")
val searchTerm: StateFlow<String> = _searchTerm
// True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
@ -69,9 +77,17 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
val isVpnPrepared: StateFlow<Boolean> = vpnViewModel.vpnPrepared
val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive
var searchJob: Job? = null
// Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
fun updateSearchTerm(term: String) {
_searchTerm.value = term
}
fun hidePeerDropdownMenu() {
expandedMenuPeer.set(null)
}
@ -94,19 +110,24 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
viewModelScope.launch {
var previousState: State? = null
combine(Notifier.state, isVpnPrepared) { state, prepared -> state to prepared }
.collect { (currentState, prepared) ->
stateRes.set(userStringRes(currentState, previousState, prepared))
combine(Notifier.state, isVpnActive) { state, active -> state to active }
.collect { (currentState, active) ->
// Determine the correct state resource string
stateRes.set(userStringRes(currentState, previousState, active))
// Determine if the VPN toggle should be on
val isOn =
when {
prepared && currentState == State.Running || currentState == State.Starting ->
active && (currentState == State.Running || currentState == State.Starting) ->
true
previousState == State.NoState && currentState == State.Starting -> true
else -> false
}
// Update the VPN toggle state
_vpnToggleState.value = isOn
// Update the previous state
previousState = currentState
}
}
@ -114,8 +135,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
viewModelScope.launch {
Notifier.netmap.collect { it ->
it?.let { netmap ->
peerCategorizer.regenerateGroupedPeers(netmap)
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
searchJob?.cancel()
launch(Dispatchers.Default) {
peerCategorizer.regenerateGroupedPeers(netmap)
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value)
_peers.value = filteredPeers
}
if (netmap.SelfNode.keyDoesNotExpire) {
showExpiry.set(false)
@ -133,8 +158,11 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
}
viewModelScope.launch {
searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) }
}
searchTerm.collect { term ->
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term)
_peers.value = filteredPeers
}
}
viewModelScope.launch {
App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) }
@ -164,8 +192,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
}
fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm)
}
_searchTerm.value = searchTerm // Update the search term
viewModelScope.launch(Dispatchers.Default) {
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm)
_peers.value = filteredPeers // Update filtered peers
}
}
fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
// No intent means we're already authorized
@ -173,17 +205,17 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
}
}
private fun userStringRes(currentState: State?, previousState: State?, vpnPrepared: Boolean): Int {
private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int {
return when {
previousState == State.NoState && currentState == State.Starting -> R.string.starting
currentState == State.NoState -> R.string.placeholder
currentState == State.InUseOtherUser -> R.string.placeholder
currentState == State.NeedsLogin ->
if (vpnPrepared) R.string.please_login else R.string.connect_to_vpn
if (vpnActive) R.string.please_login else R.string.connect_to_vpn
currentState == State.NeedsMachineAuth -> R.string.needs_machine_auth
currentState == State.Stopped -> R.string.stopped
currentState == State.Starting -> R.string.starting
currentState == State.Running -> if (vpnPrepared) R.string.connected else R.string.placeholder
currentState == State.Running -> if (vpnActive) R.string.connected else R.string.placeholder
else -> R.string.placeholder
}
}

@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.viewModel
import android.content.Context
import android.os.CountDownTimer
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@ -16,6 +15,7 @@ import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.ConnectionMode
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.roundedString
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -39,7 +39,7 @@ class PingViewModel : ViewModel() {
}
override fun onFinish() {
Log.d(TAG, "Ping timer terminated")
TSLog.d(TAG, "Ping timer terminated")
}
}
@ -94,7 +94,7 @@ class PingViewModel : ViewModel() {
response.onFailure { error ->
val context: Context = App.get().applicationContext
val stringError = error.toString()
Log.d(TAG, "Ping request failed: $stringError")
TSLog.d(TAG, "Ping request failed: $stringError")
if (stringError.contains("timeout")) {
this.errorMessage.set(
context.getString(
@ -125,7 +125,7 @@ class PingViewModel : ViewModel() {
}
}
}
statusResult.onFailure { Log.d(TAG, "Failed to fetch status: $it") }
statusResult.onFailure { TSLog.d(TAG, "Failed to fetch status: $it") }
}
}
}

@ -4,7 +4,6 @@
package com.tailscale.ipn.ui.viewModel
import android.content.Context
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.MaterialTheme
@ -26,6 +25,7 @@ import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.ActivityIndicator
import com.tailscale.ipn.ui.view.CheckedIndicator
import com.tailscale.ipn.ui.view.ErrorDialogType
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -144,7 +144,7 @@ class TaildropViewModel(
allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name }
myPeers.set(onlinePeers + offlinePeers)
}
.onFailure { Log.e(TAG, "Error loading targets: ${it.message}") }
.onFailure { TSLog.e(TAG, "Error loading targets: ${it.message}") }
}
}

@ -26,9 +26,15 @@ class VpnViewModelFactory(private val application: Application) : ViewModelProvi
// application scoped because Tailscale might be toggled on and off outside of the activity
// lifecycle.
class VpnViewModel(application: Application) : AndroidViewModel(application) {
// Whether the VPN is prepared
// Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or
// if the user has previously consented to the VPN application. This is used to determine whether
// a VPN permission launcher needs to be shown.
val _vpnPrepared = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
// Whether a VPN interface has been established. This is set by net.updateTUN upon
// VpnServiceBuilder.establish, and consumed by UI to reflect VPN state.
val _vpnActive = MutableStateFlow(false)
val vpnActive: StateFlow<Boolean> = _vpnActive
val TAG = "VpnViewModel"
init {
@ -49,7 +55,11 @@ class VpnViewModel(application: Application) : AndroidViewModel(application) {
}
}
fun setVpnPrepared(prepared: Boolean) {
_vpnPrepared.value = prepared
fun setVpnActive(isActive: Boolean) {
_vpnActive.value = isActive
}
fun setVpnPrepared(isPrepared: Boolean) {
_vpnPrepared.value = isPrepared
}
}

@ -0,0 +1,44 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.util
import android.util.Log
import libtailscale.Libtailscale
object TSLog {
var libtailscaleWrapper = LibtailscaleWrapper()
fun d(tag: String?, message: String) {
Log.d(tag, message)
libtailscaleWrapper.sendLog(tag, message)
}
fun w(tag: String, message: String) {
Log.w(tag, message)
libtailscaleWrapper.sendLog(tag, message)
}
// Overloaded function without Throwable because Java does not support default parameters
@JvmStatic
fun e(tag: String?, message: String) {
Log.e(tag, message)
libtailscaleWrapper.sendLog(tag, message)
}
fun e(tag: String?, message: String, throwable: Throwable? = null) {
if (throwable == null) {
Log.e(tag, message)
libtailscaleWrapper.sendLog(tag, message)
} else {
Log.e(tag, message, throwable)
libtailscaleWrapper.sendLog(tag, "$message ${throwable?.localizedMessage}")
}
}
class LibtailscaleWrapper {
public fun sendLog(tag: String?, message: String) {
val logTag = tag ?: ""
Libtailscale.sendLog((logTag + ": " + message).toByteArray(Charsets.UTF_8))
}
}
}

@ -208,6 +208,8 @@
<string name="manage_tailnet_lock_visibility">Manage Tailnet lock visibility</string>
<string name="shows_or_hides_the_ui_to_run_the_android_device_as_an_exit_node">Shows or hides the UI to run the Android device as an exit node.</string>
<string name="run_as_exit_node_visibility">Run as exit node visibility</string>
<string name="defines_an_auth_key_that_will_be_used_for_login">Defines an auth key that will be used for login.</string>
<string name="auth_key">Auth Key</string>
<!-- Permissions Management -->
<string name="permissions">Permissions</string>
@ -242,6 +244,7 @@
<string name="welcome1">Tailscale is a mesh VPN for securely connecting your devices.</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="enter_code_to_connect_to_tailnet">or enter this code in the Machines > Add device section of the admin console: </string>
<!-- Strings for intent handling -->
<string name="vpn_is_not_ready_to_start">VPN is not ready to start</string>
@ -298,7 +301,5 @@
<string name="multiple_vpn_explainer">Only one VPN can be active, and it appears another is already running. Before starting Tailscale, disable the other VPN.</string>
<string name="go_to_settings">Go to Settings</string>
<string name="cancel">Cancel</string>
<string name="defines_an_auth_key_that_will_be_used_for_login">Defines an auth key that will be used for login.</string>
<string name="auth_key">Auth Key</string>
</resources>

@ -1,60 +1,107 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailcale.ipn.ui.util
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.util.TSLog
import com.tailscale.ipn.util.TSLog.LibtailscaleWrapper
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.doNothing
import org.mockito.Mockito.mock
import java.time.Duration
class TimeUtilTest {
@Test
fun durationInvalidMsUnits() {
val input = "5s10ms"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationInvalidUsUnits() {
val input = "5s10us"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationTestHappyPath() {
val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y")
val expectedSeconds =
arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000)
val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) }
val actual = input.map { TimeUtil.duration(it) }
assertEquals("Incorrect conversion", expected, actual)
}
@Test
fun testBadDurationString() {
val input = "1..0y1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun testBadDInputString() {
val input = "1.0yy1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun testIgnoreFractionalSeconds() {
val input = "10.9s"
val expectedSeconds = 10
val expected = Duration.ofSeconds(expectedSeconds.toLong())
val actual = TimeUtil.duration(input)
assertEquals("Should return $expectedSeconds seconds", expected, actual)
}
private lateinit var libtailscaleWrapperMock: LibtailscaleWrapper
private lateinit var originalWrapper: LibtailscaleWrapper
@Before
fun setUp() {
libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java)
doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString())
// Store the original wrapper so we can reset it later
originalWrapper = TSLog.libtailscaleWrapper
// Inject mock into TSLog
TSLog.libtailscaleWrapper = libtailscaleWrapperMock
}
@After
fun tearDown() {
// Reset TSLog after each test to avoid side effects
TSLog.libtailscaleWrapper = originalWrapper
}
@Test
fun durationInvalidMsUnits() {
val input = "5s10ms"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationInvalidUsUnits() {
val input = "5s10us"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationTestHappyPath() {
val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y")
val expectedSeconds =
arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000)
val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) }
val actual = input.map { TimeUtil.duration(it) }
assertEquals("Incorrect conversion", expected, actual)
}
@Test
fun testBadDurationString() {
val input = "1..0y1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun testBadDInputString() {
val libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java)
doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString())
val input = "1.0yy1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun testIgnoreFractionalSeconds() {
val input = "10.9s"
val expectedSeconds = 10
val expected = Duration.ofSeconds(expectedSeconds.toLong())
val actual = TimeUtil.duration(input)
assertEquals("Should return $expectedSeconds seconds", expected, actual)
}
}

@ -0,0 +1,16 @@
#!/bin/bash
if [[ -z "$TOOLCHAIN_DIR" ]]; then
# By default, if TOOLCHAIN_DIR is unset, we assume we're
# using the Tailscale Go toolchain (github.com/tailscale/go)
# at the revision specified by go.toolchain.rev. If so,
# we tell our caller to use the "tailscale_go" build tag.
echo "tailscale_go"
else
# Otherwise, if TOOLCHAIN_DIR is specified, we assume
# we're F-Droid or something using a stock Go toolchain.
# That's fine. But we don't set the tailscale_go build tag.
# Return some no-op build tag that's non-empty for clarity
# when debugging.
echo "not_tailscale_go"
fi

@ -24,7 +24,7 @@ ENV PATH $PATH:$HOME/bin:$ANDROID_HOME/platform-tools
# We need some version of Go new enough to support the "embed" package
# to run "go run tailscale.com/cmd/printdep" to figure out which Tailscale Go
# version we need later, but otherwise this toolchain isn't used:
RUN curl -L https://go.dev/dl/go1.22.0.linux-amd64.tar.gz | tar -C /usr/local -zxv
RUN curl -L https://go.dev/dl/go1.23.0.linux-amd64.tar.gz | tar -C /usr/local -zxv
RUN ln -s /usr/local/go/bin/go /usr/bin
RUN mkdir -p $HOME/tailscale-android
@ -42,5 +42,5 @@ COPY android/gradle android/gradle
RUN ./android/gradlew
# Build the android app, bump the playstore version code, and make the tv release
CMD make release && make bump_version_code && make release-tv
CMD make clean && make release && make bump_version_code && make release-tv

@ -24,7 +24,7 @@ ENV PATH $PATH:$HOME/bin:$ANDROID_HOME/platform-tools
# We need some version of Go new enough to support the "embed" package
# to run "go run tailscale.com/cmd/printdep" to figure out which Tailscale Go
# version we need later, but otherwise this toolchain isn't used:
RUN curl -L https://go.dev/dl/go1.22.0.linux-amd64.tar.gz | tar -C /usr/local -zxv
RUN curl -L https://go.dev/dl/go1.23.0.linux-amd64.tar.gz | tar -C /usr/local -zxv
RUN ln -s /usr/local/go/bin/go /usr/bin
RUN mkdir -p $HOME/tailscale-android

@ -1,13 +1,12 @@
module github.com/tailscale/tailscale-android
go 1.22.0
go 1.23.1
require (
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87
golang.org/x/sys v0.22.0
github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3
golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab
inet.af/netaddr v0.0.0-20220617031823-097006376321
tailscale.com v1.73.0-pre.0.20240821174438-af3d3c433b67
tailscale.com v1.77.0-pre.0.20241202172800-8d0c690f8997
)
require (
@ -47,13 +46,13 @@ require (
github.com/gorilla/csrf v1.7.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/illarion/gonotify v1.0.1 // indirect
github.com/illarion/gonotify/v2 v2.0.3 // indirect
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect
github.com/jellydator/ttlcache/v3 v3.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
@ -69,28 +68,28 @@ require (
github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 // indirect
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e // indirect
github.com/tcnksm/go-httpstat v0.2.0 // indirect
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // indirect
github.com/vishvananda/netlink v1.2.1-beta.2 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
go4.org/intern v0.0.0-20211027215823-ae77deb06f29 // indirect
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
go4.org/unsafe/assume-no-moving-gc v0.0.0-20230525183740-e7c30c78aeb2 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect
golang.org/x/mod v0.19.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/term v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/mod v0.20.0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sync v0.9.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/term v0.23.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.23.0 // indirect
golang.org/x/tools v0.24.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 // indirect

@ -86,8 +86,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio=
github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE=
github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A=
github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
@ -101,8 +101,8 @@ github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8=
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@ -131,8 +131,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -150,14 +150,14 @@ github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPx
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk=
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 h1:Gz0rz40FvFVLTBk/K8UNAenb36EbDSnh+q7Z9ldcC8w=
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4/go.mod h1:phI29ccmHQBc+wvroosENp1IF9195449VDnFDhJ4rJU=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98 h1:RNpJrXfI5u6e+uzyIzvmnXbhmhdRkVf//90sMBH3lso=
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 h1:dmoPb3dG27tZgMtrvqfD/LW4w7gA6BSWl8prCPNmkCQ=
github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
@ -166,8 +166,6 @@ github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ0
github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8=
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw=
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs=
github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
@ -189,27 +187,27 @@ go4.org/unsafe/assume-no-moving-gc v0.0.0-20230525183740-e7c30c78aeb2/go.mod h1:
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87 h1:dm00oNtDy265HReLTARPfIDXTRb2IG0jqQVpn7p5MKE=
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87/go.mod h1:DN+F2TpepQEh5goqWnM3gopfFakSWM8OmHiz0rPRjT4=
golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ=
golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys=
golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab h1:KONOFF8Uy3b60HEzOsGnNghORNhY4ImyOx0PGm73K9k=
golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab/go.mod h1:udWezQGYjqrCxz5nV321pXQTx5oGbZx+khZvFjZNOPM=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -222,21 +220,21 @@ golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -258,5 +256,5 @@ inet.af/netaddr v0.0.0-20220617031823-097006376321 h1:B4dC8ySKTQXasnjDTMsoCMf1sQ
inet.af/netaddr v0.0.0-20220617031823-097006376321/go.mod h1:OIezDfdzOgFhuw4HuWapWq2e9l0H9tK4F1j+ETRtF3k=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.73.0-pre.0.20240821174438-af3d3c433b67 h1:QiC96H1MvmzSjMn1Bs5oboovD+qXRecko3sJhbOfEr8=
tailscale.com v1.73.0-pre.0.20240821174438-af3d3c433b67/go.mod h1:v7OHtg0KLAnhOVf81Z8WrjNefj238QbFhgkWJQoKxbs=
tailscale.com v1.77.0-pre.0.20241202172800-8d0c690f8997 h1:jAL0TXstGYT1L0V2qH+zpQSEBwBGgIOVkflckrU0kqo=
tailscale.com v1.77.0-pre.0.20241202172800-8d0c690f8997/go.mod h1:OQHJodEdJx4e8lKkSpYmK2H/B1D/VN0EU7g9imiGj2k=

@ -1 +1 @@
22ef9eb38e9a2d21b4a45f7adc75addb05f3efb8
96578f73d04e1a231fa2a495ad3fa97747785bc6

@ -11,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
"reflect"
"runtime/debug"
"sync"
"sync/atomic"
@ -105,9 +106,7 @@ type settingsFunc func(*router.Config, *dns.OSConfig) error
func (a *App) runBackend(ctx context.Context) error {
paths.AppSharedDir.Store(a.dataDir)
hostinfo.SetOSVersion(a.osVersion())
if !a.appCtx.IsPlayVersion() {
hostinfo.SetPackage("nogoogle")
}
hostinfo.SetPackage(a.appCtx.GetInstallSource())
deviceModel := a.modelName()
if a.isChromeOS() {
deviceModel = "ChromeOS: " + deviceModel
@ -150,7 +149,6 @@ func (a *App) runBackend(ctx context.Context) error {
cfg configPair
state ipn.State
networkMap *netmap.NetworkMap
service IPNService
)
stateCh := make(chan ipn.State)
@ -168,38 +166,26 @@ func (a *App) runBackend(ctx context.Context) error {
select {
case s := <-stateCh:
state = s
if cfg.rcfg != nil && state >= ipn.Starting && service != nil {
if state >= ipn.Starting && vpnService.service != nil && b.isConfigNonNilAndDifferent(cfg.rcfg, cfg.dcfg) {
// On state change, check if there are router or config changes requiring an update to VPNBuilder
if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil {
if err := b.updateTUN(cfg.rcfg, cfg.dcfg); err != nil {
if errors.Is(err, errMultipleUsers) {
// TODO: surface error to user
}
log.Printf("VPN update failed: %v", err)
mp := new(ipn.MaskedPrefs)
mp.WantRunning = false
mp.WantRunningSet = true
_, err := a.EditPrefs(*mp)
if err != nil {
log.Printf("localapi edit prefs error %v", err)
}
b.lastCfg = nil
b.CloseTUNs()
a.closeVpnService(err, b)
}
}
case n := <-netmapCh:
networkMap = n
case c := <-configs:
cfg = c
if b == nil || service == nil || cfg.rcfg == nil {
if vpnService.service == nil || !b.isConfigNonNilAndDifferent(cfg.rcfg, cfg.dcfg) {
configErrs <- nil
break
}
configErrs <- b.updateTUN(service, cfg.rcfg, cfg.dcfg)
configErrs <- b.updateTUN(cfg.rcfg, cfg.dcfg)
case s := <-onVPNRequested:
if service != nil && service.ID() == s.ID() {
if vpnService.service != nil && vpnService.service.ID() == s.ID() {
// Still the same VPN instance, do nothing
break
}
@ -228,29 +214,24 @@ func (a *App) runBackend(ctx context.Context) error {
// See https://github.com/tailscale/corp/issues/13814
b.backend.DebugRebind()
service = s
vpnService.service = s
if networkMap != nil {
// TODO
}
if cfg.rcfg != nil && state >= ipn.Starting {
if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil {
log.Printf("VPN update failed: %v", err)
service.Close()
b.lastCfg = nil
b.CloseTUNs()
if state >= ipn.Starting && b.isConfigNonNilAndDifferent(cfg.rcfg, cfg.dcfg) {
if err := b.updateTUN(cfg.rcfg, cfg.dcfg); err != nil {
a.closeVpnService(err, b)
}
}
case s := <-onDisconnect:
b.CloseTUNs()
if service != nil && service.ID() == s.ID() {
if vpnService.service != nil && vpnService.service.ID() == s.ID() {
netns.SetAndroidProtectFunc(nil)
service = nil
vpnService.service = nil
}
case i := <-onDNSConfigChanged:
if b != nil {
go b.NetworkChanged(i)
}
go b.NetworkChanged(i)
}
}
}
@ -305,6 +286,7 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor
SetSubsystem: sys.Set,
NetMon: b.netMon,
HealthTracker: sys.HealthTracker(),
Metrics: sys.UserMetricsRegistry(),
DriveForLocal: driveimpl.NewFileSystemForLocal(logf),
})
if err != nil {
@ -312,7 +294,7 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor
}
sys.Set(engine)
b.logIDPublic = logID.Public()
ns, err := netstack.Create(logf, sys.Tun.Get(), engine, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper(), nil)
ns, err := netstack.Create(logf, sys.Tun.Get(), engine, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
if err != nil {
return nil, fmt.Errorf("netstack.Create: %w", err)
}
@ -349,3 +331,29 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor
}()
return b, nil
}
func (b *backend) isConfigNonNilAndDifferent(rcfg *router.Config, dcfg *dns.OSConfig) bool {
if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) {
b.logger.Logf("isConfigNonNilAndDifferent: no change to Routes or DNS, ignore")
return false
}
return rcfg != nil
}
func (a *App) closeVpnService(err error, b *backend) {
log.Printf("VPN update failed: %v", err)
mp := new(ipn.MaskedPrefs)
mp.WantRunning = false
mp.WantRunningSet = true
if _, localApiErr := a.EditPrefs(*mp); localApiErr != nil {
log.Printf("localapi edit prefs error %v", localApiErr)
}
b.lastCfg = nil
b.CloseTUNs()
vpnService.service.DisconnectVPN()
vpnService.service = nil
}

@ -20,6 +20,9 @@ var (
// onDNSConfigChanged is notified when the network changes and the DNS config needs to be updated. It receives the updated interface name.
onDNSConfigChanged = make(chan string, 1)
// onLog receives Android logs to be sent to the logger
onLog = make(chan string, 10)
)
// ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available.

@ -3,7 +3,11 @@
package libtailscale
import _ "golang.org/x/mobile/bind"
import (
"log"
_ "golang.org/x/mobile/bind"
)
// Start starts the application, storing state in the given dataDir and using
// the given appCtx.
@ -31,9 +35,11 @@ type AppContext interface {
// GetModelName gets the Android device's model name.
GetModelName() (string, error)
// IsPlayVersion reports whether this is the Google Play version of the app
// (as opposed to F-droid/sideloaded).
IsPlayVersion() bool
// GetInstallSource gets information about how the app was installed or updated.
GetInstallSource() string
// ShouldUseGoogleDNSFallback reports whether or not to use Google for DNS fallback.
ShouldUseGoogleDNSFallback() bool
// IsChromeOS reports whether we're on a ChromeOS device.
IsChromeOS() (bool, error)
@ -73,6 +79,10 @@ type IPNService interface {
NewBuilder() VPNServiceBuilder
Close()
DisconnectVPN()
UpdateVpnStatus(bool)
}
// VPNServiceBuilder corresponds to Android's VpnService.Builder.
@ -162,3 +172,13 @@ func RequestVPN(service IPNService) {
func ServiceDisconnect(service IPNService) {
onDisconnect <- service
}
func SendLog(logstr []byte) {
select {
case onLog <- string(logstr):
// Successfully sent log
default:
// Channel is full, log not sent
log.Printf("Log %v not sent", logstr) // missing argument in original code
}
}

@ -9,12 +9,11 @@ import (
"log"
"net"
"net/netip"
"reflect"
"runtime/debug"
"strings"
"syscall"
"github.com/tailscale/wireguard-go/tun"
"golang.org/x/sys/unix"
"inet.af/netaddr"
"tailscale.com/net/dns"
"tailscale.com/net/netmon"
@ -33,6 +32,15 @@ var errVPNNotPrepared = errors.New("VPN service not prepared or was revoked")
// https://github.com/tailscale/tailscale/issues/2180
var errMultipleUsers = errors.New("VPN cannot be created on this device due to an Android bug with multiple users")
// VpnService contains the IPNService class from Android, the file descriptor, and whether the descriptor has been detached.
type VpnService struct {
service IPNService
fd int32
fdDetached bool
}
var vpnService = &VpnService{}
// Report interfaces in the device in net.Interface format.
func (a *App) getInterfaces() ([]netmon.Interface, error) {
var ifaces []netmon.Interface
@ -116,12 +124,7 @@ var googleDNSServers = []netip.Addr{
netip.MustParseAddr("2001:4860:4860::8844"),
}
func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.OSConfig) error {
if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) {
b.logger.Logf("updateTUN: no change to Routes or DNS, ignore")
return nil
}
func (b *backend) updateTUN(rcfg *router.Config, dcfg *dns.OSConfig) error {
b.logger.Logf("updateTUN: changed")
defer b.logger.Logf("updateTUN: finished")
@ -138,7 +141,7 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O
if len(rcfg.LocalAddrs) == 0 {
return nil
}
builder := service.NewBuilder()
builder := vpnService.service.NewBuilder()
b.logger.Logf("updateTUN: got new builder")
if err := builder.SetMTU(defaultMTU); err != nil {
@ -193,10 +196,15 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O
parcelFD, err := builder.Establish()
if err != nil {
if strings.Contains(err.Error(), "INTERACT_ACROSS_USERS") {
// Update VPN status if VPN interface cannot be created
b.logger.Logf("updateTUN: could not establish VPN because %v", err)
vpnService.service.UpdateVpnStatus(false)
return errMultipleUsers
}
return fmt.Errorf("VpnService.Builder.establish: %v", err)
}
log.Printf("Setting vpn activity status to true")
vpnService.service.UpdateVpnStatus(true)
b.logger.Logf("updateTUN: established VPN")
if parcelFD == nil {
@ -205,6 +213,9 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O
// detachFd.
tunFD, err := parcelFD.Detach()
vpnService.fdDetached = true
vpnService.fd = tunFD
if err != nil {
return fmt.Errorf("detachFd: %v", err)
}
@ -213,7 +224,7 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O
// Create TUN device.
tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD))
if err != nil {
unix.Close(int(tunFD))
closeFileDescriptor()
return err
}
b.logger.Logf("updateTUN: created TUN device")
@ -226,6 +237,16 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O
return nil
}
func closeFileDescriptor() error {
if vpnService.fd != -1 && vpnService.fdDetached {
err := syscall.Close(int(vpnService.fd))
vpnService.fd = -1
vpnService.fdDetached = false
return fmt.Errorf("error closing file descriptor: %w", err)
}
return nil
}
// CloseVPN closes any active TUN devices.
func (b *backend) CloseTUNs() {
b.lastCfg = nil
@ -258,7 +279,7 @@ func (b *backend) getDNSBaseConfig() (ret dns.OSConfig, _ error) {
// DNS config are lacking, and almost all Android phones use Google
// services anyway, so it's a reasonable default: it's an ecosystem the
// user has selected by having an Android device.
if len(ret.Nameservers) == 0 && b.appCtx.IsPlayVersion() {
if len(ret.Nameservers) == 0 && b.appCtx.ShouldUseGoogleDNSFallback() {
log.Printf("getDNSBaseConfig: none found; falling back to Google public DNS")
ret.Nameservers = append(ret.Nameservers, googleDNSServers...)
}

@ -134,4 +134,13 @@ func (b *backend) setupLogs(logDir string, logID logid.PrivateID, logf logger.Lo
if filchErr != nil {
log.Printf("SetupLogs: filch setup failed: %v", filchErr)
}
go func() {
for {
select {
case logstr := <-onLog:
b.logger.Logf(logstr)
}
}
}()
}

@ -8,6 +8,8 @@ if [[ "${CI:-}" == "true" && "${NOBASHDEBUG:-}" != "true" ]]; then
set -x
fi
tsandroid=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )/.." &> /dev/null && pwd )
# Allow TOOLCHAINDIR to be overridden, as a special case for the fdroid build
if [[ -z "${TOOLCHAINDIR}" ]]; then
toolchain="$HOME/.cache/tailscale-go"
@ -29,10 +31,13 @@ if [[ -z "${TOOLCHAINDIR}" ]]; then
rm -rf "$toolchain" "$toolchain.extracted"
fi
fi
if [[ ! -d "$toolchain" ]]; then
mkdir -p "$HOME/.cache"
read -r REV <go.toolchain.rev
REV="$(<${tsandroid}/go.toolchain.rev)"
EREV=""
[[ -f ${toolchain}.extracted ]] && EREV="$(<${toolchain}.extracted)"
if [[ ! -d "$toolchain" || "$EREV" != "$REV" ]]; then
mkdir -p "$HOME/.cache"
case "$REV" in
/*)
@ -61,8 +66,8 @@ if [[ -z "${TOOLCHAINDIR}" ]]; then
esac
fi
else
# fdroid supplies it's own toolchain, rather than using ours.
# fdroid supplies its own toolchain, rather than using ours.
toolchain="${TOOLCHAINDIR}"
fi
exec "${toolchain}/bin/go" "$@"
exec "${toolchain}/bin/go" "$@"

@ -0,0 +1,10 @@
#!/usr/bin/env bash
source tailscale.version || echo >&2 "no tailscale.version file found"
if [[ -z "${VERSION_LONG}" ]]; then
exit 1
fi
echo "-X tailscale.com/version.longStamp=${VERSION_LONG}"
echo "-X tailscale.com/version.shortStamp=${VERSION_SHORT}"
echo "-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}"
echo "-X tailscale.com/version.extraGitCommitStamp=${VERSION_EXTRA_HASH}"

@ -1,43 +0,0 @@
#!/usr/bin/env bash
# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
# Print the version tailscale repository corresponding
# to the version listed in go.mod.
set -euo pipefail
go_list=$(go list -m tailscale.com)
# go list outputs `tailscale.com <version>`. Extract the version.
mod_version=${go_list#tailscale.com}
if [ -z "$mod_version" ]; then
echo >&2 "no version reported by go list -m tailscale.com: $go_list"
exit 1
fi
case "$mod_version" in
*-*-*)
# A pseudo-version such as "v1.1.1-0.20201030135043-eab6e9ea4e45"
# includes the commit hash.
mod_version=${mod_version##*-*-}
;;
esac
tailscale_clone=$(mktemp -d -t tailscale-clone-XXXXXXXXXX)
git clone -q https://github.com/tailscale/tailscale.git "$tailscale_clone"
cd $tailscale_clone
git reset --hard -q
git clean -d -x -f
git fetch -q --all --tags
git checkout -q ${mod_version}
eval $(./build_dist.sh shellvars)
git_hash=$(git rev-parse HEAD)
short_hash=$(echo "$git_hash" | cut -c1-9)
echo ${VERSION_SHORT}-t${short_hash}
cd /tmp
rm -rf "$tailscale_clone"
Loading…
Cancel
Save